mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Add usage record column controls
This commit is contained in:
@@ -139,6 +139,19 @@
|
|||||||
<!-- 分隔线 -->
|
<!-- 分隔线 -->
|
||||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||||
|
|
||||||
|
<!-- 列显示配置(桌面端) -->
|
||||||
|
<MultiSelect
|
||||||
|
v-model="visibleColumnIds"
|
||||||
|
:options="columnSelectOptions"
|
||||||
|
placeholder="显示列"
|
||||||
|
trigger-class="hidden md:flex w-40 h-8 text-xs border-border/60"
|
||||||
|
dropdown-min-width="14rem"
|
||||||
|
:searchable="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 分隔线 -->
|
||||||
|
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||||
|
|
||||||
<!-- 自动刷新按钮 -->
|
<!-- 自动刷新按钮 -->
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -293,35 +306,43 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 桌面端表格视图 -->
|
<!-- 桌面端表格视图 -->
|
||||||
<Table :class="['hidden md:table table-fixed w-full', isAdmin ? 'min-w-[1120px]' : 'min-w-[960px]']">
|
<Table :class="['hidden md:table table-fixed w-full', desktopTableMinWidthClass]">
|
||||||
<colgroup v-if="isAdmin">
|
<colgroup v-if="isAdmin">
|
||||||
<col class="w-[8%]">
|
<col v-if="isColumnVisible('time')" class="w-[8%]">
|
||||||
<col class="w-[12%]">
|
<col v-if="isColumnVisible('user')" class="w-[12%]">
|
||||||
<col class="w-[14%]">
|
<col v-if="isColumnVisible('model')" class="w-[14%]">
|
||||||
<col class="w-[16%]">
|
<col v-if="isColumnVisible('provider')" class="w-[16%]">
|
||||||
<col class="w-[15%]">
|
<col v-if="isColumnVisible('api_format')" class="w-[15%]">
|
||||||
<col class="w-[10%]">
|
<col v-if="isColumnVisible('status')" class="w-[10%]">
|
||||||
<col class="w-[10%]">
|
<col v-if="isColumnVisible('tokens')" class="w-[10%]">
|
||||||
<col class="w-[6%]">
|
<col v-if="isColumnVisible('cost')" class="w-[6%]">
|
||||||
<col class="w-[9%]">
|
<col v-if="isColumnVisible('performance')" class="w-[9%]">
|
||||||
|
<col v-if="isColumnVisible('client_family')" class="w-[9%]">
|
||||||
|
<col v-if="isColumnVisible('client_ip')" class="w-[10%]">
|
||||||
|
<col v-if="isColumnVisible('user_agent')" class="w-[13%]">
|
||||||
|
<col v-if="isColumnVisible('request_path')" class="w-[13%]">
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<colgroup v-else>
|
<colgroup v-else>
|
||||||
<col class="w-[9%]">
|
<col v-if="isColumnVisible('time')" class="w-[9%]">
|
||||||
<col class="w-[17%]">
|
<col v-if="isColumnVisible('key')" class="w-[17%]">
|
||||||
<col class="w-[22%]">
|
<col v-if="isColumnVisible('model')" class="w-[22%]">
|
||||||
<col class="w-[14%]">
|
<col v-if="isColumnVisible('api_format')" class="w-[14%]">
|
||||||
<col class="w-[10%]">
|
<col v-if="isColumnVisible('status')" class="w-[10%]">
|
||||||
<col class="w-[11%]">
|
<col v-if="isColumnVisible('tokens')" class="w-[11%]">
|
||||||
<col class="w-[7%]">
|
<col v-if="isColumnVisible('cost')" class="w-[7%]">
|
||||||
<col class="w-[10%]">
|
<col v-if="isColumnVisible('performance')" class="w-[10%]">
|
||||||
|
<col v-if="isColumnVisible('client_family')" class="w-[9%]">
|
||||||
|
<col v-if="isColumnVisible('client_ip')" class="w-[10%]">
|
||||||
|
<col v-if="isColumnVisible('user_agent')" class="w-[13%]">
|
||||||
|
<col v-if="isColumnVisible('request_path')" class="w-[13%]">
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||||
<TableHead class="h-12 font-semibold w-[8%]">
|
<TableHead v-if="isColumnVisible('time')" class="h-12 font-semibold w-[8%]">
|
||||||
时间
|
时间
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<SortableTableHead
|
<SortableTableHead
|
||||||
v-if="isAdmin"
|
v-if="isAdmin && isColumnVisible('user')"
|
||||||
class="h-12 font-semibold w-[12%]"
|
class="h-12 font-semibold w-[12%]"
|
||||||
column-key="user"
|
column-key="user"
|
||||||
:sortable="false"
|
:sortable="false"
|
||||||
@@ -340,12 +361,13 @@
|
|||||||
</template>
|
</template>
|
||||||
</SortableTableHead>
|
</SortableTableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
v-if="!isAdmin"
|
v-if="!isAdmin && isColumnVisible('key')"
|
||||||
class="h-12 font-semibold w-[17%]"
|
class="h-12 font-semibold w-[17%]"
|
||||||
>
|
>
|
||||||
密钥
|
密钥
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<SortableTableHead
|
<SortableTableHead
|
||||||
|
v-if="isColumnVisible('model')"
|
||||||
:class="['h-12 font-semibold', isAdmin ? 'w-[14%]' : 'w-[22%]']"
|
:class="['h-12 font-semibold', isAdmin ? 'w-[14%]' : 'w-[22%]']"
|
||||||
column-key="model"
|
column-key="model"
|
||||||
:sortable="false"
|
:sortable="false"
|
||||||
@@ -364,7 +386,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</SortableTableHead>
|
</SortableTableHead>
|
||||||
<SortableTableHead
|
<SortableTableHead
|
||||||
v-if="isAdmin"
|
v-if="isAdmin && isColumnVisible('provider')"
|
||||||
class="h-12 font-semibold w-[16%]"
|
class="h-12 font-semibold w-[16%]"
|
||||||
column-key="provider"
|
column-key="provider"
|
||||||
:sortable="false"
|
:sortable="false"
|
||||||
@@ -383,6 +405,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</SortableTableHead>
|
</SortableTableHead>
|
||||||
<SortableTableHead
|
<SortableTableHead
|
||||||
|
v-if="isColumnVisible('api_format')"
|
||||||
:class="['h-12 font-semibold', isAdmin ? 'w-[15%]' : 'w-[14%]']"
|
:class="['h-12 font-semibold', isAdmin ? 'w-[15%]' : 'w-[14%]']"
|
||||||
column-key="api_format"
|
column-key="api_format"
|
||||||
:sortable="false"
|
:sortable="false"
|
||||||
@@ -401,6 +424,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</SortableTableHead>
|
</SortableTableHead>
|
||||||
<SortableTableHead
|
<SortableTableHead
|
||||||
|
v-if="isColumnVisible('status')"
|
||||||
class="h-12 font-semibold w-[10%] text-center"
|
class="h-12 font-semibold w-[10%] text-center"
|
||||||
column-key="status"
|
column-key="status"
|
||||||
:sortable="false"
|
:sortable="false"
|
||||||
@@ -419,24 +443,52 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</SortableTableHead>
|
</SortableTableHead>
|
||||||
<TableHead class="h-12 font-semibold w-[10%] text-center">
|
<TableHead v-if="isColumnVisible('tokens')" class="h-12 font-semibold w-[10%] text-center">
|
||||||
Tokens
|
Tokens
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="h-12 font-semibold w-[6%] text-right">
|
<TableHead v-if="isColumnVisible('cost')" class="h-12 font-semibold w-[6%] text-right">
|
||||||
费用
|
费用
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="h-12 font-semibold w-[9%] text-right">
|
<TableHead v-if="isColumnVisible('performance')" class="h-12 font-semibold w-[9%] text-right">
|
||||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||||
<span class="whitespace-nowrap">首字/总耗时</span>
|
<span class="whitespace-nowrap">首字/总耗时</span>
|
||||||
<span class="text-muted-foreground font-normal">输出速度</span>
|
<span class="text-muted-foreground font-normal">输出速度</span>
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<SortableTableHead
|
||||||
|
v-if="isColumnVisible('client_family')"
|
||||||
|
class="h-12 font-semibold w-[9%]"
|
||||||
|
column-key="client_family"
|
||||||
|
:sortable="false"
|
||||||
|
:filter-active="filterClientFamily !== '__all__'"
|
||||||
|
filter-title="筛选客户端"
|
||||||
|
filter-content-class="w-44 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
|
||||||
|
>
|
||||||
|
客户端
|
||||||
|
<template #filter="{ close }">
|
||||||
|
<TableFilterMenu
|
||||||
|
:model-value="filterClientFamily"
|
||||||
|
:options="clientFamilyFilterOptions"
|
||||||
|
@update:model-value="$emit('update:filterClientFamily', $event)"
|
||||||
|
@select="close"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SortableTableHead>
|
||||||
|
<TableHead v-if="isColumnVisible('client_ip')" class="h-12 font-semibold w-[10%]">
|
||||||
|
IP 地址
|
||||||
|
</TableHead>
|
||||||
|
<TableHead v-if="isColumnVisible('user_agent')" class="h-12 font-semibold w-[13%]">
|
||||||
|
User-Agent
|
||||||
|
</TableHead>
|
||||||
|
<TableHead v-if="isColumnVisible('request_path')" class="h-12 font-semibold w-[13%]">
|
||||||
|
请求路径
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-if="records.length === 0">
|
<TableRow v-if="records.length === 0">
|
||||||
<TableCell
|
<TableCell
|
||||||
:colspan="isAdmin ? 9 : 8"
|
:colspan="visibleColumnCount"
|
||||||
class="text-center py-12 text-muted-foreground"
|
class="text-center py-12 text-muted-foreground"
|
||||||
>
|
>
|
||||||
暂无请求记录
|
暂无请求记录
|
||||||
@@ -450,7 +502,7 @@
|
|||||||
@mousedown="handleRowMouseDown($event, record.id)"
|
@mousedown="handleRowMouseDown($event, record.id)"
|
||||||
@click="handleRowClick($event, record.id)"
|
@click="handleRowClick($event, record.id)"
|
||||||
>
|
>
|
||||||
<TableCell class="py-4 w-[8%] align-top">
|
<TableCell v-if="isColumnVisible('time')" class="py-4 w-[8%] align-top">
|
||||||
<div class="flex flex-col gap-0.5 leading-tight">
|
<div class="flex flex-col gap-0.5 leading-tight">
|
||||||
<span class="text-xs text-foreground tabular-nums whitespace-nowrap">
|
<span class="text-xs text-foreground tabular-nums whitespace-nowrap">
|
||||||
{{ formatRecordTime(record.created_at) }}
|
{{ formatRecordTime(record.created_at) }}
|
||||||
@@ -461,7 +513,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
v-if="isAdmin"
|
v-if="isAdmin && isColumnVisible('user')"
|
||||||
class="py-4 w-[12%] truncate"
|
class="py-4 w-[12%] truncate"
|
||||||
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
|
:title="record.username || record.user_email || (record.user_id ? `User ${record.user_id}` : '已删除用户')"
|
||||||
>
|
>
|
||||||
@@ -480,7 +532,7 @@
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<!-- 用户页面的密钥列 -->
|
<!-- 用户页面的密钥列 -->
|
||||||
<TableCell
|
<TableCell
|
||||||
v-if="!isAdmin"
|
v-if="!isAdmin && isColumnVisible('key')"
|
||||||
class="py-4 w-[17%]"
|
class="py-4 w-[17%]"
|
||||||
:title="record.api_key?.name || '-'"
|
:title="record.api_key?.name || '-'"
|
||||||
>
|
>
|
||||||
@@ -495,6 +547,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
|
v-if="isColumnVisible('model')"
|
||||||
:class="['font-medium py-4', isAdmin ? 'w-[14%]' : 'w-[22%]']"
|
:class="['font-medium py-4', isAdmin ? 'w-[14%]' : 'w-[22%]']"
|
||||||
:title="getModelTooltip(record)"
|
:title="getModelTooltip(record)"
|
||||||
>
|
>
|
||||||
@@ -525,7 +578,7 @@
|
|||||||
>{{ record.model }}</span>
|
>{{ record.model }}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
v-if="isAdmin"
|
v-if="isAdmin && isColumnVisible('provider')"
|
||||||
class="py-4 w-[16%]"
|
class="py-4 w-[16%]"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-center gap-1">
|
<div class="flex min-w-0 items-center gap-1">
|
||||||
@@ -582,6 +635,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
|
v-if="isColumnVisible('api_format')"
|
||||||
:class="['py-4', isAdmin ? 'w-[15%]' : 'w-[14%]']"
|
:class="['py-4', isAdmin ? 'w-[15%]' : 'w-[14%]']"
|
||||||
:title="getApiFormatTooltip(record)"
|
:title="getApiFormatTooltip(record)"
|
||||||
>
|
>
|
||||||
@@ -617,7 +671,7 @@
|
|||||||
class="text-muted-foreground text-xs"
|
class="text-muted-foreground text-xs"
|
||||||
>-</span>
|
>-</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-center py-4 w-[10%]">
|
<TableCell v-if="isColumnVisible('status')" class="text-center py-4 w-[10%]">
|
||||||
<!-- 优先显示请求状态 -->
|
<!-- 优先显示请求状态 -->
|
||||||
<Badge
|
<Badge
|
||||||
v-if="getDisplayStatus(record) === 'pending'"
|
v-if="getDisplayStatus(record) === 'pending'"
|
||||||
@@ -668,7 +722,7 @@
|
|||||||
{{ getStreamModeLabel(record) }}
|
{{ getStreamModeLabel(record) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="py-4 w-[10%]">
|
<TableCell v-if="isColumnVisible('tokens')" class="py-4 w-[10%]">
|
||||||
<div class="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-x-1 text-xs leading-tight tabular-nums">
|
<div class="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-x-1 text-xs leading-tight tabular-nums">
|
||||||
<span class="justify-self-end whitespace-nowrap text-right">
|
<span class="justify-self-end whitespace-nowrap text-right">
|
||||||
{{ formatTokens(getRecordEffectiveInputTokens(record)) }}
|
{{ formatTokens(getRecordEffectiveInputTokens(record)) }}
|
||||||
@@ -702,7 +756,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-right py-4 w-[6%]">
|
<TableCell v-if="isColumnVisible('cost')" class="text-right py-4 w-[6%]">
|
||||||
<div class="flex flex-col items-end text-xs gap-0.5">
|
<div class="flex flex-col items-end text-xs gap-0.5">
|
||||||
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
|
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
|
||||||
<span
|
<span
|
||||||
@@ -713,7 +767,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-right py-4 w-[9%]">
|
<TableCell v-if="isColumnVisible('performance')" class="text-right py-4 w-[9%]">
|
||||||
<!-- pending/streaming 状态:首字与动态总耗时保留在同一行 -->
|
<!-- pending/streaming 状态:首字与动态总耗时保留在同一行 -->
|
||||||
<div
|
<div
|
||||||
v-if="getDisplayStatus(record) === 'pending' || getDisplayStatus(record) === 'streaming'"
|
v-if="getDisplayStatus(record) === 'pending' || getDisplayStatus(record) === 'streaming'"
|
||||||
@@ -746,6 +800,39 @@
|
|||||||
class="text-muted-foreground"
|
class="text-muted-foreground"
|
||||||
>-</span>
|
>-</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
v-if="isColumnVisible('client_family')"
|
||||||
|
class="py-4 w-[9%] text-xs"
|
||||||
|
:title="record.client_family || '-'"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="whitespace-nowrap border-border/60 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ formatClientFamily(record.client_family) }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
v-if="isColumnVisible('client_ip')"
|
||||||
|
class="py-4 w-[10%] text-xs truncate"
|
||||||
|
:title="record.client_ip || '-'"
|
||||||
|
>
|
||||||
|
{{ record.client_ip || '-' }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
v-if="isColumnVisible('user_agent')"
|
||||||
|
class="py-4 w-[13%] text-xs truncate"
|
||||||
|
:title="record.user_agent || '-'"
|
||||||
|
>
|
||||||
|
{{ formatUserAgent(record.user_agent) }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
v-if="isColumnVisible('request_path')"
|
||||||
|
class="py-4 w-[13%] text-xs truncate"
|
||||||
|
:title="getRequestPath(record) || '-'"
|
||||||
|
>
|
||||||
|
{{ getRequestPath(record) || '-' }}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -768,7 +855,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
import { useDebounceFn, useLocalStorage } from '@vueuse/core'
|
||||||
import {
|
import {
|
||||||
TableCard,
|
TableCard,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -808,7 +895,8 @@ import {
|
|||||||
import { useRowClick } from '@/composables/useRowClick'
|
import { useRowClick } from '@/composables/useRowClick'
|
||||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
import type { DateRangeParams, UsageRecord } from '../types'
|
import type { DateRangeParams, UsageRecord } from '../types'
|
||||||
import { TimeRangePicker } from '@/components/common'
|
import { MultiSelect, TimeRangePicker } from '@/components/common'
|
||||||
|
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
|
||||||
import ElapsedTimeText from './ElapsedTimeText.vue'
|
import ElapsedTimeText from './ElapsedTimeText.vue'
|
||||||
import ServerUserSelector from './ServerUserSelector.vue'
|
import ServerUserSelector from './ServerUserSelector.vue'
|
||||||
|
|
||||||
@@ -824,6 +912,69 @@ interface FilterOption {
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UsageRecordColumnId =
|
||||||
|
| 'time'
|
||||||
|
| 'user'
|
||||||
|
| 'key'
|
||||||
|
| 'model'
|
||||||
|
| 'provider'
|
||||||
|
| 'api_format'
|
||||||
|
| 'status'
|
||||||
|
| 'tokens'
|
||||||
|
| 'cost'
|
||||||
|
| 'performance'
|
||||||
|
| 'client_family'
|
||||||
|
| 'client_ip'
|
||||||
|
| 'user_agent'
|
||||||
|
| 'request_path'
|
||||||
|
|
||||||
|
interface UsageRecordColumnOption {
|
||||||
|
id: UsageRecordColumnId
|
||||||
|
label: string
|
||||||
|
adminOnly?: boolean
|
||||||
|
userOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const USAGE_RECORD_COLUMN_OPTIONS: UsageRecordColumnOption[] = [
|
||||||
|
{ id: 'time', label: '时间' },
|
||||||
|
{ id: 'user', label: '用户', adminOnly: true },
|
||||||
|
{ id: 'key', label: '密钥', userOnly: true },
|
||||||
|
{ id: 'model', label: '模型' },
|
||||||
|
{ id: 'provider', label: '提供商', adminOnly: true },
|
||||||
|
{ id: 'api_format', label: 'API格式' },
|
||||||
|
{ id: 'status', label: '类型/状态' },
|
||||||
|
{ id: 'tokens', label: 'Tokens' },
|
||||||
|
{ id: 'cost', label: '费用' },
|
||||||
|
{ id: 'performance', label: '耗时/速度' },
|
||||||
|
{ id: 'client_family', label: '客户端类型' },
|
||||||
|
{ id: 'client_ip', label: 'IP 地址' },
|
||||||
|
{ id: 'user_agent', label: 'User-Agent' },
|
||||||
|
{ id: 'request_path', label: '请求路径' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEFAULT_ADMIN_COLUMNS: UsageRecordColumnId[] = [
|
||||||
|
'time',
|
||||||
|
'user',
|
||||||
|
'model',
|
||||||
|
'provider',
|
||||||
|
'api_format',
|
||||||
|
'status',
|
||||||
|
'tokens',
|
||||||
|
'cost',
|
||||||
|
'performance',
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEFAULT_USER_COLUMNS: UsageRecordColumnId[] = [
|
||||||
|
'time',
|
||||||
|
'key',
|
||||||
|
'model',
|
||||||
|
'api_format',
|
||||||
|
'status',
|
||||||
|
'tokens',
|
||||||
|
'cost',
|
||||||
|
'performance',
|
||||||
|
]
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
records: UsageRecord[]
|
records: UsageRecord[]
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
@@ -838,9 +989,11 @@ const props = defineProps<{
|
|||||||
filterProvider: string
|
filterProvider: string
|
||||||
filterApiFormat: string
|
filterApiFormat: string
|
||||||
filterStatus: string
|
filterStatus: string
|
||||||
|
filterClientFamily: string
|
||||||
availableUsers: UserOption[]
|
availableUsers: UserOption[]
|
||||||
availableModels: string[]
|
availableModels: string[]
|
||||||
availableProviders: string[]
|
availableProviders: string[]
|
||||||
|
availableClientFamilies: string[]
|
||||||
// 分页
|
// 分页
|
||||||
currentPage: number
|
currentPage: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
@@ -858,6 +1011,7 @@ const emit = defineEmits<{
|
|||||||
'update:filterProvider': [value: string]
|
'update:filterProvider': [value: string]
|
||||||
'update:filterApiFormat': [value: string]
|
'update:filterApiFormat': [value: string]
|
||||||
'update:filterStatus': [value: string]
|
'update:filterStatus': [value: string]
|
||||||
|
'update:filterClientFamily': [value: string]
|
||||||
'update:currentPage': [value: number]
|
'update:currentPage': [value: number]
|
||||||
'update:pageSize': [value: number]
|
'update:pageSize': [value: number]
|
||||||
'update:autoRefresh': [value: boolean]
|
'update:autoRefresh': [value: boolean]
|
||||||
@@ -881,6 +1035,75 @@ const AVAILABLE_API_FORMATS = [
|
|||||||
// 使用模块级常量
|
// 使用模块级常量
|
||||||
const availableApiFormats = AVAILABLE_API_FORMATS
|
const availableApiFormats = AVAILABLE_API_FORMATS
|
||||||
|
|
||||||
|
const adminVisibleColumnIds = useLocalStorage<UsageRecordColumnId[]>(
|
||||||
|
'usage-records-visible-columns-admin',
|
||||||
|
DEFAULT_ADMIN_COLUMNS,
|
||||||
|
)
|
||||||
|
const userVisibleColumnIds = useLocalStorage<UsageRecordColumnId[]>(
|
||||||
|
'usage-records-visible-columns-user',
|
||||||
|
DEFAULT_USER_COLUMNS,
|
||||||
|
)
|
||||||
|
|
||||||
|
const roleColumnOptions = computed(() => USAGE_RECORD_COLUMN_OPTIONS.filter((column) => {
|
||||||
|
if (column.adminOnly && !props.isAdmin) return false
|
||||||
|
if (column.userOnly && props.isAdmin) return false
|
||||||
|
return true
|
||||||
|
}))
|
||||||
|
|
||||||
|
const roleColumnIds = computed(() => new Set(roleColumnOptions.value.map(column => column.id)))
|
||||||
|
|
||||||
|
function sanitizeColumnIds(
|
||||||
|
ids: readonly string[],
|
||||||
|
fallback: readonly UsageRecordColumnId[],
|
||||||
|
): UsageRecordColumnId[] {
|
||||||
|
const seen = new Set<UsageRecordColumnId>()
|
||||||
|
const sanitized = ids.filter((id): id is UsageRecordColumnId => {
|
||||||
|
if (!roleColumnIds.value.has(id as UsageRecordColumnId)) return false
|
||||||
|
if (seen.has(id as UsageRecordColumnId)) return false
|
||||||
|
seen.add(id as UsageRecordColumnId)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return sanitized.length > 0 ? sanitized : [...fallback]
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleColumnIds = computed<UsageRecordColumnId[]>({
|
||||||
|
get: () => sanitizeColumnIds(
|
||||||
|
props.isAdmin ? adminVisibleColumnIds.value : userVisibleColumnIds.value,
|
||||||
|
props.isAdmin ? DEFAULT_ADMIN_COLUMNS : DEFAULT_USER_COLUMNS,
|
||||||
|
),
|
||||||
|
set: (value) => {
|
||||||
|
const sanitized = sanitizeColumnIds(value, props.isAdmin ? DEFAULT_ADMIN_COLUMNS : DEFAULT_USER_COLUMNS)
|
||||||
|
if (props.isAdmin) {
|
||||||
|
adminVisibleColumnIds.value = sanitized
|
||||||
|
} else {
|
||||||
|
userVisibleColumnIds.value = sanitized
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleColumnSet = computed(() => new Set<UsageRecordColumnId>(visibleColumnIds.value))
|
||||||
|
const visibleColumnCount = computed(() => visibleColumnIds.value.length)
|
||||||
|
const desktopTableMinWidthClass = computed(() => {
|
||||||
|
const metadataColumnCount = visibleColumnIds.value.filter(column => (
|
||||||
|
column === 'client_family' ||
|
||||||
|
column === 'client_ip' ||
|
||||||
|
column === 'user_agent' ||
|
||||||
|
column === 'request_path'
|
||||||
|
)).length
|
||||||
|
if (metadataColumnCount >= 3) return 'min-w-[1520px]'
|
||||||
|
if (metadataColumnCount > 0) return 'min-w-[1320px]'
|
||||||
|
return props.isAdmin ? 'min-w-[1120px]' : 'min-w-[960px]'
|
||||||
|
})
|
||||||
|
|
||||||
|
const columnSelectOptions = computed<MultiSelectOption[]>(() => roleColumnOptions.value.map(column => ({
|
||||||
|
value: column.id,
|
||||||
|
label: column.label,
|
||||||
|
})))
|
||||||
|
|
||||||
|
function isColumnVisible(column: UsageRecordColumnId): boolean {
|
||||||
|
return visibleColumnSet.value.has(column)
|
||||||
|
}
|
||||||
|
|
||||||
const modelFilterOptions = computed<FilterOption[]>(() => [
|
const modelFilterOptions = computed<FilterOption[]>(() => [
|
||||||
{ value: '__all__', label: '全部模型' },
|
{ value: '__all__', label: '全部模型' },
|
||||||
...props.availableModels.map((model) => ({
|
...props.availableModels.map((model) => ({
|
||||||
@@ -897,6 +1120,31 @@ const providerFilterOptions = computed<FilterOption[]>(() => [
|
|||||||
})),
|
})),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
function formatClientFamily(value: string | null | undefined): string {
|
||||||
|
const normalized = value?.trim().toLowerCase()
|
||||||
|
if (!normalized) return '-'
|
||||||
|
if (normalized === 'codex') return 'Codex'
|
||||||
|
if (normalized === 'claude_code') return 'Claude Code'
|
||||||
|
if (normalized === 'opencode') return 'OpenCode'
|
||||||
|
if (normalized === 'generic') return '通用客户端'
|
||||||
|
return value?.trim() || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientFamilyFilterOptions = computed<FilterOption[]>(() => {
|
||||||
|
const families = new Set<string>(props.availableClientFamilies)
|
||||||
|
props.records.forEach((record) => {
|
||||||
|
const family = record.client_family?.trim()
|
||||||
|
if (family) families.add(family)
|
||||||
|
})
|
||||||
|
return [
|
||||||
|
{ value: '__all__', label: '全部客户端' },
|
||||||
|
...Array.from(families).sort().map((family) => ({
|
||||||
|
value: family,
|
||||||
|
label: formatClientFamily(family),
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const apiFormatFilterOptions = computed<FilterOption[]>(() => [
|
const apiFormatFilterOptions = computed<FilterOption[]>(() => [
|
||||||
{ value: '__all__', label: '全部格式' },
|
{ value: '__all__', label: '全部格式' },
|
||||||
...availableApiFormats.map((format) => ({
|
...availableApiFormats.map((format) => ({
|
||||||
@@ -1048,6 +1296,16 @@ function formatOutputRateTokensPerSecond(outputRate: number | null | undefined):
|
|||||||
return `${value} tokens/s`
|
return `${value} tokens/s`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatUserAgent(value: string | null | undefined): string {
|
||||||
|
const userAgent = value?.trim()
|
||||||
|
if (!userAgent) return '-'
|
||||||
|
return userAgent.length > 48 ? `${userAgent.slice(0, 45)}...` : userAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestPath(record: UsageRecord): string | null {
|
||||||
|
return record.request_path_and_query?.trim() || record.request_path?.trim() || null
|
||||||
|
}
|
||||||
|
|
||||||
// useDebounceFn 自动处理清理,无需 onUnmounted
|
// useDebounceFn 自动处理清理,无需 onUnmounted
|
||||||
|
|
||||||
// 判断是否应该显示格式转换信息
|
// 判断是否应该显示格式转换信息
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ vi.mock('@/components/common', async () => {
|
|||||||
const { defineComponent, h } = await import('vue')
|
const { defineComponent, h } = await import('vue')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
MultiSelect: defineComponent({
|
||||||
|
name: 'MultiSelectStub',
|
||||||
|
setup() {
|
||||||
|
return () => h('div')
|
||||||
|
},
|
||||||
|
}),
|
||||||
TimeRangePicker: defineComponent({
|
TimeRangePicker: defineComponent({
|
||||||
name: 'TimeRangePickerStub',
|
name: 'TimeRangePickerStub',
|
||||||
setup() {
|
setup() {
|
||||||
@@ -135,9 +141,11 @@ function mountUsageRecordsTable(records: UsageRecord[], overrides: Record<string
|
|||||||
filterProvider: '__all__',
|
filterProvider: '__all__',
|
||||||
filterApiFormat: '__all__',
|
filterApiFormat: '__all__',
|
||||||
filterStatus: '__all__',
|
filterStatus: '__all__',
|
||||||
|
filterClientFamily: '__all__',
|
||||||
availableUsers: [],
|
availableUsers: [],
|
||||||
availableModels: [],
|
availableModels: [],
|
||||||
availableProviders: [],
|
availableProviders: [],
|
||||||
|
availableClientFamilies: [],
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
totalRecords: records.length,
|
totalRecords: records.length,
|
||||||
|
|||||||
@@ -90,9 +90,11 @@
|
|||||||
:filter-provider="filterProvider"
|
:filter-provider="filterProvider"
|
||||||
:filter-api-format="filterApiFormat"
|
:filter-api-format="filterApiFormat"
|
||||||
:filter-status="filterStatus"
|
:filter-status="filterStatus"
|
||||||
|
:filter-client-family="filterClientFamily"
|
||||||
:available-users="availableUsers"
|
:available-users="availableUsers"
|
||||||
:available-models="availableModels"
|
:available-models="availableModels"
|
||||||
:available-providers="availableProviders"
|
:available-providers="availableProviders"
|
||||||
|
:available-client-families="availableClientFamilies"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
:total-records="effectiveTotalRecords"
|
:total-records="effectiveTotalRecords"
|
||||||
@@ -105,6 +107,7 @@
|
|||||||
@update:filter-provider="handleFilterProviderChange"
|
@update:filter-provider="handleFilterProviderChange"
|
||||||
@update:filter-api-format="handleFilterApiFormatChange"
|
@update:filter-api-format="handleFilterApiFormatChange"
|
||||||
@update:filter-status="handleFilterStatusChange"
|
@update:filter-status="handleFilterStatusChange"
|
||||||
|
@update:filter-client-family="handleFilterClientFamilyChange"
|
||||||
@update:current-page="handlePageChange"
|
@update:current-page="handlePageChange"
|
||||||
@update:page-size="handlePageSizeChange"
|
@update:page-size="handlePageSizeChange"
|
||||||
@update:auto-refresh="handleAutoRefreshChange"
|
@update:auto-refresh="handleAutoRefreshChange"
|
||||||
@@ -226,6 +229,7 @@ const filterModel = ref('__all__')
|
|||||||
const filterProvider = ref('__all__')
|
const filterProvider = ref('__all__')
|
||||||
const filterApiFormat = ref('__all__')
|
const filterApiFormat = ref('__all__')
|
||||||
const filterStatus = ref<FilterStatusValue>('__all__')
|
const filterStatus = ref<FilterStatusValue>('__all__')
|
||||||
|
const filterClientFamily = ref('__all__')
|
||||||
|
|
||||||
// 用户列表(仅管理员页面使用)
|
// 用户列表(仅管理员页面使用)
|
||||||
const availableUsers = ref<UserOption[]>([])
|
const availableUsers = ref<UserOption[]>([])
|
||||||
@@ -372,9 +376,15 @@ const filteredRecords = computed(() => {
|
|||||||
records = records.filter(record => record.status === 'cancelled')
|
records = records.filter(record => record.status === 'cancelled')
|
||||||
} else if (filterStatus.value === 'has_fallback') {
|
} else if (filterStatus.value === 'has_fallback') {
|
||||||
records = records.filter(record => hasUsageFallback(record))
|
records = records.filter(record => hasUsageFallback(record))
|
||||||
|
} else if (filterStatus.value === 'has_retry') {
|
||||||
|
records = records.filter(record => record.has_retry === true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filterClientFamily.value !== '__all__') {
|
||||||
|
records = records.filter(record => record.client_family === filterClientFamily.value)
|
||||||
|
}
|
||||||
|
|
||||||
return records
|
return records
|
||||||
}
|
}
|
||||||
return currentRecords.value
|
return currentRecords.value
|
||||||
@@ -704,6 +714,15 @@ const effectiveTotalRecords = computed(() => {
|
|||||||
// 显示的记录
|
// 显示的记录
|
||||||
const displayRecords = computed(() => paginatedRecords.value)
|
const displayRecords = computed(() => paginatedRecords.value)
|
||||||
|
|
||||||
|
const availableClientFamilies = computed(() => {
|
||||||
|
const families = new Set<string>()
|
||||||
|
currentRecords.value.forEach((record) => {
|
||||||
|
const family = record.client_family?.trim()
|
||||||
|
if (family) families.add(family)
|
||||||
|
})
|
||||||
|
return Array.from(families).sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
// 详情弹窗状态
|
// 详情弹窗状态
|
||||||
const detailModalOpen = ref(false)
|
const detailModalOpen = ref(false)
|
||||||
@@ -787,7 +806,8 @@ function getCurrentFilters() {
|
|||||||
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
model: filterModel.value !== '__all__' ? filterModel.value : undefined,
|
||||||
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
provider: filterProvider.value !== '__all__' ? filterProvider.value : undefined,
|
||||||
api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined,
|
api_format: filterApiFormat.value !== '__all__' ? filterApiFormat.value : undefined,
|
||||||
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined
|
status: filterStatus.value !== '__all__' ? filterStatus.value : undefined,
|
||||||
|
client_family: filterClientFamily.value !== '__all__' ? filterClientFamily.value : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -848,6 +868,15 @@ async function handleFilterStatusChange(value: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleFilterClientFamilyChange(value: string) {
|
||||||
|
filterClientFamily.value = value
|
||||||
|
currentPage.value = 1
|
||||||
|
|
||||||
|
if (isAdminPage.value) {
|
||||||
|
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 刷新数据
|
// 刷新数据
|
||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
if (!isPageVisible.value) return
|
if (!isPageVisible.value) return
|
||||||
|
|||||||
Reference in New Issue
Block a user