From 87cbd1897dbcd8834ea45e9a88c639bc4c6682ac Mon Sep 17 00:00:00 2001 From: X-Zero-L Date: Mon, 2 Mar 2026 22:17:00 +0800 Subject: [PATCH] refactor: extract temp-unavailable policy and reusable account UI --- src/routes/admin/claudeAccounts.js | 58 +++++++++- src/services/account/claudeAccountService.js | 60 +++++++++- src/utils/tempUnavailablePolicy.js | 56 +++++++++ src/utils/upstreamErrorHelper.js | 109 +++++++++++++++++- .../src/components/accounts/AccountForm.vue | 69 ++++++++++- .../accounts/TempUnavailablePolicyFields.vue | 101 ++++++++++++++++ web/admin-spa/src/views/AccountsView.vue | 35 +++++- 7 files changed, 472 insertions(+), 16 deletions(-) create mode 100644 src/utils/tempUnavailablePolicy.js create mode 100644 web/admin-spa/src/components/accounts/TempUnavailablePolicyFields.vue diff --git a/src/routes/admin/claudeAccounts.js b/src/routes/admin/claudeAccounts.js index c0c1c744..ff1215d2 100644 --- a/src/routes/admin/claudeAccounts.js +++ b/src/routes/admin/claudeAccounts.js @@ -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 && diff --git a/src/services/account/claudeAccountService.js b/src/services/account/claudeAccountService.js index 2c0a3fc0..16952951 100644 --- a/src/services/account/claudeAccountService.js +++ b/src/services/account/claudeAccountService.js @@ -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) diff --git a/src/utils/tempUnavailablePolicy.js b/src/utils/tempUnavailablePolicy.js new file mode 100644 index 00000000..04d98b51 --- /dev/null +++ b/src/utils/tempUnavailablePolicy.js @@ -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 +} diff --git a/src/utils/upstreamErrorHelper.js b/src/utils/upstreamErrorHelper.js index 838ab1fa..4cebffb2 100644 --- a/src/utils/upstreamErrorHelper.js +++ b/src/utils/upstreamErrorHelper.js @@ -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, diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index b70e8c74..b2f80fcd 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -1632,6 +1632,13 @@ 勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流

+ + @@ -3410,6 +3417,13 @@

+ +
@@ -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账户,加载实时使用情况 diff --git a/web/admin-spa/src/components/accounts/TempUnavailablePolicyFields.vue b/web/admin-spa/src/components/accounts/TempUnavailablePolicyFields.vue new file mode 100644 index 00000000..48b06e99 --- /dev/null +++ b/web/admin-spa/src/components/accounts/TempUnavailablePolicyFields.vue @@ -0,0 +1,101 @@ + + + diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 8c8c1867..e8247d55 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -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) })