feat(web): 管理面调用日志——筛选明细与 key/成员跳转链路
This commit is contained in:
@@ -317,12 +317,23 @@ export function adminUsageRoutes() {
|
|||||||
page,
|
page,
|
||||||
page_size,
|
page_size,
|
||||||
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
|
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
|
||||||
member_id: q.member_id ? Number(q.member_id) : undefined,
|
member_id:
|
||||||
billing_source: q.billing_source || undefined,
|
q.member_id === "unassigned"
|
||||||
|
? "unassigned"
|
||||||
|
: q.member_id
|
||||||
|
? Number(q.member_id)
|
||||||
|
: undefined,
|
||||||
|
billing_source:
|
||||||
|
q.billing_source === "none" ? "none" : q.billing_source || undefined,
|
||||||
subscription_id: q.subscription_id
|
subscription_id: q.subscription_id
|
||||||
? Number(q.subscription_id)
|
? Number(q.subscription_id)
|
||||||
: undefined,
|
: undefined,
|
||||||
type: q.type != null && q.type !== "" ? Number(q.type) : undefined,
|
type:
|
||||||
|
q.type === "error"
|
||||||
|
? "error"
|
||||||
|
: q.type != null && q.type !== ""
|
||||||
|
? Number(q.type)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const items: LogItem[] = rows.map((row) => {
|
const items: LogItem[] = rows.map((row) => {
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import type { LogItem, Paginated } from "@new-api-key-usage/core";
|
import type { LogItem, Paginated } from "@new-api-key-usage/core";
|
||||||
import { adminHttp } from "../http.ts";
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export type AdminLogFilters = {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
api_key_id?: number;
|
||||||
|
member_id?: number | "unassigned";
|
||||||
|
billing_source?: string;
|
||||||
|
subscription_id?: number;
|
||||||
|
type?: number | "error";
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchLogs(
|
export async function fetchLogs(
|
||||||
range: { from: string; to: string; page: number; page_size: number },
|
params: AdminLogFilters,
|
||||||
): Promise<Paginated<LogItem>> {
|
): Promise<Paginated<LogItem>> {
|
||||||
const { data } = await adminHttp.get<Paginated<LogItem>>("/logs", {
|
const { data } = await adminHttp.get<Paginated<LogItem>>("/logs", {
|
||||||
params: range,
|
params,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,20 @@ export const qk = {
|
|||||||
overview: (range: TimeRange) =>
|
overview: (range: TimeRange) =>
|
||||||
["admin", "overview", range.from, range.to] as const,
|
["admin", "overview", range.from, range.to] as const,
|
||||||
keys: (filters: KeyFilters) => ["admin", "keys", filters] as const,
|
keys: (filters: KeyFilters) => ["admin", "keys", filters] as const,
|
||||||
logs: (range: TimeRange & PageQuery) =>
|
logs: (range: TimeRange & PageQuery & Record<string, unknown>) =>
|
||||||
["admin", "logs", range.from, range.to, range.page, range.page_size] as const,
|
[
|
||||||
|
"admin",
|
||||||
|
"logs",
|
||||||
|
range.from,
|
||||||
|
range.to,
|
||||||
|
range.page,
|
||||||
|
range.page_size,
|
||||||
|
range.api_key_id ?? "",
|
||||||
|
range.member_id ?? "",
|
||||||
|
range.billing_source ?? "",
|
||||||
|
range.subscription_id ?? "",
|
||||||
|
range.type ?? "",
|
||||||
|
] as const,
|
||||||
payments: () => ["admin", "payments"] as const,
|
payments: () => ["admin", "payments"] as const,
|
||||||
qpuLatest: () => ["admin", "qpu-latest"] as const,
|
qpuLatest: () => ["admin", "qpu-latest"] as const,
|
||||||
members: () => ["admin", "members"] as const,
|
members: () => ["admin", "members"] as const,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
AuditOutlined,
|
AuditOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
DownOutlined,
|
DownOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
KeyOutlined,
|
KeyOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
@@ -42,6 +43,7 @@ const username = computed(
|
|||||||
/** 收起时仅剩图标——每项必须带 icon(交互共性侧边栏六项) */
|
/** 收起时仅剩图标——每项必须带 icon(交互共性侧边栏六项) */
|
||||||
const items: MenuItemType[] = [
|
const items: MenuItemType[] = [
|
||||||
{ key: "/admin/overview", icon: DashboardOutlined, label: "总览" },
|
{ key: "/admin/overview", icon: DashboardOutlined, label: "总览" },
|
||||||
|
{ key: "/admin/logs", icon: FileTextOutlined, label: "调用日志" },
|
||||||
{ key: "/admin/keys", icon: KeyOutlined, label: "Key 管理" },
|
{ key: "/admin/keys", icon: KeyOutlined, label: "Key 管理" },
|
||||||
{ key: "/admin/payments", icon: AccountBookOutlined, label: "支付记录" },
|
{ key: "/admin/payments", icon: AccountBookOutlined, label: "支付记录" },
|
||||||
{ key: "/admin/members", icon: TeamOutlined, label: "成员管理" },
|
{ key: "/admin/members", icon: TeamOutlined, label: "成员管理" },
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ export function usePatchKey() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAdminLogs(params: () => TimeRange & PageQuery) {
|
export function useAdminLogs(
|
||||||
|
params: () => Parameters<typeof fetchLogs>[0],
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: () => qk.admin.logs(params()),
|
queryKey: () => qk.admin.logs(params()),
|
||||||
queryFn: () => fetchLogs(params()),
|
queryFn: () => fetchLogs(params()),
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export const adminRoutes: RouteRecordRaw[] = [
|
|||||||
name: "admin-overview",
|
name: "admin-overview",
|
||||||
component: () => import("@/views/admin/overview-view.vue"),
|
component: () => import("@/views/admin/overview-view.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "logs",
|
||||||
|
name: "admin-logs",
|
||||||
|
component: () => import("@/views/admin/logs-view.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "keys",
|
path: "keys",
|
||||||
name: "admin-keys",
|
name: "admin-keys",
|
||||||
|
|||||||
@@ -297,6 +297,13 @@ function onRowClass(record: KeyItem) {
|
|||||||
{{ formatMinute(record.registered_at) }}
|
{{ formatMinute(record.registered_at) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
|
<a-space>
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
@click="() => router.push({ path: '/admin/logs', query: { api_key_id: String(record.id) } })"
|
||||||
|
>
|
||||||
|
查调用
|
||||||
|
</a-button>
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
v-if="record.status === 'active'"
|
v-if="record.status === 'active'"
|
||||||
title="停用后移出轮询清单(数据保留)"
|
title="停用后移出轮询清单(数据保留)"
|
||||||
@@ -310,6 +317,7 @@ function onRowClass(record: KeyItem) {
|
|||||||
<a-button v-else size="small" @click="() => onToggleStatus(record)">
|
<a-button v-else size="small" @click="() => onToggleStatus(record)">
|
||||||
启用
|
启用
|
||||||
</a-button>
|
</a-button>
|
||||||
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 管理面调用日志(交互设计-调用日志):筛选 + 明细表 + 与 key/成员跳转
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||||
|
import type { LogItem } from "@new-api-key-usage/core";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
import TimeRangePicker from "@/components/common/time-range-picker.vue";
|
||||||
|
import QuotaText from "@/components/common/quota-text.vue";
|
||||||
|
import { useLiveTimeRange } from "@/composables/use-live-time-range.ts";
|
||||||
|
import { useAdminLogs, useKeys, useMembers } from "@/queries/admin.ts";
|
||||||
|
import { formatDateTime, recentDaysRange } from "@/utils/time.ts";
|
||||||
|
import { formatInt } from "@/utils/format.ts";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { range, setRange } = useLiveTimeRange("recent-30");
|
||||||
|
|
||||||
|
const memberId = ref<number | "unassigned" | null>(null);
|
||||||
|
const apiKeyId = ref<number | null>(null);
|
||||||
|
const billingSource = ref<string | null>(null);
|
||||||
|
const subscriptionId = ref<number | null>(null);
|
||||||
|
const typeFilter = ref<number | "error" | null>(null);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(50);
|
||||||
|
|
||||||
|
const membersQuery = useMembers();
|
||||||
|
const keysQuery = useKeys(() => ({}));
|
||||||
|
|
||||||
|
const keyById = computed(() => {
|
||||||
|
const m = new Map<number, { token_name: string; member_id: number | null; member_name: string | null }>();
|
||||||
|
for (const k of keysQuery.data.value ?? []) {
|
||||||
|
m.set(k.id, {
|
||||||
|
token_name: k.token_name,
|
||||||
|
member_id: k.member_id,
|
||||||
|
member_name: k.member_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = computed(() => ({
|
||||||
|
...range.value,
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
api_key_id: apiKeyId.value ?? undefined,
|
||||||
|
member_id: memberId.value ?? undefined,
|
||||||
|
billing_source: billingSource.value ?? undefined,
|
||||||
|
subscription_id: subscriptionId.value ?? undefined,
|
||||||
|
type: typeFilter.value ?? undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const logsQuery = useAdminLogs(() => params.value);
|
||||||
|
const rows = computed(() => logsQuery.data.value?.items ?? []);
|
||||||
|
const total = computed(() => logsQuery.data.value?.total ?? 0);
|
||||||
|
|
||||||
|
const memberOptions = computed(() => [
|
||||||
|
{ value: null, label: "全部成员" },
|
||||||
|
{ value: "unassigned", label: "未归属" },
|
||||||
|
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const keyOptions = computed(() => [
|
||||||
|
{ value: null, label: "全部 Key" },
|
||||||
|
...(keysQuery.data.value ?? []).map((k) => ({
|
||||||
|
value: k.id,
|
||||||
|
label: k.token_name,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: "时间", key: "created_at", width: 150 },
|
||||||
|
{ title: "Key", key: "token_name", width: 120 },
|
||||||
|
{ title: "归属", key: "member_name", width: 110 },
|
||||||
|
{ title: "模型", key: "model_name", width: 110 },
|
||||||
|
{ title: "类型", key: "type", width: 80 },
|
||||||
|
{ title: "来源", key: "source", width: 110 },
|
||||||
|
{ title: "用量", key: "usage", width: 140 },
|
||||||
|
{ title: "Tokens", key: "tokens", width: 110 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function syncFromRoute() {
|
||||||
|
const q = route.query;
|
||||||
|
const m = q.member_id;
|
||||||
|
if (m === "unassigned") memberId.value = "unassigned";
|
||||||
|
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);
|
||||||
|
if (typeof q.billing_source === "string" && q.billing_source) {
|
||||||
|
billingSource.value = q.billing_source;
|
||||||
|
}
|
||||||
|
const s = q.subscription_id;
|
||||||
|
if (typeof s === "string" && s) subscriptionId.value = Number(s);
|
||||||
|
const t = q.type;
|
||||||
|
if (t === "2") typeFilter.value = 2;
|
||||||
|
else if (t === "error") typeFilter.value = "error";
|
||||||
|
else typeFilter.value = null;
|
||||||
|
const p = q.page;
|
||||||
|
if (typeof p === "string" && p) page.value = Number(p) || 1;
|
||||||
|
const from = q.from;
|
||||||
|
const to = q.to;
|
||||||
|
if (typeof from === "string" && from && typeof to === "string" && to) {
|
||||||
|
setRange({ from, to }, "fixed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushQuery() {
|
||||||
|
const query: Record<string, string> = {
|
||||||
|
from: range.value.from,
|
||||||
|
to: range.value.to,
|
||||||
|
};
|
||||||
|
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 (billingSource.value) query.billing_source = billingSource.value;
|
||||||
|
if (subscriptionId.value != null) {
|
||||||
|
query.subscription_id = String(subscriptionId.value);
|
||||||
|
}
|
||||||
|
if (typeFilter.value) query.type = String(typeFilter.value);
|
||||||
|
if (page.value > 1) query.page = String(page.value);
|
||||||
|
void router.replace({ path: "/admin/logs", query });
|
||||||
|
}
|
||||||
|
|
||||||
|
syncFromRoute();
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
() => syncFromRoute(),
|
||||||
|
);
|
||||||
|
watch([memberId, apiKeyId, billingSource, subscriptionId, typeFilter, range], () => {
|
||||||
|
page.value = 1;
|
||||||
|
pushQuery();
|
||||||
|
});
|
||||||
|
watch(page, () => pushQuery());
|
||||||
|
|
||||||
|
function sourceBadge(record: LogItem) {
|
||||||
|
if (record.billing_source === "subscription" && record.subscription_id != null) {
|
||||||
|
return { text: `订阅 #${record.subscription_id}`, color: "blue" as const };
|
||||||
|
}
|
||||||
|
if (record.billing_source === "wallet") {
|
||||||
|
return { text: "充值", color: "gold" as const };
|
||||||
|
}
|
||||||
|
return { text: "未关联", color: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
function goKey(keyId: number) {
|
||||||
|
void router.push({ path: "/admin/keys", query: { key_id: String(keyId) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function goMember(memberId: number | null) {
|
||||||
|
if (memberId == null) return;
|
||||||
|
void router.push({
|
||||||
|
path: "/admin/members",
|
||||||
|
query: { member_id: String(memberId) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let ro: ResizeObserver | null = null;
|
||||||
|
const boxRef = ref<HTMLElement | null>(null);
|
||||||
|
const bodyY = ref(480);
|
||||||
|
const CHROME = 96;
|
||||||
|
|
||||||
|
function measure() {
|
||||||
|
const h = boxRef.value?.clientHeight ?? 0;
|
||||||
|
if (h > 0) bodyY.value = Math.max(200, h - CHROME);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
measure();
|
||||||
|
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
|
||||||
|
ro = new ResizeObserver(() => measure());
|
||||||
|
ro.observe(boxRef.value);
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", measure);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
ro?.disconnect();
|
||||||
|
window.removeEventListener("resize", measure);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="call-logs">
|
||||||
|
<PageHeader title="调用日志" subtitle="全池模型请求查询与筛选">
|
||||||
|
<template #actions>
|
||||||
|
<span class="call-logs__label">时间范围</span>
|
||||||
|
<TimeRangePicker :model-value="range" @change="(v, k) => setRange(v, k)" />
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="call-logs__filters">
|
||||||
|
<a-select
|
||||||
|
v-model:value="memberId"
|
||||||
|
:options="memberOptions"
|
||||||
|
style="width: 140px"
|
||||||
|
allow-clear
|
||||||
|
placeholder="成员"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="apiKeyId"
|
||||||
|
:options="keyOptions"
|
||||||
|
style="width: 160px"
|
||||||
|
allow-clear
|
||||||
|
placeholder="Key"
|
||||||
|
show-search
|
||||||
|
option-filter-prop="label"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="billingSource"
|
||||||
|
:options="[
|
||||||
|
{ value: null, label: '全部来源' },
|
||||||
|
{ value: 'subscription', label: '订阅' },
|
||||||
|
{ value: 'wallet', label: '充值' },
|
||||||
|
{ value: 'none', label: '未关联' },
|
||||||
|
]"
|
||||||
|
style="width: 120px"
|
||||||
|
allow-clear
|
||||||
|
placeholder="来源"
|
||||||
|
/>
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="subscriptionId"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
style="width: 120px"
|
||||||
|
placeholder="订阅 ID"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="typeFilter"
|
||||||
|
:options="[
|
||||||
|
{ value: null, label: '全部类型' },
|
||||||
|
{ value: 2, label: '消费' },
|
||||||
|
{ value: 'error', label: '错误' },
|
||||||
|
]"
|
||||||
|
style="width: 120px"
|
||||||
|
placeholder="类型"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty
|
||||||
|
v-if="!logsQuery.isPending.value && rows.length === 0"
|
||||||
|
description="该时段内无调用日志"
|
||||||
|
/>
|
||||||
|
<div v-else ref="boxRef" class="call-logs__box">
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="rows"
|
||||||
|
:row-key="(r: LogItem) => r.id"
|
||||||
|
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
|
||||||
|
:pagination="{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['20', '50', '100', '200'],
|
||||||
|
showTotal: (t: number) => `共 ${t} 条`,
|
||||||
|
onChange: (p: number, ps: number) => {
|
||||||
|
page = p;
|
||||||
|
pageSize = ps;
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
size="middle"
|
||||||
|
:scroll="{ y: bodyY }"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'created_at'">
|
||||||
|
{{ formatDateTime(record.created_at) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'token_name'">
|
||||||
|
<a @click="goKey(record.api_key_id)">{{ record.token_name }}</a>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'member_name'">
|
||||||
|
<a
|
||||||
|
v-if="keyById.get(record.api_key_id)?.member_id != null"
|
||||||
|
@click="goMember(keyById.get(record.api_key_id)!.member_id)"
|
||||||
|
>
|
||||||
|
{{ keyById.get(record.api_key_id)?.member_name ?? "成员" }}
|
||||||
|
</a>
|
||||||
|
<span v-else>未归属</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'type'">
|
||||||
|
<a-tag :color="record.type === 2 ? 'blue' : 'red'">
|
||||||
|
{{ record.type === 2 ? "消费" : "错误" }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'source'">
|
||||||
|
<a-tag :color="sourceBadge(record).color">
|
||||||
|
{{ sourceBadge(record).text }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'usage'">
|
||||||
|
<QuotaText :quota="record.quota" :quota-usd="record.quota_usd" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'tokens'">
|
||||||
|
{{ formatInt(record.prompt_tokens) }} / {{ formatInt(record.completion_tokens) }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.call-logs {
|
||||||
|
height: calc(100vh - 64px - 48px - 48px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-logs__label {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 32px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-logs__filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-logs__box {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,10 @@
|
|||||||
import { computed, h, reactive, ref } from "vue";
|
import { computed, h, reactive, ref } from "vue";
|
||||||
import { Tooltip, message } from "antdv-next";
|
import { Tooltip, message } from "antdv-next";
|
||||||
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
import type { MemberItem } from "@new-api-key-usage/core";
|
import type { MemberItem } from "@new-api-key-usage/core";
|
||||||
import type { ApiError } from "@/api/http.ts";
|
import type { ApiError } from "@/api/http.ts";
|
||||||
import PageHeader from "@/components/common/page-header.vue";
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
@@ -58,7 +62,15 @@ const columns = [
|
|||||||
{ title: "操作", key: "actions", width: 200 },
|
{ title: "操作", key: "actions", width: 200 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = computed(() => membersQuery.data.value ?? []);
|
const rows = computed(() => {
|
||||||
|
const list = membersQuery.data.value ?? [];
|
||||||
|
const mid = route.query.member_id;
|
||||||
|
if (typeof mid === "string" && mid) {
|
||||||
|
const n = Number(mid);
|
||||||
|
return list.filter((m) => m.id === n);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
async function onCreate() {
|
async function onCreate() {
|
||||||
createError.value = "";
|
createError.value = "";
|
||||||
@@ -156,6 +168,12 @@ async function onToggle(record: MemberItem) {
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
<a-space>
|
<a-space>
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
@click="() => router.push({ path: '/admin/logs', query: { member_id: String(record.id) } })"
|
||||||
|
>
|
||||||
|
查调用
|
||||||
|
</a-button>
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
title="重发后旧令牌立即失效"
|
title="重发后旧令牌立即失效"
|
||||||
description="该成员正在用的访问将中断"
|
description="该成员正在用的访问将中断"
|
||||||
|
|||||||
@@ -77,7 +77,20 @@ function onMemberClick(memberId: number | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onKeyClick(keyId: number) {
|
function onKeyClick(keyId: number) {
|
||||||
void router.push({ path: "/admin/keys", query: { key_id: String(keyId) } });
|
void router.push({
|
||||||
|
path: "/admin/logs",
|
||||||
|
query: { api_key_id: String(keyId), ...range.value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMemberLogs(memberId: number | null) {
|
||||||
|
void router.push({
|
||||||
|
path: "/admin/logs",
|
||||||
|
query: {
|
||||||
|
...(memberId == null ? { member_id: "unassigned" } : { member_id: String(memberId) }),
|
||||||
|
...range.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function goFailedKeys() {
|
function goFailedKeys() {
|
||||||
@@ -220,6 +233,7 @@ const keyColumns = [
|
|||||||
<a @click="onMemberClick(record.member_id)">
|
<a @click="onMemberClick(record.member_id)">
|
||||||
{{ record.member_name ?? "未归属" }}
|
{{ record.member_name ?? "未归属" }}
|
||||||
</a>
|
</a>
|
||||||
|
<a class="ov-log-link" @click="onMemberLogs(record.member_id)">查调用</a>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'key_count'">
|
<template v-else-if="column.key === 'key_count'">
|
||||||
{{ record.key_count }}
|
{{ record.key_count }}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
$ vite
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
type UsageLogRow,
|
type UsageLogRow,
|
||||||
type UserRow,
|
type UserRow,
|
||||||
} from "./schema.ts";
|
} from "./schema.ts";
|
||||||
import { and, asc, count, desc, eq, gt, gte, lt, or, sql } from "drizzle-orm";
|
import { and, asc, count, desc, eq, gt, gte, isNull, lt, ne, or, sql } from "drizzle-orm";
|
||||||
|
|
||||||
export interface SessionUser {
|
export interface SessionUser {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -456,10 +456,10 @@ export function createRepos(db: Db) {
|
|||||||
from: Date;
|
from: Date;
|
||||||
to: Date;
|
to: Date;
|
||||||
api_key_id?: number;
|
api_key_id?: number;
|
||||||
member_id?: number;
|
member_id?: number | "unassigned";
|
||||||
billing_source?: string;
|
billing_source?: string | "none";
|
||||||
subscription_id?: number;
|
subscription_id?: number;
|
||||||
type?: number;
|
type?: number | "error";
|
||||||
page: number;
|
page: number;
|
||||||
page_size: number;
|
page_size: number;
|
||||||
}): Promise<{ rows: Array<UsageLogRow & { token_name: string }>; total: number }> {
|
}): Promise<{ rows: Array<UsageLogRow & { token_name: string }>; total: number }> {
|
||||||
@@ -470,16 +470,22 @@ export function createRepos(db: Db) {
|
|||||||
if (params.api_key_id != null) {
|
if (params.api_key_id != null) {
|
||||||
conds.push(eq(usageLogs.api_key_id, params.api_key_id));
|
conds.push(eq(usageLogs.api_key_id, params.api_key_id));
|
||||||
}
|
}
|
||||||
if (params.billing_source) {
|
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));
|
conds.push(eq(usageLogs.billing_source, params.billing_source));
|
||||||
}
|
}
|
||||||
if (params.subscription_id != null) {
|
if (params.subscription_id != null) {
|
||||||
conds.push(eq(usageLogs.subscription_id, params.subscription_id));
|
conds.push(eq(usageLogs.subscription_id, params.subscription_id));
|
||||||
}
|
}
|
||||||
if (params.type != null) {
|
if (params.type === "error") {
|
||||||
|
conds.push(ne(usageLogs.type, 2));
|
||||||
|
} else if (typeof params.type === "number") {
|
||||||
conds.push(eq(usageLogs.type, params.type));
|
conds.push(eq(usageLogs.type, params.type));
|
||||||
}
|
}
|
||||||
if (params.member_id != null) {
|
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));
|
conds.push(eq(apiKeys.member_id, params.member_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user