diff --git a/src/routes/admin.js b/src/routes/admin.js index b0aad407..f0a3035e 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -2266,7 +2266,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { autoStopOnWarning, useUnifiedUserAgent, useUnifiedClientId, - unifiedClientId + unifiedClientId, + expiresAt } = req.body if (!name) { @@ -2309,7 +2310,8 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { autoStopOnWarning: autoStopOnWarning === true, // 默认为false useUnifiedUserAgent: useUnifiedUserAgent === true, // 默认为false useUnifiedClientId: useUnifiedClientId === true, // 默认为false - unifiedClientId: unifiedClientId || '' // 统一的客户端标识 + unifiedClientId: unifiedClientId || '', // 统一的客户端标识 + expiresAt: expiresAt || null // 账户订阅到期时间 }) // 如果是分组类型,将账户添加到分组 @@ -2396,7 +2398,14 @@ router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => } } - await claudeAccountService.updateAccount(accountId, updates) + // 映射字段名:前端的expiresAt -> 后端的subscriptionExpiresAt + const mappedUpdates = { ...updates } + if ('expiresAt' in mappedUpdates) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + delete mappedUpdates.expiresAt + } + + await claudeAccountService.updateAccount(accountId, mappedUpdates) logger.success(`📝 Admin updated Claude account: ${accountId}`) return res.json({ success: true, message: 'Claude account updated successfully' }) diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index ec9e5b11..c796e7c9 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -73,7 +73,8 @@ class ClaudeAccountService { autoStopOnWarning = false, // 5小时使用量接近限制时自动停止调度 useUnifiedUserAgent = false, // 是否使用统一Claude Code版本的User-Agent useUnifiedClientId = false, // 是否使用统一的客户端标识 - unifiedClientId = '' // 统一的客户端标识 + unifiedClientId = '', // 统一的客户端标识 + expiresAt = null // 账户订阅到期时间 } = options const accountId = uuidv4() @@ -113,7 +114,9 @@ class ClaudeAccountService { ? JSON.stringify(subscriptionInfo) : claudeAiOauth.subscriptionInfo ? JSON.stringify(claudeAiOauth.subscriptionInfo) - : '' + : '', + // 账户订阅到期时间 + subscriptionExpiresAt: expiresAt || '' } } else { // 兼容旧格式 @@ -141,7 +144,9 @@ class ClaudeAccountService { autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度 useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent // 手动设置的订阅信息 - subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : '' + subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : '', + // 账户订阅到期时间 + subscriptionExpiresAt: expiresAt || '' } } @@ -486,7 +491,7 @@ class ClaudeAccountService { createdAt: account.createdAt, lastUsedAt: account.lastUsedAt, lastRefreshAt: account.lastRefreshAt, - expiresAt: account.expiresAt, + expiresAt: account.subscriptionExpiresAt || null, // 账户订阅到期时间 // 添加 scopes 字段用于判断认证方式 // 处理空字符串的情况,避免返回 [''] scopes: account.scopes && account.scopes.trim() ? account.scopes.split(' ') : [], @@ -618,7 +623,8 @@ class ClaudeAccountService { 'autoStopOnWarning', 'useUnifiedUserAgent', 'useUnifiedClientId', - 'unifiedClientId' + 'unifiedClientId', + 'subscriptionExpiresAt' ] const updatedData = { ...accountData } let shouldClearAutoStopFields = false @@ -637,6 +643,9 @@ class ClaudeAccountService { } else if (field === 'subscriptionInfo') { // 处理订阅信息更新 updatedData[field] = typeof value === 'string' ? value : JSON.stringify(value) + } else if (field === 'subscriptionExpiresAt') { + // 处理订阅到期时间,允许 null 值(永不过期) + updatedData[field] = value ? value.toString() : '' } else if (field === 'claudeAiOauth') { // 更新 Claude AI OAuth 数据 if (value) { @@ -650,7 +659,7 @@ class ClaudeAccountService { updatedData.lastRefreshAt = new Date().toISOString() } } else { - updatedData[field] = value.toString() + updatedData[field] = value !== null && value !== undefined ? value.toString() : '' } } } diff --git a/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue b/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue new file mode 100644 index 00000000..046c3332 --- /dev/null +++ b/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue @@ -0,0 +1,416 @@ + + + + + diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 8d6a1b9b..09877616 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -641,6 +641,49 @@

+ +
+ +
+ +
+ +
+

+ + 将于 {{ formatExpireDate(form.expiresAt) }} 过期 +

+

+ + 账户永不过期 +

+
+

+ 设置 Claude Max/Pro 订阅的到期时间,到期后将停止调度此账户 +

+
+
+ +
+ +
+ +
+ +
+

+ + 将于 {{ formatExpireDate(form.expiresAt) }} 过期 +

+

+ + 账户永不过期 +

+
+

+ 设置 Claude Max/Pro 订阅的到期时间,到期后将停止调度此账户 +

+
+
+ +
+ + + + + 已过期 + + + + {{ formatExpireDate(account.expiresAt) }} + + + {{ formatExpireDate(account.expiresAt) }} + + + + + + 永不过期 + +
+
+ + +
@@ -1664,6 +1731,7 @@ import { useConfirm } from '@/composables/useConfirm' import AccountForm from '@/components/accounts/AccountForm.vue' import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue' import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue' +import AccountExpiryEditModal from '@/components/accounts/AccountExpiryEditModal.vue' import ConfirmModal from '@/components/common/ConfirmModal.vue' import CustomDropdown from '@/components/common/CustomDropdown.vue' @@ -1720,6 +1788,10 @@ const supportedUsagePlatforms = [ 'droid' ] +// 过期时间编辑弹窗状态 +const editingExpiryAccount = ref(null) +const expiryEditModalRef = ref(null) + // 缓存状态标志 const apiKeysLoaded = ref(false) const groupsLoaded = ref(false) @@ -3618,6 +3690,70 @@ watch(paginatedAccounts, () => { watch(accounts, () => { cleanupSelectedAccounts() }) +// 到期时间相关方法 +const formatExpireDate = (dateString) => { + if (!dateString) return '' + const date = new Date(dateString) + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }) +} + +const isExpired = (expiresAt) => { + if (!expiresAt) return false + return new Date(expiresAt) < new Date() +} + +const isExpiringSoon = (expiresAt) => { + if (!expiresAt) return false + const now = new Date() + const expireDate = new Date(expiresAt) + const daysUntilExpire = (expireDate - now) / (1000 * 60 * 60 * 24) + return daysUntilExpire > 0 && daysUntilExpire <= 7 +} + +// 开始编辑账户过期时间 +const startEditAccountExpiry = (account) => { + editingExpiryAccount.value = account +} + +// 关闭账户过期时间编辑 +const closeAccountExpiryEdit = () => { + editingExpiryAccount.value = null +} + +// 保存账户过期时间 +const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => { + try { + const data = await apiClient.put(`/admin/claude-accounts/${accountId}`, { + expiresAt: expiresAt || null + }) + + if (data.success) { + showToast('账户到期时间已更新', 'success') + // 更新本地数据 + const account = accounts.value.find((acc) => acc.id === accountId) + if (account) { + account.expiresAt = expiresAt || null + } + closeAccountExpiryEdit() + } else { + showToast(data.message || '更新失败', 'error') + // 重置保存状态 + if (expiryEditModalRef.value) { + expiryEditModalRef.value.resetSaving() + } + } + } catch (error) { + showToast('更新失败', 'error') + // 重置保存状态 + if (expiryEditModalRef.value) { + expiryEditModalRef.value.resetSaving() + } + } +} onMounted(() => { // 首次加载时强制刷新所有数据