mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-03 00:02:28 +08:00
Compare commits
5 Commits
41719a00e7
...
96094cfde2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96094cfde2 | ||
|
|
7e26af5476 | ||
|
|
c8dfb784bc | ||
|
|
fd3a5a5afe | ||
|
|
599b3d4c95 |
@@ -87,6 +87,8 @@ export interface DashboardStatsResponse {
|
|||||||
cache_stats?: CacheStats
|
cache_stats?: CacheStats
|
||||||
users?: UserStats
|
users?: UserStats
|
||||||
token_breakdown?: TokenBreakdown
|
token_breakdown?: TokenBreakdown
|
||||||
|
// 普通用户专用字段
|
||||||
|
monthly_cost?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecentRequestsResponse {
|
export interface RecentRequestsResponse {
|
||||||
|
|||||||
@@ -110,6 +110,14 @@ export async function updateEndpointKey(
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取完整的 API Key(用于查看和复制)
|
||||||
|
*/
|
||||||
|
export async function revealEndpointKey(keyId: string): Promise<{ api_key: string }> {
|
||||||
|
const response = await client.get(`/api/admin/endpoints/keys/${keyId}/reveal`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除 Endpoint Key
|
* 删除 Endpoint Key
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -116,6 +116,19 @@
|
|||||||
{{ model.global_model_name }}
|
{{ model.global_model_name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 测试按钮 -->
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7 shrink-0"
|
||||||
|
title="测试模型连接"
|
||||||
|
:disabled="testingModelName === model.global_model_name"
|
||||||
|
@click.stop="testModelConnection(model)"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="testingModelName === model.global_model_name" class="w-3.5 h-3.5 animate-spin" />
|
||||||
|
<Play v-else class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,16 +161,17 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { Box, Loader2, Settings2 } from 'lucide-vue-next'
|
import { Box, Loader2, Settings2, Play } from 'lucide-vue-next'
|
||||||
import { Dialog } from '@/components/ui'
|
import { Dialog } from '@/components/ui'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import Checkbox from '@/components/ui/checkbox.vue'
|
import Checkbox from '@/components/ui/checkbox.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
|
||||||
import {
|
import {
|
||||||
updateEndpointKey,
|
updateEndpointKey,
|
||||||
getProviderAvailableSourceModels,
|
getProviderAvailableSourceModels,
|
||||||
|
testModel,
|
||||||
type EndpointAPIKey,
|
type EndpointAPIKey,
|
||||||
type ProviderAvailableSourceModel
|
type ProviderAvailableSourceModel
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
@@ -181,6 +195,7 @@ const loadingModels = ref(false)
|
|||||||
const availableModels = ref<ProviderAvailableSourceModel[]>([])
|
const availableModels = ref<ProviderAvailableSourceModel[]>([])
|
||||||
const selectedModels = ref<string[]>([])
|
const selectedModels = ref<string[]>([])
|
||||||
const initialModels = ref<string[]>([])
|
const initialModels = ref<string[]>([])
|
||||||
|
const testingModelName = ref<string | null>(null)
|
||||||
|
|
||||||
// 监听对话框打开
|
// 监听对话框打开
|
||||||
watch(() => props.open, (open) => {
|
watch(() => props.open, (open) => {
|
||||||
@@ -268,6 +283,32 @@ function clearModels() {
|
|||||||
selectedModels.value = []
|
selectedModels.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 测试模型连接
|
||||||
|
async function testModelConnection(model: ProviderAvailableSourceModel) {
|
||||||
|
if (!props.providerId || !props.apiKey || testingModelName.value) return
|
||||||
|
|
||||||
|
testingModelName.value = model.global_model_name
|
||||||
|
try {
|
||||||
|
const result = await testModel({
|
||||||
|
provider_id: props.providerId,
|
||||||
|
model_name: model.provider_model_name,
|
||||||
|
api_key_id: props.apiKey.id,
|
||||||
|
message: "hello"
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
success(`模型 "${model.display_name}" 测试成功`)
|
||||||
|
} else {
|
||||||
|
showError(`模型测试失败: ${parseTestModelError(result)}`)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
|
||||||
|
showError(`模型测试失败: ${errorMsg}`)
|
||||||
|
} finally {
|
||||||
|
testingModelName.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function areArraysEqual(a: string[], b: string[]): boolean {
|
function areArraysEqual(a: string[], b: string[]): boolean {
|
||||||
if (a.length !== b.length) return false
|
if (a.length !== b.length) return false
|
||||||
const sortedA = [...a].sort()
|
const sortedA = [...a].sort()
|
||||||
|
|||||||
@@ -337,8 +337,40 @@
|
|||||||
{{ key.is_active ? '活跃' : '禁用' }}
|
{{ key.is_active ? '活跃' : '禁用' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[10px] font-mono text-muted-foreground truncate">
|
<div class="flex items-center gap-1">
|
||||||
{{ key.api_key_masked }}
|
<span class="text-[10px] font-mono text-muted-foreground truncate max-w-[180px]">
|
||||||
|
{{ revealedKeys.has(key.id) ? revealedKeys.get(key.id) : key.api_key_masked }}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-5 w-5 shrink-0"
|
||||||
|
:title="revealedKeys.has(key.id) ? '隐藏密钥' : '显示密钥'"
|
||||||
|
:disabled="revealingKeyId === key.id"
|
||||||
|
@click.stop="toggleKeyReveal(key)"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
v-if="revealingKeyId === key.id"
|
||||||
|
class="w-3 h-3 animate-spin"
|
||||||
|
/>
|
||||||
|
<EyeOff
|
||||||
|
v-else-if="revealedKeys.has(key.id)"
|
||||||
|
class="w-3 h-3"
|
||||||
|
/>
|
||||||
|
<Eye
|
||||||
|
v-else
|
||||||
|
class="w-3 h-3"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-5 w-5 shrink-0"
|
||||||
|
title="复制密钥"
|
||||||
|
@click.stop="copyFullKey(key)"
|
||||||
|
>
|
||||||
|
<Copy class="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 ml-auto shrink-0">
|
<div class="flex items-center gap-1.5 ml-auto shrink-0">
|
||||||
@@ -654,7 +686,9 @@ import {
|
|||||||
Power,
|
Power,
|
||||||
Layers,
|
Layers,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Copy
|
Copy,
|
||||||
|
Eye,
|
||||||
|
EyeOff
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
@@ -681,6 +715,7 @@ import {
|
|||||||
updateEndpoint,
|
updateEndpoint,
|
||||||
updateEndpointKey,
|
updateEndpointKey,
|
||||||
batchUpdateKeyPriority,
|
batchUpdateKeyPriority,
|
||||||
|
revealEndpointKey,
|
||||||
type ProviderEndpoint,
|
type ProviderEndpoint,
|
||||||
type EndpointAPIKey,
|
type EndpointAPIKey,
|
||||||
type Model
|
type Model
|
||||||
@@ -731,6 +766,10 @@ const recoveringEndpointId = ref<string | null>(null)
|
|||||||
const togglingEndpointId = ref<string | null>(null)
|
const togglingEndpointId = ref<string | null>(null)
|
||||||
const togglingKeyId = ref<string | null>(null)
|
const togglingKeyId = ref<string | null>(null)
|
||||||
|
|
||||||
|
// 密钥显示状态:key_id -> 完整密钥
|
||||||
|
const revealedKeys = ref<Map<string, string>>(new Map())
|
||||||
|
const revealingKeyId = ref<string | null>(null)
|
||||||
|
|
||||||
// 模型相关状态
|
// 模型相关状态
|
||||||
const modelFormDialogOpen = ref(false)
|
const modelFormDialogOpen = ref(false)
|
||||||
const editingModel = ref<Model | null>(null)
|
const editingModel = ref<Model | null>(null)
|
||||||
@@ -800,6 +839,9 @@ watch(() => props.open, (newOpen) => {
|
|||||||
currentEndpoint.value = null
|
currentEndpoint.value = null
|
||||||
editingKey.value = null
|
editingKey.value = null
|
||||||
keyToDelete.value = null
|
keyToDelete.value = null
|
||||||
|
|
||||||
|
// 清除已显示的密钥(安全考虑)
|
||||||
|
revealedKeys.value.clear()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -888,6 +930,43 @@ function handleConfigKeyModels(key: EndpointAPIKey) {
|
|||||||
keyAllowedModelsDialogOpen.value = true
|
keyAllowedModelsDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 切换密钥显示/隐藏
|
||||||
|
async function toggleKeyReveal(key: EndpointAPIKey) {
|
||||||
|
if (revealedKeys.value.has(key.id)) {
|
||||||
|
// 已显示,隐藏它
|
||||||
|
revealedKeys.value.delete(key.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未显示,调用 API 获取完整密钥
|
||||||
|
revealingKeyId.value = key.id
|
||||||
|
try {
|
||||||
|
const result = await revealEndpointKey(key.id)
|
||||||
|
revealedKeys.value.set(key.id, result.api_key)
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err.response?.data?.detail || '获取密钥失败', '错误')
|
||||||
|
} finally {
|
||||||
|
revealingKeyId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制完整密钥
|
||||||
|
async function copyFullKey(key: EndpointAPIKey) {
|
||||||
|
// 如果已经显示了,直接复制
|
||||||
|
if (revealedKeys.value.has(key.id)) {
|
||||||
|
copyToClipboard(revealedKeys.value.get(key.id)!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则先获取再复制
|
||||||
|
try {
|
||||||
|
const result = await revealEndpointKey(key.id)
|
||||||
|
copyToClipboard(result.api_key)
|
||||||
|
} catch (err: any) {
|
||||||
|
showError(err.response?.data?.detail || '获取密钥失败', '错误')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleDeleteKey(key: EndpointAPIKey) {
|
function handleDeleteKey(key: EndpointAPIKey) {
|
||||||
keyToDelete.value = key
|
keyToDelete.value = key
|
||||||
deleteKeyConfirmOpen.value = true
|
deleteKeyConfirmOpen.value = true
|
||||||
|
|||||||
@@ -156,17 +156,6 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="align-top px-4 py-3">
|
<td class="align-top px-4 py-3">
|
||||||
<div class="flex justify-center gap-1.5">
|
<div class="flex justify-center gap-1.5">
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8"
|
|
||||||
title="测试模型"
|
|
||||||
:disabled="testingModelId === model.id"
|
|
||||||
@click="testModelConnection(model)"
|
|
||||||
>
|
|
||||||
<Loader2 v-if="testingModelId === model.id" class="w-3.5 h-3.5 animate-spin" />
|
|
||||||
<Play v-else class="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -220,14 +209,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image, Loader2, Play } from 'lucide-vue-next'
|
import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image } from 'lucide-vue-next'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { getProviderModels, testModel, type Model } from '@/api/endpoints'
|
import { getProviderModels, type Model } from '@/api/endpoints'
|
||||||
import { updateModel } from '@/api/endpoints/models'
|
import { updateModel } from '@/api/endpoints/models'
|
||||||
import { parseTestModelError } from '@/utils/errorParser'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
provider: any
|
provider: any
|
||||||
@@ -246,7 +234,6 @@ const { copyToClipboard } = useClipboard()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const models = ref<Model[]>([])
|
const models = ref<Model[]>([])
|
||||||
const togglingModelId = ref<string | null>(null)
|
const togglingModelId = ref<string | null>(null)
|
||||||
const testingModelId = ref<string | null>(null)
|
|
||||||
|
|
||||||
// 按名称排序的模型列表
|
// 按名称排序的模型列表
|
||||||
const sortedModels = computed(() => {
|
const sortedModels = computed(() => {
|
||||||
@@ -390,39 +377,6 @@ async function toggleModelActive(model: Model) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试模型连接性
|
|
||||||
async function testModelConnection(model: Model) {
|
|
||||||
if (testingModelId.value) return
|
|
||||||
|
|
||||||
testingModelId.value = model.id
|
|
||||||
try {
|
|
||||||
const result = await testModel({
|
|
||||||
provider_id: props.provider.id,
|
|
||||||
model_name: model.provider_model_name,
|
|
||||||
message: "hello"
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
showSuccess(`模型 "${model.provider_model_name}" 测试成功`)
|
|
||||||
|
|
||||||
// 如果有响应内容,可以显示更多信息
|
|
||||||
if (result.data?.response?.choices?.[0]?.message?.content) {
|
|
||||||
const content = result.data.response.choices[0].message.content
|
|
||||||
showSuccess(`测试成功,响应: ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`)
|
|
||||||
} else if (result.data?.content_preview) {
|
|
||||||
showSuccess(`流式测试成功,预览: ${result.data.content_preview}`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showError(`模型测试失败: ${parseTestModelError(result)}`)
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
|
|
||||||
showError(`模型测试失败: ${errorMsg}`)
|
|
||||||
} finally {
|
|
||||||
testingModelId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadModels()
|
loadModels()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -145,10 +145,10 @@
|
|||||||
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||||
<div class="pr-6">
|
<div class="pr-6">
|
||||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||||
实际成本
|
本月费用
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
|
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
|
||||||
{{ formatCurrency(costStats.total_actual_cost) }}
|
{{ formatCurrency(costStats.total_cost) }}
|
||||||
</p>
|
</p>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="costStats.cost_savings > 0"
|
v-if="costStats.cost_savings > 0"
|
||||||
@@ -162,14 +162,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 普通用户:缓存统计 -->
|
<!-- 普通用户:月度统计 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="!isAdmin && cacheStats && cacheStats.total_cache_tokens > 0"
|
v-else-if="!isAdmin && (hasCacheData || (userMonthlyCost !== null && userMonthlyCost > 0))"
|
||||||
class="mt-6"
|
class="mt-6"
|
||||||
>
|
>
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div class="mb-3 flex items-center justify-between">
|
||||||
<h3 class="text-sm font-medium text-foreground">
|
<h3 class="text-sm font-medium text-foreground">
|
||||||
本月缓存使用
|
本月统计
|
||||||
</h3>
|
</h3>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -178,8 +178,16 @@
|
|||||||
Monthly
|
Monthly
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
|
<div
|
||||||
<Card class="relative p-3 sm:p-4 border-book-cloth/30">
|
:class="[
|
||||||
|
'grid gap-2 sm:gap-3',
|
||||||
|
hasCacheData ? 'grid-cols-2 xl:grid-cols-4' : 'grid-cols-1 max-w-xs'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
v-if="cacheStats"
|
||||||
|
class="relative p-3 sm:p-4 border-book-cloth/30"
|
||||||
|
>
|
||||||
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||||
<div class="pr-6">
|
<div class="pr-6">
|
||||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||||
@@ -190,7 +198,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card class="relative p-3 sm:p-4 border-kraft/30">
|
<Card
|
||||||
|
v-if="cacheStats"
|
||||||
|
class="relative p-3 sm:p-4 border-kraft/30"
|
||||||
|
>
|
||||||
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||||
<div class="pr-6">
|
<div class="pr-6">
|
||||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||||
@@ -201,7 +212,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card class="relative p-3 sm:p-4 border-book-cloth/25">
|
<Card
|
||||||
|
v-if="cacheStats"
|
||||||
|
class="relative p-3 sm:p-4 border-book-cloth/25"
|
||||||
|
>
|
||||||
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||||
<div class="pr-6">
|
<div class="pr-6">
|
||||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||||
@@ -213,19 +227,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card
|
<Card
|
||||||
v-if="tokenBreakdown"
|
v-if="userMonthlyCost !== null"
|
||||||
class="relative p-3 sm:p-4 border-manilla/40"
|
class="relative p-3 sm:p-4 border-manilla/40"
|
||||||
>
|
>
|
||||||
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||||
<div class="pr-6">
|
<div class="pr-6">
|
||||||
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
|
||||||
总Token
|
本月费用
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
|
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
|
||||||
{{ formatTokens((tokenBreakdown.input || 0) + (tokenBreakdown.output || 0)) }}
|
{{ formatCurrency(userMonthlyCost) }}
|
||||||
</p>
|
|
||||||
<p class="mt-0.5 sm:mt-1 text-[9px] sm:text-[10px] text-muted-foreground">
|
|
||||||
输入 {{ formatTokens(tokenBreakdown.input || 0) }} / 输出 {{ formatTokens(tokenBreakdown.output || 0) }}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -831,6 +842,12 @@ const cacheStats = ref<{
|
|||||||
total_cache_tokens: number
|
total_cache_tokens: number
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
|
const userMonthlyCost = ref<number | null>(null)
|
||||||
|
|
||||||
|
const hasCacheData = computed(() =>
|
||||||
|
cacheStats.value && cacheStats.value.total_cache_tokens > 0
|
||||||
|
)
|
||||||
|
|
||||||
const tokenBreakdown = ref<{
|
const tokenBreakdown = ref<{
|
||||||
input: number
|
input: number
|
||||||
output: number
|
output: number
|
||||||
@@ -1086,6 +1103,7 @@ async function loadDashboardData() {
|
|||||||
} else {
|
} else {
|
||||||
if (statsData.cache_stats) cacheStats.value = statsData.cache_stats
|
if (statsData.cache_stats) cacheStats.value = statsData.cache_stats
|
||||||
if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown
|
if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown
|
||||||
|
if (statsData.monthly_cost !== undefined) userMonthlyCost.value = statsData.monthly_cost
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ async def get_keys_grouped_by_format(
|
|||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/keys/{key_id}/reveal")
|
||||||
|
async def reveal_endpoint_key(
|
||||||
|
key_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""获取完整的 API Key(用于查看和复制)"""
|
||||||
|
adapter = AdminRevealEndpointKeyAdapter(key_id=key_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/keys/{key_id}")
|
@router.delete("/keys/{key_id}")
|
||||||
async def delete_endpoint_key(
|
async def delete_endpoint_key(
|
||||||
key_id: str,
|
key_id: str,
|
||||||
@@ -293,6 +304,30 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
|||||||
return EndpointAPIKeyResponse(**response_dict)
|
return EndpointAPIKeyResponse(**response_dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminRevealEndpointKeyAdapter(AdminApiAdapter):
|
||||||
|
"""获取完整的 API Key(用于查看和复制)"""
|
||||||
|
|
||||||
|
key_id: str
|
||||||
|
|
||||||
|
async def handle(self, context): # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
|
||||||
|
if not key:
|
||||||
|
raise NotFoundException(f"Key {self.key_id} 不存在")
|
||||||
|
|
||||||
|
try:
|
||||||
|
decrypted_key = crypto_service.decrypt(key.api_key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解密 Key 失败: ID={self.key_id}, Error={e}")
|
||||||
|
raise InvalidRequestException(
|
||||||
|
"无法解密 API Key,可能是加密密钥已更改。请重新添加该密钥。"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[REVEAL] 查看完整 Key: ID={self.key_id}, Name={key.name}")
|
||||||
|
return {"api_key": decrypted_key}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
|
class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
|
||||||
key_id: str
|
key_id: str
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
|||||||
# 转换为 UTC 用于与 stats_daily.date 比较(存储的是业务日期对应的 UTC 开始时间)
|
# 转换为 UTC 用于与 stats_daily.date 比较(存储的是业务日期对应的 UTC 开始时间)
|
||||||
today = today_local.astimezone(timezone.utc)
|
today = today_local.astimezone(timezone.utc)
|
||||||
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
|
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
|
||||||
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
|
# 本月第一天(自然月)
|
||||||
|
month_start_local = today_local.replace(day=1)
|
||||||
|
month_start = month_start_local.astimezone(timezone.utc)
|
||||||
|
|
||||||
# ==================== 使用预聚合数据 ====================
|
# ==================== 使用预聚合数据 ====================
|
||||||
# 从 stats_summary + 今日实时数据获取全局统计
|
# 从 stats_summary + 今日实时数据获取全局统计
|
||||||
@@ -208,7 +210,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
|||||||
func.sum(StatsDaily.cache_read_cost).label("cache_read_cost"),
|
func.sum(StatsDaily.cache_read_cost).label("cache_read_cost"),
|
||||||
func.sum(StatsDaily.fallback_count).label("fallback_count"),
|
func.sum(StatsDaily.fallback_count).label("fallback_count"),
|
||||||
)
|
)
|
||||||
.filter(StatsDaily.date >= last_month, StatsDaily.date < today)
|
.filter(StatsDaily.date >= month_start, StatsDaily.date < today)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -227,24 +229,24 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
|||||||
else:
|
else:
|
||||||
# 回退到实时查询(没有预聚合数据时)
|
# 回退到实时查询(没有预聚合数据时)
|
||||||
total_requests = (
|
total_requests = (
|
||||||
db.query(func.count(Usage.id)).filter(Usage.created_at >= last_month).scalar() or 0
|
db.query(func.count(Usage.id)).filter(Usage.created_at >= month_start).scalar() or 0
|
||||||
)
|
)
|
||||||
total_cost = (
|
total_cost = (
|
||||||
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= last_month).scalar() or 0
|
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= month_start).scalar() or 0
|
||||||
)
|
)
|
||||||
total_actual_cost = (
|
total_actual_cost = (
|
||||||
db.query(func.sum(Usage.actual_total_cost_usd))
|
db.query(func.sum(Usage.actual_total_cost_usd))
|
||||||
.filter(Usage.created_at >= last_month).scalar() or 0
|
.filter(Usage.created_at >= month_start).scalar() or 0
|
||||||
)
|
)
|
||||||
error_requests = (
|
error_requests = (
|
||||||
db.query(func.count(Usage.id))
|
db.query(func.count(Usage.id))
|
||||||
.filter(
|
.filter(
|
||||||
Usage.created_at >= last_month,
|
Usage.created_at >= month_start,
|
||||||
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)),
|
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)),
|
||||||
).scalar() or 0
|
).scalar() or 0
|
||||||
)
|
)
|
||||||
total_tokens = (
|
total_tokens = (
|
||||||
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= last_month).scalar() or 0
|
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= month_start).scalar() or 0
|
||||||
)
|
)
|
||||||
cache_stats = (
|
cache_stats = (
|
||||||
db.query(
|
db.query(
|
||||||
@@ -253,7 +255,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
|||||||
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
|
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
|
||||||
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
|
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
|
||||||
)
|
)
|
||||||
.filter(Usage.created_at >= last_month)
|
.filter(Usage.created_at >= month_start)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
|
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
|
||||||
@@ -267,7 +269,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
|
|||||||
RequestCandidate.request_id, func.count(RequestCandidate.id).label("executed_count")
|
RequestCandidate.request_id, func.count(RequestCandidate.id).label("executed_count")
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
RequestCandidate.created_at >= last_month,
|
RequestCandidate.created_at >= month_start,
|
||||||
RequestCandidate.status.in_(["success", "failed"]),
|
RequestCandidate.status.in_(["success", "failed"]),
|
||||||
)
|
)
|
||||||
.group_by(RequestCandidate.request_id)
|
.group_by(RequestCandidate.request_id)
|
||||||
@@ -447,7 +449,9 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
|||||||
# 转换为 UTC 用于数据库查询
|
# 转换为 UTC 用于数据库查询
|
||||||
today = today_local.astimezone(timezone.utc)
|
today = today_local.astimezone(timezone.utc)
|
||||||
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
|
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
|
||||||
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
|
# 本月第一天(自然月)
|
||||||
|
month_start_local = today_local.replace(day=1)
|
||||||
|
month_start = month_start_local.astimezone(timezone.utc)
|
||||||
|
|
||||||
user_api_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user.id).scalar()
|
user_api_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user.id).scalar()
|
||||||
active_keys = (
|
active_keys = (
|
||||||
@@ -483,12 +487,12 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
|||||||
# 本月请求统计
|
# 本月请求统计
|
||||||
user_requests = (
|
user_requests = (
|
||||||
db.query(func.count(Usage.id))
|
db.query(func.count(Usage.id))
|
||||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
|
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
|
||||||
.scalar()
|
.scalar()
|
||||||
)
|
)
|
||||||
user_cost = (
|
user_cost = (
|
||||||
db.query(func.sum(Usage.total_cost_usd))
|
db.query(func.sum(Usage.total_cost_usd))
|
||||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
|
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
|
||||||
.scalar()
|
.scalar()
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
@@ -532,18 +536,19 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
|||||||
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
|
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
|
||||||
func.sum(Usage.input_tokens).label("total_input_tokens"),
|
func.sum(Usage.input_tokens).label("total_input_tokens"),
|
||||||
)
|
)
|
||||||
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
|
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
|
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
|
||||||
cache_read_tokens = int(cache_stats.cache_read_tokens or 0) if cache_stats else 0
|
cache_read_tokens = int(cache_stats.cache_read_tokens or 0) if cache_stats else 0
|
||||||
|
monthly_input_tokens = int(cache_stats.total_input_tokens or 0) if cache_stats else 0
|
||||||
|
|
||||||
# 计算缓存命中率:cache_read / (input_tokens + cache_read)
|
# 计算本月缓存命中率:cache_read / (input_tokens + cache_read)
|
||||||
# input_tokens 是实际发送给模型的输入(不含缓存读取),cache_read 是从缓存读取的
|
# input_tokens 是实际发送给模型的输入(不含缓存读取),cache_read 是从缓存读取的
|
||||||
# 总输入 = input_tokens + cache_read,缓存命中率 = cache_read / 总输入
|
# 总输入 = input_tokens + cache_read,缓存命中率 = cache_read / 总输入
|
||||||
total_input_with_cache = all_time_input_tokens + all_time_cache_read
|
total_input_with_cache = monthly_input_tokens + cache_read_tokens
|
||||||
cache_hit_rate = (
|
cache_hit_rate = (
|
||||||
round((all_time_cache_read / total_input_with_cache) * 100, 1)
|
round((cache_read_tokens / total_input_with_cache) * 100, 1)
|
||||||
if total_input_with_cache > 0
|
if total_input_with_cache > 0
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
@@ -569,15 +574,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
|||||||
quota_value = "无限制"
|
quota_value = "无限制"
|
||||||
quota_change = f"已用 ${user.used_usd:.2f}"
|
quota_change = f"已用 ${user.used_usd:.2f}"
|
||||||
quota_high = False
|
quota_high = False
|
||||||
elif user.quota_usd and user.quota_usd > 0:
|
elif user.quota_usd > 0:
|
||||||
percent = min(100, int((user.used_usd / user.quota_usd) * 100))
|
percent = min(100, int((user.used_usd / user.quota_usd) * 100))
|
||||||
quota_value = "无限制"
|
quota_value = f"${user.quota_usd:.0f}"
|
||||||
quota_change = f"已用 ${user.used_usd:.2f}"
|
quota_change = f"已用 ${user.used_usd:.2f}"
|
||||||
quota_high = percent > 80
|
quota_high = percent > 80
|
||||||
else:
|
else:
|
||||||
quota_value = "0%"
|
quota_value = "$0"
|
||||||
quota_change = f"已用 ${user.used_usd:.2f}"
|
quota_change = f"已用 ${user.used_usd:.2f}"
|
||||||
quota_high = False
|
quota_high = True
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"stats": [
|
"stats": [
|
||||||
@@ -605,9 +610,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
|||||||
"icon": "TrendingUp",
|
"icon": "TrendingUp",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "本月费用",
|
"name": "总Token",
|
||||||
"value": f"${user_cost:.2f}",
|
"value": format_tokens(
|
||||||
"icon": "DollarSign",
|
all_time_input_tokens
|
||||||
|
+ all_time_output_tokens
|
||||||
|
+ all_time_cache_creation
|
||||||
|
+ all_time_cache_read
|
||||||
|
),
|
||||||
|
"subValue": f"输入 {format_tokens(all_time_input_tokens)} / 输出 {format_tokens(all_time_output_tokens)}",
|
||||||
|
"icon": "Hash",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"today": {
|
"today": {
|
||||||
@@ -631,6 +642,8 @@ class UserDashboardStatsAdapter(DashboardAdapter):
|
|||||||
"cache_hit_rate": cache_hit_rate,
|
"cache_hit_rate": cache_hit_rate,
|
||||||
"total_cache_tokens": cache_creation_tokens + cache_read_tokens,
|
"total_cache_tokens": cache_creation_tokens + cache_read_tokens,
|
||||||
},
|
},
|
||||||
|
# 本月费用(用于下方缓存区域显示)
|
||||||
|
"monthly_cost": float(user_cost),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user