mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-10 19:52:27 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d5c84f9d3 | ||
|
|
53e6a82480 | ||
|
|
bd11ebdbd5 | ||
|
|
1dac4cb156 | ||
|
|
50abb55c94 |
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 统一处理对话框逻辑
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 状态筛选
|
// 状态筛选
|
||||||
|
|||||||
@@ -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))
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 能力筛选
|
// 能力筛选
|
||||||
|
|||||||
@@ -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))
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排序
|
// 排序
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统计数据
|
// 统计数据
|
||||||
|
|||||||
@@ -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))
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 能力筛选
|
// 能力筛选
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"}
|
|
||||||
|
|||||||
@@ -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": "配置导入成功",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
67
src/services/cache/aware_scheduler.py
vendored
67
src/services/cache/aware_scheduler.py
vendored
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 状态清理"""
|
||||||
|
|||||||
@@ -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 Key(True=物理删除,False=仅禁用),仅管理员可配置",
|
"description": "是否自动删除过期的API Key(True=物理删除,False=仅禁用),仅管理员可配置",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user