Commit 2b528c5f authored by LLLLLLiulei's avatar LLLLLLiulei
Browse files

feat: auto-pause expired accounts

parent d5ba7b80
...@@ -105,6 +105,9 @@ func (m *mockAccountRepoForPlatform) SetError(ctx context.Context, id int64, err ...@@ -105,6 +105,9 @@ func (m *mockAccountRepoForPlatform) SetError(ctx context.Context, id int64, err
func (m *mockAccountRepoForPlatform) SetSchedulable(ctx context.Context, id int64, schedulable bool) error { func (m *mockAccountRepoForPlatform) SetSchedulable(ctx context.Context, id int64, schedulable bool) error {
return nil return nil
} }
func (m *mockAccountRepoForPlatform) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) {
return 0, nil
}
func (m *mockAccountRepoForPlatform) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error { func (m *mockAccountRepoForPlatform) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
return nil return nil
} }
......
...@@ -90,6 +90,9 @@ func (m *mockAccountRepoForGemini) SetError(ctx context.Context, id int64, error ...@@ -90,6 +90,9 @@ func (m *mockAccountRepoForGemini) SetError(ctx context.Context, id int64, error
func (m *mockAccountRepoForGemini) SetSchedulable(ctx context.Context, id int64, schedulable bool) error { func (m *mockAccountRepoForGemini) SetSchedulable(ctx context.Context, id int64, schedulable bool) error {
return nil return nil
} }
func (m *mockAccountRepoForGemini) AutoPauseExpiredAccounts(ctx context.Context, now time.Time) (int64, error) {
return 0, nil
}
func (m *mockAccountRepoForGemini) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error { func (m *mockAccountRepoForGemini) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
return nil return nil
} }
......
...@@ -47,6 +47,13 @@ func ProvideTokenRefreshService( ...@@ -47,6 +47,13 @@ func ProvideTokenRefreshService(
return svc return svc
} }
// ProvideAccountExpiryService creates and starts AccountExpiryService.
func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpiryService {
svc := NewAccountExpiryService(accountRepo, time.Minute)
svc.Start()
return svc
}
// ProvideTimingWheelService creates and starts TimingWheelService // ProvideTimingWheelService creates and starts TimingWheelService
func ProvideTimingWheelService() *TimingWheelService { func ProvideTimingWheelService() *TimingWheelService {
svc := NewTimingWheelService() svc := NewTimingWheelService()
...@@ -110,6 +117,7 @@ var ProviderSet = wire.NewSet( ...@@ -110,6 +117,7 @@ var ProviderSet = wire.NewSet(
NewCRSSyncService, NewCRSSyncService,
ProvideUpdateService, ProvideUpdateService,
ProvideTokenRefreshService, ProvideTokenRefreshService,
ProvideAccountExpiryService,
ProvideTimingWheelService, ProvideTimingWheelService,
ProvideDeferredService, ProvideDeferredService,
NewAntigravityQuotaFetcher, NewAntigravityQuotaFetcher,
......
-- Add expires_at for account expiration configuration
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS expires_at timestamptz;
-- Document expires_at meaning
COMMENT ON COLUMN accounts.expires_at IS 'Account expiration time (NULL means no expiration).';
-- Add auto_pause_on_expired for account expiration scheduling control
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS auto_pause_on_expired boolean NOT NULL DEFAULT true;
-- Document auto_pause_on_expired meaning
COMMENT ON COLUMN accounts.auto_pause_on_expired IS 'Auto pause scheduling when account expires.';
-- Ensure existing accounts are enabled by default
UPDATE accounts SET auto_pause_on_expired = true;
...@@ -1012,7 +1012,7 @@ ...@@ -1012,7 +1012,7 @@
</div> </div>
<!-- Temp Unschedulable Rules --> <!-- Temp Unschedulable Rules -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<div> <div>
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
...@@ -1213,7 +1213,41 @@ ...@@ -1213,7 +1213,41 @@
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p> <p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
</div> </div>
</div> </div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
<input v-model="expiresAtInput" type="datetime-local" class="input" />
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
</div>
<div>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{
t('admin.accounts.autoPauseOnExpired')
}}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.autoPauseOnExpiredDesc') }}
</p>
</div>
<button
type="button"
@click="autoPauseOnExpired = !autoPauseOnExpired"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
autoPauseOnExpired ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
autoPauseOnExpired ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<!-- Mixed Scheduling (only for antigravity accounts) --> <!-- Mixed Scheduling (only for antigravity accounts) -->
<div v-if="form.platform === 'antigravity'" class="flex items-center gap-2"> <div v-if="form.platform === 'antigravity'" class="flex items-center gap-2">
<label class="flex cursor-pointer items-center gap-2"> <label class="flex cursor-pointer items-center gap-2">
...@@ -1253,6 +1287,7 @@ ...@@ -1253,6 +1287,7 @@
:mixed-scheduling="mixedScheduling" :mixed-scheduling="mixedScheduling"
data-tour="account-form-groups" data-tour="account-form-groups"
/> />
</div>
</form> </form>
...@@ -1598,6 +1633,7 @@ import Icon from '@/components/icons/Icon.vue' ...@@ -1598,6 +1633,7 @@ import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component // Type for exposed OAuthAuthorizationFlow component
...@@ -1713,6 +1749,7 @@ const customErrorCodesEnabled = ref(false) ...@@ -1713,6 +1749,7 @@ const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([]) const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(true)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
...@@ -1795,7 +1832,8 @@ const form = reactive({ ...@@ -1795,7 +1832,8 @@ const form = reactive({
proxy_id: null as number | null, proxy_id: null as number | null,
concurrency: 10, concurrency: 10,
priority: 1, priority: 1,
group_ids: [] as number[] group_ids: [] as number[],
expires_at: null as number | null
}) })
// Helper to check if current type needs OAuth flow // Helper to check if current type needs OAuth flow
...@@ -1805,6 +1843,13 @@ const isManualInputMethod = computed(() => { ...@@ -1805,6 +1843,13 @@ const isManualInputMethod = computed(() => {
return oauthFlowRef.value?.inputMethod === 'manual' return oauthFlowRef.value?.inputMethod === 'manual'
}) })
const expiresAtInput = computed({
get: () => formatDateTimeLocal(form.expires_at),
set: (value: string) => {
form.expires_at = parseDateTimeLocal(value)
}
})
const canExchangeCode = computed(() => { const canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode || '' const authCode = oauthFlowRef.value?.authCode || ''
if (form.platform === 'openai') { if (form.platform === 'openai') {
...@@ -2055,6 +2100,7 @@ const resetForm = () => { ...@@ -2055,6 +2100,7 @@ const resetForm = () => {
form.concurrency = 10 form.concurrency = 10
form.priority = 1 form.priority = 1
form.group_ids = [] form.group_ids = []
form.expires_at = null
accountCategory.value = 'oauth-based' accountCategory.value = 'oauth-based'
addMethod.value = 'oauth' addMethod.value = 'oauth'
apiKeyBaseUrl.value = 'https://api.anthropic.com' apiKeyBaseUrl.value = 'https://api.anthropic.com'
...@@ -2066,6 +2112,7 @@ const resetForm = () => { ...@@ -2066,6 +2112,7 @@ const resetForm = () => {
selectedErrorCodes.value = [] selectedErrorCodes.value = []
customErrorCodeInput.value = null customErrorCodeInput.value = null
interceptWarmupRequests.value = false interceptWarmupRequests.value = false
autoPauseOnExpired.value = true
tempUnschedEnabled.value = false tempUnschedEnabled.value = false
tempUnschedRules.value = [] tempUnschedRules.value = []
geminiOAuthType.value = 'code_assist' geminiOAuthType.value = 'code_assist'
...@@ -2133,7 +2180,6 @@ const handleSubmit = async () => { ...@@ -2133,7 +2180,6 @@ const handleSubmit = async () => {
if (interceptWarmupRequests.value) { if (interceptWarmupRequests.value) {
credentials.intercept_warmup_requests = true credentials.intercept_warmup_requests = true
} }
if (!applyTempUnschedConfig(credentials)) { if (!applyTempUnschedConfig(credentials)) {
return return
} }
...@@ -2144,7 +2190,8 @@ const handleSubmit = async () => { ...@@ -2144,7 +2190,8 @@ const handleSubmit = async () => {
try { try {
await adminAPI.accounts.create({ await adminAPI.accounts.create({
...form, ...form,
group_ids: form.group_ids group_ids: form.group_ids,
auto_pause_on_expired: autoPauseOnExpired.value
}) })
appStore.showSuccess(t('admin.accounts.accountCreated')) appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created') emit('created')
...@@ -2182,6 +2229,9 @@ const handleGenerateUrl = async () => { ...@@ -2182,6 +2229,9 @@ const handleGenerateUrl = async () => {
} }
} }
const formatDateTimeLocal = formatDateTimeLocalInput
const parseDateTimeLocal = parseDateTimeLocalInput
// Create account and handle success/failure // Create account and handle success/failure
const createAccountAndFinish = async ( const createAccountAndFinish = async (
platform: AccountPlatform, platform: AccountPlatform,
...@@ -2202,7 +2252,9 @@ const createAccountAndFinish = async ( ...@@ -2202,7 +2252,9 @@ const createAccountAndFinish = async (
proxy_id: form.proxy_id, proxy_id: form.proxy_id,
concurrency: form.concurrency, concurrency: form.concurrency,
priority: form.priority, priority: form.priority,
group_ids: form.group_ids group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
}) })
appStore.showSuccess(t('admin.accounts.accountCreated')) appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created') emit('created')
...@@ -2416,7 +2468,8 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -2416,7 +2468,8 @@ const handleCookieAuth = async (sessionKey: string) => {
extra, extra,
proxy_id: form.proxy_id, proxy_id: form.proxy_id,
concurrency: form.concurrency, concurrency: form.concurrency,
priority: form.priority priority: form.priority,
auto_pause_on_expired: autoPauseOnExpired.value
}) })
successCount++ successCount++
......
...@@ -365,7 +365,7 @@ ...@@ -365,7 +365,7 @@
</div> </div>
<!-- Temp Unschedulable Rules --> <!-- Temp Unschedulable Rules -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<div> <div>
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label> <label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
...@@ -565,7 +565,41 @@ ...@@ -565,7 +565,41 @@
/> />
</div> </div>
</div> </div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
<input v-model="expiresAtInput" type="datetime-local" class="input" />
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
</div>
<div>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{
t('admin.accounts.autoPauseOnExpired')
}}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.autoPauseOnExpiredDesc') }}
</p>
</div>
<button
type="button"
@click="autoPauseOnExpired = !autoPauseOnExpired"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
autoPauseOnExpired ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
autoPauseOnExpired ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div> <div>
<label class="input-label">{{ t('common.status') }}</label> <label class="input-label">{{ t('common.status') }}</label>
<Select v-model="form.status" :options="statusOptions" /> <Select v-model="form.status" :options="statusOptions" />
...@@ -601,6 +635,7 @@ ...@@ -601,6 +635,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Group Selection - 仅标准模式显示 --> <!-- Group Selection - 仅标准模式显示 -->
<GroupSelector <GroupSelector
...@@ -666,6 +701,7 @@ import Icon from '@/components/icons/Icon.vue' ...@@ -666,6 +701,7 @@ import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import { import {
getPresetMappingsByPlatform, getPresetMappingsByPlatform,
commonErrorCodes, commonErrorCodes,
...@@ -721,6 +757,7 @@ const customErrorCodesEnabled = ref(false) ...@@ -721,6 +757,7 @@ const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([]) const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null) const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false) const interceptWarmupRequests = ref(false)
const autoPauseOnExpired = ref(false)
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
const tempUnschedEnabled = ref(false) const tempUnschedEnabled = ref(false)
const tempUnschedRules = ref<TempUnschedRuleForm[]>([]) const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
...@@ -771,7 +808,8 @@ const form = reactive({ ...@@ -771,7 +808,8 @@ const form = reactive({
concurrency: 1, concurrency: 1,
priority: 1, priority: 1,
status: 'active' as 'active' | 'inactive', status: 'active' as 'active' | 'inactive',
group_ids: [] as number[] group_ids: [] as number[],
expires_at: null as number | null
}) })
const statusOptions = computed(() => [ const statusOptions = computed(() => [
...@@ -779,6 +817,13 @@ const statusOptions = computed(() => [ ...@@ -779,6 +817,13 @@ const statusOptions = computed(() => [
{ value: 'inactive', label: t('common.inactive') } { value: 'inactive', label: t('common.inactive') }
]) ])
const expiresAtInput = computed({
get: () => formatDateTimeLocal(form.expires_at),
set: (value: string) => {
form.expires_at = parseDateTimeLocal(value)
}
})
// Watchers // Watchers
watch( watch(
() => props.account, () => props.account,
...@@ -791,10 +836,12 @@ watch( ...@@ -791,10 +836,12 @@ watch(
form.priority = newAccount.priority form.priority = newAccount.priority
form.status = newAccount.status as 'active' | 'inactive' form.status = newAccount.status as 'active' | 'inactive'
form.group_ids = newAccount.group_ids || [] form.group_ids = newAccount.group_ids || []
form.expires_at = newAccount.expires_at ?? null
// Load intercept warmup requests setting (applies to all account types) // Load intercept warmup requests setting (applies to all account types)
const credentials = newAccount.credentials as Record<string, unknown> | undefined const credentials = newAccount.credentials as Record<string, unknown> | undefined
interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
autoPauseOnExpired.value = newAccount.auto_pause_on_expired === true
// Load mixed scheduling setting (only for antigravity accounts) // Load mixed scheduling setting (only for antigravity accounts)
const extra = newAccount.extra as Record<string, unknown> | undefined const extra = newAccount.extra as Record<string, unknown> | undefined
...@@ -1042,6 +1089,9 @@ function toPositiveNumber(value: unknown) { ...@@ -1042,6 +1089,9 @@ function toPositiveNumber(value: unknown) {
return Math.trunc(num) return Math.trunc(num)
} }
const formatDateTimeLocal = formatDateTimeLocalInput
const parseDateTimeLocal = parseDateTimeLocalInput
// Methods // Methods
const handleClose = () => { const handleClose = () => {
emit('close') emit('close')
...@@ -1057,6 +1107,10 @@ const handleSubmit = async () => { ...@@ -1057,6 +1107,10 @@ const handleSubmit = async () => {
if (updatePayload.proxy_id === null) { if (updatePayload.proxy_id === null) {
updatePayload.proxy_id = 0 updatePayload.proxy_id = 0
} }
if (form.expires_at === null) {
updatePayload.expires_at = 0
}
updatePayload.auto_pause_on_expired = autoPauseOnExpired.value
// For apikey type, handle credentials update // For apikey type, handle credentials update
if (props.account.type === 'apikey') { if (props.account.type === 'apikey') {
...@@ -1097,7 +1151,6 @@ const handleSubmit = async () => { ...@@ -1097,7 +1151,6 @@ const handleSubmit = async () => {
if (interceptWarmupRequests.value) { if (interceptWarmupRequests.value) {
newCredentials.intercept_warmup_requests = true newCredentials.intercept_warmup_requests = true
} }
if (!applyTempUnschedConfig(newCredentials)) { if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false submitting.value = false
return return
...@@ -1114,7 +1167,6 @@ const handleSubmit = async () => { ...@@ -1114,7 +1167,6 @@ const handleSubmit = async () => {
} else { } else {
delete newCredentials.intercept_warmup_requests delete newCredentials.intercept_warmup_requests
} }
if (!applyTempUnschedConfig(newCredentials)) { if (!applyTempUnschedConfig(newCredentials)) {
submitting.value = false submitting.value = false
return return
......
...@@ -1011,6 +1011,7 @@ export default { ...@@ -1011,6 +1011,7 @@ export default {
groups: 'Groups', groups: 'Groups',
usageWindows: 'Usage Windows', usageWindows: 'Usage Windows',
lastUsed: 'Last Used', lastUsed: 'Last Used',
expiresAt: 'Expires At',
actions: 'Actions' actions: 'Actions'
}, },
tempUnschedulable: { tempUnschedulable: {
...@@ -1152,11 +1153,16 @@ export default { ...@@ -1152,11 +1153,16 @@ export default {
interceptWarmupRequests: 'Intercept Warmup Requests', interceptWarmupRequests: 'Intercept Warmup Requests',
interceptWarmupRequestsDesc: interceptWarmupRequestsDesc:
'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens', 'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens',
autoPauseOnExpired: 'Auto Pause On Expired',
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
expired: 'Expired',
proxy: 'Proxy', proxy: 'Proxy',
noProxy: 'No Proxy', noProxy: 'No Proxy',
concurrency: 'Concurrency', concurrency: 'Concurrency',
priority: 'Priority', priority: 'Priority',
priorityHint: 'Higher priority accounts are used first', priorityHint: 'Higher priority accounts are used first',
expiresAt: 'Expires At',
expiresAtHint: 'Leave empty for no expiration',
higherPriorityFirst: 'Higher value means higher priority', higherPriorityFirst: 'Higher value means higher priority',
mixedScheduling: 'Use in /v1/messages', mixedScheduling: 'Use in /v1/messages',
mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling', mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling',
......
...@@ -1061,6 +1061,7 @@ export default { ...@@ -1061,6 +1061,7 @@ export default {
groups: '分组', groups: '分组',
usageWindows: '用量窗口', usageWindows: '用量窗口',
lastUsed: '最近使用', lastUsed: '最近使用',
expiresAt: '过期时间',
actions: '操作' actions: '操作'
}, },
clearRateLimit: '清除速率限制', clearRateLimit: '清除速率限制',
...@@ -1286,11 +1287,16 @@ export default { ...@@ -1286,11 +1287,16 @@ export default {
errorCodeExists: '该错误码已被选中', errorCodeExists: '该错误码已被选中',
interceptWarmupRequests: '拦截预热请求', interceptWarmupRequests: '拦截预热请求',
interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token', interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token',
autoPauseOnExpired: '过期自动暂停调度',
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
expired: '已过期',
proxy: '代理', proxy: '代理',
noProxy: '无代理', noProxy: '无代理',
concurrency: '并发数', concurrency: '并发数',
priority: '优先级', priority: '优先级',
priorityHint: '优先级越高的账号优先使用', priorityHint: '优先级越高的账号优先使用',
expiresAt: '过期时间',
expiresAtHint: '留空表示不过期',
higherPriorityFirst: '数值越高优先级越高', higherPriorityFirst: '数值越高优先级越高',
mixedScheduling: '在 /v1/messages 中使用', mixedScheduling: '在 /v1/messages 中使用',
mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度', mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度',
......
...@@ -401,6 +401,8 @@ export interface Account { ...@@ -401,6 +401,8 @@ export interface Account {
status: 'active' | 'inactive' | 'error' status: 'active' | 'inactive' | 'error'
error_message: string | null error_message: string | null
last_used_at: string | null last_used_at: string | null
expires_at: number | null
auto_pause_on_expired: boolean
created_at: string created_at: string
updated_at: string updated_at: string
proxy?: Proxy proxy?: Proxy
...@@ -491,6 +493,8 @@ export interface CreateAccountRequest { ...@@ -491,6 +493,8 @@ export interface CreateAccountRequest {
concurrency?: number concurrency?: number
priority?: number priority?: number
group_ids?: number[] group_ids?: number[]
expires_at?: number | null
auto_pause_on_expired?: boolean
confirm_mixed_channel_risk?: boolean confirm_mixed_channel_risk?: boolean
} }
...@@ -506,6 +510,8 @@ export interface UpdateAccountRequest { ...@@ -506,6 +510,8 @@ export interface UpdateAccountRequest {
schedulable?: boolean schedulable?: boolean
status?: 'active' | 'inactive' status?: 'active' | 'inactive'
group_ids?: number[] group_ids?: number[]
expires_at?: number | null
auto_pause_on_expired?: boolean
confirm_mixed_channel_risk?: boolean confirm_mixed_channel_risk?: boolean
} }
......
...@@ -96,6 +96,7 @@ export function formatBytes(bytes: number, decimals: number = 2): string { ...@@ -96,6 +96,7 @@ export function formatBytes(bytes: number, decimals: number = 2): string {
* 格式化日期 * 格式化日期
* @param date 日期字符串或 Date 对象 * @param date 日期字符串或 Date 对象
* @param options Intl.DateTimeFormatOptions * @param options Intl.DateTimeFormatOptions
* @param localeOverride 可选 locale 覆盖
* @returns 格式化后的日期字符串 * @returns 格式化后的日期字符串
*/ */
export function formatDate( export function formatDate(
...@@ -108,14 +109,15 @@ export function formatDate( ...@@ -108,14 +109,15 @@ export function formatDate(
minute: '2-digit', minute: '2-digit',
second: '2-digit', second: '2-digit',
hour12: false hour12: false
} },
localeOverride?: string
): string { ): string {
if (!date) return '' if (!date) return ''
const d = new Date(date) const d = new Date(date)
if (isNaN(d.getTime())) return '' if (isNaN(d.getTime())) return ''
const locale = getLocale() const locale = localeOverride ?? getLocale()
return new Intl.DateTimeFormat(locale, options).format(d) return new Intl.DateTimeFormat(locale, options).format(d)
} }
...@@ -135,10 +137,41 @@ export function formatDateOnly(date: string | Date | null | undefined): string { ...@@ -135,10 +137,41 @@ export function formatDateOnly(date: string | Date | null | undefined): string {
/** /**
* 格式化日期时间(完整格式) * 格式化日期时间(完整格式)
* @param date 日期字符串或 Date 对象 * @param date 日期字符串或 Date 对象
* @param options Intl.DateTimeFormatOptions
* @param localeOverride 可选 locale 覆盖
* @returns 格式化后的日期时间字符串 * @returns 格式化后的日期时间字符串
*/ */
export function formatDateTime(date: string | Date | null | undefined): string { export function formatDateTime(
return formatDate(date) date: string | Date | null | undefined,
options?: Intl.DateTimeFormatOptions,
localeOverride?: string
): string {
return formatDate(date, options, localeOverride)
}
/**
* 格式化为 datetime-local 控件值(YYYY-MM-DDTHH:mm,使用本地时间)
*/
export function formatDateTimeLocalInput(timestampSeconds: number | null): string {
if (!timestampSeconds) return ''
const date = new Date(timestampSeconds * 1000)
if (isNaN(date.getTime())) return ''
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
/**
* 解析 datetime-local 控件值为时间戳(秒,使用本地时间)
*/
export function parseDateTimeLocalInput(value: string): number | null {
if (!value) return null
const date = new Date(value)
if (isNaN(date.getTime())) return null
return Math.floor(date.getTime() / 1000)
} }
/** /**
......
...@@ -70,6 +70,25 @@ ...@@ -70,6 +70,25 @@
<template #cell-last_used_at="{ value }"> <template #cell-last_used_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatRelativeTime(value) }}</span> <span class="text-sm text-gray-500 dark:text-dark-400">{{ formatRelativeTime(value) }}</span>
</template> </template>
<template #cell-expires_at="{ row, value }">
<div class="flex flex-col items-start gap-1">
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatExpiresAt(value) }}</span>
<div v-if="isExpired(value) || (row.auto_pause_on_expired && value)" class="flex items-center gap-1">
<span
v-if="isExpired(value)"
class="inline-flex items-center rounded-md bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{ t('admin.accounts.expired') }}
</span>
<span
v-if="row.auto_pause_on_expired && value"
class="inline-flex items-center rounded-md bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
>
{{ t('admin.accounts.autoPauseOnExpired') }}
</span>
</div>
</div>
</template>
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button @click="handleEdit(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"> <button @click="handleEdit(row)" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400">
...@@ -128,7 +147,7 @@ import AccountUsageCell from '@/components/account/AccountUsageCell.vue' ...@@ -128,7 +147,7 @@ import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue' import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
import GroupBadge from '@/components/common/GroupBadge.vue' import GroupBadge from '@/components/common/GroupBadge.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue' import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import { formatRelativeTime } from '@/utils/format' import { formatDateTime, formatRelativeTime } from '@/utils/format'
import type { Account, Proxy, Group } from '@/types' import type { Account, Proxy, Group } from '@/types'
const { t } = useI18n() const { t } = useI18n()
...@@ -178,6 +197,7 @@ const cols = computed(() => { ...@@ -178,6 +197,7 @@ const cols = computed(() => {
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false }, { key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true }, { key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true }, { key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
{ key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false }, { key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false },
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false } { key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
) )
...@@ -204,6 +224,25 @@ const confirmDelete = async () => { if(!deletingAcc.value) return; try { await a ...@@ -204,6 +224,25 @@ const confirmDelete = async () => { if(!deletingAcc.value) return; try { await a
const handleToggleSchedulable = async (a: Account) => { togglingSchedulable.value = a.id; try { await adminAPI.accounts.setSchedulable(a.id, !a.schedulable); load() } finally { togglingSchedulable.value = null } } const handleToggleSchedulable = async (a: Account) => { togglingSchedulable.value = a.id; try { await adminAPI.accounts.setSchedulable(a.id, !a.schedulable); load() } finally { togglingSchedulable.value = null } }
const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true } const handleShowTempUnsched = (a: Account) => { tempUnschedAcc.value = a; showTempUnsched.value = true }
const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch (error) { console.error('Failed to reset temp unscheduled:', error) } } const handleTempUnschedReset = async () => { if(!tempUnschedAcc.value) return; try { await adminAPI.accounts.clearError(tempUnschedAcc.value.id); showTempUnsched.value = false; tempUnschedAcc.value = null; load() } catch (error) { console.error('Failed to reset temp unscheduled:', error) } }
const formatExpiresAt = (value: number | null) => {
if (!value) return '-'
return formatDateTime(
new Date(value * 1000),
{
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
},
'sv-SE'
)
}
const isExpired = (value: number | null) => {
if (!value) return false
return value * 1000 <= Date.now()
}
onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) } }) onMounted(async () => { load(); try { const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]); proxies.value = p; groups.value = g } catch (error) { console.error('Failed to load proxies/groups:', error) } })
</script> </script>
...@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue' ...@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
import checker from 'vite-plugin-checker' import checker from 'vite-plugin-checker'
import { resolve } from 'path' import { resolve } from 'path'
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
...@@ -29,7 +30,7 @@ export default defineConfig({ ...@@ -29,7 +30,7 @@ export default defineConfig({
}, },
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 3000, port: Number(process.env.VITE_DEV_PORT || 3000),
proxy: { proxy: {
'/api': { '/api': {
target: process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080', target: process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080',
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment