mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-06 08:49:24 +00:00
feat(proxies): 代理有效期与失败回退
- schema/迁移: 代理有效期、提醒天数、失败回退配置 + 账号 fallback 来源字段 - service/repo/DTO/handler: CRUD 透传新字段 + 校验 - fallback 目标解析纯函数(链式解析 + 环检测 + 兜底) - SweepExpiredProxies 到期改投账号 + outbox 失效 - ProxyExpiryService 后台到期扫描任务 + wire 注册 - 账号侧手动回切原代理 + fallback 来源徽章/按钮 - 前端: 创建/编辑表单、列表到期徽章、类型/API/i18n - ops 告警: proxy_expired_count / proxy_expiring_soon_count 指标 - 导入导出携带有效期/回退字段(备用按 name 映射) - 补全测试 stub + 集成测试 + review 问题修复
This commit is contained in:
1 parent
f20e6bf769
commit
af19d44327
61 files changed
+3430
-281
No files matched your search
@@ -659,6 +659,16 @@ export interface BatchOperationResult {
|
||||
warnings?: Array<{ account_id: number; warning: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert account proxy to original before fallback
|
||||
* @param id - Account ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function revertProxyFallback(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.post<{ message: string }>(`/admin/accounts/${id}/revert-proxy-fallback`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch clear account errors
|
||||
* @param accountIds - Array of account IDs
|
||||
@@ -735,7 +745,8 @@ export const accountsAPI = {
|
||||
getAntigravityDefaultModelMapping,
|
||||
batchClearError,
|
||||
batchRefresh,
|
||||
setPrivacy
|
||||
setPrivacy,
|
||||
revertProxyFallback
|
||||
}
|
||||
|
||||
export default accountsAPI
|
||||
@@ -27,7 +27,7 @@ export async function list(
|
||||
pageSize: number = 20,
|
||||
filters?: {
|
||||
protocol?: string
|
||||
status?: 'active' | 'inactive'
|
||||
status?: 'active' | 'inactive' | 'expired'
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
@@ -227,7 +227,7 @@ export async function exportData(options?: {
|
||||
ids?: number[]
|
||||
filters?: {
|
||||
protocol?: string
|
||||
status?: 'active' | 'inactive'
|
||||
status?: 'active' | 'inactive' | 'expired'
|
||||
search?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
|
||||
@@ -3300,6 +3300,11 @@ export default {
|
||||
recoverStateHint: 'Used to recover error, rate-limit, and temporary unschedulable runtime state.',
|
||||
recoverStateSuccess: 'Account state recovered successfully',
|
||||
recoverStateFailed: 'Failed to recover account state',
|
||||
fallbackActive: 'Fallback',
|
||||
fallbackActiveTip: 'Origin proxy {origin} expired',
|
||||
revertProxy: 'Revert proxy',
|
||||
revertProxySuccess: 'Successfully reverted to original proxy',
|
||||
revertProxyFailed: 'Failed to revert proxy',
|
||||
resetStatus: 'Reset Status',
|
||||
statusReset: 'Account status reset successfully',
|
||||
failedToResetStatus: 'Failed to reset account status',
|
||||
@@ -4176,6 +4181,8 @@ export default {
|
||||
status: 'Status',
|
||||
accounts: 'Accounts',
|
||||
latency: 'Latency',
|
||||
expiry: 'Validity',
|
||||
createdAt: 'Created',
|
||||
actions: 'Actions'
|
||||
},
|
||||
testConnection: 'Test Connection',
|
||||
@@ -4273,7 +4280,21 @@ export default {
|
||||
hostRequired: 'Please enter host address',
|
||||
portInvalid: 'Port must be between 1-65535',
|
||||
deleteConfirm:
|
||||
"Are you sure you want to delete '{name}'? Accounts using this proxy will have their proxy removed."
|
||||
"Are you sure you want to delete '{name}'? Accounts using this proxy will have their proxy removed.",
|
||||
neverExpires: 'Never',
|
||||
expired: 'Expired',
|
||||
overdueDays: 'Overdue {days}d',
|
||||
expiringInDays: 'Expires in {days}d',
|
||||
remainingDays: '{days}d left',
|
||||
expiresAt: 'Validity',
|
||||
nDays: '{days}d',
|
||||
expiryDaysPlaceholder: 'Custom days, empty = never',
|
||||
expiryWarnDays: 'Expiry warning (days)',
|
||||
fallbackMode: 'Failure fallback',
|
||||
fallbackNone: 'No fallback',
|
||||
fallbackProxy: 'Backup proxy',
|
||||
fallbackDirect: 'Direct connection',
|
||||
backupProxy: 'Backup proxy',
|
||||
},
|
||||
|
||||
// Redeem Codes
|
||||
|
||||
@@ -3446,6 +3446,11 @@ export default {
|
||||
recoverStateHint: '用于恢复错误、限流和临时不可调度等可恢复状态。',
|
||||
recoverStateSuccess: '账号状态已恢复',
|
||||
recoverStateFailed: '恢复账号状态失败',
|
||||
fallbackActive: '已回退',
|
||||
fallbackActiveTip: '原代理 {origin} 已到期,当前使用备用代理',
|
||||
revertProxy: '切回原代理',
|
||||
revertProxySuccess: '已成功切回原代理',
|
||||
revertProxyFailed: '切回原代理失败',
|
||||
resetStatus: '重置状态',
|
||||
statusReset: '账号状态已重置',
|
||||
failedToResetStatus: '重置账号状态失败',
|
||||
@@ -4259,6 +4264,8 @@ export default {
|
||||
status: '状态',
|
||||
accounts: '账号数',
|
||||
latency: '延迟',
|
||||
expiry: '有效期',
|
||||
createdAt: '创建时间',
|
||||
actions: '操作',
|
||||
nameLabel: '名称',
|
||||
namePlaceholder: '请输入代理名称',
|
||||
@@ -4394,7 +4401,21 @@ export default {
|
||||
nameRequired: '请输入代理名称',
|
||||
hostRequired: '请输入主机地址',
|
||||
portInvalid: '端口必须在 1-65535 之间',
|
||||
deleteConfirm: "确定要删除代理 '{name}' 吗?使用此代理的账号将被移除代理设置。"
|
||||
deleteConfirm: "确定要删除代理 '{name}' 吗?使用此代理的账号将被移除代理设置。",
|
||||
neverExpires: '永不过期',
|
||||
expired: '已过期',
|
||||
overdueDays: '已超期 {days} 天',
|
||||
expiringInDays: '{days} 天后到期',
|
||||
remainingDays: '剩余 {days} 天',
|
||||
expiresAt: '有效期',
|
||||
nDays: '{days} 天',
|
||||
expiryDaysPlaceholder: '自定义天数,留空 = 永不过期',
|
||||
expiryWarnDays: '到期提醒提前天数',
|
||||
fallbackMode: '失败回退',
|
||||
fallbackNone: '不回退',
|
||||
fallbackProxy: '指定备用代理',
|
||||
fallbackDirect: '回退直连',
|
||||
backupProxy: '备用代理',
|
||||
},
|
||||
|
||||
// Redeem Codes Management
|
||||
|
||||
@@ -711,7 +711,7 @@ export interface Proxy {
|
||||
port: number
|
||||
username: string | null
|
||||
password?: string | null
|
||||
status: 'active' | 'inactive'
|
||||
status: 'active' | 'inactive' | 'expired'
|
||||
account_count?: number // Number of accounts using this proxy
|
||||
latency_ms?: number
|
||||
latency_status?: 'success' | 'failed'
|
||||
@@ -726,6 +726,10 @@ export interface Proxy {
|
||||
quality_grade?: string
|
||||
quality_summary?: string
|
||||
quality_checked?: number
|
||||
expires_at: string | null
|
||||
fallback_mode: 'none' | 'proxy' | 'direct'
|
||||
backup_proxy_id?: number | null
|
||||
expiry_warn_days: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -831,6 +835,8 @@ export interface Account {
|
||||
antigravity_credits_overages?: Record<string, { activated_at: string; active_until: string }>
|
||||
} & Record<string, unknown>)
|
||||
proxy_id: number | null
|
||||
proxy_fallback_origin_id?: number | null
|
||||
proxy_fallback_origin_name?: string | null
|
||||
concurrency: number
|
||||
load_factor?: number | null
|
||||
current_concurrency?: number // Real-time concurrency count from Redis
|
||||
@@ -1079,6 +1085,10 @@ export interface CreateProxyRequest {
|
||||
port: number
|
||||
username?: string | null
|
||||
password?: string | null
|
||||
expires_at?: number | null // unix 秒;null/0 = 永不过期
|
||||
fallback_mode?: 'none' | 'proxy' | 'direct'
|
||||
backup_proxy_id?: number | null
|
||||
expiry_warn_days?: number
|
||||
}
|
||||
|
||||
export interface UpdateProxyRequest {
|
||||
@@ -1089,6 +1099,10 @@ export interface UpdateProxyRequest {
|
||||
username?: string | null
|
||||
password?: string | null
|
||||
status?: 'active' | 'inactive'
|
||||
expires_at?: number | null // unix 秒;null/0 = 永不过期
|
||||
fallback_mode?: 'none' | 'proxy' | 'direct'
|
||||
backup_proxy_id?: number | null
|
||||
expiry_warn_days?: number
|
||||
}
|
||||
|
||||
export interface AdminDataPayload {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { daysUntil, proxyExpiryBadgeClass, proxyExpiryLabelKey } from '../proxyExpiry'
|
||||
|
||||
// 固定「现在」,按天数构造确定输入:isoInDays(n) 距今正好 n 天
|
||||
const NOW = new Date('2026-06-02T00:00:00Z')
|
||||
const isoInDays = (n: number): string => new Date(NOW.getTime() + n * 86400000).toISOString()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('daysUntil', () => {
|
||||
it('返回距今整天数', () => {
|
||||
expect(daysUntil(isoInDays(10))).toBe(10)
|
||||
expect(daysUntil(isoInDays(-3))).toBe(-3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('proxyExpiryBadgeClass', () => {
|
||||
it('status=expired → danger', () => {
|
||||
expect(proxyExpiryBadgeClass(isoInDays(30), 'expired')).toBe('badge badge-danger')
|
||||
})
|
||||
it('≤3 天 → danger(含边界 3)', () => {
|
||||
expect(proxyExpiryBadgeClass(isoInDays(2), 'active')).toBe('badge badge-danger')
|
||||
expect(proxyExpiryBadgeClass(isoInDays(3), 'active')).toBe('badge badge-danger')
|
||||
})
|
||||
it('4–7 天 → warning(含边界 7)', () => {
|
||||
expect(proxyExpiryBadgeClass(isoInDays(5), 'active')).toBe('badge badge-warning')
|
||||
expect(proxyExpiryBadgeClass(isoInDays(7), 'active')).toBe('badge badge-warning')
|
||||
})
|
||||
it('>7 天 → gray', () => {
|
||||
expect(proxyExpiryBadgeClass(isoInDays(30), 'active')).toBe('text-gray-500')
|
||||
})
|
||||
})
|
||||
|
||||
describe('proxyExpiryLabelKey', () => {
|
||||
it('status=expired → expired key', () => {
|
||||
expect(proxyExpiryLabelKey(isoInDays(30), 'expired')).toEqual({ key: 'admin.proxies.expired' })
|
||||
})
|
||||
it('已逾期(d<0) → overdueDays', () => {
|
||||
expect(proxyExpiryLabelKey(isoInDays(-3), 'active')).toEqual({
|
||||
key: 'admin.proxies.overdueDays',
|
||||
params: { days: 3 },
|
||||
})
|
||||
})
|
||||
it('≤7 天 → expiringInDays', () => {
|
||||
expect(proxyExpiryLabelKey(isoInDays(5), 'active')).toEqual({
|
||||
key: 'admin.proxies.expiringInDays',
|
||||
params: { days: 5 },
|
||||
})
|
||||
})
|
||||
it('>7 天 → remainingDays', () => {
|
||||
expect(proxyExpiryLabelKey(isoInDays(30), 'active')).toEqual({
|
||||
key: 'admin.proxies.remainingDays',
|
||||
params: { days: 30 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
// 代理有效期展示逻辑(ProxiesView 与 AccountsView 共用)。
|
||||
// 到期紧迫度固定两档:剩余 ≤3 天红、≤7 天黄(不读 per-proxy expiry_warn_days)。
|
||||
export const EXPIRY_WARN_DAYS = 7
|
||||
export const EXPIRY_DANGER_DAYS = 3
|
||||
|
||||
// 距今整天数(向上取整)。
|
||||
export const daysUntil = (iso: string): number =>
|
||||
Math.ceil((new Date(iso).getTime() - Date.now()) / 86400000)
|
||||
|
||||
// 倒计时徽章的 CSS class(纯函数,无 i18n 依赖)。
|
||||
export function proxyExpiryBadgeClass(expiresAt: string | null, status?: string): string {
|
||||
if (status === 'expired') return 'badge badge-danger'
|
||||
const d = expiresAt ? daysUntil(expiresAt) : Infinity
|
||||
if (d <= EXPIRY_DANGER_DAYS) return 'badge badge-danger'
|
||||
if (d <= EXPIRY_WARN_DAYS) return 'badge badge-warning'
|
||||
return 'text-gray-500'
|
||||
}
|
||||
|
||||
// 倒计时文案的 i18n key + 参数(返回 key 而非已翻译文本,便于单测且不耦合 i18n)。
|
||||
export function proxyExpiryLabelKey(
|
||||
expiresAt: string | null,
|
||||
status?: string,
|
||||
): { key: string; params?: { days: number } } {
|
||||
if (status === 'expired') return { key: 'admin.proxies.expired' }
|
||||
const d = expiresAt ? daysUntil(expiresAt) : Infinity
|
||||
if (d < 0) return { key: 'admin.proxies.overdueDays', params: { days: Math.abs(d) } }
|
||||
if (d <= EXPIRY_WARN_DAYS) return { key: 'admin.proxies.expiringInDays', params: { days: d } }
|
||||
return { key: 'admin.proxies.remainingDays', params: { days: d } }
|
||||
}
|
||||
@@ -288,13 +288,25 @@
|
||||
/>
|
||||
</template>
|
||||
<template #cell-proxy="{ row }">
|
||||
<div v-if="row.proxy" class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ row.proxy.name }}</span>
|
||||
<span v-if="row.proxy.country_code" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
({{ row.proxy.country_code }})
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div v-if="row.proxy" class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ row.proxy.name }}</span>
|
||||
<span v-if="row.proxy.country_code" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
({{ row.proxy.country_code }})
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||
<div v-if="row.proxy && row.proxy.expires_at" class="flex items-center gap-2 text-xs">
|
||||
<span class="text-gray-600 dark:text-gray-300">{{ formatDateTime(row.proxy.expires_at) }}</span>
|
||||
<span :class="proxyExpiryBadge(row.proxy)">{{ proxyExpiryText(row.proxy) }}</span>
|
||||
</div>
|
||||
<div v-if="row.proxy_fallback_origin_id" class="flex items-center gap-1">
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" :title="t('admin.accounts.fallbackActiveTip', { origin: row.proxy_fallback_origin_name })">
|
||||
{{ t('admin.accounts.fallbackActive') }}
|
||||
</span>
|
||||
<button class="text-xs px-1.5 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700" @click="onRevertFallback(row)">{{ t('admin.accounts.revertProxy') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||
</template>
|
||||
<template #cell-rate_multiplier="{ row }">
|
||||
<span class="text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
@@ -421,6 +433,7 @@ import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRules
|
||||
import TLSFingerprintProfilesModal from '@/components/admin/TLSFingerprintProfilesModal.vue'
|
||||
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry'
|
||||
import type { Account, AccountPlatform, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -1597,6 +1610,16 @@ const handleSetPrivacy = async (a: Account) => {
|
||||
appStore.showError(error?.response?.data?.message || t('admin.accounts.privacyFailed'))
|
||||
}
|
||||
}
|
||||
const onRevertFallback = async (a: Account) => {
|
||||
try {
|
||||
await adminAPI.accounts.revertProxyFallback(a.id)
|
||||
appStore.showSuccess(t('admin.accounts.revertProxySuccess'))
|
||||
reload()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to revert proxy fallback:', error)
|
||||
appStore.showError(error?.response?.data?.message || t('admin.accounts.revertProxyFailed'))
|
||||
}
|
||||
}
|
||||
const handleDelete = (a: Account) => { deletingAcc.value = a; showDeleteDialog.value = true }
|
||||
const confirmDelete = async () => { if(!deletingAcc.value) return; try { await adminAPI.accounts.delete(deletingAcc.value.id); showDeleteDialog.value = false; deletingAcc.value = null; reload() } catch (error) { console.error('Failed to delete account:', error) } }
|
||||
const handleToggleSchedulable = async (a: Account) => {
|
||||
@@ -1639,6 +1662,12 @@ const isExpired = (value: number | null) => {
|
||||
if (!value) return false
|
||||
return value * 1000 <= Date.now()
|
||||
}
|
||||
// 所绑定代理的有效期(逻辑同 /admin/proxies,见 utils/proxyExpiry)
|
||||
const proxyExpiryBadge = (p: AccountProxy): string => proxyExpiryBadgeClass(p.expires_at, p.status)
|
||||
const proxyExpiryText = (p: AccountProxy): string => {
|
||||
const { key, params } = proxyExpiryLabelKey(p.expires_at, p.status)
|
||||
return params ? t(key, params) : t(key)
|
||||
}
|
||||
|
||||
// 滚动时关闭操作菜单(不关闭列设置下拉菜单)
|
||||
const handleScroll = () => {
|
||||
|
||||
@@ -244,8 +244,25 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-expiry="{ row }">
|
||||
<span v-if="!row.expires_at" class="text-sm text-gray-400">{{ t('admin.proxies.neverExpires') }}</span>
|
||||
<div v-else class="flex flex-col text-xs">
|
||||
<span class="text-gray-700 dark:text-gray-200">{{ formatDateTime(row.expires_at) }}</span>
|
||||
<span :class="expiryBadgeClass(row)">{{ expiryLabel(row) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-created_at="{ row }">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-300">{{ formatDateTime(row.created_at) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'active' ? 'badge-success' : value === 'expired' ? 'badge-danger' : 'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ t('admin.accounts.status.' + value) }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -475,6 +492,41 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.expiresAt') }}</label>
|
||||
<div class="mb-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="d in EXPIRY_PRESETS"
|
||||
:key="d"
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="createForm.expires_at === addDaysToBase('', d) ? 'btn-primary' : 'btn-secondary'"
|
||||
@click="createExpiresDays = d"
|
||||
>
|
||||
{{ t('admin.proxies.nDays', { days: d }) }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="createExpiresDays"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input mb-2"
|
||||
:placeholder="t('admin.proxies.expiryDaysPlaceholder')"
|
||||
/>
|
||||
<input v-model="createForm.expires_at" type="date" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.fallbackMode') }}</label>
|
||||
<Select v-model="createForm.fallback_mode" :options="[
|
||||
{ label: t('admin.proxies.fallbackNone'), value: 'none' },
|
||||
{ label: t('admin.proxies.fallbackProxy'), value: 'proxy' },
|
||||
{ label: t('admin.proxies.fallbackDirect'), value: 'direct' },
|
||||
]" />
|
||||
</div>
|
||||
<div v-if="createForm.fallback_mode === 'proxy'">
|
||||
<label class="input-label">{{ t('admin.proxies.backupProxy') }}</label>
|
||||
<Select v-model="createForm.backup_proxy_id" :options="backupProxyOptions()" />
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -673,6 +725,41 @@
|
||||
<label class="input-label">{{ t('admin.proxies.status') }}</label>
|
||||
<Select v-model="editForm.status" :options="editStatusOptions" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.expiresAt') }}</label>
|
||||
<div class="mb-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="d in EXPIRY_PRESETS"
|
||||
:key="d"
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="editForm.expires_at === addDaysToBase(editBaseDate, d) ? 'btn-primary' : 'btn-secondary'"
|
||||
@click="editExpiresDays = d"
|
||||
>
|
||||
{{ t('admin.proxies.nDays', { days: d }) }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="editExpiresDays"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input mb-2"
|
||||
:placeholder="t('admin.proxies.expiryDaysPlaceholder')"
|
||||
/>
|
||||
<input v-model="editForm.expires_at" type="date" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.fallbackMode') }}</label>
|
||||
<Select v-model="editForm.fallback_mode" :options="[
|
||||
{ label: t('admin.proxies.fallbackNone'), value: 'none' },
|
||||
{ label: t('admin.proxies.fallbackProxy'), value: 'proxy' },
|
||||
{ label: t('admin.proxies.fallbackDirect'), value: 'direct' },
|
||||
]" />
|
||||
</div>
|
||||
<div v-if="editForm.fallback_mode === 'proxy'">
|
||||
<label class="input-label">{{ t('admin.proxies.backupProxy') }}</label>
|
||||
<Select v-model="editForm.backup_proxy_id" :options="backupProxyOptions(editingProxy?.id)" />
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -899,6 +986,8 @@ import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useSwipeSelect } from '@/composables/useSwipeSelect'
|
||||
import { useTableSelection } from '@/composables/useTableSelection'
|
||||
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -913,6 +1002,8 @@ const columns = computed<Column[]>(() => [
|
||||
{ key: 'location', label: t('admin.proxies.columns.location'), sortable: false },
|
||||
{ key: 'account_count', label: t('admin.proxies.columns.accounts'), sortable: true },
|
||||
{ key: 'latency', label: t('admin.proxies.columns.latency'), sortable: false },
|
||||
{ key: 'expiry', label: t('admin.proxies.columns.expiry'), sortable: true },
|
||||
{ key: 'created_at', label: t('admin.proxies.columns.createdAt'), sortable: true },
|
||||
{ key: 'status', label: t('admin.proxies.columns.status'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.proxies.columns.actions'), sortable: false }
|
||||
])
|
||||
@@ -929,7 +1020,8 @@ const protocolOptions = computed(() => [
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.proxies.allStatus') },
|
||||
{ value: 'active', label: t('admin.accounts.status.active') },
|
||||
{ value: 'inactive', label: t('admin.accounts.status.inactive') }
|
||||
{ value: 'inactive', label: t('admin.accounts.status.inactive') },
|
||||
{ value: 'expired', label: t('admin.proxies.expired') }
|
||||
])
|
||||
|
||||
// Form options
|
||||
@@ -1035,7 +1127,11 @@ const createForm = reactive({
|
||||
host: '',
|
||||
port: 8080,
|
||||
username: '',
|
||||
password: ''
|
||||
password: '',
|
||||
expires_at: '' as string,
|
||||
fallback_mode: 'none' as 'none' | 'proxy' | 'direct',
|
||||
backup_proxy_id: null as number | null,
|
||||
expiry_warn_days: 7 as number,
|
||||
})
|
||||
|
||||
const editForm = reactive({
|
||||
@@ -1045,9 +1141,22 @@ const editForm = reactive({
|
||||
port: 8080,
|
||||
username: '',
|
||||
password: '',
|
||||
status: 'active' as 'active' | 'inactive'
|
||||
status: 'active' as 'active' | 'inactive' | 'expired',
|
||||
expires_at: '' as string,
|
||||
fallback_mode: 'none' as 'none' | 'proxy' | 'direct',
|
||||
backup_proxy_id: null as number | null,
|
||||
expiry_warn_days: 7 as number,
|
||||
})
|
||||
|
||||
const allProxiesForBackup = ref<Proxy[]>([])
|
||||
const loadBackupProxyOptions = async () => {
|
||||
allProxiesForBackup.value = await adminAPI.proxies.getAllWithCount()
|
||||
}
|
||||
const backupProxyOptions = (excludeId?: number) =>
|
||||
allProxiesForBackup.value
|
||||
.filter(p => p.id !== excludeId)
|
||||
.map(p => ({ label: `${p.name} (${p.host}:${p.port})`, value: p.id }))
|
||||
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
const isAbortError = (error: unknown) => {
|
||||
@@ -1072,7 +1181,7 @@ const toggleSelectAllVisible = (event: Event) => {
|
||||
|
||||
const buildProxyQueryFilters = () => ({
|
||||
protocol: filters.protocol || undefined,
|
||||
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
|
||||
status: (filters.status || undefined) as 'active' | 'inactive' | 'expired' | undefined,
|
||||
search: searchQuery.value || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
@@ -1148,6 +1257,10 @@ const closeCreateModal = () => {
|
||||
createForm.port = 8080
|
||||
createForm.username = ''
|
||||
createForm.password = ''
|
||||
createForm.expires_at = ''
|
||||
createForm.fallback_mode = 'none'
|
||||
createForm.backup_proxy_id = null
|
||||
createForm.expiry_warn_days = 7
|
||||
createPasswordVisible.value = false
|
||||
batchInput.value = ''
|
||||
batchParseResult.total = 0
|
||||
@@ -1272,7 +1385,11 @@ const handleCreateProxy = async () => {
|
||||
host: createForm.host.trim(),
|
||||
port: createForm.port,
|
||||
username: createForm.username.trim() || null,
|
||||
password: createForm.password.trim() || null
|
||||
password: createForm.password.trim() || null,
|
||||
expires_at: createForm.expires_at ? Math.floor(new Date(createForm.expires_at).getTime() / 1000) : null,
|
||||
fallback_mode: createForm.fallback_mode,
|
||||
backup_proxy_id: createForm.fallback_mode === 'proxy' ? createForm.backup_proxy_id : null,
|
||||
expiry_warn_days: createForm.expiry_warn_days,
|
||||
})
|
||||
appStore.showSuccess(t('admin.proxies.proxyCreated'))
|
||||
closeCreateModal()
|
||||
@@ -1293,7 +1410,11 @@ const handleEdit = (proxy: Proxy) => {
|
||||
editForm.port = proxy.port
|
||||
editForm.username = proxy.username || ''
|
||||
editForm.password = proxy.password || ''
|
||||
editForm.status = proxy.status
|
||||
editForm.status = proxy.status === 'expired' ? 'inactive' : proxy.status
|
||||
editForm.expires_at = proxy.expires_at ? proxy.expires_at.slice(0, 10) : ''
|
||||
editForm.fallback_mode = proxy.fallback_mode || 'none'
|
||||
editForm.backup_proxy_id = proxy.backup_proxy_id ?? null
|
||||
editForm.expiry_warn_days = proxy.expiry_warn_days ?? 7
|
||||
editPasswordVisible.value = false
|
||||
editPasswordDirty.value = false
|
||||
showEditModal.value = true
|
||||
@@ -1329,7 +1450,11 @@ const handleUpdateProxy = async () => {
|
||||
host: editForm.host.trim(),
|
||||
port: editForm.port,
|
||||
username: editForm.username.trim() || null,
|
||||
status: editForm.status
|
||||
status: editForm.status,
|
||||
expires_at: editForm.expires_at ? Math.floor(new Date(editForm.expires_at).getTime() / 1000) : null,
|
||||
fallback_mode: editForm.fallback_mode,
|
||||
backup_proxy_id: editForm.fallback_mode === 'proxy' ? editForm.backup_proxy_id : null,
|
||||
expiry_warn_days: editForm.expiry_warn_days,
|
||||
}
|
||||
|
||||
// Only include password if user actually modified the field
|
||||
@@ -1575,6 +1700,59 @@ const qualityStatusLabel = (status: string) => {
|
||||
return t('admin.proxies.qualityStatusFail')
|
||||
}
|
||||
|
||||
// 有效期「选天数」⇄ 日历联动:天数自 base 起算(创建=今天;编辑=代理创建日),本地日历日 round-trip 稳定;canonical 仍是 expires_at 日期串
|
||||
const EXPIRY_PRESETS = [7, 30, 90, 180]
|
||||
const toLocalDateStr = (dt: Date): string => {
|
||||
const y = dt.getFullYear()
|
||||
const m = String(dt.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(dt.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
// base 为空 → 今天本地 00:00;否则该日期本地 00:00
|
||||
const baseDateOrToday = (baseDateStr: string): Date => {
|
||||
const base = baseDateStr ? new Date(`${baseDateStr}T00:00:00`) : new Date()
|
||||
base.setHours(0, 0, 0, 0)
|
||||
return base
|
||||
}
|
||||
// base + N 天 → 本地 YYYY-MM-DD;N≤0/空 → '' 表示永不过期
|
||||
const addDaysToBase = (baseDateStr: string, n: number | null): string => {
|
||||
const days = Number(n)
|
||||
if (!days || days <= 0) return ''
|
||||
const dt = baseDateOrToday(baseDateStr)
|
||||
dt.setDate(dt.getDate() + days)
|
||||
return toLocalDateStr(dt)
|
||||
}
|
||||
// target 相对 base 的整天数(本地日历差,避免时区/时刻抖动)
|
||||
const daysFromBase = (baseDateStr: string, targetDateStr: string): number | null => {
|
||||
if (!targetDateStr) return null
|
||||
const target = new Date(`${targetDateStr}T00:00:00`)
|
||||
return Math.round((target.getTime() - baseDateOrToday(baseDateStr).getTime()) / 86400000)
|
||||
}
|
||||
// 编辑时有效期自「代理创建日」起算;创建时无 created_at → base='' 用今天
|
||||
const editBaseDate = computed(() =>
|
||||
editingProxy.value?.created_at ? editingProxy.value.created_at.slice(0, 10) : '',
|
||||
)
|
||||
const createExpiresDays = computed<number | null>({
|
||||
get: () => daysFromBase('', createForm.expires_at),
|
||||
set: (v) => {
|
||||
createForm.expires_at = addDaysToBase('', v)
|
||||
},
|
||||
})
|
||||
const editExpiresDays = computed<number | null>({
|
||||
get: () => daysFromBase(editBaseDate.value, editForm.expires_at),
|
||||
set: (v) => {
|
||||
editForm.expires_at = addDaysToBase(editBaseDate.value, v)
|
||||
},
|
||||
})
|
||||
|
||||
const expiryLabel = (row: Proxy): string => {
|
||||
const { key, params } = proxyExpiryLabelKey(row.expires_at, row.status)
|
||||
return params ? t(key, params) : t(key)
|
||||
}
|
||||
|
||||
const expiryBadgeClass = (row: Proxy): string =>
|
||||
proxyExpiryBadgeClass(row.expires_at, row.status)
|
||||
|
||||
const qualityOverallClass = (status?: string) => {
|
||||
if (status === 'healthy') return 'badge-success'
|
||||
if (status === 'warn') return 'badge-warning'
|
||||
@@ -1877,6 +2055,7 @@ function closeCopyMenu() {
|
||||
|
||||
onMounted(() => {
|
||||
loadProxies()
|
||||
loadBackupProxyOptions()
|
||||
document.addEventListener('click', closeCopyMenu)
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user