feat: 添加账户订阅到期时间管理功能

## 新增功能
- 支持为 Claude 账户设置订阅到期时间
- 前端提供到期时间选择器(快捷选项 + 自定义日期)
- 账户列表显示到期状态(已过期/即将过期/永不过期)
- 新增独立的到期时间编辑弹窗组件

## 技术变更
- 后端新增 subscriptionExpiresAt 字段存储
- 前端使用 expiresAt 字段进行交互
- 支持创建、编辑、显示完整流程

## 包含文件
- src/routes/admin.js: POST/PUT 端点支持 expiresAt 字段
- src/services/claudeAccountService.js: 存储和返回到期时间
- web/admin-spa/src/components/accounts/AccountForm.vue: 表单添加到期时间选择
- web/admin-spa/src/views/AccountsView.vue: 列表显示和编辑功能
- web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue: 新增编辑弹窗
- account_expire_feature.md: 代码评审报告和优化建议

## 注意事项
⚠️ 本次提交包含初步实现,详细的优化建议请查看 account_expire_feature.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
litongtongxue
2025-10-11 01:05:21 +08:00
parent 80059e2b09
commit a82dcebd7b
6 changed files with 1484 additions and 10 deletions

View File

@@ -641,6 +641,49 @@
</p>
</div>
<!-- 到期时间 -->
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>到期时间 (可选)</label
>
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
>
<select
v-model="form.expireDuration"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
@change="updateAccountExpireAt"
>
<option value="">永不过期</option>
<option value="30d">30 天</option>
<option value="90d">90 天</option>
<option value="180d">180 天</option>
<option value="365d">365 天</option>
<option value="custom">自定义日期</option>
</select>
<div v-if="form.expireDuration === 'custom'" class="mt-3">
<input
v-model="form.customExpireDate"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:min="minDateTime"
type="datetime-local"
@change="updateAccountCustomExpireAt"
/>
</div>
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-calendar-alt mr-1" />
将于 {{ formatExpireDate(form.expiresAt) }} 过期
</p>
<p v-else class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-infinity mr-1" />
账户永不过期
</p>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
设置 Claude Max/Pro 订阅的到期时间,到期后将停止调度此账户
</p>
</div>
<!-- 分组选择器 -->
<div v-if="form.accountType === 'group'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -2066,6 +2109,49 @@
</p>
</div>
<!-- 到期时间 -->
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>到期时间 (可选)</label
>
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
>
<select
v-model="form.expireDuration"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
@change="updateAccountExpireAt"
>
<option value="">永不过期</option>
<option value="30d">30 天</option>
<option value="90d">90 天</option>
<option value="180d">180 天</option>
<option value="365d">365 天</option>
<option value="custom">自定义日期</option>
</select>
<div v-if="form.expireDuration === 'custom'" class="mt-3">
<input
v-model="form.customExpireDate"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
:min="minDateTime"
type="datetime-local"
@change="updateAccountCustomExpireAt"
/>
</div>
<p v-if="form.expiresAt" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-calendar-alt mr-1" />
将于 {{ formatExpireDate(form.expiresAt) }} 过期
</p>
<p v-else class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-infinity mr-1" />
账户永不过期
</p>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
设置 Claude Max/Pro 订阅的到期时间,到期后将停止调度此账户
</p>
</div>
<!-- 分组选择器 -->
<div v-if="form.accountType === 'group'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -3222,7 +3308,11 @@ const form = ref({
// Azure OpenAI 特定字段
azureEndpoint: props.account?.azureEndpoint || '',
apiVersion: props.account?.apiVersion || '',
deploymentName: props.account?.deploymentName || ''
deploymentName: props.account?.deploymentName || '',
// 到期时间字段
expireDuration: '',
customExpireDate: '',
expiresAt: props.account?.expiresAt || null
})
// 模型限制配置
@@ -3612,6 +3702,7 @@ const handleOAuthSuccess = async (tokenInfo) => {
accountType: form.value.accountType,
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
expiresAt: form.value.expiresAt || undefined,
proxy: form.value.proxy.enabled
? {
type: form.value.proxy.type,
@@ -3909,6 +4000,7 @@ const createAccount = async () => {
accountType: form.value.accountType,
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
expiresAt: form.value.expiresAt || undefined,
proxy: form.value.proxy.enabled
? {
type: form.value.proxy.type,
@@ -4174,6 +4266,7 @@ const updateAccount = async () => {
accountType: form.value.accountType,
groupId: form.value.accountType === 'group' ? form.value.groupId : undefined,
groupIds: form.value.accountType === 'group' ? form.value.groupIds : undefined,
expiresAt: form.value.expiresAt || undefined,
proxy: form.value.proxy.enabled
? {
type: form.value.proxy.type,
@@ -4978,6 +5071,61 @@ const handleUnifiedClientIdChange = () => {
}
}
// 到期时间相关方法
// 计算最小日期时间
const minDateTime = computed(() => {
const now = new Date()
now.setMinutes(now.getMinutes() + 1)
return now.toISOString().slice(0, 16)
})
// 更新账户过期时间
const updateAccountExpireAt = () => {
if (!form.value.expireDuration) {
form.value.expiresAt = null
return
}
if (form.value.expireDuration === 'custom') {
return
}
const now = new Date()
const duration = form.value.expireDuration
const match = duration.match(/(\d+)([d])/)
if (match) {
const [, value, unit] = match
const num = parseInt(value)
if (unit === 'd') {
now.setDate(now.getDate() + num)
}
form.value.expiresAt = now.toISOString()
}
}
// 更新自定义过期时间
const updateAccountCustomExpireAt = () => {
if (form.value.customExpireDate) {
form.value.expiresAt = new Date(form.value.customExpireDate).toISOString()
}
}
// 格式化过期日期
const formatExpireDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 组件挂载时获取统一 User-Agent 信息
onMounted(() => {
// 初始化平台分组