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)
})