feat: improve routing policy diagnostics

This commit is contained in:
fawney19
2026-05-28 12:11:43 +08:00
parent 14ad6e9b75
commit 93d3de1644
23 changed files with 2015 additions and 1087 deletions
@@ -329,6 +329,23 @@ import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
import TurnstileWidget from './TurnstileWidget.vue'
const props = withDefaults(defineProps<Props>(), {
open: false,
requireEmailVerification: false,
emailConfigured: true,
passwordPolicyLevel: 'weak',
turnstileEnabled: false,
turnstileSiteKey: null,
privacyPolicy: () => ({
enabled: false,
format: 'markdown',
content: '',
version: ''
})
})
const emit = defineEmits<Emits>()
const INVITE_CODE_STORAGE_KEY = 'aether_invite_code'
interface Props {
@@ -347,22 +364,6 @@ interface Emits {
(e: 'switchToLogin'): void
}
const props = withDefaults(defineProps<Props>(), {
open: false,
requireEmailVerification: false,
emailConfigured: true,
passwordPolicyLevel: 'weak',
turnstileEnabled: false,
turnstileSiteKey: null,
privacyPolicy: () => ({
enabled: false,
format: 'markdown',
content: '',
version: ''
})
})
const emit = defineEmits<Emits>()
const { success, error: showError } = useToast()
// Form nonce for password fields (prevent autofill)
@@ -137,7 +137,6 @@
</p>
</div>
</div>
</div>
<!-- 默认定价 -->
@@ -301,19 +301,17 @@ type ImageOutputPriceRangeRow = {
prices: Partial<Record<ImageOutputQuality, number>>
}
const DEFAULT_IMAGE_OUTPUT_SIZES = ['1024x1024', '1536x1024', '1024x1536']
const DEFAULT_IMAGE_OUTPUT_PIXEL_LIMITS = [1_048_576, 1_572_864, 2_097_152]
const IMAGE_OUTPUT_QUALITIES: ImageOutputQuality[] = ['low', 'medium', 'high']
const props = defineProps<{
modelValue?: TieredPricingConfig | null
showCache1h?: boolean
showImagePricing?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: TieredPricingConfig | null]
}>()
const DEFAULT_IMAGE_OUTPUT_SIZES = ['1024x1024', '1536x1024', '1024x1536']
const DEFAULT_IMAGE_OUTPUT_PIXEL_LIMITS = [1_048_576, 1_572_864, 2_097_152]
const IMAGE_OUTPUT_QUALITIES: ImageOutputQuality[] = ['low', 'medium', 'high']
// 本地状态
const localTiers = ref<PricingTier[]>([])
@@ -122,20 +122,36 @@
>
<div class="grid gap-2 text-xs sm:grid-cols-4">
<div class="rounded-lg border bg-background px-3 py-2">
<div class="text-muted-foreground">号池账号</div>
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.totalKeys }}</div>
<div class="text-muted-foreground">
号池账号
</div>
<div class="mt-1 text-base font-semibold tabular-nums">
{{ plan.totalKeys }}
</div>
</div>
<div class="rounded-lg border bg-background px-3 py-2">
<div class="text-muted-foreground">代理节点</div>
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.nodeCount }}</div>
<div class="text-muted-foreground">
代理节点
</div>
<div class="mt-1 text-base font-semibold tabular-nums">
{{ plan.nodeCount }}
</div>
</div>
<div class="rounded-lg border bg-background px-3 py-2">
<div class="text-muted-foreground">单节点上限</div>
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.maxPerNode }}</div>
<div class="text-muted-foreground">
单节点上限
</div>
<div class="mt-1 text-base font-semibold tabular-nums">
{{ plan.maxPerNode }}
</div>
</div>
<div class="rounded-lg border bg-background px-3 py-2">
<div class="text-muted-foreground">待写入</div>
<div class="mt-1 text-base font-semibold tabular-nums">{{ plan.changedCount }}</div>
<div class="text-muted-foreground">
待写入
</div>
<div class="mt-1 text-base font-semibold tabular-nums">
{{ plan.changedCount }}
</div>
</div>
</div>
@@ -163,8 +179,12 @@
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<Badge variant="outline">目标 {{ item.targetCount }}</Badge>
<Badge variant="secondary">保留 {{ item.retainedCount }}</Badge>
<Badge variant="outline">
目标 {{ item.targetCount }}
</Badge>
<Badge variant="secondary">
保留 {{ item.retainedCount }}
</Badge>
<Badge :variant="item.changedCount > 0 ? 'default' : 'outline'">
写入 {{ item.changedCount }}
</Badge>
@@ -14,9 +14,6 @@
{{ group.description || '未填写描述' }}
</p>
</div>
<span class="shrink-0 rounded-md border px-2 py-1 text-xs text-muted-foreground">
v{{ group.version }}
</span>
</div>
</div>
</div>
@@ -28,7 +25,6 @@ export interface RoutingGroupListItem {
name: string
description?: string | null
enabled: boolean
version: number
}
defineProps<{
@@ -69,37 +69,35 @@
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<select
<button
v-if="effectivePriorityMode === 'provider'"
type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-xs font-medium transition-colors"
:class="providerMultiSelectEnabled
? 'bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'"
@click="toggleProviderMultiSelect"
>
<ListChecks class="h-3.5 w-3.5" />
{{ providerMultiSelectEnabled ? '退出多选' : '多选' }}
</button>
<Select
v-if="effectivePriorityMode === 'global_key'"
v-model="selectedApiFormat"
class="h-9 min-w-[180px] rounded-md border border-border bg-background px-3 text-sm"
>
<option
v-for="format in apiFormats"
:key="format"
:value="format"
>
{{ formatLabel(format) }}
</option>
</select>
<button
type="button"
class="inline-flex h-9 items-center gap-2 rounded-md border border-border px-3 text-xs"
@click="refresh"
>
<RefreshCw
class="h-3.5 w-3.5"
:class="{ 'animate-spin': loading }"
/>
刷新
</button>
<button
type="button"
class="h-9 rounded-md border border-border px-3 text-xs text-muted-foreground"
@click="clearActiveOverrides"
>
清空排序
</button>
<SelectTrigger class="h-8 w-[180px] rounded-lg border-border/60 bg-background/80 px-3 text-xs">
<SelectValue placeholder="选择端点" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="format in apiFormats"
:key="format"
:value="format"
>
{{ formatLabel(format) }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
@@ -131,12 +129,8 @@
v-for="(row, index) in providerRows"
v-else
:key="row.id"
class="group grid items-center gap-3 rounded-lg border px-3 py-2 transition-colors sm:grid-cols-[auto_auto_76px_minmax(0,1fr)_auto]"
:class="draggedProviderId === row.id
? 'border-primary/50 bg-primary/5 shadow-sm'
: dragOverProviderId === row.id
? 'border-primary/30 bg-primary/5'
: 'border-border/50 bg-background hover:bg-muted/30'"
class="group grid min-h-[56px] items-center gap-3 rounded-lg border px-3 py-2 transition-colors"
:class="[providerGridClass, providerRowClass(row.id)]"
draggable="true"
@dragstart="handleProviderDragStart(row.id, $event)"
@dragend="handleProviderDragEnd"
@@ -144,6 +138,15 @@
@dragleave="handleProviderDragLeave"
@drop="handleProviderDrop(row.id)"
>
<Checkbox
v-if="providerMultiSelectEnabled"
class="shrink-0"
:checked="isProviderSelected(row.id)"
:aria-label="`选择 ${row.name}`"
@click.stop
@change.stop
@update:checked="checked => setProviderSelected(row.id, checked)"
/>
<div class="cursor-grab rounded p-1 text-muted-foreground/40 transition-colors group-hover:text-muted-foreground active:cursor-grabbing">
<GripVertical class="h-4 w-4" />
</div>
@@ -151,7 +154,7 @@
<button
type="button"
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
:disabled="index === 0"
:disabled="providerMoveDisabled(row.id, index, -1)"
@click="moveProvider(row.id, -1)"
>
<ArrowUp class="h-4 w-4" />
@@ -159,7 +162,7 @@
<button
type="button"
class="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
:disabled="index === providerRows.length - 1"
:disabled="providerMoveDisabled(row.id, index, 1)"
@click="moveProvider(row.id, 1)"
>
<ArrowDown class="h-4 w-4" />
@@ -169,7 +172,7 @@
:value="row.priority"
type="number"
min="0"
class="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
class="priority-input h-8 w-14 rounded-md border border-border bg-background px-2 text-center text-sm"
@change="event => setProviderPriority(row.id, event)"
>
<div class="min-w-0">
@@ -188,17 +191,15 @@
停用
</span>
</div>
<div class="mt-0.5 truncate text-xs text-muted-foreground">
{{ row.id }}
</div>
</div>
<div class="hidden max-w-[240px] flex-wrap justify-end gap-1 sm:flex">
<span
v-for="format in row.api_formats.slice(0, 3)"
:key="format"
:title="formatLabel(format)"
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{{ format }}
{{ formatShortLabel(format) }}
</span>
</div>
</div>
@@ -218,7 +219,7 @@
v-for="(row, index) in keyRows"
v-else
:key="row.id"
class="group grid items-center gap-3 rounded-lg border px-3 py-2 transition-colors sm:grid-cols-[auto_auto_76px_minmax(0,1fr)_auto]"
class="group grid min-h-[56px] items-center gap-3 rounded-lg border px-3 py-2 transition-colors sm:grid-cols-[auto_auto_56px_minmax(0,1fr)_auto]"
:class="draggedKeyId === row.id
? 'border-primary/50 bg-primary/5 shadow-sm'
: dragOverKeyId === row.id
@@ -256,7 +257,7 @@
:value="row.priority"
type="number"
min="0"
class="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
class="priority-input h-8 w-14 rounded-md border border-border bg-background px-2 text-center text-sm"
@change="event => setKeyPriority(row.id, event)"
>
<div class="min-w-0">
@@ -277,9 +278,10 @@
<span
v-for="format in row.api_formats.slice(0, 3)"
:key="format"
:title="formatLabel(format)"
class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{{ format }}
{{ formatShortLabel(format) }}
</span>
</div>
</div>
@@ -291,14 +293,22 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ArrowDown, ArrowUp, GripVertical, Key, Layers, RefreshCw } from 'lucide-vue-next'
import { ArrowDown, ArrowUp, GripVertical, Key, Layers, ListChecks } from 'lucide-vue-next'
import client from '@/api/client'
import {
Checkbox,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import {
getProvidersSummary,
type ProviderWithEndpointsSummary,
} from '@/api/endpoints'
import { formatApiFormat, normalizeApiFormatAlias, sortApiFormats } from '@/api/endpoints/types/api-format'
import { formatApiFormat, formatApiFormatShort, normalizeApiFormatAlias, sortApiFormats } from '@/api/endpoints/types/api-format'
import { parseApiError } from '@/utils/errorParser'
import {
DEFAULT_ROUTING_POLICY_MODEL,
@@ -385,6 +395,8 @@ const draggedProviderId = ref<string | null>(null)
const dragOverProviderId = ref<string | null>(null)
const draggedKeyId = ref<string | null>(null)
const dragOverKeyId = ref<string | null>(null)
const providerMultiSelectEnabled = ref(false)
const selectedProviderIds = ref<Set<string>>(new Set())
const config = computed(() => normalizeRoutingGroupConfig(props.config))
const targetModel = computed(() => props.model?.trim() || DEFAULT_ROUTING_POLICY_MODEL)
@@ -398,6 +410,9 @@ const effectiveSchedulingMode = computed(() => props.schedulingMode ?? config.va
const subtitle = computed(() => props.subtitle ?? '默认作用于全部模型')
const loading = computed(() => loadingProviders.value || loadingKeys.value)
const apiFormats = computed(() => sortApiFormats(Object.keys(keysByFormat.value)))
const providerGridClass = computed(() => providerMultiSelectEnabled.value
? 'sm:grid-cols-[auto_auto_auto_56px_minmax(0,1fr)_auto]'
: 'sm:grid-cols-[auto_auto_56px_minmax(0,1fr)_auto]')
const providerById = computed(() => {
const map = new Map<string, ProviderWithEndpointsSummary>()
for (const provider of providers.value) {
@@ -477,6 +492,16 @@ const keyRows = computed<KeyPriorityRow[]>(() => {
watch(effectivePriorityMode, mode => {
if (mode === 'global_key') {
void loadGlobalKeys()
providerMultiSelectEnabled.value = false
selectedProviderIds.value = new Set()
}
})
watch(providerRows, rows => {
const visibleIds = new Set(rows.map(row => row.id))
const next = new Set([...selectedProviderIds.value].filter(id => visibleIds.has(id)))
if (next.size !== selectedProviderIds.value.size) {
selectedProviderIds.value = next
}
})
@@ -525,15 +550,6 @@ function updateSchedulingMode(mode: RoutingSchedulingMode): void {
updateDefaultPolicy({ scheduling_mode: mode })
}
async function refresh(): Promise<void> {
if (effectivePriorityMode.value === 'provider') {
await loadProviders()
} else {
await loadProviders()
await loadGlobalKeys(true)
}
}
async function loadProviders(): Promise<void> {
loadingProviders.value = true
loadError.value = null
@@ -583,7 +599,10 @@ function setProviderPriority(providerId: string, event: Event): void {
}
function moveProvider(providerId: string, direction: -1 | 1): void {
const rows = moveRow(providerRows.value, providerId, direction)
const movingIds = providerMoveIds(providerId)
const rows = movingIds.length > 1
? moveRowsByGroup(providerRows.value, movingIds, direction)
: moveRow(providerRows.value, providerId, direction)
updateProviderOverrides(Object.fromEntries(rows.map((row, index) => [row.id, index])))
}
@@ -591,6 +610,70 @@ function updateProviderOverrides(overrides: Record<string, number>): void {
updateConfig(setModelProviderPriorityOverrides(config.value, targetModel.value, overrides))
}
function isProviderSelected(providerId: string): boolean {
return providerMultiSelectEnabled.value && selectedProviderIds.value.has(providerId)
}
function setProviderSelected(providerId: string, selected: boolean): void {
if (!providerMultiSelectEnabled.value) return
const next = new Set(selectedProviderIds.value)
if (selected) {
next.add(providerId)
} else {
next.delete(providerId)
}
selectedProviderIds.value = next
}
function toggleProviderMultiSelect(): void {
providerMultiSelectEnabled.value = !providerMultiSelectEnabled.value
if (!providerMultiSelectEnabled.value) {
selectedProviderIds.value = new Set()
}
}
function providerMoveIds(providerId: string): string[] {
if (!providerMultiSelectEnabled.value || !selectedProviderIds.value.has(providerId)) {
return [providerId]
}
return providerRows.value
.map(row => row.id)
.filter(id => selectedProviderIds.value.has(id))
}
function providerMoveDisabled(providerId: string, index: number, direction: -1 | 1): boolean {
const movingIds = providerMoveIds(providerId)
if (movingIds.length <= 1) {
return direction === -1 ? index === 0 : index === providerRows.value.length - 1
}
const movingSet = new Set(movingIds)
const movingIndexes = providerRows.value
.map((row, rowIndex) => movingSet.has(row.id) ? rowIndex : -1)
.filter(rowIndex => rowIndex >= 0)
if (movingIndexes.length === 0) return true
return direction === -1
? Math.min(...movingIndexes) === 0
: Math.max(...movingIndexes) === providerRows.value.length - 1
}
function providerRowClass(providerId: string): string {
if (isProviderDragged(providerId)) {
return 'border-primary/50 bg-primary/5 shadow-sm'
}
if (dragOverProviderId.value === providerId) {
return 'border-primary/30 bg-primary/5'
}
if (isProviderSelected(providerId)) {
return 'border-primary/40 bg-primary/5'
}
return 'border-border/50 bg-background hover:bg-muted/30'
}
function isProviderDragged(providerId: string): boolean {
const draggedId = draggedProviderId.value
return Boolean(draggedId && providerMoveIds(draggedId).includes(providerId))
}
function setKeyPriority(keyId: string, event: Event): void {
const priority = readPriorityInput(event)
if (priority == null) return
@@ -684,7 +767,14 @@ function handleProviderDrop(providerId: string): void {
handleProviderDragEnd()
return
}
const rows = reorderRows(providerRows.value, draggedId, providerId)
const movingIds = providerMoveIds(draggedId)
if (movingIds.includes(providerId)) {
handleProviderDragEnd()
return
}
const rows = movingIds.length > 1
? reorderRowsByGroup(providerRows.value, movingIds, providerId)
: reorderRows(providerRows.value, draggedId, providerId)
updateProviderOverrides(Object.fromEntries(rows.map((row, index) => [row.id, index])))
handleProviderDragEnd()
}
@@ -721,14 +811,6 @@ function handleKeyDrop(keyId: string): void {
handleKeyDragEnd()
}
function clearActiveOverrides(): void {
if (effectivePriorityMode.value === 'provider') {
updateProviderOverrides({})
} else {
updateVisibleKeyAndPoolOverrides([])
}
}
function moveRow<T extends { id: string }>(rows: T[], id: string, direction: -1 | 1): T[] {
const next = [...rows]
const index = next.findIndex(row => row.id === id)
@@ -741,6 +823,26 @@ function moveRow<T extends { id: string }>(rows: T[], id: string, direction: -1
return next
}
function moveRowsByGroup<T extends { id: string }>(rows: T[], movingIds: string[], direction: -1 | 1): T[] {
const movingSet = new Set(movingIds)
const movingRows = rows.filter(row => movingSet.has(row.id))
if (movingRows.length === 0) return [...rows]
const firstMovingIndex = rows.findIndex(row => movingSet.has(row.id))
const remainingRows = rows.filter(row => !movingSet.has(row.id))
const baseInsertIndex = rows
.slice(0, firstMovingIndex)
.filter(row => !movingSet.has(row.id))
.length
const insertIndex = direction === -1
? Math.max(0, baseInsertIndex - 1)
: Math.min(remainingRows.length, baseInsertIndex + 1)
const next = [...remainingRows]
next.splice(insertIndex, 0, ...movingRows)
return next
}
function reorderRows<T extends { id: string }>(rows: T[], draggedId: string, targetId: string): T[] {
const next = [...rows]
const fromIndex = next.findIndex(row => row.id === draggedId)
@@ -751,6 +853,29 @@ function reorderRows<T extends { id: string }>(rows: T[], draggedId: string, tar
return next
}
function reorderRowsByGroup<T extends { id: string }>(rows: T[], movingIds: string[], targetId: string): T[] {
const movingSet = new Set(movingIds)
if (movingSet.has(targetId)) return [...rows]
const movingRows = rows.filter(row => movingSet.has(row.id))
if (movingRows.length === 0) return [...rows]
const targetIndex = rows.findIndex(row => row.id === targetId)
const firstMovingIndex = rows.findIndex(row => movingSet.has(row.id))
if (targetIndex < 0 || firstMovingIndex < 0) return [...rows]
const remainingRows = rows.filter(row => !movingSet.has(row.id))
const remainingTargetIndex = remainingRows.findIndex(row => row.id === targetId)
if (remainingTargetIndex < 0) return [...rows]
const insertIndex = firstMovingIndex < targetIndex
? remainingTargetIndex + 1
: remainingTargetIndex
const next = [...remainingRows]
next.splice(insertIndex, 0, ...movingRows)
return next
}
function readPriorityInput(event: Event): number | null {
const value = Number((event.target as HTMLInputElement).value)
if (!Number.isFinite(value) || value < 0) {
@@ -858,6 +983,10 @@ function formatLabel(format: string): string {
return formatApiFormat(format)
}
function formatShortLabel(format: string): string {
return formatApiFormatShort(format)
}
function normalizePriorityMap(value: Record<string, unknown> | null | undefined): Record<string, number> {
if (!value) return {}
const normalized: Record<string, number> = {}
@@ -882,3 +1011,15 @@ function comparePriorityRows(left: ProviderPriorityRow | KeyPriorityRow, right:
|| left.id.localeCompare(right.id)
}
</script>
<style scoped>
.priority-input::-webkit-outer-spin-button,
.priority-input::-webkit-inner-spin-button {
margin: 0;
appearance: none;
}
.priority-input[type='number'] {
appearance: textfield;
}
</style>
@@ -776,14 +776,6 @@ import {
type RequestStateStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
const REQUEST_STATE_STATUSES = new Set<RequestStateStatus>([
'pending',
'streaming',
'completed',
'failed',
'cancelled',
])
const props = defineProps<{
isOpen: boolean
requestId: string | null
@@ -802,6 +794,14 @@ const emit = defineEmits<{
}]
}>()
const REQUEST_STATE_STATUSES = new Set<RequestStateStatus>([
'pending',
'streaming',
'completed',
'failed',
'cancelled',
])
const loading = ref(false)
const error = ref<string | null>(null)
const detail = ref<RequestDetail | null>(null)
@@ -354,35 +354,107 @@
:class="[desktopTableMinWidthClass]"
>
<colgroup v-if="isAdmin">
<col v-if="isColumnVisible('time')" class="w-[8%]">
<col v-if="isColumnVisible('user')" class="w-[12%]">
<col v-if="isColumnVisible('model')" class="w-[14%]">
<col v-if="isColumnVisible('provider')" class="w-[16%]">
<col v-if="isColumnVisible('api_format')" class="w-[15%]">
<col v-if="isColumnVisible('status')" class="w-[10%]">
<col v-if="isColumnVisible('tokens')" class="w-[10%]">
<col v-if="isColumnVisible('cost')" class="w-[6%]">
<col v-if="isColumnVisible('performance')" class="w-[9%]">
<col v-if="isColumnVisible('client_family')" class="w-[12%]">
<col v-if="isColumnVisible('client_ip')" class="w-[10%]">
<col v-if="isColumnVisible('user_agent')" class="w-[13%]">
<col
v-if="isColumnVisible('time')"
class="w-[8%]"
>
<col
v-if="isColumnVisible('user')"
class="w-[12%]"
>
<col
v-if="isColumnVisible('model')"
class="w-[14%]"
>
<col
v-if="isColumnVisible('provider')"
class="w-[16%]"
>
<col
v-if="isColumnVisible('api_format')"
class="w-[15%]"
>
<col
v-if="isColumnVisible('status')"
class="w-[10%]"
>
<col
v-if="isColumnVisible('tokens')"
class="w-[10%]"
>
<col
v-if="isColumnVisible('cost')"
class="w-[6%]"
>
<col
v-if="isColumnVisible('performance')"
class="w-[9%]"
>
<col
v-if="isColumnVisible('client_family')"
class="w-[12%]"
>
<col
v-if="isColumnVisible('client_ip')"
class="w-[10%]"
>
<col
v-if="isColumnVisible('user_agent')"
class="w-[13%]"
>
</colgroup>
<colgroup v-else>
<col v-if="isColumnVisible('time')" class="w-[9%]">
<col v-if="isColumnVisible('key')" class="w-[17%]">
<col v-if="isColumnVisible('model')" class="w-[22%]">
<col v-if="isColumnVisible('api_format')" class="w-[14%]">
<col v-if="isColumnVisible('status')" class="w-[10%]">
<col v-if="isColumnVisible('tokens')" class="w-[11%]">
<col v-if="isColumnVisible('cost')" class="w-[7%]">
<col v-if="isColumnVisible('performance')" class="w-[10%]">
<col v-if="isColumnVisible('client_family')" class="w-[12%]">
<col v-if="isColumnVisible('client_ip')" class="w-[10%]">
<col v-if="isColumnVisible('user_agent')" class="w-[13%]">
<col
v-if="isColumnVisible('time')"
class="w-[9%]"
>
<col
v-if="isColumnVisible('key')"
class="w-[17%]"
>
<col
v-if="isColumnVisible('model')"
class="w-[22%]"
>
<col
v-if="isColumnVisible('api_format')"
class="w-[14%]"
>
<col
v-if="isColumnVisible('status')"
class="w-[10%]"
>
<col
v-if="isColumnVisible('tokens')"
class="w-[11%]"
>
<col
v-if="isColumnVisible('cost')"
class="w-[7%]"
>
<col
v-if="isColumnVisible('performance')"
class="w-[10%]"
>
<col
v-if="isColumnVisible('client_family')"
class="w-[12%]"
>
<col
v-if="isColumnVisible('client_ip')"
class="w-[10%]"
>
<col
v-if="isColumnVisible('user_agent')"
class="w-[13%]"
>
</colgroup>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead v-if="isColumnVisible('time')" class="h-12 font-semibold w-[8%]">
<TableHead
v-if="isColumnVisible('time')"
class="h-12 font-semibold w-[8%]"
>
时间
</TableHead>
<SortableTableHead
@@ -489,13 +561,22 @@
/>
</template>
</SortableTableHead>
<TableHead v-if="isColumnVisible('tokens')" class="h-12 font-semibold w-[10%] text-center">
<TableHead
v-if="isColumnVisible('tokens')"
class="h-12 font-semibold w-[10%] text-center"
>
Tokens
</TableHead>
<TableHead v-if="isColumnVisible('cost')" 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 v-if="isColumnVisible('performance')" 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">
<span class="whitespace-nowrap">首字/总耗时</span>
<span class="text-muted-foreground font-normal">输出速度</span>
@@ -520,10 +601,16 @@
/>
</template>
</SortableTableHead>
<TableHead v-if="isColumnVisible('client_ip')" class="h-12 font-semibold w-[10%]">
<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%]">
<TableHead
v-if="isColumnVisible('user_agent')"
class="h-12 font-semibold w-[13%]"
>
User-Agent
</TableHead>
</TableRow>
@@ -545,7 +632,10 @@
@mousedown="handleRowMouseDown($event, record.id)"
@click="handleRowClick($event, record.id)"
>
<TableCell v-if="isColumnVisible('time')" 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">
<span class="text-xs text-foreground tabular-nums whitespace-nowrap">
{{ formatRecordTime(record.created_at) }}
@@ -728,7 +818,10 @@
class="text-muted-foreground text-xs"
>-</span>
</TableCell>
<TableCell v-if="isColumnVisible('status')" class="text-center py-4 w-[10%]">
<TableCell
v-if="isColumnVisible('status')"
class="text-center py-4 w-[10%]"
>
<!-- 优先显示请求状态 -->
<Badge
v-if="isUsageRecordFailed(record)"
@@ -779,7 +872,10 @@
{{ getStreamModeLabel(record) }}
</Badge>
</TableCell>
<TableCell v-if="isColumnVisible('tokens')" 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">
<span class="justify-self-end whitespace-nowrap text-right">
{{ formatTokens(getRecordEffectiveInputTokens(record)) }}
@@ -813,7 +909,10 @@
</span>
</div>
</TableCell>
<TableCell v-if="isColumnVisible('cost')" 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">
<span class="text-primary font-medium">{{ formatCurrency(record.cost || 0) }}</span>
<span
@@ -824,7 +923,10 @@
</span>
</div>
</TableCell>
<TableCell v-if="isColumnVisible('performance')" class="text-right py-4 w-[9%]">
<TableCell
v-if="isColumnVisible('performance')"
class="text-right py-4 w-[9%]"
>
<!-- pending/streaming 状态:首字与动态总耗时保留在同一行 -->
<div
v-if="getDisplayStatus(record) === 'pending' || getDisplayStatus(record) === 'streaming'"
@@ -986,45 +1088,6 @@ interface UsageRecordColumnOption {
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' },
]
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<{
records: UsageRecord[]
isAdmin: boolean
@@ -1070,6 +1133,45 @@ const emit = defineEmits<{
'prefetchDetail': [id: string]
}>()
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' },
]
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',
]
// 使用统一 API 格式枚举,避免使用记录筛选项和系统格式列表漂移。
const availableApiFormats = API_FORMAT_ORDER.map((value) => ({
value,