5 Commits

Author SHA1 Message Date
fawney19
9d5c84f9d3 refactor: add scheduling mode support and optimize system settings UI
- Add fixed_order and cache_affinity scheduling modes to CacheAwareScheduler
- Only apply cache affinity in cache_affinity mode; use fixed order otherwise
- Simplify Dialog components with title/description props
- Remove unnecessary button shadows in SystemSettings
- Optimize import dialog UI structure
- Update ModelAliasesTab shadow styling
- Fix fallback orchestrator type hints
- Add scheduling_mode configuration in system config
2025-12-17 19:15:08 +08:00
fawney19
53e6a82480 Merge pull request #25 from fawney19/fix/22-dark-mode-settings-bug
fix: 修复个人设置页面深色模式切换后刷新失效的问题
2025-12-17 18:11:53 +08:00
fawney19
bd11ebdbd5 fix: 修复个人设置页面深色模式切换后刷新失效的问题
- 前端使用 useDarkMode composable 统一主题切换逻辑
- 后端支持 system 主题值(之前只支持 auto)
- 主题以本地 localStorage 为准,避免刷新时被服务端旧值覆盖

Fixes #22
2025-12-17 18:02:19 +08:00
fawney19
1dac4cb156 refactor: optimize provider query and stats aggregation logic 2025-12-17 16:41:10 +08:00
fawney19
50abb55c94 fix(models): clear form state when loading model data for edit
Reset model selection, search query, and expanded provider state
when switching to edit mode to prevent stale UI state carrying over
from previous operations. Also ensure tieredPricing is properly set
or reset based on model data.
2025-12-16 18:42:58 +08:00
34 changed files with 2250 additions and 959 deletions

View File

@@ -20,10 +20,10 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# Create ENUM types # Create ENUM types (with IF NOT EXISTS for idempotency)
op.execute("CREATE TYPE userrole AS ENUM ('admin', 'user')") op.execute("DO $$ BEGIN CREATE TYPE userrole AS ENUM ('admin', 'user'); EXCEPTION WHEN duplicate_object THEN NULL; END $$")
op.execute( op.execute(
"CREATE TYPE providerbillingtype AS ENUM ('monthly_quota', 'pay_as_you_go', 'free_tier')" "DO $$ BEGIN CREATE TYPE providerbillingtype AS ENUM ('monthly_quota', 'pay_as_you_go', 'free_tier'); EXCEPTION WHEN duplicate_object THEN NULL; END $$"
) )
# ==================== users ==================== # ==================== users ====================
@@ -35,7 +35,7 @@ def upgrade() -> None:
sa.Column("password_hash", sa.String(255), nullable=False), sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column( sa.Column(
"role", "role",
sa.Enum("admin", "user", name="userrole", create_type=False), postgresql.ENUM("admin", "user", name="userrole", create_type=False),
nullable=False, nullable=False,
server_default="user", server_default="user",
), ),
@@ -67,7 +67,7 @@ def upgrade() -> None:
sa.Column("website", sa.String(500), nullable=True), sa.Column("website", sa.String(500), nullable=True),
sa.Column( sa.Column(
"billing_type", "billing_type",
sa.Enum( postgresql.ENUM(
"monthly_quota", "pay_as_you_go", "free_tier", name="providerbillingtype", create_type=False "monthly_quota", "pay_as_you_go", "free_tier", name="providerbillingtype", create_type=False
), ),
nullable=False, nullable=False,

View File

@@ -124,6 +124,27 @@ export interface ModelExport {
config?: any config?: any
} }
// Provider 模型查询响应
export interface ProviderModelsQueryResponse {
success: boolean
data: {
models: Array<{
id: string
object?: string
created?: number
owned_by?: string
display_name?: string
api_format?: string
}>
error?: string
}
provider: {
id: string
name: string
display_name: string
}
}
export interface ConfigImportRequest extends ConfigExportData { export interface ConfigImportRequest extends ConfigExportData {
merge_mode: 'skip' | 'overwrite' | 'error' merge_mode: 'skip' | 'overwrite' | 'error'
} }
@@ -356,5 +377,14 @@ export const adminApi = {
data data
) )
return response.data return response.data
},
// 查询 Provider 可用模型(从上游 API 获取)
async queryProviderModels(providerId: string, apiKeyId?: string): Promise<ProviderModelsQueryResponse> {
const response = await apiClient.post<ProviderModelsQueryResponse>(
'/api/admin/provider-query/models',
{ provider_id: providerId, api_key_id: apiKeyId }
)
return response.data
} }
} }

View File

@@ -1,3 +1,25 @@
// API 格式常量
export const API_FORMATS = {
CLAUDE: 'CLAUDE',
CLAUDE_CLI: 'CLAUDE_CLI',
OPENAI: 'OPENAI',
OPENAI_CLI: 'OPENAI_CLI',
GEMINI: 'GEMINI',
GEMINI_CLI: 'GEMINI_CLI',
} as const
export type APIFormat = typeof API_FORMATS[keyof typeof API_FORMATS]
// API 格式显示名称映射按品牌分组API 在前CLI 在后)
export const API_FORMAT_LABELS: Record<string, string> = {
[API_FORMATS.CLAUDE]: 'Claude',
[API_FORMATS.CLAUDE_CLI]: 'Claude CLI',
[API_FORMATS.OPENAI]: 'OpenAI',
[API_FORMATS.OPENAI_CLI]: 'OpenAI CLI',
[API_FORMATS.GEMINI]: 'Gemini',
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
}
export interface ProviderEndpoint { export interface ProviderEndpoint {
id: string id: string
provider_id: string provider_id: string
@@ -214,6 +236,7 @@ export interface ConcurrencyStatus {
export interface ProviderModelAlias { export interface ProviderModelAlias {
name: string name: string
priority: number // 优先级(数字越小优先级越高) priority: number // 优先级(数字越小优先级越高)
api_formats?: string[] // 作用域(适用的 API 格式),为空表示对所有格式生效
} }
export interface Model { export interface Model {

View File

@@ -34,11 +34,10 @@ const buttonClass = computed(() => {
'inline-flex items-center justify-center rounded-xl text-sm font-semibold transition-all duration-200 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]' 'inline-flex items-center justify-center rounded-xl text-sm font-semibold transition-all duration-200 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]'
const variantClasses = { const variantClasses = {
default: default: 'bg-primary text-white hover:bg-primary/90',
'bg-primary text-white shadow-[0_20px_35px_rgba(204,120,92,0.35)] hover:bg-primary/90 hover:shadow-[0_25px_45px_rgba(204,120,92,0.45)]', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/85',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/85 shadow-sm',
outline: outline:
'border border-border/60 bg-card/60 text-foreground hover:border-primary/60 hover:text-primary hover:bg-primary/10 shadow-sm backdrop-blur transition-all', 'border border-border/60 bg-card/60 text-foreground hover:border-primary/60 hover:text-primary hover:bg-primary/10 backdrop-blur transition-all',
secondary: secondary:
'bg-secondary text-secondary-foreground shadow-inner hover:bg-secondary/80', 'bg-secondary text-secondary-foreground shadow-inner hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',

View File

@@ -2,7 +2,7 @@
<Teleport to="body"> <Teleport to="body">
<div <div
v-if="isOpen" v-if="isOpen"
class="fixed inset-0 overflow-y-auto" class="fixed inset-0 overflow-y-auto pointer-events-none"
:style="{ zIndex: containerZIndex }" :style="{ zIndex: containerZIndex }"
> >
<!-- 背景遮罩 --> <!-- 背景遮罩 -->
@@ -16,7 +16,7 @@
> >
<div <div
v-if="isOpen" v-if="isOpen"
class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity" class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity pointer-events-auto"
:style="{ zIndex: backdropZIndex }" :style="{ zIndex: backdropZIndex }"
@click="handleClose" @click="handleClose"
/> />
@@ -34,7 +34,7 @@
> >
<div <div
v-if="isOpen" v-if="isOpen"
class="relative transform rounded-lg bg-background text-left shadow-2xl transition-all sm:my-8 sm:w-full border border-border" class="relative transform rounded-lg bg-background text-left shadow-2xl transition-all sm:my-8 sm:w-full border border-border pointer-events-auto"
:style="{ zIndex: contentZIndex }" :style="{ zIndex: contentZIndex }"
:class="maxWidthClass" :class="maxWidthClass"
@click.stop @click.stop

View File

@@ -45,7 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
const contentClass = computed(() => const contentClass = computed(() =>
cn( cn(
'z-[100] max-h-96 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto', 'z-[200] max-h-96 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class props.class

View File

@@ -396,15 +396,13 @@ interface ProviderGroup {
const groupedModels = computed(() => { const groupedModels = computed(() => {
let models = allModels.value.filter(m => !m.deprecated) let models = allModels.value.filter(m => !m.deprecated)
// 搜索(支持空格分隔的多关键词 AND 搜索)
if (searchQuery.value) { if (searchQuery.value) {
const query = searchQuery.value.toLowerCase() const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
models = models.filter(model => models = models.filter(model => {
model.providerId.toLowerCase().includes(query) || const searchableText = `${model.providerId} ${model.providerName} ${model.modelId} ${model.modelName} ${model.family || ''}`.toLowerCase()
model.providerName.toLowerCase().includes(query) || return keywords.every(keyword => searchableText.includes(keyword))
model.modelId.toLowerCase().includes(query) || })
model.modelName.toLowerCase().includes(query) ||
model.family?.toLowerCase().includes(query)
)
} }
// 按提供商分组 // 按提供商分组
@@ -425,10 +423,12 @@ const groupedModels = computed(() => {
// 如果有搜索词,把提供商名称/ID匹配的排在前面 // 如果有搜索词,把提供商名称/ID匹配的排在前面
if (searchQuery.value) { if (searchQuery.value) {
const query = searchQuery.value.toLowerCase() const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
result.sort((a, b) => { result.sort((a, b) => {
const aProviderMatch = a.providerId.toLowerCase().includes(query) || a.providerName.toLowerCase().includes(query) const aText = `${a.providerId} ${a.providerName}`.toLowerCase()
const bProviderMatch = b.providerId.toLowerCase().includes(query) || b.providerName.toLowerCase().includes(query) const bText = `${b.providerId} ${b.providerName}`.toLowerCase()
const aProviderMatch = keywords.some(k => aText.includes(k))
const bProviderMatch = keywords.some(k => bText.includes(k))
if (aProviderMatch && !bProviderMatch) return -1 if (aProviderMatch && !bProviderMatch) return -1
if (!aProviderMatch && bProviderMatch) return 1 if (!aProviderMatch && bProviderMatch) return 1
return a.providerName.localeCompare(b.providerName) return a.providerName.localeCompare(b.providerName)
@@ -604,6 +604,11 @@ function resetForm() {
// 加载模型数据(编辑模式) // 加载模型数据(编辑模式)
function loadModelData() { function loadModelData() {
if (!props.model) return if (!props.model) return
// 先重置创建模式的残留状态
selectedModel.value = null
searchQuery.value = ''
expandedProvider.value = null
form.value = { form.value = {
name: props.model.name, name: props.model.name,
display_name: props.model.display_name, display_name: props.model.display_name,
@@ -612,9 +617,10 @@ function loadModelData() {
config: props.model.config ? { ...props.model.config } : { streaming: true }, config: props.model.config ? { ...props.model.config } : { streaming: true },
is_active: props.model.is_active, is_active: props.model.is_active,
} }
if (props.model.default_tiered_pricing) { // 确保 tieredPricing 也被正确设置或重置
tieredPricing.value = JSON.parse(JSON.stringify(props.model.default_tiered_pricing)) tieredPricing.value = props.model.default_tiered_pricing
} ? JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
: null
} }
// 使用 useFormDialog 统一处理对话框逻辑 // 使用 useFormDialog 统一处理对话框逻辑

View File

@@ -312,8 +312,41 @@
<template #footer> <template #footer>
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<div class="text-xs text-muted-foreground"> <div class="flex items-center gap-4">
当前模式: <span class="font-medium">{{ activeMainTab === 'provider' ? '提供商优先' : 'Key 优先' }}</span> <div class="text-xs text-muted-foreground">
当前模式: <span class="font-medium">{{ activeMainTab === 'provider' ? '提供商优先' : 'Key 优先' }}</span>
</div>
<div class="flex items-center gap-2 pl-4 border-l border-border">
<span class="text-xs text-muted-foreground">调度:</span>
<div class="flex gap-0.5 p-0.5 bg-muted/40 rounded-md">
<button
type="button"
class="px-2 py-1 text-xs font-medium rounded transition-all"
:class="[
schedulingMode === 'fixed_order'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
]"
title="严格按优先级顺序,不考虑缓存"
@click="schedulingMode = 'fixed_order'"
>
固定顺序
</button>
<button
type="button"
class="px-2 py-1 text-xs font-medium rounded transition-all"
:class="[
schedulingMode === 'cache_affinity'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
]"
title="优先使用已缓存的Provider利用Prompt Cache"
@click="schedulingMode = 'cache_affinity'"
>
缓存亲和
</button>
</div>
</div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button
@@ -410,6 +443,9 @@ const saving = ref(false)
// Key 优先级编辑状态 // Key 优先级编辑状态
const editingKeyPriority = ref<Record<string, string | null>>({}) // format -> keyId const editingKeyPriority = ref<Record<string, string | null>>({}) // format -> keyId
// 调度模式状态
const schedulingMode = ref<'fixed_order' | 'cache_affinity'>('cache_affinity')
// 可用的 API 格式 // 可用的 API 格式
const availableFormats = computed(() => { const availableFormats = computed(() => {
return Object.keys(keysByFormat.value).sort() return Object.keys(keysByFormat.value).sort()
@@ -433,11 +469,18 @@ watch(internalOpen, async (open) => {
// 加载当前的优先级模式配置 // 加载当前的优先级模式配置
async function loadCurrentPriorityMode() { async function loadCurrentPriorityMode() {
try { try {
const response = await adminApi.getSystemConfig('provider_priority_mode') const [priorityResponse, schedulingResponse] = await Promise.all([
const currentMode = response.value || 'provider' adminApi.getSystemConfig('provider_priority_mode'),
adminApi.getSystemConfig('scheduling_mode')
])
const currentMode = priorityResponse.value || 'provider'
activeMainTab.value = currentMode === 'global_key' ? 'key' : 'provider' activeMainTab.value = currentMode === 'global_key' ? 'key' : 'provider'
const currentSchedulingMode = schedulingResponse.value || 'cache_affinity'
schedulingMode.value = currentSchedulingMode === 'fixed_order' ? 'fixed_order' : 'cache_affinity'
} catch { } catch {
activeMainTab.value = 'provider' activeMainTab.value = 'provider'
schedulingMode.value = 'cache_affinity'
} }
} }
@@ -611,11 +654,19 @@ async function save() {
const newMode = activeMainTab.value === 'key' ? 'global_key' : 'provider' const newMode = activeMainTab.value === 'key' ? 'global_key' : 'provider'
await adminApi.updateSystemConfig( // 保存优先级模式和调度模式
'provider_priority_mode', await Promise.all([
newMode, adminApi.updateSystemConfig(
'Provider/Key 优先级策略provider(提供商优先模式) 或 global_key(全局Key优先模式)' 'provider_priority_mode',
) newMode,
'Provider/Key 优先级策略provider(提供商优先模式) 或 global_key(全局Key优先模式)'
),
adminApi.updateSystemConfig(
'scheduling_mode',
schedulingMode.value,
'调度模式fixed_order(固定顺序模式) 或 cache_affinity(缓存亲和模式)'
)
])
const providerUpdates = sortedProviders.value.map((provider, index) => const providerUpdates = sortedProviders.value.map((provider, index) =>
updateProvider(provider.id, { provider_priority: index + 1 }) updateProvider(provider.id, { provider_priority: index + 1 })

View File

@@ -526,7 +526,14 @@
@edit-model="handleEditModel" @edit-model="handleEditModel"
@delete-model="handleDeleteModel" @delete-model="handleDeleteModel"
@batch-assign="handleBatchAssign" @batch-assign="handleBatchAssign"
@manage-alias="handleManageAlias" />
<!-- 模型名称映射 -->
<ModelAliasesTab
v-if="provider"
:key="`aliases-${provider.id}`"
:provider="provider"
@refresh="handleRelatedDataRefresh"
/> />
</div> </div>
</template> </template>
@@ -629,16 +636,6 @@
@update:open="batchAssignDialogOpen = $event" @update:open="batchAssignDialogOpen = $event"
@changed="handleBatchAssignChanged" @changed="handleBatchAssignChanged"
/> />
<!-- 模型别名管理对话框 -->
<ModelAliasDialog
v-if="open && provider"
:open="aliasDialogOpen"
:provider-id="provider.id"
:model="aliasEditingModel"
@update:open="aliasDialogOpen = $event"
@saved="handleAliasSaved"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -667,8 +664,8 @@ import {
KeyFormDialog, KeyFormDialog,
KeyAllowedModelsDialog, KeyAllowedModelsDialog,
ModelsTab, ModelsTab,
BatchAssignModelsDialog, ModelAliasesTab,
ModelAliasDialog BatchAssignModelsDialog
} from '@/features/providers/components' } from '@/features/providers/components'
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue' import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue' import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
@@ -737,10 +734,6 @@ const deleteModelConfirmOpen = ref(false)
const modelToDelete = ref<Model | null>(null) const modelToDelete = ref<Model | null>(null)
const batchAssignDialogOpen = ref(false) const batchAssignDialogOpen = ref(false)
// 别名管理相关状态
const aliasDialogOpen = ref(false)
const aliasEditingModel = ref<Model | null>(null)
// 拖动排序相关状态 // 拖动排序相关状态
const dragState = ref({ const dragState = ref({
isDragging: false, isDragging: false,
@@ -762,8 +755,7 @@ const hasBlockingDialogOpen = computed(() =>
deleteKeyConfirmOpen.value || deleteKeyConfirmOpen.value ||
modelFormDialogOpen.value || modelFormDialogOpen.value ||
deleteModelConfirmOpen.value || deleteModelConfirmOpen.value ||
batchAssignDialogOpen.value || batchAssignDialogOpen.value
aliasDialogOpen.value
) )
// 监听 providerId 变化 // 监听 providerId 变化
@@ -792,7 +784,6 @@ watch(() => props.open, (newOpen) => {
keyAllowedModelsDialogOpen.value = false keyAllowedModelsDialogOpen.value = false
deleteKeyConfirmOpen.value = false deleteKeyConfirmOpen.value = false
batchAssignDialogOpen.value = false batchAssignDialogOpen.value = false
aliasDialogOpen.value = false
// 重置临时数据 // 重置临时数据
endpointToEdit.value = null endpointToEdit.value = null
@@ -1030,19 +1021,6 @@ async function handleBatchAssignChanged() {
emit('refresh') emit('refresh')
} }
// 处理管理映射 - 打开别名对话框
function handleManageAlias(model: Model) {
aliasEditingModel.value = model
aliasDialogOpen.value = true
}
// 处理别名保存完成
async function handleAliasSaved() {
aliasEditingModel.value = null
await loadProvider()
emit('refresh')
}
// 处理模型保存完成 // 处理模型保存完成
async function handleModelSaved() { async function handleModelSaved() {
editingModel.value = null editingModel.value = null

View File

@@ -10,3 +10,4 @@ export { default as BatchAssignModelsDialog } from './BatchAssignModelsDialog.vu
export { default as ModelAliasDialog } from './ModelAliasDialog.vue' export { default as ModelAliasDialog } from './ModelAliasDialog.vue'
export { default as ModelsTab } from './provider-tabs/ModelsTab.vue' export { default as ModelsTab } from './provider-tabs/ModelsTab.vue'
export { default as ModelAliasesTab } from './provider-tabs/ModelAliasesTab.vue'

File diff suppressed because it is too large Load Diff

View File

@@ -165,15 +165,6 @@
> >
<Edit class="w-3.5 h-3.5" /> <Edit class="w-3.5 h-3.5" />
</Button> </Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="管理映射"
@click="openAliasDialog(model)"
>
<Tag class="w-3.5 h-3.5" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -218,7 +209,7 @@
<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, Tag } 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'
@@ -233,7 +224,6 @@ const emit = defineEmits<{
'editModel': [model: Model] 'editModel': [model: Model]
'deleteModel': [model: Model] 'deleteModel': [model: Model]
'batchAssign': [] 'batchAssign': []
'manageAlias': [model: Model]
}>() }>()
const { error: showError, success: showSuccess } = useToast() const { error: showError, success: showSuccess } = useToast()
@@ -373,11 +363,6 @@ function openBatchAssignDialog() {
emit('batchAssign') emit('batchAssign')
} }
// 打开别名管理对话框
function openAliasDialog(model: Model) {
emit('manageAlias', model)
}
// 切换模型启用状态 // 切换模型启用状态
async function toggleModelActive(model: Model) { async function toggleModelActive(model: Model) {
if (togglingModelId.value) return if (togglingModelId.value) return

View File

@@ -751,15 +751,13 @@ const expiringSoonCount = computed(() => apiKeys.value.filter(key => isExpiringS
const filteredApiKeys = computed(() => { const filteredApiKeys = computed(() => {
let result = apiKeys.value let result = apiKeys.value
// 搜索筛选 // 搜索筛选(支持空格分隔的多关键词 AND 搜索)
if (searchQuery.value) { if (searchQuery.value) {
const query = searchQuery.value.toLowerCase() const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
result = result.filter(key => result = result.filter(key => {
(key.name && key.name.toLowerCase().includes(query)) || const searchableText = `${key.name || ''} ${key.key_display || ''} ${key.username || ''} ${key.user_email || ''}`.toLowerCase()
(key.key_display && key.key_display.toLowerCase().includes(query)) || return keywords.every(keyword => searchableText.includes(keyword))
(key.username && key.username.toLowerCase().includes(query)) || })
(key.user_email && key.user_email.toLowerCase().includes(query))
)
} }
// 状态筛选 // 状态筛选

View File

@@ -1002,13 +1002,13 @@ async function batchRemoveSelectedProviders() {
const filteredGlobalModels = computed(() => { const filteredGlobalModels = computed(() => {
let result = globalModels.value let result = globalModels.value
// 搜索 // 搜索(支持空格分隔的多关键词 AND 搜索)
if (searchQuery.value) { if (searchQuery.value) {
const query = searchQuery.value.toLowerCase() const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
result = result.filter(m => result = result.filter(m => {
m.name.toLowerCase().includes(query) || const searchableText = `${m.name} ${m.display_name || ''}`.toLowerCase()
m.display_name?.toLowerCase().includes(query) return keywords.every(keyword => searchableText.includes(keyword))
) })
} }
// 能力筛选 // 能力筛选

View File

@@ -505,13 +505,13 @@ const priorityModeConfig = computed(() => {
const filteredProviders = computed(() => { const filteredProviders = computed(() => {
let result = [...providers.value] let result = [...providers.value]
// 搜索筛选 // 搜索筛选(支持空格分隔的多关键词 AND 搜索)
if (searchQuery.value.trim()) { if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase() const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
result = result.filter(p => result = result.filter(p => {
p.display_name.toLowerCase().includes(query) || const searchableText = `${p.display_name} ${p.name}`.toLowerCase()
p.name.toLowerCase().includes(query) return keywords.every(keyword => searchableText.includes(keyword))
) })
} }
// 排序 // 排序

View File

@@ -7,6 +7,7 @@
<template #actions> <template #actions>
<Button <Button
:disabled="loading" :disabled="loading"
class="shadow-none hover:shadow-none"
@click="saveSystemConfig" @click="saveSystemConfig"
> >
{{ loading ? '保存中...' : '保存所有配置' }} {{ loading ? '保存中...' : '保存所有配置' }}
@@ -465,317 +466,303 @@
</div> </div>
<!-- 导入配置对话框 --> <!-- 导入配置对话框 -->
<Dialog v-model:open="importDialogOpen"> <Dialog
<DialogContent class="max-w-lg"> v-model:open="importDialogOpen"
<DialogHeader> title="导入配置"
<DialogTitle>导入配置</DialogTitle> description="选择冲突处理模式并确认导入"
<DialogDescription> >
选择冲突处理模式并确认导入 <div class="space-y-4">
</DialogDescription> <div
</DialogHeader> v-if="importPreview"
class="p-3 bg-muted rounded-lg text-sm"
<div class="space-y-4 py-4"> >
<div <p class="font-medium mb-2">
v-if="importPreview" 配置预览
class="p-3 bg-muted rounded-lg text-sm" </p>
> <ul class="space-y-1 text-muted-foreground">
<p class="font-medium mb-2"> <li>全局模型: {{ importPreview.global_models?.length || 0 }} </li>
配置预览 <li>提供商: {{ importPreview.providers?.length || 0 }} </li>
</p> <li>
<ul class="space-y-1 text-muted-foreground"> 端点: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + (p.endpoints?.length || 0), 0) }}
<li>全局模型: {{ importPreview.global_models?.length || 0 }} </li> </li>
<li>提供商: {{ importPreview.providers?.length || 0 }} </li> <li>
<li> API Keys: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + p.endpoints?.reduce((s: number, e: any) => s + (e.keys?.length || 0), 0), 0) }}
端点: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + (p.endpoints?.length || 0), 0) }} </li>
</li> </ul>
<li>
API Keys: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + p.endpoints?.reduce((s: number, e: any) => s + (e.keys?.length || 0), 0), 0) }}
</li>
</ul>
</div>
<div>
<Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
<Select v-model="mergeMode">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">
跳过 - 保留现有配置
</SelectItem>
<SelectItem value="overwrite">
覆盖 - 用导入配置替换
</SelectItem>
<SelectItem value="error">
报错 - 遇到冲突时中止
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
<template v-if="mergeMode === 'skip'">
已存在的配置将被保留仅导入新配置
</template>
<template v-else-if="mergeMode === 'overwrite'">
已存在的配置将被导入的配置覆盖
</template>
<template v-else>
如果发现任何冲突导入将中止并回滚
</template>
</p>
</div>
<div class="p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<p class="text-sm text-yellow-600 dark:text-yellow-400">
注意相同的 API Keys 会自动跳过不会创建重复记录
</p>
</div>
</div> </div>
<DialogFooter> <div>
<Button <Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
variant="outline" <Select
@click="importDialogOpen = false" v-model="mergeMode"
v-model:open="mergeModeSelectOpen"
> >
取消 <SelectTrigger>
</Button> <SelectValue />
<Button </SelectTrigger>
:disabled="importLoading" <SelectContent>
@click="confirmImport" <SelectItem value="skip">
> 跳过 - 保留现有配置
{{ importLoading ? '导入中...' : '确认导入' }} </SelectItem>
</Button> <SelectItem value="overwrite">
</DialogFooter> 覆盖 - 用导入配置替换
</DialogContent> </SelectItem>
<SelectItem value="error">
报错 - 遇到冲突时中止
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
<template v-if="mergeMode === 'skip'">
已存在的配置将被保留仅导入新配置
</template>
<template v-else-if="mergeMode === 'overwrite'">
已存在的配置将被导入的配置覆盖
</template>
<template v-else>
如果发现任何冲突导入将中止并回滚
</template>
</p>
</div>
<p class="text-xs text-muted-foreground">
注意相同的 API Keys 会自动跳过不会创建重复记录
</p>
</div>
<template #footer>
<Button
variant="outline"
@click="importDialogOpen = false; mergeModeSelectOpen = false"
>
取消
</Button>
<Button
:disabled="importLoading"
@click="confirmImport"
>
{{ importLoading ? '导入中...' : '确认导入' }}
</Button>
</template>
</Dialog> </Dialog>
<!-- 导入结果对话框 --> <!-- 导入结果对话框 -->
<Dialog v-model:open="importResultDialogOpen"> <Dialog
<DialogContent class="max-w-lg"> v-model:open="importResultDialogOpen"
<DialogHeader> title="导入完成"
<DialogTitle>导入完成</DialogTitle> >
</DialogHeader> <div
v-if="importResult"
<div class="space-y-4"
v-if="importResult" >
class="space-y-4 py-4" <div class="grid grid-cols-2 gap-4 text-sm">
> <div class="p-3 bg-muted rounded-lg">
<div class="grid grid-cols-2 gap-4 text-sm"> <p class="font-medium">
<div class="p-3 bg-muted rounded-lg"> 全局模型
<p class="font-medium"> </p>
全局模型 <p class="text-muted-foreground">
</p> 创建: {{ importResult.stats.global_models.created }},
<p class="text-muted-foreground"> 更新: {{ importResult.stats.global_models.updated }},
创建: {{ importResult.stats.global_models.created }}, 跳过: {{ importResult.stats.global_models.skipped }}
更新: {{ importResult.stats.global_models.updated }}, </p>
跳过: {{ importResult.stats.global_models.skipped }} </div>
</p> <div class="p-3 bg-muted rounded-lg">
</div> <p class="font-medium">
<div class="p-3 bg-muted rounded-lg"> 提供商
<p class="font-medium"> </p>
提供商 <p class="text-muted-foreground">
</p> 创建: {{ importResult.stats.providers.created }},
<p class="text-muted-foreground"> 更新: {{ importResult.stats.providers.updated }},
创建: {{ importResult.stats.providers.created }}, 跳过: {{ importResult.stats.providers.skipped }}
更新: {{ importResult.stats.providers.updated }}, </p>
跳过: {{ importResult.stats.providers.skipped }} </div>
</p> <div class="p-3 bg-muted rounded-lg">
</div> <p class="font-medium">
<div class="p-3 bg-muted rounded-lg"> 端点
<p class="font-medium"> </p>
端点 <p class="text-muted-foreground">
</p> 创建: {{ importResult.stats.endpoints.created }},
<p class="text-muted-foreground"> 更新: {{ importResult.stats.endpoints.updated }},
创建: {{ importResult.stats.endpoints.created }}, 跳过: {{ importResult.stats.endpoints.skipped }}
更新: {{ importResult.stats.endpoints.updated }}, </p>
跳过: {{ importResult.stats.endpoints.skipped }} </div>
</p> <div class="p-3 bg-muted rounded-lg">
</div> <p class="font-medium">
<div class="p-3 bg-muted rounded-lg"> API Keys
<p class="font-medium"> </p>
API Keys <p class="text-muted-foreground">
</p> 创建: {{ importResult.stats.keys.created }},
<p class="text-muted-foreground"> 跳过: {{ importResult.stats.keys.skipped }}
创建: {{ importResult.stats.keys.created }}, </p>
跳过: {{ importResult.stats.keys.skipped }} </div>
</p> <div class="p-3 bg-muted rounded-lg col-span-2">
</div> <p class="font-medium">
<div class="p-3 bg-muted rounded-lg col-span-2"> 模型配置
<p class="font-medium"> </p>
模型配置 <p class="text-muted-foreground">
</p> 创建: {{ importResult.stats.models.created }},
<p class="text-muted-foreground"> 更新: {{ importResult.stats.models.updated }},
创建: {{ importResult.stats.models.created }}, 跳过: {{ importResult.stats.models.skipped }}
更新: {{ importResult.stats.models.updated }},
跳过: {{ importResult.stats.models.skipped }}
</p>
</div>
</div>
<div
v-if="importResult.stats.errors.length > 0"
class="p-3 bg-red-500/10 border border-red-500/20 rounded-lg"
>
<p class="font-medium text-red-600 dark:text-red-400 mb-2">
警告信息
</p> </p>
<ul class="text-sm text-red-600 dark:text-red-400 space-y-1">
<li
v-for="(err, index) in importResult.stats.errors"
:key="index"
>
{{ err }}
</li>
</ul>
</div> </div>
</div> </div>
<DialogFooter> <div
<Button @click="importResultDialogOpen = false"> v-if="importResult.stats.errors.length > 0"
确定 class="p-3 bg-destructive/10 rounded-lg"
</Button> >
</DialogFooter> <p class="font-medium text-destructive mb-2">
</DialogContent> 警告信息
</p>
<ul class="text-sm text-destructive space-y-1">
<li
v-for="(err, index) in importResult.stats.errors"
:key="index"
>
{{ err }}
</li>
</ul>
</div>
</div>
<template #footer>
<Button @click="importResultDialogOpen = false">
确定
</Button>
</template>
</Dialog> </Dialog>
<!-- 用户数据导入对话框 --> <!-- 用户数据导入对话框 -->
<Dialog v-model:open="importUsersDialogOpen"> <Dialog
<DialogContent class="max-w-lg"> v-model:open="importUsersDialogOpen"
<DialogHeader> title="导入用户数据"
<DialogTitle>导入用户数据</DialogTitle> description="选择冲突处理模式并确认导入"
<DialogDescription> >
选择冲突处理模式并确认导入 <div class="space-y-4">
</DialogDescription> <div
</DialogHeader> v-if="importUsersPreview"
class="p-3 bg-muted rounded-lg text-sm"
<div class="space-y-4 py-4"> >
<div <p class="font-medium mb-2">
v-if="importUsersPreview" 数据预览
class="p-3 bg-muted rounded-lg text-sm" </p>
> <ul class="space-y-1 text-muted-foreground">
<p class="font-medium mb-2"> <li>用户: {{ importUsersPreview.users?.length || 0 }} </li>
数据预览 <li>
</p> API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }}
<ul class="space-y-1 text-muted-foreground"> </li>
<li>用户: {{ importUsersPreview.users?.length || 0 }} </li> </ul>
<li>
API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }}
</li>
</ul>
</div>
<div>
<Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
<Select v-model="usersMergeMode">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">
跳过 - 保留现有用户
</SelectItem>
<SelectItem value="overwrite">
覆盖 - 用导入数据替换
</SelectItem>
<SelectItem value="error">
报错 - 遇到冲突时中止
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
<template v-if="usersMergeMode === 'skip'">
已存在的用户将被保留仅导入新用户
</template>
<template v-else-if="usersMergeMode === 'overwrite'">
已存在的用户将被导入的数据覆盖
</template>
<template v-else>
如果发现任何冲突导入将中止并回滚
</template>
</p>
</div>
<div class="p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<p class="text-sm text-yellow-600 dark:text-yellow-400">
注意用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY 环境变量才能正常工作
</p>
</div>
</div> </div>
<DialogFooter> <div>
<Button <Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
variant="outline" <Select
@click="importUsersDialogOpen = false" v-model="usersMergeMode"
v-model:open="usersMergeModeSelectOpen"
> >
取消 <SelectTrigger>
</Button> <SelectValue />
<Button </SelectTrigger>
:disabled="importUsersLoading" <SelectContent>
@click="confirmImportUsers" <SelectItem value="skip">
> 跳过 - 保留现有用户
{{ importUsersLoading ? '导入中...' : '确认导入' }} </SelectItem>
</Button> <SelectItem value="overwrite">
</DialogFooter> 覆盖 - 用导入数据替换
</DialogContent> </SelectItem>
<SelectItem value="error">
报错 - 遇到冲突时中止
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
<template v-if="usersMergeMode === 'skip'">
已存在的用户将被保留仅导入新用户
</template>
<template v-else-if="usersMergeMode === 'overwrite'">
已存在的用户将被导入的数据覆盖
</template>
<template v-else>
如果发现任何冲突导入将中止并回滚
</template>
</p>
</div>
<p class="text-xs text-muted-foreground">
注意用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY 环境变量才能正常工作
</p>
</div>
<template #footer>
<Button
variant="outline"
@click="importUsersDialogOpen = false; usersMergeModeSelectOpen = false"
>
取消
</Button>
<Button
:disabled="importUsersLoading"
@click="confirmImportUsers"
>
{{ importUsersLoading ? '导入中...' : '确认导入' }}
</Button>
</template>
</Dialog> </Dialog>
<!-- 用户数据导入结果对话框 --> <!-- 用户数据导入结果对话框 -->
<Dialog v-model:open="importUsersResultDialogOpen"> <Dialog
<DialogContent class="max-w-lg"> v-model:open="importUsersResultDialogOpen"
<DialogHeader> title="用户数据导入完成"
<DialogTitle>用户数据导入完成</DialogTitle> >
</DialogHeader> <div
v-if="importUsersResult"
<div class="space-y-4"
v-if="importUsersResult" >
class="space-y-4 py-4" <div class="grid grid-cols-2 gap-4 text-sm">
> <div class="p-3 bg-muted rounded-lg">
<div class="grid grid-cols-2 gap-4 text-sm"> <p class="font-medium">
<div class="p-3 bg-muted rounded-lg"> 用户
<p class="font-medium"> </p>
用户 <p class="text-muted-foreground">
</p> 创建: {{ importUsersResult.stats.users.created }},
<p class="text-muted-foreground"> 更新: {{ importUsersResult.stats.users.updated }},
创建: {{ importUsersResult.stats.users.created }}, 跳过: {{ importUsersResult.stats.users.skipped }}
更新: {{ importUsersResult.stats.users.updated }}, </p>
跳过: {{ importUsersResult.stats.users.skipped }} </div>
</p> <div class="p-3 bg-muted rounded-lg">
</div> <p class="font-medium">
<div class="p-3 bg-muted rounded-lg"> API Keys
<p class="font-medium"> </p>
API Keys <p class="text-muted-foreground">
</p> 创建: {{ importUsersResult.stats.api_keys.created }},
<p class="text-muted-foreground"> 跳过: {{ importUsersResult.stats.api_keys.skipped }}
创建: {{ importUsersResult.stats.api_keys.created }},
跳过: {{ importUsersResult.stats.api_keys.skipped }}
</p>
</div>
</div>
<div
v-if="importUsersResult.stats.errors.length > 0"
class="p-3 bg-red-500/10 border border-red-500/20 rounded-lg"
>
<p class="font-medium text-red-600 dark:text-red-400 mb-2">
警告信息
</p> </p>
<ul class="text-sm text-red-600 dark:text-red-400 space-y-1">
<li
v-for="(err, index) in importUsersResult.stats.errors"
:key="index"
>
{{ err }}
</li>
</ul>
</div> </div>
</div> </div>
<DialogFooter> <div
<Button @click="importUsersResultDialogOpen = false"> v-if="importUsersResult.stats.errors.length > 0"
确定 class="p-3 bg-destructive/10 rounded-lg"
</Button> >
</DialogFooter> <p class="font-medium text-destructive mb-2">
</DialogContent> 警告信息
</p>
<ul class="text-sm text-destructive space-y-1">
<li
v-for="(err, index) in importUsersResult.stats.errors"
:key="index"
>
{{ err }}
</li>
</ul>
</div>
</div>
<template #footer>
<Button @click="importUsersResultDialogOpen = false">
确定
</Button>
</template>
</Dialog> </Dialog>
</PageContainer> </PageContainer>
</template> </template>
@@ -794,11 +781,6 @@ import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue' import SelectItem from '@/components/ui/select-item.vue'
import { import {
Dialog, Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter
} from '@/components/ui' } from '@/components/ui'
import { PageHeader, PageContainer, CardSection } from '@/components/layout' import { PageHeader, PageContainer, CardSection } from '@/components/layout'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
@@ -843,6 +825,7 @@ const configFileInput = ref<HTMLInputElement | null>(null)
const importPreview = ref<ConfigExportData | null>(null) const importPreview = ref<ConfigExportData | null>(null)
const importResult = ref<ConfigImportResponse | null>(null) const importResult = ref<ConfigImportResponse | null>(null)
const mergeMode = ref<'skip' | 'overwrite' | 'error'>('skip') const mergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
const mergeModeSelectOpen = ref(false)
// 用户数据导出/导入相关 // 用户数据导出/导入相关
const exportUsersLoading = ref(false) const exportUsersLoading = ref(false)
@@ -853,6 +836,7 @@ const usersFileInput = ref<HTMLInputElement | null>(null)
const importUsersPreview = ref<UsersExportData | null>(null) const importUsersPreview = ref<UsersExportData | null>(null)
const importUsersResult = ref<UsersImportResponse | null>(null) const importUsersResult = ref<UsersImportResponse | null>(null)
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip') const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
const usersMergeModeSelectOpen = ref(false)
const systemConfig = ref<SystemConfig>({ const systemConfig = ref<SystemConfig>({
// 基础配置 // 基础配置
@@ -1136,6 +1120,7 @@ async function confirmImport() {
}) })
importResult.value = result importResult.value = result
importDialogOpen.value = false importDialogOpen.value = false
mergeModeSelectOpen.value = false
importResultDialogOpen.value = true importResultDialogOpen.value = true
success('配置导入成功') success('配置导入成功')
} catch (err: any) { } catch (err: any) {
@@ -1224,6 +1209,7 @@ async function confirmImportUsers() {
}) })
importUsersResult.value = result importUsersResult.value = result
importUsersDialogOpen.value = false importUsersDialogOpen.value = false
usersMergeModeSelectOpen.value = false
importUsersResultDialogOpen.value = true importUsersResultDialogOpen.value = true
success('用户数据导入成功') success('用户数据导入成功')
} catch (err: any) { } catch (err: any) {

View File

@@ -791,11 +791,13 @@ const filteredUsers = computed(() => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime() return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
}) })
// 搜索(支持空格分隔的多关键词 AND 搜索)
if (searchQuery.value) { if (searchQuery.value) {
const query = searchQuery.value.toLowerCase() const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
filtered = filtered.filter( filtered = filtered.filter(u => {
u => u.username.toLowerCase().includes(query) || u.email?.toLowerCase().includes(query) const searchableText = `${u.username} ${u.email || ''}`.toLowerCase()
) return keywords.every(keyword => searchableText.includes(keyword))
})
} }
if (filterRole.value !== 'all') { if (filterRole.value !== 'all') {

View File

@@ -103,7 +103,7 @@
</div> </div>
<div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4"> <div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Card class="relative p-3 sm:p-4 border-book-cloth/30"> <Card class="relative p-3 sm:p-4 border-book-cloth/30">
<Clock class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-book-cloth" /> <Clock 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">
平均响应 平均响应
@@ -114,7 +114,7 @@
</div> </div>
</Card> </Card>
<Card class="relative p-3 sm:p-4 border-kraft/30"> <Card class="relative p-3 sm:p-4 border-kraft/30">
<AlertTriangle class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-kraft" /> <AlertTriangle 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">
错误率 错误率
@@ -128,7 +128,7 @@
</div> </div>
</Card> </Card>
<Card class="relative p-3 sm:p-4 border-book-cloth/25"> <Card class="relative p-3 sm:p-4 border-book-cloth/25">
<Shuffle class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-kraft" /> <Shuffle 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">
转移次数 转移次数
@@ -142,7 +142,7 @@
v-if="costStats" v-if="costStats"
class="relative p-3 sm:p-4 border-manilla/40" class="relative p-3 sm:p-4 border-manilla/40"
> >
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-book-cloth" /> <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">
实际成本 实际成本
@@ -180,7 +180,7 @@
</div> </div>
<div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4"> <div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Card class="relative p-3 sm:p-4 border-book-cloth/30"> <Card 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-book-cloth" /> <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">
缓存命中率 缓存命中率
@@ -191,7 +191,7 @@
</div> </div>
</Card> </Card>
<Card class="relative p-3 sm:p-4 border-kraft/30"> <Card 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-kraft" /> <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">
缓存读取 缓存读取
@@ -202,7 +202,7 @@
</div> </div>
</Card> </Card>
<Card class="relative p-3 sm:p-4 border-book-cloth/25"> <Card 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-kraft" /> <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">
缓存创建 缓存创建
@@ -216,7 +216,7 @@
v-if="tokenBreakdown" v-if="tokenBreakdown"
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-book-cloth" /> <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">
总Token 总Token
@@ -254,16 +254,16 @@
<Card class="overflow-hidden p-4 flex flex-col flex-1 min-h-0 h-full max-h-[280px] sm:max-h-none"> <Card class="overflow-hidden p-4 flex flex-col flex-1 min-h-0 h-full max-h-[280px] sm:max-h-none">
<div <div
v-if="loadingAnnouncements" v-if="loadingAnnouncements"
class="py-8 text-center" class="flex-1 flex items-center justify-center"
> >
<Loader2 class="h-5 w-5 animate-spin mx-auto text-muted-foreground" /> <Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
</div> </div>
<div <div
v-else-if="announcements.length === 0" v-else-if="announcements.length === 0"
class="py-8 text-center" class="flex-1 flex flex-col items-center justify-center"
> >
<Bell class="h-8 w-8 mx-auto text-muted-foreground/40" /> <Bell class="h-8 w-8 text-muted-foreground/40" />
<p class="mt-2 text-xs text-muted-foreground"> <p class="mt-2 text-xs text-muted-foreground">
暂无公告 暂无公告
</p> </p>
@@ -793,9 +793,8 @@ const statCardGlows = [
'bg-kraft/30' 'bg-kraft/30'
] ]
const getStatIconColor = (index: number): string => { const getStatIconColor = (_index: number): string => {
const colors = ['text-book-cloth', 'text-kraft', 'text-book-cloth', 'text-kraft'] return 'text-muted-foreground'
return colors[index % colors.length]
} }
// 统计数据 // 统计数据

View File

@@ -474,13 +474,13 @@ async function toggleCapability(modelName: string, capName: string) {
const filteredModels = computed(() => { const filteredModels = computed(() => {
let result = models.value let result = models.value
// 搜索 // 搜索(支持空格分隔的多关键词 AND 搜索)
if (searchQuery.value) { if (searchQuery.value) {
const query = searchQuery.value.toLowerCase() const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
result = result.filter(m => result = result.filter(m => {
m.name.toLowerCase().includes(query) || const searchableText = `${m.name} ${m.display_name || ''}`.toLowerCase()
m.display_name?.toLowerCase().includes(query) return keywords.every(keyword => searchableText.includes(keyword))
) })
} }
// 能力筛选 // 能力筛选

View File

@@ -62,6 +62,7 @@
<Button <Button
type="submit" type="submit"
:disabled="savingProfile" :disabled="savingProfile"
class="shadow-none hover:shadow-none"
> >
{{ savingProfile ? '保存中...' : '保存修改' }} {{ savingProfile ? '保存中...' : '保存修改' }}
</Button> </Button>
@@ -107,6 +108,7 @@
<Button <Button
type="submit" type="submit"
:disabled="changingPassword" :disabled="changingPassword"
class="shadow-none hover:shadow-none"
> >
{{ changingPassword ? '修改中...' : '修改密码' }} {{ changingPassword ? '修改中...' : '修改密码' }}
</Button> </Button>
@@ -320,6 +322,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { meApi, type Profile } from '@/api/me' import { meApi, type Profile } from '@/api/me'
import { useDarkMode, type ThemeMode } from '@/composables/useDarkMode'
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 Badge from '@/components/ui/badge.vue' import Badge from '@/components/ui/badge.vue'
@@ -338,6 +341,7 @@ import { log } from '@/utils/logger'
const authStore = useAuthStore() const authStore = useAuthStore()
const { success, error: showError } = useToast() const { success, error: showError } = useToast()
const { setThemeMode } = useDarkMode()
const profile = ref<Profile | null>(null) const profile = ref<Profile | null>(null)
@@ -375,20 +379,8 @@ function handleThemeChange(value: string) {
themeSelectOpen.value = false themeSelectOpen.value = false
updatePreferences() updatePreferences()
// 应用主题 // 使用 useDarkMode 统一切换主题
if (value === 'dark') { setThemeMode(value as ThemeMode)
document.documentElement.classList.add('dark')
} else if (value === 'light') {
document.documentElement.classList.remove('dark')
} else {
// system: 跟随系统
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (prefersDark) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
} }
function handleLanguageChange(value: string) { function handleLanguageChange(value: string) {
@@ -418,10 +410,16 @@ async function loadProfile() {
async function loadPreferences() { async function loadPreferences() {
try { try {
const prefs = await meApi.getPreferences() const prefs = await meApi.getPreferences()
// 主题以本地 localStorage 为准useDarkMode 在应用启动时已初始化)
// 这样可以避免刷新页面时主题被服务端旧值覆盖
const { themeMode: currentThemeMode } = useDarkMode()
const localTheme = currentThemeMode.value
preferencesForm.value = { preferencesForm.value = {
avatar_url: prefs.avatar_url || '', avatar_url: prefs.avatar_url || '',
bio: prefs.bio || '', bio: prefs.bio || '',
theme: prefs.theme || 'light', theme: localTheme, // 使用本地主题,而非服务端返回值
language: prefs.language || 'zh-CN', language: prefs.language || 'zh-CN',
timezone: prefs.timezone || 'Asia/Shanghai', timezone: prefs.timezone || 'Asia/Shanghai',
notifications: { notifications: {
@@ -431,11 +429,12 @@ async function loadPreferences() {
} }
} }
// 应用主题 // 如果本地主题和服务端不一致,同步到服务端(静默更新,不提示用户)
if (preferencesForm.value.theme === 'dark') { const serverTheme = prefs.theme || 'light'
document.documentElement.classList.add('dark') if (localTheme !== serverTheme) {
} else if (preferencesForm.value.theme === 'light') { meApi.updatePreferences({ theme: localTheme }).catch(() => {
document.documentElement.classList.remove('dark') // 静默失败,不影响用户体验
})
} }
} catch (error) { } catch (error) {
log.error('加载偏好设置失败:', error) log.error('加载偏好设置失败:', error)

View File

@@ -7,6 +7,7 @@ from .api_keys import router as api_keys_router
from .endpoints import router as endpoints_router from .endpoints import router as endpoints_router
from .models import router as models_router from .models import router as models_router
from .monitoring import router as monitoring_router from .monitoring import router as monitoring_router
from .provider_query import router as provider_query_router
from .provider_strategy import router as provider_strategy_router from .provider_strategy import router as provider_strategy_router
from .providers import router as providers_router from .providers import router as providers_router
from .security import router as security_router from .security import router as security_router
@@ -26,5 +27,6 @@ router.include_router(provider_strategy_router)
router.include_router(adaptive_router) router.include_router(adaptive_router)
router.include_router(models_router) router.include_router(models_router)
router.include_router(security_router) router.include_router(security_router)
router.include_router(provider_query_router)
__all__ = ["router"] __all__ = ["router"]

View File

@@ -21,7 +21,8 @@ from src.core.logger import logger
from src.database import get_db from src.database import get_db
from src.models.database import ApiKey, User from src.models.database import ApiKey, User
from src.services.cache.affinity_manager import get_affinity_manager from src.services.cache.affinity_manager import get_affinity_manager
from src.services.cache.aware_scheduler import get_cache_aware_scheduler from src.services.cache.aware_scheduler import CacheAwareScheduler, get_cache_aware_scheduler
from src.services.system.config import SystemConfigService
router = APIRouter(prefix="/api/admin/monitoring/cache", tags=["Admin - Monitoring: Cache"]) router = APIRouter(prefix="/api/admin/monitoring/cache", tags=["Admin - Monitoring: Cache"])
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
@@ -250,7 +251,22 @@ class AdminCacheStatsAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Dict[str, Any]: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Dict[str, Any]: # type: ignore[override]
try: try:
redis_client = get_redis_client_sync() redis_client = get_redis_client_sync()
scheduler = await get_cache_aware_scheduler(redis_client) # 读取系统配置,确保监控接口与编排器使用一致的模式
priority_mode = SystemConfigService.get_config(
context.db,
"provider_priority_mode",
CacheAwareScheduler.PRIORITY_MODE_PROVIDER,
)
scheduling_mode = SystemConfigService.get_config(
context.db,
"scheduling_mode",
CacheAwareScheduler.SCHEDULING_MODE_CACHE_AFFINITY,
)
scheduler = await get_cache_aware_scheduler(
redis_client,
priority_mode=priority_mode,
scheduling_mode=scheduling_mode,
)
stats = await scheduler.get_stats() stats = await scheduler.get_stats()
logger.info("缓存统计信息查询成功") logger.info("缓存统计信息查询成功")
context.add_audit_metadata( context.add_audit_metadata(
@@ -270,7 +286,22 @@ class AdminCacheMetricsAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> PlainTextResponse: async def handle(self, context: ApiRequestContext) -> PlainTextResponse:
try: try:
redis_client = get_redis_client_sync() redis_client = get_redis_client_sync()
scheduler = await get_cache_aware_scheduler(redis_client) # 读取系统配置,确保监控接口与编排器使用一致的模式
priority_mode = SystemConfigService.get_config(
context.db,
"provider_priority_mode",
CacheAwareScheduler.PRIORITY_MODE_PROVIDER,
)
scheduling_mode = SystemConfigService.get_config(
context.db,
"scheduling_mode",
CacheAwareScheduler.SCHEDULING_MODE_CACHE_AFFINITY,
)
scheduler = await get_cache_aware_scheduler(
redis_client,
priority_mode=priority_mode,
scheduling_mode=scheduling_mode,
)
stats = await scheduler.get_stats() stats = await scheduler.get_stats()
payload = self._format_prometheus(stats) payload = self._format_prometheus(stats)
context.add_audit_metadata( context.add_audit_metadata(

View File

@@ -1,46 +1,28 @@
""" """
Provider Query API 端点 Provider Query API 端点
用于查询提供商的余额、使用记录等信息 用于查询提供商的模型列表等信息
""" """
from datetime import datetime import asyncio
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session, joinedload
from src.core.crypto import crypto_service
from src.core.logger import logger from src.core.logger import logger
from src.database.database import get_db from src.database.database import get_db
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint, User from src.models.database import Provider, ProviderEndpoint, User
# 初始化适配器注册
from src.plugins.provider_query import init # noqa
from src.plugins.provider_query import get_query_registry
from src.plugins.provider_query.base import QueryCapability
from src.utils.auth_utils import get_current_user from src.utils.auth_utils import get_current_user
router = APIRouter(prefix="/provider-query", tags=["Provider Query"]) router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"])
# ============ Request/Response Models ============ # ============ Request/Response Models ============
class BalanceQueryRequest(BaseModel):
"""余额查询请求"""
provider_id: str
api_key_id: Optional[str] = None # 如果不指定,使用提供商的第一个可用 API Key
class UsageSummaryQueryRequest(BaseModel):
"""使用汇总查询请求"""
provider_id: str
api_key_id: Optional[str] = None
period: str = "month" # day, week, month, year
class ModelsQueryRequest(BaseModel): class ModelsQueryRequest(BaseModel):
"""模型列表查询请求""" """模型列表查询请求"""
@@ -51,360 +33,281 @@ class ModelsQueryRequest(BaseModel):
# ============ API Endpoints ============ # ============ API Endpoints ============
@router.get("/adapters") async def _fetch_openai_models(
async def list_adapters( client: httpx.AsyncClient,
current_user: User = Depends(get_current_user), base_url: str,
): api_key: str,
""" api_format: str,
获取所有可用的查询适配器 extra_headers: Optional[dict] = None,
) -> tuple[list, Optional[str]]:
"""获取 OpenAI 格式的模型列表
Returns: Returns:
适配器列表 tuple[list, Optional[str]]: (模型列表, 错误信息)
""" """
registry = get_query_registry() headers = {"Authorization": f"Bearer {api_key}"}
adapters = registry.list_adapters() if extra_headers:
# 防止 extra_headers 覆盖 Authorization
safe_headers = {k: v for k, v in extra_headers.items() if k.lower() != "authorization"}
headers.update(safe_headers)
return {"success": True, "data": adapters} # 构建 /v1/models URL
if base_url.endswith("/v1"):
models_url = f"{base_url}/models"
else:
models_url = f"{base_url}/v1/models"
try:
response = await client.get(models_url, headers=headers)
logger.debug(f"OpenAI models request to {models_url}: status={response.status_code}")
if response.status_code == 200:
data = response.json()
models = []
if "data" in data:
models = data["data"]
elif isinstance(data, list):
models = data
# 为每个模型添加 api_format 字段
for m in models:
m["api_format"] = api_format
return models, None
else:
# 记录详细的错误信息
error_body = response.text[:500] if response.text else "(empty)"
error_msg = f"HTTP {response.status_code}: {error_body}"
logger.warning(f"OpenAI models request to {models_url} failed: {error_msg}")
return [], error_msg
except Exception as e:
error_msg = f"Request error: {str(e)}"
logger.warning(f"Failed to fetch models from {models_url}: {e}")
return [], error_msg
@router.get("/capabilities/{provider_id}") async def _fetch_claude_models(
async def get_provider_capabilities( client: httpx.AsyncClient, base_url: str, api_key: str, api_format: str
provider_id: str, ) -> tuple[list, Optional[str]]:
db: AsyncSession = Depends(get_db), """获取 Claude 格式的模型列表
current_user: User = Depends(get_current_user),
):
"""
获取提供商支持的查询能力
Args:
provider_id: 提供商 ID
Returns: Returns:
支持的查询能力列表 tuple[list, Optional[str]]: (模型列表, 错误信息)
""" """
# 获取提供商 headers = {
from sqlalchemy import select "x-api-key": api_key,
"Authorization": f"Bearer {api_key}",
result = await db.execute(select(Provider).where(Provider.id == provider_id)) "anthropic-version": "2023-06-01",
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
registry = get_query_registry()
capabilities = registry.get_capabilities_for_provider(provider.name)
if capabilities is None:
return {
"success": True,
"data": {
"provider_id": provider_id,
"provider_name": provider.name,
"capabilities": [],
"has_adapter": False,
"message": "No query adapter available for this provider",
},
}
return {
"success": True,
"data": {
"provider_id": provider_id,
"provider_name": provider.name,
"capabilities": [c.name for c in capabilities],
"has_adapter": True,
},
} }
# 构建 /v1/models URL
if base_url.endswith("/v1"):
models_url = f"{base_url}/models"
else:
models_url = f"{base_url}/v1/models"
@router.post("/balance") try:
async def query_balance( response = await client.get(models_url, headers=headers)
request: BalanceQueryRequest, logger.debug(f"Claude models request to {models_url}: status={response.status_code}")
db: AsyncSession = Depends(get_db), if response.status_code == 200:
current_user: User = Depends(get_current_user), data = response.json()
): models = []
""" if "data" in data:
查询提供商余额 models = data["data"]
elif isinstance(data, list):
models = data
# 为每个模型添加 api_format 字段
for m in models:
m["api_format"] = api_format
return models, None
else:
error_body = response.text[:500] if response.text else "(empty)"
error_msg = f"HTTP {response.status_code}: {error_body}"
logger.warning(f"Claude models request to {models_url} failed: {error_msg}")
return [], error_msg
except Exception as e:
error_msg = f"Request error: {str(e)}"
logger.warning(f"Failed to fetch Claude models from {models_url}: {e}")
return [], error_msg
Args:
request: 查询请求 async def _fetch_gemini_models(
client: httpx.AsyncClient, base_url: str, api_key: str, api_format: str
) -> tuple[list, Optional[str]]:
"""获取 Gemini 格式的模型列表
Returns: Returns:
余额信息 tuple[list, Optional[str]]: (模型列表, 错误信息)
""" """
from sqlalchemy import select # 兼容 base_url 已包含 /v1beta 的情况
from sqlalchemy.orm import selectinload base_url_clean = base_url.rstrip("/")
if base_url_clean.endswith("/v1beta"):
models_url = f"{base_url_clean}/models?key={api_key}"
else:
models_url = f"{base_url_clean}/v1beta/models?key={api_key}"
# 获取提供商及其端点 try:
result = await db.execute( response = await client.get(models_url)
select(Provider) logger.debug(f"Gemini models request to {models_url}: status={response.status_code}")
.options(selectinload(Provider.endpoints).selectinload(ProviderEndpoint.api_keys)) if response.status_code == 200:
.where(Provider.id == request.provider_id) data = response.json()
) if "models" in data:
provider = result.scalar_one_or_none() # 转换为统一格式
return [
if not provider: {
raise HTTPException(status_code=404, detail="Provider not found") "id": m.get("name", "").replace("models/", ""),
"owned_by": "google",
# 获取 API Key "display_name": m.get("displayName", ""),
api_key_value = None "api_format": api_format,
endpoint_config = None
if request.api_key_id:
# 查找指定的 API Key
for endpoint in provider.endpoints:
for api_key in endpoint.api_keys:
if api_key.id == request.api_key_id:
api_key_value = api_key.api_key
endpoint_config = {
"base_url": endpoint.base_url,
"api_format": endpoint.api_format if endpoint.api_format else None,
} }
break for m in data["models"]
if api_key_value: ], None
break return [], None
else:
if not api_key_value: error_body = response.text[:500] if response.text else "(empty)"
raise HTTPException(status_code=404, detail="API Key not found") error_msg = f"HTTP {response.status_code}: {error_body}"
else: logger.warning(f"Gemini models request to {models_url} failed: {error_msg}")
# 使用第一个可用的 API Key return [], error_msg
for endpoint in provider.endpoints: except Exception as e:
if endpoint.is_active and endpoint.api_keys: error_msg = f"Request error: {str(e)}"
for api_key in endpoint.api_keys: logger.warning(f"Failed to fetch Gemini models from {models_url}: {e}")
if api_key.is_active: return [], error_msg
api_key_value = api_key.api_key
endpoint_config = {
"base_url": endpoint.base_url,
"api_format": endpoint.api_format if endpoint.api_format else None,
}
break
if api_key_value:
break
if not api_key_value:
raise HTTPException(status_code=400, detail="No active API Key found for this provider")
# 查询余额
registry = get_query_registry()
query_result = await registry.query_provider_balance(
provider_type=provider.name, api_key=api_key_value, endpoint_config=endpoint_config
)
if not query_result.success:
logger.warning(f"Balance query failed for provider {provider.name}: {query_result.error}")
return {
"success": query_result.success,
"data": query_result.to_dict(),
"provider": {
"id": provider.id,
"name": provider.name,
"display_name": provider.display_name,
},
}
@router.post("/usage-summary")
async def query_usage_summary(
request: UsageSummaryQueryRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
查询提供商使用汇总
Args:
request: 查询请求
Returns:
使用汇总信息
"""
from sqlalchemy import select
from sqlalchemy.orm import selectinload
# 获取提供商及其端点
result = await db.execute(
select(Provider)
.options(selectinload(Provider.endpoints).selectinload(ProviderEndpoint.api_keys))
.where(Provider.id == request.provider_id)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
# 获取 API Key逻辑同上
api_key_value = None
endpoint_config = None
if request.api_key_id:
for endpoint in provider.endpoints:
for api_key in endpoint.api_keys:
if api_key.id == request.api_key_id:
api_key_value = api_key.api_key
endpoint_config = {"base_url": endpoint.base_url}
break
if api_key_value:
break
if not api_key_value:
raise HTTPException(status_code=404, detail="API Key not found")
else:
for endpoint in provider.endpoints:
if endpoint.is_active and endpoint.api_keys:
for api_key in endpoint.api_keys:
if api_key.is_active:
api_key_value = api_key.api_key
endpoint_config = {"base_url": endpoint.base_url}
break
if api_key_value:
break
if not api_key_value:
raise HTTPException(status_code=400, detail="No active API Key found for this provider")
# 查询使用汇总
registry = get_query_registry()
query_result = await registry.query_provider_usage(
provider_type=provider.name,
api_key=api_key_value,
period=request.period,
endpoint_config=endpoint_config,
)
return {
"success": query_result.success,
"data": query_result.to_dict(),
"provider": {
"id": provider.id,
"name": provider.name,
"display_name": provider.display_name,
},
}
@router.post("/models") @router.post("/models")
async def query_available_models( async def query_available_models(
request: ModelsQueryRequest, request: ModelsQueryRequest,
db: AsyncSession = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
查询提供商可用模型 查询提供商可用模型
遍历所有活跃端点,根据端点的 API 格式选择正确的请求方式:
- OPENAI/OPENAI_CLI: /v1/models (Bearer token)
- CLAUDE/CLAUDE_CLI: /v1/models (x-api-key)
- GEMINI/GEMINI_CLI: /v1beta/models (URL key parameter)
Args: Args:
request: 查询请求 request: 查询请求
Returns: Returns:
模型列表 所有端点的模型列表(合并)
""" """
from sqlalchemy import select
from sqlalchemy.orm import selectinload
# 获取提供商及其端点 # 获取提供商及其端点
result = await db.execute( provider = (
select(Provider) db.query(Provider)
.options(selectinload(Provider.endpoints).selectinload(ProviderEndpoint.api_keys)) .options(joinedload(Provider.endpoints).joinedload(ProviderEndpoint.api_keys))
.where(Provider.id == request.provider_id) .filter(Provider.id == request.provider_id)
.first()
) )
provider = result.scalar_one_or_none()
if not provider: if not provider:
raise HTTPException(status_code=404, detail="Provider not found") raise HTTPException(status_code=404, detail="Provider not found")
# 获取 API Key # 收集所有活跃端点的配置
api_key_value = None endpoint_configs: list[dict] = []
endpoint_config = None
if request.api_key_id: if request.api_key_id:
# 指定了特定的 API Key只使用该 Key 对应的端点
for endpoint in provider.endpoints: for endpoint in provider.endpoints:
for api_key in endpoint.api_keys: for api_key in endpoint.api_keys:
if api_key.id == request.api_key_id: if api_key.id == request.api_key_id:
api_key_value = api_key.api_key try:
endpoint_config = {"base_url": endpoint.base_url} api_key_value = crypto_service.decrypt(api_key.api_key)
except Exception as e:
logger.error(f"Failed to decrypt API key: {e}")
raise HTTPException(status_code=500, detail="Failed to decrypt API key")
endpoint_configs.append({
"api_key": api_key_value,
"base_url": endpoint.base_url,
"api_format": endpoint.api_format,
"extra_headers": endpoint.headers,
})
break break
if api_key_value: if endpoint_configs:
break break
if not api_key_value: if not endpoint_configs:
raise HTTPException(status_code=404, detail="API Key not found") raise HTTPException(status_code=404, detail="API Key not found")
else: else:
# 遍历所有活跃端点,每个端点取第一个可用的 Key
for endpoint in provider.endpoints: for endpoint in provider.endpoints:
if endpoint.is_active and endpoint.api_keys: if not endpoint.is_active or not endpoint.api_keys:
for api_key in endpoint.api_keys: continue
if api_key.is_active:
api_key_value = api_key.api_key
endpoint_config = {"base_url": endpoint.base_url}
break
if api_key_value:
break
if not api_key_value: # 找第一个可用的 Key
for api_key in endpoint.api_keys:
if api_key.is_active:
try:
api_key_value = crypto_service.decrypt(api_key.api_key)
except Exception as e:
logger.error(f"Failed to decrypt API key: {e}")
continue # 尝试下一个 Key
endpoint_configs.append({
"api_key": api_key_value,
"base_url": endpoint.base_url,
"api_format": endpoint.api_format,
"extra_headers": endpoint.headers,
})
break # 只取第一个可用的 Key
if not endpoint_configs:
raise HTTPException(status_code=400, detail="No active API Key found for this provider") raise HTTPException(status_code=400, detail="No active API Key found for this provider")
# 查询模型 # 并发请求所有端点的模型列表
registry = get_query_registry() all_models: list = []
adapter = registry.get_adapter_for_provider(provider.name) errors: list[str] = []
if not adapter: async def fetch_endpoint_models(
raise HTTPException( client: httpx.AsyncClient, config: dict
status_code=400, detail=f"No query adapter available for provider: {provider.name}" ) -> tuple[list, Optional[str]]:
base_url = config["base_url"]
if not base_url:
return [], None
base_url = base_url.rstrip("/")
api_format = config["api_format"]
api_key_value = config["api_key"]
extra_headers = config["extra_headers"]
try:
if api_format in ["CLAUDE", "CLAUDE_CLI"]:
return await _fetch_claude_models(client, base_url, api_key_value, api_format)
elif api_format in ["GEMINI", "GEMINI_CLI"]:
return await _fetch_gemini_models(client, base_url, api_key_value, api_format)
else:
return await _fetch_openai_models(
client, base_url, api_key_value, api_format, extra_headers
)
except Exception as e:
logger.error(f"Error fetching models from {api_format} endpoint: {e}")
return [], f"{api_format}: {str(e)}"
async with httpx.AsyncClient(timeout=30.0) as client:
results = await asyncio.gather(
*[fetch_endpoint_models(client, c) for c in endpoint_configs]
) )
for models, error in results:
all_models.extend(models)
if error:
errors.append(error)
query_result = await adapter.query_available_models( # 按 model id 去重(保留第一个)
api_key=api_key_value, endpoint_config=endpoint_config seen_ids: set[str] = set()
) unique_models: list = []
for model in all_models:
model_id = model.get("id")
if model_id and model_id not in seen_ids:
seen_ids.add(model_id)
unique_models.append(model)
error = "; ".join(errors) if errors else None
if not unique_models and not error:
error = "No models returned from any endpoint"
return { return {
"success": query_result.success, "success": len(unique_models) > 0,
"data": query_result.to_dict(), "data": {"models": unique_models, "error": error},
"provider": { "provider": {
"id": provider.id, "id": provider.id,
"name": provider.name, "name": provider.name,
"display_name": provider.display_name, "display_name": provider.display_name,
}, },
} }
@router.delete("/cache/{provider_id}")
async def clear_query_cache(
provider_id: str,
api_key_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
清除查询缓存
Args:
provider_id: 提供商 ID
api_key_id: 可选,指定清除某个 API Key 的缓存
Returns:
清除结果
"""
from sqlalchemy import select
# 获取提供商
result = await db.execute(select(Provider).where(Provider.id == provider_id))
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
registry = get_query_registry()
adapter = registry.get_adapter_for_provider(provider.name)
if adapter:
if api_key_id:
# 获取 API Key 值来清除缓存
from sqlalchemy.orm import selectinload
result = await db.execute(select(ProviderAPIKey).where(ProviderAPIKey.id == api_key_id))
api_key = result.scalar_one_or_none()
if api_key:
adapter.clear_cache(api_key.api_key)
else:
adapter.clear_cache()
return {"success": True, "message": "Cache cleared successfully"}

View File

@@ -852,7 +852,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
from src.services.cache.invalidation import get_cache_invalidation_service from src.services.cache.invalidation import get_cache_invalidation_service
cache_service = get_cache_invalidation_service() cache_service = get_cache_invalidation_service()
cache_service.invalidate_all() cache_service.clear_all_caches()
return { return {
"message": "配置导入成功", "message": "配置导入成功",

View File

@@ -731,8 +731,15 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
) )
# stats_daily.date 存储的是业务日期对应的 UTC 开始时间 # stats_daily.date 存储的是业务日期对应的 UTC 开始时间
# 需要转回业务时区再取日期,才能与日期序列匹配 # 需要转回业务时区再取日期,才能与日期序列匹配
def _to_business_date_str(value: datetime) -> str:
if value.tzinfo is None:
value_utc = value.replace(tzinfo=timezone.utc)
else:
value_utc = value.astimezone(timezone.utc)
return value_utc.astimezone(app_tz).date().isoformat()
stats_map = { stats_map = {
stat.date.replace(tzinfo=timezone.utc).astimezone(app_tz).date().isoformat(): { _to_business_date_str(stat.date): {
"requests": stat.total_requests, "requests": stat.total_requests,
"tokens": stat.input_tokens + stat.output_tokens + stat.cache_creation_tokens + stat.cache_read_tokens, "tokens": stat.input_tokens + stat.output_tokens + stat.cache_creation_tokens + stat.cache_read_tokens,
"cost": stat.total_cost, "cost": stat.total_cost,
@@ -790,6 +797,38 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
"unique_providers": today_unique_providers, "unique_providers": today_unique_providers,
"fallback_count": today_fallback_count, "fallback_count": today_fallback_count,
} }
# 历史预聚合缺失时兜底:按业务日范围实时计算(仅补最近少量缺失,避免全表扫描)
yesterday_date = today_local.date() - timedelta(days=1)
historical_end = min(end_date_local.date(), yesterday_date)
missing_dates: list[str] = []
cursor = start_date_local.date()
while cursor <= historical_end:
date_str = cursor.isoformat()
if date_str not in stats_map:
missing_dates.append(date_str)
cursor += timedelta(days=1)
if missing_dates:
for date_str in missing_dates[-7:]:
target_local = datetime.fromisoformat(date_str).replace(tzinfo=app_tz)
computed = StatsAggregatorService.compute_daily_stats(db, target_local)
stats_map[date_str] = {
"requests": computed["total_requests"],
"tokens": (
computed["input_tokens"]
+ computed["output_tokens"]
+ computed["cache_creation_tokens"]
+ computed["cache_read_tokens"]
),
"cost": computed["total_cost"],
"avg_response_time": computed["avg_response_time_ms"] / 1000.0
if computed["avg_response_time_ms"]
else 0,
"unique_models": computed["unique_models"],
"unique_providers": computed["unique_providers"],
"fallback_count": computed["fallback_count"],
}
else: else:
# 普通用户:仍需实时查询(用户级预聚合可选) # 普通用户:仍需实时查询(用户级预聚合可选)
query = db.query(Usage).filter( query = db.query(Usage).filter(

View File

@@ -266,8 +266,11 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
if mapping and mapping.model: if mapping and mapping.model:
# 使用 select_provider_model_name 支持别名功能 # 使用 select_provider_model_name 支持别名功能
# 传入 api_key.id 作为 affinity_key实现相同用户稳定选择同一别名 # 传入 api_key.id 作为 affinity_key实现相同用户稳定选择同一别名
# 传入 api_format 用于过滤适用的别名作用域
affinity_key = self.api_key.id if self.api_key else None affinity_key = self.api_key.id if self.api_key else None
mapped_name = mapping.model.select_provider_model_name(affinity_key) mapped_name = mapping.model.select_provider_model_name(
affinity_key, api_format=self.FORMAT_ID
)
logger.debug(f"[Chat] 模型映射: {source_model} -> {mapped_name}") logger.debug(f"[Chat] 模型映射: {source_model} -> {mapped_name}")
return mapped_name return mapped_name

View File

@@ -155,8 +155,11 @@ class CliMessageHandlerBase(BaseMessageHandler):
if mapping and mapping.model: if mapping and mapping.model:
# 使用 select_provider_model_name 支持别名功能 # 使用 select_provider_model_name 支持别名功能
# 传入 api_key.id 作为 affinity_key实现相同用户稳定选择同一别名 # 传入 api_key.id 作为 affinity_key实现相同用户稳定选择同一别名
# 传入 api_format 用于过滤适用的别名作用域
affinity_key = self.api_key.id if self.api_key else None affinity_key = self.api_key.id if self.api_key else None
mapped_name = mapping.model.select_provider_model_name(affinity_key) mapped_name = mapping.model.select_provider_model_name(
affinity_key, api_format=self.FORMAT_ID
)
logger.debug(f"[CLI] 模型映射: {source_model} -> {mapped_name} (provider={provider_id[:8]}...)") logger.debug(f"[CLI] 模型映射: {source_model} -> {mapped_name} (provider={provider_id[:8]}...)")
return mapped_name return mapped_name

View File

@@ -813,7 +813,9 @@ class Model(Base):
def get_effective_supports_image_generation(self) -> bool: def get_effective_supports_image_generation(self) -> bool:
return self._get_effective_capability("supports_image_generation", False) return self._get_effective_capability("supports_image_generation", False)
def select_provider_model_name(self, affinity_key: Optional[str] = None) -> str: def select_provider_model_name(
self, affinity_key: Optional[str] = None, api_format: Optional[str] = None
) -> str:
"""按优先级选择要使用的 Provider 模型名称 """按优先级选择要使用的 Provider 模型名称
如果配置了 provider_model_aliases按优先级选择数字越小越优先 如果配置了 provider_model_aliases按优先级选择数字越小越优先
@@ -822,6 +824,7 @@ class Model(Base):
Args: Args:
affinity_key: 用于哈希分散的亲和键(如用户 API Key 哈希),确保同一用户稳定选择同一别名 affinity_key: 用于哈希分散的亲和键(如用户 API Key 哈希),确保同一用户稳定选择同一别名
api_format: 当前请求的 API 格式(如 CLAUDE、OPENAI 等),用于过滤适用的别名
""" """
import hashlib import hashlib
@@ -840,6 +843,13 @@ class Model(Base):
if not isinstance(name, str) or not name.strip(): if not isinstance(name, str) or not name.strip():
continue continue
# 检查 api_formats 作用域(如果配置了且当前有 api_format
alias_api_formats = raw.get("api_formats")
if api_format and alias_api_formats:
# 如果配置了作用域,只有匹配时才生效
if isinstance(alias_api_formats, list) and api_format not in alias_api_formats:
continue
raw_priority = raw.get("priority", 1) raw_priority = raw.get("priority", 1)
try: try:
priority = int(raw_priority) priority = int(raw_priority)

View File

@@ -121,8 +121,17 @@ class CacheAwareScheduler:
PRIORITY_MODE_PROVIDER, PRIORITY_MODE_PROVIDER,
PRIORITY_MODE_GLOBAL_KEY, PRIORITY_MODE_GLOBAL_KEY,
} }
# 调度模式常量
SCHEDULING_MODE_FIXED_ORDER = "fixed_order" # 固定顺序模式
SCHEDULING_MODE_CACHE_AFFINITY = "cache_affinity" # 缓存亲和模式
ALLOWED_SCHEDULING_MODES = {
SCHEDULING_MODE_FIXED_ORDER,
SCHEDULING_MODE_CACHE_AFFINITY,
}
def __init__(self, redis_client=None, priority_mode: Optional[str] = None): def __init__(
self, redis_client=None, priority_mode: Optional[str] = None, scheduling_mode: Optional[str] = None
):
""" """
初始化调度器 初始化调度器
@@ -132,12 +141,16 @@ class CacheAwareScheduler:
Args: Args:
redis_client: Redis客户端可选 redis_client: Redis客户端可选
priority_mode: 候选排序策略provider | global_key priority_mode: 候选排序策略provider | global_key
scheduling_mode: 调度模式fixed_order | cache_affinity
""" """
self.redis = redis_client self.redis = redis_client
self.priority_mode = self._normalize_priority_mode( self.priority_mode = self._normalize_priority_mode(
priority_mode or self.PRIORITY_MODE_PROVIDER priority_mode or self.PRIORITY_MODE_PROVIDER
) )
logger.debug(f"[CacheAwareScheduler] 初始化优先级模式: {self.priority_mode}") self.scheduling_mode = self._normalize_scheduling_mode(
scheduling_mode or self.SCHEDULING_MODE_CACHE_AFFINITY
)
logger.debug(f"[CacheAwareScheduler] 初始化优先级模式: {self.priority_mode}, 调度模式: {self.scheduling_mode}")
# 初始化子组件(将在第一次使用时异步初始化) # 初始化子组件(将在第一次使用时异步初始化)
self._affinity_manager: Optional[CacheAffinityManager] = None self._affinity_manager: Optional[CacheAffinityManager] = None
@@ -673,14 +686,19 @@ class CacheAwareScheduler:
f"(api_format={target_format.value}, model={model_name})" f"(api_format={target_format.value}, model={model_name})"
) )
# 4. 应用缓存亲和性排序(使用 global_model_id 作为模型标识 # 4. 应用缓存亲和性排序(仅在缓存亲和模式下启用
if affinity_key and candidates: if self.scheduling_mode == self.SCHEDULING_MODE_CACHE_AFFINITY:
candidates = await self._apply_cache_affinity( if affinity_key and candidates:
candidates=candidates, candidates = await self._apply_cache_affinity(
affinity_key=affinity_key, candidates=candidates,
api_format=target_format, affinity_key=affinity_key,
global_model_id=global_model_id, api_format=target_format,
) global_model_id=global_model_id,
)
else:
# 固定顺序模式:标记所有候选为非缓存
for candidate in candidates:
candidate.is_cached = False
return candidates, global_model_id return candidates, global_model_id
@@ -1060,6 +1078,22 @@ class CacheAwareScheduler:
self.priority_mode = normalized self.priority_mode = normalized
logger.debug(f"[CacheAwareScheduler] 切换优先级模式为: {self.priority_mode}") logger.debug(f"[CacheAwareScheduler] 切换优先级模式为: {self.priority_mode}")
def _normalize_scheduling_mode(self, mode: Optional[str]) -> str:
normalized = (mode or "").strip().lower()
if normalized not in self.ALLOWED_SCHEDULING_MODES:
if normalized:
logger.warning(f"[CacheAwareScheduler] 无效的调度模式 '{mode}',回退为 cache_affinity")
return self.SCHEDULING_MODE_CACHE_AFFINITY
return normalized
def set_scheduling_mode(self, mode: Optional[str]) -> None:
"""运行时更新调度模式"""
normalized = self._normalize_scheduling_mode(mode)
if normalized == self.scheduling_mode:
return
self.scheduling_mode = normalized
logger.debug(f"[CacheAwareScheduler] 切换调度模式为: {self.scheduling_mode}")
def _apply_priority_mode_sort( def _apply_priority_mode_sort(
self, candidates: List[ProviderCandidate], affinity_key: Optional[str] = None self, candidates: List[ProviderCandidate], affinity_key: Optional[str] = None
) -> List[ProviderCandidate]: ) -> List[ProviderCandidate]:
@@ -1307,6 +1341,7 @@ _scheduler: Optional[CacheAwareScheduler] = None
async def get_cache_aware_scheduler( async def get_cache_aware_scheduler(
redis_client=None, redis_client=None,
priority_mode: Optional[str] = None, priority_mode: Optional[str] = None,
scheduling_mode: Optional[str] = None,
) -> CacheAwareScheduler: ) -> CacheAwareScheduler:
""" """
获取全局CacheAwareScheduler实例 获取全局CacheAwareScheduler实例
@@ -1317,6 +1352,7 @@ async def get_cache_aware_scheduler(
Args: Args:
redis_client: Redis客户端可选 redis_client: Redis客户端可选
priority_mode: 外部覆盖的优先级模式provider | global_key priority_mode: 外部覆盖的优先级模式provider | global_key
scheduling_mode: 外部覆盖的调度模式fixed_order | cache_affinity
Returns: Returns:
CacheAwareScheduler实例 CacheAwareScheduler实例
@@ -1324,8 +1360,13 @@ async def get_cache_aware_scheduler(
global _scheduler global _scheduler
if _scheduler is None: if _scheduler is None:
_scheduler = CacheAwareScheduler(redis_client, priority_mode=priority_mode) _scheduler = CacheAwareScheduler(
elif priority_mode: redis_client, priority_mode=priority_mode, scheduling_mode=scheduling_mode
_scheduler.set_priority_mode(priority_mode) )
else:
if priority_mode:
_scheduler.set_priority_mode(priority_mode)
if scheduling_mode:
_scheduler.set_scheduling_mode(scheduling_mode)
return _scheduler return _scheduler

View File

@@ -102,9 +102,15 @@ class FallbackOrchestrator:
"provider_priority_mode", "provider_priority_mode",
CacheAwareScheduler.PRIORITY_MODE_PROVIDER, CacheAwareScheduler.PRIORITY_MODE_PROVIDER,
) )
scheduling_mode = SystemConfigService.get_config(
self.db,
"scheduling_mode",
CacheAwareScheduler.SCHEDULING_MODE_CACHE_AFFINITY,
)
self.cache_scheduler = await get_cache_aware_scheduler( self.cache_scheduler = await get_cache_aware_scheduler(
self.redis, self.redis,
priority_mode=priority_mode, priority_mode=priority_mode,
scheduling_mode=scheduling_mode,
) )
else: else:
# 确保运行时配置变更能生效 # 确保运行时配置变更能生效
@@ -113,7 +119,13 @@ class FallbackOrchestrator:
"provider_priority_mode", "provider_priority_mode",
CacheAwareScheduler.PRIORITY_MODE_PROVIDER, CacheAwareScheduler.PRIORITY_MODE_PROVIDER,
) )
scheduling_mode = SystemConfigService.get_config(
self.db,
"scheduling_mode",
CacheAwareScheduler.SCHEDULING_MODE_CACHE_AFFINITY,
)
self.cache_scheduler.set_priority_mode(priority_mode) self.cache_scheduler.set_priority_mode(priority_mode)
self.cache_scheduler.set_scheduling_mode(scheduling_mode)
# 确保 cache_scheduler 内部组件也已初始化 # 确保 cache_scheduler 内部组件也已初始化
await self.cache_scheduler._ensure_initialized() await self.cache_scheduler._ensure_initialized()

View File

@@ -35,6 +35,7 @@ class CleanupScheduler:
def __init__(self): def __init__(self):
self.running = False self.running = False
self._interval_tasks = [] self._interval_tasks = []
self._stats_aggregation_lock = asyncio.Lock()
async def start(self): async def start(self):
"""启动调度器""" """启动调度器"""
@@ -56,6 +57,14 @@ class CleanupScheduler:
job_id="stats_aggregation", job_id="stats_aggregation",
name="统计数据聚合", name="统计数据聚合",
) )
# 统计聚合补偿任务 - 每 30 分钟检查缺失并回填
scheduler.add_interval_job(
self._scheduled_stats_aggregation,
minutes=30,
job_id="stats_aggregation_backfill",
name="统计数据聚合补偿",
backfill=True,
)
# 清理任务 - 凌晨 3 点执行 # 清理任务 - 凌晨 3 点执行
scheduler.add_cron_job( scheduler.add_cron_job(
@@ -115,9 +124,9 @@ class CleanupScheduler:
# ========== 任务函数APScheduler 直接调用异步函数) ========== # ========== 任务函数APScheduler 直接调用异步函数) ==========
async def _scheduled_stats_aggregation(self): async def _scheduled_stats_aggregation(self, backfill: bool = False):
"""统计聚合任务(定时调用)""" """统计聚合任务(定时调用)"""
await self._perform_stats_aggregation() await self._perform_stats_aggregation(backfill=backfill)
async def _scheduled_cleanup(self): async def _scheduled_cleanup(self):
"""清理任务(定时调用)""" """清理任务(定时调用)"""
@@ -144,136 +153,157 @@ class CleanupScheduler:
Args: Args:
backfill: 是否回填历史数据(启动时检查缺失的日期) backfill: 是否回填历史数据(启动时检查缺失的日期)
""" """
db = create_session() if self._stats_aggregation_lock.locked():
try: logger.info("统计聚合任务正在运行,跳过本次触发")
# 检查是否启用统计聚合 return
if not SystemConfigService.get_config(db, "enable_stats_aggregation", True):
logger.info("统计聚合已禁用,跳过聚合任务")
return
logger.info("开始执行统计数据聚合...") async with self._stats_aggregation_lock:
db = create_session()
from src.models.database import StatsDaily, User as DBUser try:
from src.services.system.scheduler import APP_TIMEZONE # 检查是否启用统计聚合
from zoneinfo import ZoneInfo if not SystemConfigService.get_config(db, "enable_stats_aggregation", True):
logger.info("统计聚合已禁用,跳过聚合任务")
# 使用业务时区计算日期,确保与定时任务触发时间一致
# 定时任务在 Asia/Shanghai 凌晨 1 点触发,此时应聚合 Asia/Shanghai 的"昨天"
app_tz = ZoneInfo(APP_TIMEZONE)
now_local = datetime.now(app_tz)
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
if backfill:
# 启动时检查并回填缺失的日期
from src.models.database import StatsSummary
summary = db.query(StatsSummary).first()
if not summary:
# 首次运行,回填所有历史数据
logger.info("检测到首次运行,开始回填历史统计数据...")
days_to_backfill = SystemConfigService.get_config(
db, "stats_backfill_days", 365
)
count = StatsAggregatorService.backfill_historical_data(
db, days=days_to_backfill
)
logger.info(f"历史数据回填完成,共 {count}")
return return
# 非首次运行,检查最近是否有缺失的日期需要回填 logger.info("开始执行统计数据聚合...")
latest_stat = (
db.query(StatsDaily)
.order_by(StatsDaily.date.desc())
.first()
)
if latest_stat: from src.models.database import StatsDaily, User as DBUser
latest_date_utc = latest_stat.date from src.services.system.scheduler import APP_TIMEZONE
if latest_date_utc.tzinfo is None: from zoneinfo import ZoneInfo
latest_date_utc = latest_date_utc.replace(tzinfo=timezone.utc)
else:
latest_date_utc = latest_date_utc.astimezone(timezone.utc)
# 使用业务日期计算缺失区间(避免用 UTC 年月日导致日期偏移,且对 DST 更安全) # 使用业务时区计算日期,确保与定时任务触发时间一致
latest_business_date = latest_date_utc.astimezone(app_tz).date() # 定时任务在 Asia/Shanghai 凌晨 1 点触发,此时应聚合 Asia/Shanghai 的"昨天"
yesterday_business_date = (today_local.date() - timedelta(days=1)) app_tz = ZoneInfo(APP_TIMEZONE)
missing_start_date = latest_business_date + timedelta(days=1) now_local = datetime.now(app_tz)
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
if missing_start_date <= yesterday_business_date: if backfill:
missing_days = (yesterday_business_date - missing_start_date).days + 1 # 启动时检查并回填缺失的日期
logger.info( from src.models.database import StatsSummary
f"检测到缺失 {missing_days} 天的统计数据 "
f"({missing_start_date} ~ {yesterday_business_date}),开始回填..." summary = db.query(StatsSummary).first()
if not summary:
# 首次运行,回填所有历史数据
logger.info("检测到首次运行,开始回填历史统计数据...")
days_to_backfill = SystemConfigService.get_config(
db, "stats_backfill_days", 365
) )
count = StatsAggregatorService.backfill_historical_data(
db, days=days_to_backfill
)
logger.info(f"历史数据回填完成,共 {count}")
return
current_date = missing_start_date # 非首次运行,检查最近是否有缺失的日期需要回填
users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all() latest_stat = db.query(StatsDaily).order_by(StatsDaily.date.desc()).first()
while current_date <= yesterday_business_date: if latest_stat:
try: latest_date_utc = latest_stat.date
current_date_local = datetime.combine( if latest_date_utc.tzinfo is None:
current_date, datetime.min.time(), tzinfo=app_tz latest_date_utc = latest_date_utc.replace(tzinfo=timezone.utc)
else:
latest_date_utc = latest_date_utc.astimezone(timezone.utc)
# 使用业务日期计算缺失区间(避免用 UTC 年月日导致日期偏移,且对 DST 更安全)
latest_business_date = latest_date_utc.astimezone(app_tz).date()
yesterday_business_date = today_local.date() - timedelta(days=1)
missing_start_date = latest_business_date + timedelta(days=1)
if missing_start_date <= yesterday_business_date:
missing_days = (
yesterday_business_date - missing_start_date
).days + 1
# 限制最大回填天数,防止停机很久后一次性回填太多
max_backfill_days: int = SystemConfigService.get_config(
db, "max_stats_backfill_days", 30
) or 30
if missing_days > max_backfill_days:
logger.warning(
f"缺失 {missing_days} 天数据超过最大回填限制 "
f"{max_backfill_days} 天,只回填最近 {max_backfill_days}"
) )
StatsAggregatorService.aggregate_daily_stats(db, current_date_local) missing_start_date = yesterday_business_date - timedelta(
# 聚合用户数据 days=max_backfill_days - 1
for (user_id,) in users: )
try: missing_days = max_backfill_days
StatsAggregatorService.aggregate_user_daily_stats(
db, user_id, current_date_local logger.info(
) f"检测到缺失 {missing_days} 天的统计数据 "
except Exception as e: f"({missing_start_date} ~ {yesterday_business_date}),开始回填..."
logger.warning( )
f"回填用户 {user_id} 日期 {current_date} 失败: {e}"
) current_date = missing_start_date
try: users = (
db.rollback() db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all()
except Exception: )
pass
except Exception as e: while current_date <= yesterday_business_date:
logger.warning(f"回填日期 {current_date} 失败: {e}")
try: try:
db.rollback() current_date_local = datetime.combine(
except Exception: current_date, datetime.min.time(), tzinfo=app_tz
pass )
StatsAggregatorService.aggregate_daily_stats(
db, current_date_local
)
for (user_id,) in users:
try:
StatsAggregatorService.aggregate_user_daily_stats(
db, user_id, current_date_local
)
except Exception as e:
logger.warning(
f"回填用户 {user_id} 日期 {current_date} 失败: {e}"
)
try:
db.rollback()
except Exception:
pass
except Exception as e:
logger.warning(f"回填日期 {current_date} 失败: {e}")
try:
db.rollback()
except Exception:
pass
current_date += timedelta(days=1) current_date += timedelta(days=1)
# 更新全局汇总 StatsAggregatorService.update_summary(db)
StatsAggregatorService.update_summary(db) logger.info(f"缺失数据回填完成,共 {missing_days}")
logger.info(f"缺失数据回填完成,共 {missing_days}") else:
else: logger.info("统计数据已是最新,无需回填")
logger.info("统计数据已是最新,无需回填") return
return
# 定时任务:聚合昨天的数据 # 定时任务:聚合昨天的数据
# 注意aggregate_daily_stats 期望业务时区的日期,不是 UTC yesterday_local = today_local - timedelta(days=1)
yesterday_local = today_local - timedelta(days=1)
StatsAggregatorService.aggregate_daily_stats(db, yesterday_local) StatsAggregatorService.aggregate_daily_stats(db, yesterday_local)
# 聚合所有用户的昨日数据 users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all()
users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all() for (user_id,) in users:
for (user_id,) in users:
try:
StatsAggregatorService.aggregate_user_daily_stats(db, user_id, yesterday_local)
except Exception as e:
logger.warning(f"聚合用户 {user_id} 统计数据失败: {e}")
# 回滚当前用户的失败操作,继续处理其他用户
try: try:
db.rollback() StatsAggregatorService.aggregate_user_daily_stats(
except Exception: db, user_id, yesterday_local
pass )
except Exception as e:
logger.warning(f"聚合用户 {user_id} 统计数据失败: {e}")
try:
db.rollback()
except Exception:
pass
# 更新全局汇总 StatsAggregatorService.update_summary(db)
StatsAggregatorService.update_summary(db)
logger.info("统计数据聚合完成") logger.info("统计数据聚合完成")
except Exception as e: except Exception as e:
logger.exception(f"统计聚合任务执行失败: {e}") logger.exception(f"统计聚合任务执行失败: {e}")
db.rollback() try:
finally: db.rollback()
db.close() except Exception:
pass
finally:
db.close()
async def _perform_pending_cleanup(self): async def _perform_pending_cleanup(self):
"""执行 pending 状态清理""" """执行 pending 状态清理"""

View File

@@ -71,6 +71,10 @@ class SystemConfigService:
"value": "provider", "value": "provider",
"description": "优先级策略provider(提供商优先模式) 或 global_key(全局Key优先模式)", "description": "优先级策略provider(提供商优先模式) 或 global_key(全局Key优先模式)",
}, },
"scheduling_mode": {
"value": "cache_affinity",
"description": "调度模式fixed_order(固定顺序模式,严格按优先级顺序) 或 cache_affinity(缓存亲和模式优先使用已缓存的Provider)",
},
"auto_delete_expired_keys": { "auto_delete_expired_keys": {
"value": False, "value": False,
"description": "是否自动删除过期的API KeyTrue=物理删除False=仅禁用),仅管理员可配置", "description": "是否自动删除过期的API KeyTrue=物理删除False=仅禁用),仅管理员可配置",

View File

@@ -56,65 +56,44 @@ class StatsAggregatorService:
"""统计数据聚合服务""" """统计数据聚合服务"""
@staticmethod @staticmethod
def aggregate_daily_stats(db: Session, date: datetime) -> StatsDaily: def compute_daily_stats(db: Session, date: datetime) -> dict:
"""聚合指定日期的统计数据 """计算指定业务日期的统计数据(不写入数据库)"""
Args:
db: 数据库会话
date: 要聚合的业务日期(使用 APP_TIMEZONE 时区)
Returns:
StatsDaily 记录
"""
# 将业务日期转换为 UTC 时间范围
day_start, day_end = _get_business_day_range(date) day_start, day_end = _get_business_day_range(date)
# stats_daily.date 存储的是业务日期对应的 UTC 开始时间
# 检查是否已存在该日期的记录
existing = db.query(StatsDaily).filter(StatsDaily.date == day_start).first()
if existing:
stats = existing
else:
stats = StatsDaily(id=str(uuid.uuid4()), date=day_start)
# 基础请求统计
base_query = db.query(Usage).filter( base_query = db.query(Usage).filter(
and_(Usage.created_at >= day_start, Usage.created_at < day_end) and_(Usage.created_at >= day_start, Usage.created_at < day_end)
) )
total_requests = base_query.count() total_requests = base_query.count()
# 如果没有请求,直接返回空记录
if total_requests == 0: if total_requests == 0:
stats.total_requests = 0 return {
stats.success_requests = 0 "day_start": day_start,
stats.error_requests = 0 "total_requests": 0,
stats.input_tokens = 0 "success_requests": 0,
stats.output_tokens = 0 "error_requests": 0,
stats.cache_creation_tokens = 0 "input_tokens": 0,
stats.cache_read_tokens = 0 "output_tokens": 0,
stats.total_cost = 0.0 "cache_creation_tokens": 0,
stats.actual_total_cost = 0.0 "cache_read_tokens": 0,
stats.input_cost = 0.0 "total_cost": 0.0,
stats.output_cost = 0.0 "actual_total_cost": 0.0,
stats.cache_creation_cost = 0.0 "input_cost": 0.0,
stats.cache_read_cost = 0.0 "output_cost": 0.0,
stats.avg_response_time_ms = 0.0 "cache_creation_cost": 0.0,
stats.fallback_count = 0 "cache_read_cost": 0.0,
"avg_response_time_ms": 0.0,
"fallback_count": 0,
"unique_models": 0,
"unique_providers": 0,
}
if not existing:
db.add(stats)
db.commit()
return stats
# 错误请求数
error_requests = ( error_requests = (
base_query.filter( base_query.filter(
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)) (Usage.status_code >= 400) | (Usage.error_message.isnot(None))
).count() ).count()
) )
# Token 和成本聚合
aggregated = ( aggregated = (
db.query( db.query(
func.sum(Usage.input_tokens).label("input_tokens"), func.sum(Usage.input_tokens).label("input_tokens"),
@@ -157,7 +136,6 @@ class StatsAggregatorService:
or 0 or 0
) )
# 使用维度统计
unique_models = ( unique_models = (
db.query(func.count(func.distinct(Usage.model))) db.query(func.count(func.distinct(Usage.model)))
.filter(and_(Usage.created_at >= day_start, Usage.created_at < day_end)) .filter(and_(Usage.created_at >= day_start, Usage.created_at < day_end))
@@ -171,31 +149,74 @@ class StatsAggregatorService:
or 0 or 0
) )
return {
"day_start": day_start,
"total_requests": total_requests,
"success_requests": total_requests - error_requests,
"error_requests": error_requests,
"input_tokens": int(aggregated.input_tokens or 0) if aggregated else 0,
"output_tokens": int(aggregated.output_tokens or 0) if aggregated else 0,
"cache_creation_tokens": int(aggregated.cache_creation_tokens or 0) if aggregated else 0,
"cache_read_tokens": int(aggregated.cache_read_tokens or 0) if aggregated else 0,
"total_cost": float(aggregated.total_cost or 0) if aggregated else 0.0,
"actual_total_cost": float(aggregated.actual_total_cost or 0) if aggregated else 0.0,
"input_cost": float(aggregated.input_cost or 0) if aggregated else 0.0,
"output_cost": float(aggregated.output_cost or 0) if aggregated else 0.0,
"cache_creation_cost": float(aggregated.cache_creation_cost or 0) if aggregated else 0.0,
"cache_read_cost": float(aggregated.cache_read_cost or 0) if aggregated else 0.0,
"avg_response_time_ms": float(aggregated.avg_response_time or 0) if aggregated else 0.0,
"fallback_count": fallback_count,
"unique_models": unique_models,
"unique_providers": unique_providers,
}
@staticmethod
def aggregate_daily_stats(db: Session, date: datetime) -> StatsDaily:
"""聚合指定日期的统计数据
Args:
db: 数据库会话
date: 要聚合的业务日期(使用 APP_TIMEZONE 时区)
Returns:
StatsDaily 记录
"""
computed = StatsAggregatorService.compute_daily_stats(db, date)
day_start = computed["day_start"]
# stats_daily.date 存储的是业务日期对应的 UTC 开始时间
# 检查是否已存在该日期的记录
existing = db.query(StatsDaily).filter(StatsDaily.date == day_start).first()
if existing:
stats = existing
else:
stats = StatsDaily(id=str(uuid.uuid4()), date=day_start)
# 更新统计记录 # 更新统计记录
stats.total_requests = total_requests stats.total_requests = computed["total_requests"]
stats.success_requests = total_requests - error_requests stats.success_requests = computed["success_requests"]
stats.error_requests = error_requests stats.error_requests = computed["error_requests"]
stats.input_tokens = int(aggregated.input_tokens or 0) stats.input_tokens = computed["input_tokens"]
stats.output_tokens = int(aggregated.output_tokens or 0) stats.output_tokens = computed["output_tokens"]
stats.cache_creation_tokens = int(aggregated.cache_creation_tokens or 0) stats.cache_creation_tokens = computed["cache_creation_tokens"]
stats.cache_read_tokens = int(aggregated.cache_read_tokens or 0) stats.cache_read_tokens = computed["cache_read_tokens"]
stats.total_cost = float(aggregated.total_cost or 0) stats.total_cost = computed["total_cost"]
stats.actual_total_cost = float(aggregated.actual_total_cost or 0) stats.actual_total_cost = computed["actual_total_cost"]
stats.input_cost = float(aggregated.input_cost or 0) stats.input_cost = computed["input_cost"]
stats.output_cost = float(aggregated.output_cost or 0) stats.output_cost = computed["output_cost"]
stats.cache_creation_cost = float(aggregated.cache_creation_cost or 0) stats.cache_creation_cost = computed["cache_creation_cost"]
stats.cache_read_cost = float(aggregated.cache_read_cost or 0) stats.cache_read_cost = computed["cache_read_cost"]
stats.avg_response_time_ms = float(aggregated.avg_response_time or 0) stats.avg_response_time_ms = computed["avg_response_time_ms"]
stats.fallback_count = fallback_count stats.fallback_count = computed["fallback_count"]
stats.unique_models = unique_models stats.unique_models = computed["unique_models"]
stats.unique_providers = unique_providers stats.unique_providers = computed["unique_providers"]
if not existing: if not existing:
db.add(stats) db.add(stats)
db.commit() db.commit()
# 日志使用业务日期(输入参数),而不是 UTC 日期 # 日志使用业务日期(输入参数),而不是 UTC 日期
logger.info(f"[StatsAggregator] 聚合日期 {date.date()} 完成: {total_requests} 请求") logger.info(f"[StatsAggregator] 聚合日期 {date.date()} 完成: {computed['total_requests']} 请求")
return stats return stats
@staticmethod @staticmethod

View File

@@ -71,8 +71,8 @@ class PreferenceService:
raise NotFoundException("Provider not found or inactive") raise NotFoundException("Provider not found or inactive")
preferences.default_provider_id = default_provider_id preferences.default_provider_id = default_provider_id
if theme is not None: if theme is not None:
if theme not in ["light", "dark", "auto"]: if theme not in ["light", "dark", "auto", "system"]:
raise ValueError("Invalid theme. Must be 'light', 'dark', or 'auto'") raise ValueError("Invalid theme. Must be 'light', 'dark', 'auto', or 'system'")
preferences.theme = theme preferences.theme = theme
if language is not None: if language is not None:
preferences.language = language preferences.language = language