refactor: extract temp-unavailable policy and reusable account UI

This commit is contained in:
X-Zero-L
2026-03-02 22:17:00 +08:00
parent e611f97dae
commit 87cbd1897d
7 changed files with 472 additions and 16 deletions

View File

@@ -17,8 +17,39 @@ const logger = require('../../utils/logger')
const oauthHelper = require('../../utils/oauthHelper')
const CostCalculator = require('../../utils/costCalculator')
const webhookNotifier = require('../../utils/webhookNotifier')
const {
isEmptyValue,
parseBooleanLike,
normalizeOptionalNonNegativeInteger
} = require('../../utils/tempUnavailablePolicy')
const { formatAccountExpiry, mapExpiryField } = require('./utils')
const TEMP_UNAVAILABLE_TTL_FIELDS = ['tempUnavailable503TtlSeconds', 'tempUnavailable5xxTtlSeconds']
const normalizeTempUnavailablePolicyPayload = (payload, options = {}) => {
const { partial = false } = options
const normalized = {}
for (const field of TEMP_UNAVAILABLE_TTL_FIELDS) {
if (partial && !Object.prototype.hasOwnProperty.call(payload, field)) {
continue
}
const rawValue = payload[field]
const parsedValue = normalizeOptionalNonNegativeInteger(rawValue)
if (!isEmptyValue(rawValue) && parsedValue === null) {
return { error: `${field} must be a non-negative integer` }
}
normalized[field] = parsedValue
}
if (!partial || Object.prototype.hasOwnProperty.call(payload, 'disableTempUnavailable')) {
normalized.disableTempUnavailable = parseBooleanLike(payload.disableTempUnavailable)
}
return { normalized }
}
// 生成OAuth授权URL
router.post('/claude-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
try {
@@ -594,7 +625,10 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
expiresAt,
extInfo,
maxConcurrency,
interceptWarmup
interceptWarmup,
disableTempUnavailable,
tempUnavailable503TtlSeconds,
tempUnavailable5xxTtlSeconds
} = req.body
if (!name) {
@@ -623,6 +657,16 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
return res.status(400).json({ error: 'Priority must be a number between 1 and 100' })
}
const { normalized: normalizedTempUnavailablePolicy, error: tempUnavailablePolicyError } =
normalizeTempUnavailablePolicyPayload({
disableTempUnavailable,
tempUnavailable503TtlSeconds,
tempUnavailable5xxTtlSeconds
})
if (tempUnavailablePolicyError) {
return res.status(400).json({ error: tempUnavailablePolicyError })
}
const newAccount = await claudeAccountService.createAccount({
name,
description,
@@ -641,7 +685,10 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => {
expiresAt: expiresAt || null, // 账户订阅到期时间
extInfo: extInfo || null,
maxConcurrency: maxConcurrency || 0, // 账户级串行队列0=使用全局配置,>0=强制启用
interceptWarmup: interceptWarmup === true // 拦截预热请求默认为false
interceptWarmup: interceptWarmup === true, // 拦截预热请求默认为false
disableTempUnavailable: normalizedTempUnavailablePolicy.disableTempUnavailable,
tempUnavailable503TtlSeconds: normalizedTempUnavailablePolicy.tempUnavailable503TtlSeconds,
tempUnavailable5xxTtlSeconds: normalizedTempUnavailablePolicy.tempUnavailable5xxTtlSeconds
})
// 如果是分组类型,将账户添加到分组
@@ -685,6 +732,13 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) =>
return res.status(400).json({ error: 'Priority must be a number between 1 and 100' })
}
const { normalized: normalizedTempUnavailablePolicy, error: tempUnavailablePolicyError } =
normalizeTempUnavailablePolicyPayload(mappedUpdates, { partial: true })
if (tempUnavailablePolicyError) {
return res.status(400).json({ error: tempUnavailablePolicyError })
}
Object.assign(mappedUpdates, normalizedTempUnavailablePolicy)
// 验证accountType的有效性
if (
mappedUpdates.accountType &&

View File

@@ -18,6 +18,11 @@ const tokenRefreshService = require('../tokenRefreshService')
const LRUCache = require('../../utils/lruCache')
const { formatDateWithTimezone, getISOStringWithTimezone } = require('../../utils/dateHelper')
const { isOpus45OrNewer } = require('../../utils/modelHelper')
const {
parseBooleanLike,
normalizeOptionalNonNegativeInteger,
normalizeTempUnavailablePolicyInput
} = require('../../utils/tempUnavailablePolicy')
/**
* Check if account is Pro (not Max)
@@ -94,10 +99,20 @@ class ClaudeAccountService {
expiresAt = null, // 账户订阅到期时间
extInfo = null, // 额外扩展信息
maxConcurrency = 0, // 账户级用户消息串行队列0=使用全局配置,>0=强制启用串行
interceptWarmup = false // 拦截预热请求标题生成、Warmup等
interceptWarmup = false, // 拦截预热请求标题生成、Warmup等
disableTempUnavailable = false, // 是否禁用账号级临时冷却temp_unavailable
tempUnavailable503TtlSeconds = null, // 账号级 503 冷却秒数null 跟随全局)
tempUnavailable5xxTtlSeconds = null // 账号级 5xx 冷却秒数null 跟随全局)
} = options
const accountId = uuidv4()
const normalizedTempUnavailablePolicy = normalizeTempUnavailablePolicyInput({
disableTempUnavailable,
tempUnavailable503TtlSeconds,
tempUnavailable5xxTtlSeconds
})
const normalized503Ttl = normalizedTempUnavailablePolicy.tempUnavailable503TtlSeconds
const normalized5xxTtl = normalizedTempUnavailablePolicy.tempUnavailable5xxTtlSeconds
let accountData
const normalizedExtInfo = this._normalizeExtInfo(extInfo, claudeAiOauth)
@@ -143,7 +158,11 @@ class ClaudeAccountService {
// 账户级用户消息串行队列限制
maxConcurrency: maxConcurrency.toString(),
// 拦截预热请求
interceptWarmup: interceptWarmup.toString()
interceptWarmup: interceptWarmup.toString(),
// 账号级临时冷却覆盖(空字符串表示跟随全局配置)
disableTempUnavailable: normalizedTempUnavailablePolicy.disableTempUnavailable.toString(),
tempUnavailable503TtlSeconds: normalized503Ttl !== null ? normalized503Ttl.toString() : '',
tempUnavailable5xxTtlSeconds: normalized5xxTtl !== null ? normalized5xxTtl.toString() : ''
}
} else {
// 兼容旧格式
@@ -179,7 +198,11 @@ class ClaudeAccountService {
// 账户级用户消息串行队列限制
maxConcurrency: maxConcurrency.toString(),
// 拦截预热请求
interceptWarmup: interceptWarmup.toString()
interceptWarmup: interceptWarmup.toString(),
// 账号级临时冷却覆盖(空字符串表示跟随全局配置)
disableTempUnavailable: normalizedTempUnavailablePolicy.disableTempUnavailable.toString(),
tempUnavailable503TtlSeconds: normalized503Ttl !== null ? normalized503Ttl.toString() : '',
tempUnavailable5xxTtlSeconds: normalized5xxTtl !== null ? normalized5xxTtl.toString() : ''
}
}
@@ -228,7 +251,10 @@ class ClaudeAccountService {
useUnifiedClientId,
unifiedClientId,
extInfo: normalizedExtInfo,
interceptWarmup
interceptWarmup,
disableTempUnavailable: normalizedTempUnavailablePolicy.disableTempUnavailable,
tempUnavailable503TtlSeconds: normalized503Ttl,
tempUnavailable5xxTtlSeconds: normalized5xxTtl
}
}
@@ -545,6 +571,11 @@ class ClaudeAccountService {
'subscriptionInfo',
account.id
)
const normalizedTempUnavailablePolicy = normalizeTempUnavailablePolicyInput({
disableTempUnavailable: account.disableTempUnavailable,
tempUnavailable503TtlSeconds: account.tempUnavailable503TtlSeconds,
tempUnavailable5xxTtlSeconds: account.tempUnavailable5xxTtlSeconds
})
return {
id: account.id,
@@ -619,7 +650,13 @@ class ClaudeAccountService {
// 账户级用户消息串行队列限制
maxConcurrency: parseInt(account.maxConcurrency || '0', 10),
// 拦截预热请求
interceptWarmup: account.interceptWarmup === 'true'
interceptWarmup: account.interceptWarmup === 'true',
// 账号级临时冷却覆盖
disableTempUnavailable: normalizedTempUnavailablePolicy.disableTempUnavailable,
tempUnavailable503TtlSeconds:
normalizedTempUnavailablePolicy.tempUnavailable503TtlSeconds,
tempUnavailable5xxTtlSeconds:
normalizedTempUnavailablePolicy.tempUnavailable5xxTtlSeconds
}
})
)
@@ -713,7 +750,10 @@ class ClaudeAccountService {
'subscriptionExpiresAt',
'extInfo',
'maxConcurrency',
'interceptWarmup'
'interceptWarmup',
'disableTempUnavailable',
'tempUnavailable503TtlSeconds',
'tempUnavailable5xxTtlSeconds'
]
const updatedData = { ...accountData }
let shouldClearAutoStopFields = false
@@ -730,6 +770,14 @@ class ClaudeAccountService {
updatedData[field] = value ? JSON.stringify(value) : ''
} else if (field === 'priority' || field === 'maxConcurrency') {
updatedData[field] = value.toString()
} else if (field === 'disableTempUnavailable') {
updatedData[field] = parseBooleanLike(value) ? 'true' : 'false'
} else if (
field === 'tempUnavailable503TtlSeconds' ||
field === 'tempUnavailable5xxTtlSeconds'
) {
const normalizedTtl = normalizeOptionalNonNegativeInteger(value)
updatedData[field] = normalizedTtl !== null ? normalizedTtl.toString() : ''
} else if (field === 'subscriptionInfo') {
// 处理订阅信息更新
updatedData[field] = typeof value === 'string' ? value : JSON.stringify(value)

View File

@@ -0,0 +1,56 @@
const isEmptyValue = (value) => value === undefined || value === null || value === ''
const parseBooleanLike = (value) => {
if (value === true || value === false) {
return value
}
if (value === 1 || value === '1') {
return true
}
if (value === 0 || value === '0') {
return false
}
const normalized = String(value || '')
.trim()
.toLowerCase()
return normalized === 'true' || normalized === 'yes' || normalized === 'on'
}
const normalizeOptionalNonNegativeInteger = (value) => {
if (isEmptyValue(value)) {
return null
}
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed < 0) {
return null
}
return Math.floor(parsed)
}
const normalizeTempUnavailablePolicyInput = (value = {}) => ({
disableTempUnavailable: parseBooleanLike(value.disableTempUnavailable),
tempUnavailable503TtlSeconds: normalizeOptionalNonNegativeInteger(
value.tempUnavailable503TtlSeconds
),
tempUnavailable5xxTtlSeconds: normalizeOptionalNonNegativeInteger(
value.tempUnavailable5xxTtlSeconds
)
})
const normalizeTempUnavailablePolicyFromAccountData = (accountData = {}) => {
const normalized = normalizeTempUnavailablePolicyInput(accountData)
return {
disableTempUnavailable: normalized.disableTempUnavailable,
ttl503Seconds: normalized.tempUnavailable503TtlSeconds,
ttl5xxSeconds: normalized.tempUnavailable5xxTtlSeconds
}
}
module.exports = {
isEmptyValue,
parseBooleanLike,
normalizeOptionalNonNegativeInteger,
normalizeTempUnavailablePolicyInput,
normalizeTempUnavailablePolicyFromAccountData
}

View File

@@ -1,4 +1,5 @@
const logger = require('./logger')
const { normalizeTempUnavailablePolicyFromAccountData } = require('./tempUnavailablePolicy')
const TEMP_UNAVAILABLE_PREFIX = 'temp_unavailable'
const ERROR_HISTORY_PREFIX = 'error_history'
@@ -57,6 +58,87 @@ const getRedis = () => {
return _redis
}
// 可读取账号级临时暂停配置的 Redis key 前缀映射
const ACCOUNT_KEY_PREFIX_BY_TYPE = {
'claude-official': 'claude:account:',
claude: 'claude:account:'
}
const EMPTY_TEMP_UNAVAILABLE_POLICY = {
disableTempUnavailable: false,
ttl503Seconds: null,
ttl5xxSeconds: null
}
const getAccountTempUnavailablePolicy = async (accountId, accountType) => {
try {
const accountPrefix = ACCOUNT_KEY_PREFIX_BY_TYPE[accountType]
if (!accountPrefix) {
return EMPTY_TEMP_UNAVAILABLE_POLICY
}
const redis = getRedis()
const client = redis.getClientSafe()
const accountData = await client.hgetall(`${accountPrefix}${accountId}`)
if (!accountData || Object.keys(accountData).length === 0) {
return EMPTY_TEMP_UNAVAILABLE_POLICY
}
return normalizeTempUnavailablePolicyFromAccountData(accountData)
} catch (error) {
logger.warn(
`⚠️ [UpstreamError] Failed to load account temp-unavailable policy for ${accountType}:${accountId}: ${error.message}`
)
return EMPTY_TEMP_UNAVAILABLE_POLICY
}
}
const resolveAccountTtlOverride = ({ policy, statusCode, errorType }) => {
if (!policy) {
return { skip: false, ttlOverrideSeconds: null, reason: '' }
}
if (policy.disableTempUnavailable) {
return {
skip: true,
ttlOverrideSeconds: null,
reason: 'account_temp_unavailable_disabled'
}
}
if (statusCode === 503 && policy.ttl503Seconds !== null) {
if (policy.ttl503Seconds <= 0) {
return {
skip: true,
ttlOverrideSeconds: null,
reason: 'account_503_ttl_disabled'
}
}
return {
skip: false,
ttlOverrideSeconds: policy.ttl503Seconds,
reason: 'account_503_ttl_override'
}
}
if (errorType === 'server_error' && policy.ttl5xxSeconds !== null) {
if (policy.ttl5xxSeconds <= 0) {
return {
skip: true,
ttlOverrideSeconds: null,
reason: 'account_5xx_ttl_disabled'
}
}
return {
skip: false,
ttlOverrideSeconds: policy.ttl5xxSeconds,
reason: 'account_5xx_ttl_override'
}
}
return { skip: false, ttlOverrideSeconds: null, reason: '' }
}
// 根据 HTTP 状态码分类错误类型
const classifyError = (statusCode) => {
if (statusCode === 529) {
@@ -216,18 +298,41 @@ const markTempUnavailable = async (
return { success: false, reason: 'not_a_pausable_error' }
}
const policy = await getAccountTempUnavailablePolicy(accountId, accountType)
const policyDecision = resolveAccountTtlOverride({
policy,
statusCode,
errorType
})
const key = `${TEMP_UNAVAILABLE_PREFIX}:${accountType}:${accountId}`
if (policyDecision.skip) {
const redis = getRedis()
const client = redis.getClientSafe()
await client.del(key).catch(() => {})
logger.info(
`⏭️ [UpstreamError] Skip temp-unavailable for account ${accountId} (${accountType}), reason: ${policyDecision.reason}`
)
return { success: true, skipped: true, reason: policyDecision.reason }
}
const ttlConfig = getTtlConfig()
const parsedCustomTtl = Number(customTtl)
const ttlSeconds =
let ttlSeconds =
Number.isFinite(parsedCustomTtl) && parsedCustomTtl > 0
? Math.ceil(parsedCustomTtl)
: ttlConfig[errorType]
if (
Number.isFinite(policyDecision.ttlOverrideSeconds) &&
policyDecision.ttlOverrideSeconds > 0
) {
ttlSeconds = policyDecision.ttlOverrideSeconds
}
const markedAtIso = new Date().toISOString()
const expiresAtIso = new Date(Date.now() + ttlSeconds * 1000).toISOString()
const redis = getRedis()
const client = redis.getClientSafe()
const key = `${TEMP_UNAVAILABLE_PREFIX}:${accountType}:${accountId}`
await client.setex(
key,
ttlSeconds,

View File

@@ -1632,6 +1632,13 @@
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
</p>
</div>
<TempUnavailablePolicyFields
v-if="form.platform === 'claude'"
v-model:disable-temp-unavailable="form.disableTempUnavailable"
v-model:temp-unavailable-503-ttl-seconds="form.tempUnavailable503TtlSeconds"
v-model:temp-unavailable-5xx-ttl-seconds="form.tempUnavailable5xxTtlSeconds"
/>
</div>
<!-- OpenAI-Responses 特定字段 -->
@@ -3410,6 +3417,13 @@
</p>
</div>
<TempUnavailablePolicyFields
v-if="form.platform === 'claude'"
v-model:disable-temp-unavailable="form.disableTempUnavailable"
v-model:temp-unavailable-503-ttl-seconds="form.tempUnavailable503TtlSeconds"
v-model:temp-unavailable-5xx-ttl-seconds="form.tempUnavailable5xxTtlSeconds"
/>
<!-- OpenAI-Responses 特定字段(编辑模式)-->
<div v-if="form.platform === 'openai-responses'" class="space-y-4">
<div>
@@ -4035,6 +4049,7 @@ import * as httpApis from '@/utils/http_apis'
import { useAccountsStore } from '@/stores/accounts'
import ProxyConfig from './ProxyConfig.vue'
import OAuthFlow from './OAuthFlow.vue'
import TempUnavailablePolicyFields from './TempUnavailablePolicyFields.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
import GroupManagementModal from './GroupManagementModal.vue'
import ApiKeyManagementModal from './ApiKeyManagementModal.vue'
@@ -4270,6 +4285,27 @@ const initProxyConfig = () => {
return normalizeProxyFormState(props.account?.proxy)
}
const toFormCooldownOverrideValue = (value) => {
if (value === null || value === undefined || value === '') {
return ''
}
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : ''
}
const normalizeAccountCooldownOverride = (value) => {
if (value === null || value === undefined || value === '') {
return null
}
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed < 0) {
return null
}
return Math.floor(parsed)
}
const toFormBoolean = (value) => value === true || value === 'true'
// 表单数据
const form = ref({
platform: props.account?.platform || 'claude',
@@ -4326,9 +4362,14 @@ const form = ref({
})(),
userAgent: props.account?.userAgent || '',
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
disableAutoProtection:
props.account?.disableAutoProtection === true ||
props.account?.disableAutoProtection === 'true',
disableAutoProtection: toFormBoolean(props.account?.disableAutoProtection),
disableTempUnavailable: toFormBoolean(props.account?.disableTempUnavailable),
tempUnavailable503TtlSeconds: toFormCooldownOverrideValue(
props.account?.tempUnavailable503TtlSeconds
),
tempUnavailable5xxTtlSeconds: toFormCooldownOverrideValue(
props.account?.tempUnavailable5xxTtlSeconds
),
// 额度管理字段
dailyQuota: props.account?.dailyQuota || 0,
dailyUsage: props.account?.dailyUsage || 0,
@@ -4367,6 +4408,16 @@ const form = ref({
expiresAt: props.account?.expiresAt || null
})
const buildClaudeTempUnavailablePolicyPayload = () => ({
disableTempUnavailable: !!form.value.disableTempUnavailable,
tempUnavailable503TtlSeconds: normalizeAccountCooldownOverride(
form.value.tempUnavailable503TtlSeconds
),
tempUnavailable5xxTtlSeconds: normalizeAccountCooldownOverride(
form.value.tempUnavailable5xxTtlSeconds
)
})
// 模型限制配置
const modelRestrictionMode = ref('whitelist') // 'whitelist' 或 'mapping'
const allowedModels = ref([
@@ -5031,6 +5082,7 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
data.useUnifiedClientId = form.value.useUnifiedClientId || false
data.unifiedClientId = form.value.unifiedClientId || ''
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
Object.assign(data, buildClaudeTempUnavailablePolicyPayload())
// 添加订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -5775,6 +5827,7 @@ const updateAccount = async () => {
data.useUnifiedClientId = form.value.useUnifiedClientId || false
data.unifiedClientId = form.value.unifiedClientId || ''
data.maxConcurrency = form.value.serialQueueEnabled ? 1 : 0
Object.assign(data, buildClaudeTempUnavailablePolicyPayload())
// 更新订阅类型信息
data.subscriptionInfo = {
accountType: form.value.subscriptionType || 'claude_max',
@@ -6454,8 +6507,14 @@ watch(
// 并发控制字段
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0,
// 上游错误处理
disableAutoProtection:
newAccount.disableAutoProtection === true || newAccount.disableAutoProtection === 'true'
disableAutoProtection: toFormBoolean(newAccount.disableAutoProtection),
disableTempUnavailable: toFormBoolean(newAccount.disableTempUnavailable),
tempUnavailable503TtlSeconds: toFormCooldownOverrideValue(
newAccount.tempUnavailable503TtlSeconds
),
tempUnavailable5xxTtlSeconds: toFormCooldownOverrideValue(
newAccount.tempUnavailable5xxTtlSeconds
)
}
// 如果是Claude Console账户加载实时使用情况

View File

@@ -0,0 +1,101 @@
<template>
<div :class="resolvedContainerClass">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
账号级临时冷却覆盖
</label>
<label class="inline-flex cursor-pointer items-center">
<input
:checked="disableTempUnavailable"
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
@change="handleDisableChange"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
禁用该账号临时冷却不再因 503/5xx 自动进入 TTL
</span>
</label>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
503 冷却秒数留空=全局0=关闭
</label>
<input
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
min="0"
placeholder="例如 30"
type="number"
:value="tempUnavailable503TtlSeconds"
@input="handle503Input"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
5xx 冷却秒数留空=全局0=关闭
</label>
<input
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
min="0"
placeholder="例如 120"
type="number"
:value="tempUnavailable5xxTtlSeconds"
@input="handle5xxInput"
/>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
disableTempUnavailable: {
type: Boolean,
default: false
},
tempUnavailable503TtlSeconds: {
type: [Number, String],
default: ''
},
tempUnavailable5xxTtlSeconds: {
type: [Number, String],
default: ''
},
containerClass: {
type: String,
default: ''
}
})
const emit = defineEmits([
'update:disableTempUnavailable',
'update:tempUnavailable503TtlSeconds',
'update:tempUnavailable5xxTtlSeconds'
])
const resolvedContainerClass = computed(() => {
const baseClass = 'rounded-lg border border-amber-200/60 p-3 dark:border-amber-700/40'
return props.containerClass ? `${baseClass} ${props.containerClass}` : baseClass
})
const normalizeNumericInput = (value) => {
if (value === '') {
return ''
}
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : ''
}
const handleDisableChange = (event) => {
emit('update:disableTempUnavailable', event.target.checked)
}
const handle503Input = (event) => {
emit('update:tempUnavailable503TtlSeconds', normalizeNumericInput(event.target.value))
}
const handle5xxInput = (event) => {
emit('update:tempUnavailable5xxTtlSeconds', normalizeNumericInput(event.target.value))
}
</script>

View File

@@ -2301,6 +2301,7 @@ const handleCancel = () => {
const accounts = ref([])
const accountsLoading = ref(false)
const refreshingBalances = ref(false)
const tempUnavailableNowTs = ref(Date.now())
const accountsSortBy = ref('name')
const accountsSortOrder = ref('asc')
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
@@ -3792,7 +3793,29 @@ const toPositiveInteger = (value) => {
const getTempUnavailableRemainingSeconds = (tempUnavailable) => {
if (!tempUnavailable) return 0
return toPositiveInteger(tempUnavailable.remainingSeconds || tempUnavailable.ttl)
const serverRemainingSeconds = toPositiveInteger(
tempUnavailable.remainingSeconds || tempUnavailable.ttl
)
const recoveryAt = getTempUnavailableRecoveryAt(tempUnavailable)
if (!recoveryAt) {
return serverRemainingSeconds
}
const recoveryAtTimestamp = new Date(recoveryAt).getTime()
if (Number.isNaN(recoveryAtTimestamp)) {
return serverRemainingSeconds
}
const liveRemainingSeconds = Math.max(
0,
Math.ceil((recoveryAtTimestamp - tempUnavailableNowTs.value) / 1000)
)
if (serverRemainingSeconds <= 0) {
return liveRemainingSeconds
}
return Math.min(serverRemainingSeconds, liveRemainingSeconds)
}
const getTempUnavailableCooldownSeconds = (tempUnavailable) => {
@@ -5212,11 +5235,17 @@ const checkHorizontalScroll = () => {
// 窗口大小变化时重新检测
let resizeObserver = null
let tempUnavailableCountdownTimer = null
onMounted(() => {
// 首次加载时强制刷新所有数据
loadAccounts(true)
// 让临时不可用剩余时间在页面停留时也可见地递减
tempUnavailableCountdownTimer = setInterval(() => {
tempUnavailableNowTs.value = Date.now()
}, 1000)
// 设置ResizeObserver监听表格容器大小变化
nextTick(() => {
if (tableContainerRef.value) {
@@ -5236,6 +5265,10 @@ onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
if (tempUnavailableCountdownTimer) {
clearInterval(tempUnavailableCountdownTimer)
tempUnavailableCountdownTimer = null
}
window.removeEventListener('resize', checkHorizontalScroll)
})
</script>