mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
feat: improve routing policy diagnostics
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user