feat(pool,scheduling): 号池调度维度、配额冷却机制与管理后台重构

- 新增 scheduling_dimensions 模块,为每个 Key 计算多维调度状态(手动/冷却/熔断/成本/健康)
- 新增 quota_cooldown 模块,统一判定 Key 的有效冷却原因
- Pool 管理后台 API 扩展 Key 详情字段(调度状态/维度/配额/OAuth 信息)
- 前端 Pool 管理页面重写,支持调度状态展示、批量清理封禁 Key
- Handler 基类增加请求调度元数据采集,stream telemetry 增强
- 请求时间线组件增强,支持 attempted 候选展示
- Kiro OAuth 凭证导入解析改进
- 新增 usage 表 provider_key 索引迁移
- 补充调度维度、配额冷却、候选枚举等单元测试

Closes #197

Co-authored-by: AAEE86 <33052466+AAEE86@users.noreply.github.com>
This commit is contained in:
fawney19
2026-03-03 09:22:20 +08:00
parent f787b1b02a
commit 11997c024e
40 changed files with 4419 additions and 823 deletions

View File

@@ -404,70 +404,19 @@
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
:class="mode === 'import' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
<input
ref="fileInputRef"
type="file"
accept=".json,.txt"
multiple
class="hidden"
@change="handleFileSelect"
>
<!-- 拖拽模式 -->
<div
v-if="!showManualInput"
class="rounded-xl border-2 border-dashed transition-colors cursor-pointer"
:class="isDragging
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/40'"
@click="fileInputRef?.click()"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleFileDrop"
>
<div class="flex flex-col items-center justify-center py-12 gap-2">
<div class="w-9 h-9 rounded-full bg-muted/60 flex items-center justify-center">
<Upload class="w-4 h-4 text-muted-foreground" />
</div>
<div class="text-center">
<p class="text-xs font-medium">
拖入授权文件或点击选择
</p>
<p class="text-[11px] text-muted-foreground mt-0.5">
支持 .json / .txt可多选
</p>
</div>
</div>
</div>
<!-- 粘贴模式 -->
<Textarea
v-else
<JsonImportInput
v-model="importText"
:disabled="importing"
placeholder="粘贴 Refresh Token 或 JSON 内容"
class="min-h-[200px] text-xs font-mono break-all !rounded-xl"
spellcheck="false"
:reset-key="importInputResetKey"
drop-title="拖入授权文件或点击选择"
drop-hint="支持 .json / .txt可多选"
manual-placeholder="粘贴 Refresh Token JSON 内容"
paste-toggle-text="或手动粘贴 Refresh Token"
file-toggle-text="或选择 JSON 文件导入"
textarea-class="min-h-[200px] text-xs font-mono break-all !rounded-xl"
@error="handleImportInputError"
/>
<!-- 底部切换链接 -->
<div class="flex items-center justify-center pt-1">
<button
v-if="!showManualInput"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
@click="showManualInput = true"
>
或手动粘贴 Refresh Token
</button>
<button
v-else
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
@click="showManualInput = false; importText = ''"
>
或选择 JSON 文件导入
</button>
</div>
<div
v-if="importTask"
class="rounded-xl border border-border bg-muted/20 p-3 space-y-2"
@@ -554,7 +503,7 @@ import {
ComboboxTrigger,
ComboboxViewport,
} from 'radix-vue'
import { UserPlus, Copy, ExternalLink, Upload, Globe, AlertCircle, ShieldCheck, ChevronsUpDown, Check } from 'lucide-vue-next'
import { UserPlus, Copy, ExternalLink, Globe, AlertCircle, ShieldCheck, ChevronsUpDown, Check } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { useTotp } from '@/composables/useTotp'
@@ -575,6 +524,7 @@ import type {
} from '@/api/endpoints/provider_oauth'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import JsonImportInput from '@/components/common/JsonImportInput.vue'
const props = defineProps<{
open: boolean
@@ -712,9 +662,7 @@ let countdownTimer: ReturnType<typeof setInterval> | null = null
// 导入状态
const importText = ref('')
const importing = ref(false)
const isDragging = ref(false)
const showManualInput = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
const importInputResetKey = ref(0)
const importTask = ref<OAuthBatchImportTaskStatusResponse | null>(null)
let importPollTimer: ReturnType<typeof setTimeout> | null = null
const importPolling = ref(false)
@@ -847,14 +795,10 @@ function resetForm() {
importText.value = ''
importing.value = false
importTask.value = null
isDragging.value = false
showManualInput.value = false
importInputResetKey.value += 1
proxyPopoverOpen.value = false
selectedProxyNodeId.value = ''
mode.value = 'oauth'
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function switchMode(newMode: DialogMode) {
@@ -978,81 +922,8 @@ function parseImportText(text: string): { refresh_token: string; name?: string }
return { refresh_token: trimmed }
}
function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
const content = e.target?.result
if (typeof content === 'string') resolve(content)
else reject(new Error('读取失败'))
}
reader.onerror = () => reject(new Error('读取失败'))
reader.readAsText(file)
})
}
function isValidFileType(file: File): boolean {
return file.name.endsWith('.json') || file.name.endsWith('.txt')
|| file.type === 'application/json' || file.type === 'text/plain'
}
/** 合并多个文件内容为统一的凭据文本JSON 数组) */
function mergeFileContents(contents: string[]): string {
const items: unknown[] = []
for (const raw of contents) {
const trimmed = raw.trim()
if (!trimmed) continue
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) {
items.push(...parsed)
} else {
items.push(parsed)
}
continue
} catch {
// not JSON
}
// 按行拆分(纯 Token
const lines = trimmed.split('\n').filter(l => l.trim() && !l.trim().startsWith('#'))
items.push(...lines.map(l => l.trim()))
}
if (items.length === 1) {
// 单条:保持原始格式
return typeof items[0] === 'string' ? items[0] : JSON.stringify(items[0], null, 2)
}
return JSON.stringify(items, null, 2)
}
async function readFiles(files: File[]) {
const validFiles = files.filter(isValidFileType)
if (validFiles.length === 0) {
showError('仅支持 .json 或 .txt 文件', '格式错误')
return
}
if (validFiles.length < files.length) {
showError(`已忽略 ${files.length - validFiles.length} 个不支持的文件`, '提示')
}
try {
const contents = await Promise.all(validFiles.map(readFileAsText))
const merged = validFiles.length === 1 ? contents[0] : mergeFileContents(contents)
importText.value = merged
showManualInput.value = true
} catch {
showError('文件读取失败', '错误')
}
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const files = input.files
if (files && files.length > 0) readFiles(Array.from(files))
}
function handleFileDrop(event: DragEvent) {
isDragging.value = false
const files = event.dataTransfer?.files
if (files && files.length > 0) readFiles(Array.from(files))
function handleImportInputError(payload: { message: string; title?: string }) {
showError(payload.message, payload.title)
}
async function handleImport() {