feat(proxy,oauth,pool): H2 头过滤、OAuth 过期分级标记与批量操作进度条

- proxy: 屏蔽 host/content-length 头转发,避免 H2 PROTOCOL_ERROR
- oauth: 区分 [REFRESH_FAILED] 与 [OAUTH_EXPIRED] 标记,token 过期
  自动阻止调度但不停用账号,便于管理员恢复
- pool/account_state: 识别新增的 OAUTH_EXPIRED/REFRESH_FAILED 前缀
- 前端: 批量操作显示实时进度条,倍率编辑 Escape/blur 竞态修复,
  OAuth 刷新失败后自动刷新列表
This commit is contained in:
fawney19
2026-03-06 01:47:09 +08:00
parent fa71cddb60
commit e9c3ac94c6
9 changed files with 134 additions and 16 deletions

View File

@@ -223,7 +223,22 @@
</div>
<div
v-if="lastResultMessage"
v-if="executing && progressTotal > 0"
class="space-y-1"
>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ progressLabel }}</span>
<span>{{ progressDone }} / {{ progressTotal }}</span>
</div>
<div class="h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full bg-primary transition-all duration-150"
:style="{ width: `${Math.round((progressDone / progressTotal) * 100)}%` }"
/>
</div>
</div>
<div
v-else-if="lastResultMessage"
class="rounded-md border bg-background px-3 py-2 text-xs text-muted-foreground"
>
{{ lastResultMessage }}
@@ -322,6 +337,9 @@ const searchText = ref('')
const selectedAction = ref<BatchActionValue>('delete')
const proxyNodeIdForAction = ref('')
const lastResultMessage = ref('')
const progressTotal = ref(0)
const progressDone = ref(0)
const progressLabel = ref('')
const activeQuickSelectors = ref<QuickSelectorValue[]>([])
const currentPage = ref(1)
const PAGE_SIZE = 50
@@ -571,6 +589,12 @@ async function executeAction(): Promise<void> {
let failedCount = 0
let skippedCount = 0
const actionLabel = ACTION_OPTIONS.find((a) => a.value === selectedAction.value)?.label || '执行'
progressDone.value = 0
progressTotal.value = selectedKeys.length
progressLabel.value = `正在${actionLabel}...`
lastResultMessage.value = ''
try {
if (selectedAction.value === 'refresh_quota') {
const targetIds = selectedKeys.map((key) => key.key_id)
@@ -578,6 +602,7 @@ async function executeAction(): Promise<void> {
successCount = Number(result.success || 0)
failedCount = Number(result.failed || 0)
skippedCount = Math.max(0, targetIds.length - Number(result.total || 0))
progressDone.value = targetIds.length
} else {
const CONCURRENCY = props.batchConcurrency || 8
const taskForKey = (key: PoolKeyDetail): (() => Promise<'success' | 'skip'>) | null => {
@@ -609,8 +634,12 @@ async function executeAction(): Promise<void> {
for (const key of selectedKeys) {
const task = taskForKey(key)
if (task) tasks.push(task)
else skippedCount += 1
else {
skippedCount += 1
progressDone.value += 1
}
}
progressTotal.value = selectedKeys.length
// 并发执行,限制并发数
let cursor = 0
@@ -623,6 +652,7 @@ async function executeAction(): Promise<void> {
} catch {
failedCount += 1
}
progressDone.value += 1
}
}
const workers = Array.from({ length: Math.min(CONCURRENCY, tasks.length) }, () => runNext())
@@ -647,6 +677,9 @@ async function executeAction(): Promise<void> {
showError(parseApiError(err, '批量操作失败'))
} finally {
executing.value = false
progressTotal.value = 0
progressDone.value = 0
progressLabel.value = ''
}
}

View File

@@ -2059,19 +2059,16 @@ function startEditMultiplier(key: EndpointAPIKey, format: string) {
function cancelEditMultiplier() {
editingMultiplierKey.value = null
editingMultiplierFormat.value = null
multiplierSaving.value = false
}
function handleMultiplierKeydown(e: KeyboardEvent, key: EndpointAPIKey, format: string) {
if (e.key === 'Enter') {
e.preventDefault()
e.stopPropagation()
if (!multiplierSaving.value) {
multiplierSaving.value = true
saveMultiplier(key, format)
}
saveMultiplier(key, format)
} else if (e.key === 'Escape') {
e.preventDefault()
multiplierSaving.value = true // 阻止 blur 触发保存
cancelEditMultiplier()
}
}
@@ -2082,7 +2079,7 @@ function handleMultiplierBlur(key: EndpointAPIKey, format: string) {
}
async function saveMultiplier(key: EndpointAPIKey, format: string) {
// 防止重复调用
// 防止重复调用Enter 触发后阻止 blur 再次进入)
if (multiplierSaving.value) return
multiplierSaving.value = true
@@ -2093,6 +2090,7 @@ async function saveMultiplier(key: EndpointAPIKey, format: string) {
if (!keyId || isNaN(newMultiplier)) {
showError('请输入有效的倍率值')
cancelEditMultiplier()
multiplierSaving.value = false
return
}
@@ -2100,6 +2098,7 @@ async function saveMultiplier(key: EndpointAPIKey, format: string) {
if (newMultiplier <= 0 || newMultiplier > 100) {
showError('倍率必须在 0.01 到 100 之间')
cancelEditMultiplier()
multiplierSaving.value = false
return
}
@@ -2107,6 +2106,7 @@ async function saveMultiplier(key: EndpointAPIKey, format: string) {
const currentMultiplier = getKeyRateMultiplier(key, format)
if (Math.abs(currentMultiplier - newMultiplier) < 0.0001) {
cancelEditMultiplier()
multiplierSaving.value = false
return
}

View File

@@ -44,11 +44,13 @@ export function isAccountLevelBlockReason(reason: string | null | undefined): bo
const text = reason.trim()
if (!text) return false
if (text.startsWith('[ACCOUNT_BLOCK]')) return true
if (text.startsWith('[OAUTH_EXPIRED]')) return true
const lowered = text.toLowerCase()
return ACCOUNT_BLOCK_REASON_KEYWORDS.some(keyword => lowered.includes(keyword))
}
export function classifyAccountBlockLabel(reason: string): string {
if (reason.trim().startsWith('[OAUTH_EXPIRED]')) return 'Token 失效'
const lowered = reason.toLowerCase()
if (KEYWORDS_VERIFICATION.some(kw => lowered.includes(kw))) return '需要验证'
if (KEYWORDS_DISABLED.some(kw => lowered.includes(kw))) return '账号停用'
@@ -57,5 +59,15 @@ export function classifyAccountBlockLabel(reason: string): string {
}
export function cleanAccountBlockReason(reason: string): string {
return reason.replace(/^\[ACCOUNT_BLOCK\]\s*/i, '').trim()
return reason.replace(/^\[(ACCOUNT_BLOCK|OAUTH_EXPIRED)\]\s*/i, '').trim()
}
export function isRefreshFailedReason(reason: string | null | undefined): boolean {
if (!reason) return false
return reason.trim().startsWith('[REFRESH_FAILED]')
}
export function isOAuthExpiredReason(reason: string | null | undefined): boolean {
if (!reason) return false
return reason.trim().startsWith('[OAUTH_EXPIRED]')
}

View File

@@ -1963,6 +1963,7 @@ async function handleRefreshOAuth(key: PoolKeyDetail) {
await loadKeys()
} catch (err) {
showError(parseApiError(err, 'Token 刷新失败'))
await loadKeys()
} finally {
refreshingOAuthKeyId.value = null
}