Merge remote-tracking branch 'upstream/main'
This commit is contained in:
@@ -292,8 +292,11 @@ const loadAvailableModels = async () => {
|
||||
if (availableModels.value.length > 0) {
|
||||
if (props.account.platform === 'gemini') {
|
||||
const preferred =
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.0-flash') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.5-flash') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.5-pro') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-pro')
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-flash-preview') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-pro-preview')
|
||||
selectedModelId.value = preferred?.id || availableModels.value[0].id
|
||||
} else {
|
||||
// Try to select Sonnet as default, otherwise use first model
|
||||
|
||||
@@ -1191,6 +1191,163 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quota Control Section (Anthropic OAuth/SetupToken only) -->
|
||||
<div
|
||||
v-if="form.platform === 'anthropic' && accountCategory === 'oauth-based'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaControl.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Window Cost Limit -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.windowCost.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.windowCost.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="windowCostEnabled = !windowCostEnabled"
|
||||
: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',
|
||||
windowCostEnabled ? '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',
|
||||
windowCostEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="windowCostEnabled" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.windowCost.limit') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
|
||||
<input
|
||||
v-model.number="windowCostLimit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input pl-7"
|
||||
:placeholder="t('admin.accounts.quotaControl.windowCost.limitPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.windowCost.limitHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.windowCost.stickyReserve') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
|
||||
<input
|
||||
v-model.number="windowCostStickyReserve"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input pl-7"
|
||||
:placeholder="t('admin.accounts.quotaControl.windowCost.stickyReservePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.windowCost.stickyReserveHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Limit -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.sessionLimit.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.sessionLimit.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="sessionLimitEnabled = !sessionLimitEnabled"
|
||||
: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',
|
||||
sessionLimitEnabled ? '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',
|
||||
sessionLimitEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="sessionLimitEnabled" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.sessionLimit.maxSessions') }}</label>
|
||||
<input
|
||||
v-model.number="maxSessions"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.quotaControl.sessionLimit.maxSessionsPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.sessionLimit.maxSessionsHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaControl.sessionLimit.idleTimeout') }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model.number="sessionIdleTimeout"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input pr-12"
|
||||
:placeholder="t('admin.accounts.quotaControl.sessionLimit.idleTimeoutPlaceholder')"
|
||||
/>
|
||||
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">{{ t('common.minutes') }}</span>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.sessionLimit.idleTimeoutHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TLS Fingerprint -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.tlsFingerprint.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.tlsFingerprint.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="tlsFingerprintEnabled = !tlsFingerprintEnabled"
|
||||
: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',
|
||||
tlsFingerprintEnabled ? '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',
|
||||
tlsFingerprintEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
|
||||
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
|
||||
@@ -1214,7 +1371,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
|
||||
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" />
|
||||
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.001" class="input" />
|
||||
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1763,6 +1920,15 @@ const geminiAIStudioOAuthEnabled = ref(false)
|
||||
const showAdvancedOAuth = ref(false)
|
||||
const showGeminiHelpDialog = ref(false)
|
||||
|
||||
// Quota control state (Anthropic OAuth/SetupToken only)
|
||||
const windowCostEnabled = ref(false)
|
||||
const windowCostLimit = ref<number | null>(null)
|
||||
const windowCostStickyReserve = ref<number | null>(null)
|
||||
const sessionLimitEnabled = ref(false)
|
||||
const maxSessions = ref<number | null>(null)
|
||||
const sessionIdleTimeout = ref<number | null>(null)
|
||||
const tlsFingerprintEnabled = ref(false)
|
||||
|
||||
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
|
||||
const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free')
|
||||
const geminiTierGcp = ref<'gcp_standard' | 'gcp_enterprise'>('gcp_standard')
|
||||
@@ -2140,6 +2306,14 @@ const resetForm = () => {
|
||||
customErrorCodeInput.value = null
|
||||
interceptWarmupRequests.value = false
|
||||
autoPauseOnExpired.value = true
|
||||
// Reset quota control state
|
||||
windowCostEnabled.value = false
|
||||
windowCostLimit.value = null
|
||||
windowCostStickyReserve.value = null
|
||||
sessionLimitEnabled.value = false
|
||||
maxSessions.value = null
|
||||
sessionIdleTimeout.value = null
|
||||
tlsFingerprintEnabled.value = false
|
||||
tempUnschedEnabled.value = false
|
||||
tempUnschedRules.value = []
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
@@ -2407,7 +2581,27 @@ const handleAnthropicExchange = async (authCode: string) => {
|
||||
...proxyConfig
|
||||
})
|
||||
|
||||
const extra = oauth.buildExtraInfo(tokenInfo)
|
||||
// Build extra with quota control settings
|
||||
const baseExtra = oauth.buildExtraInfo(tokenInfo) || {}
|
||||
const extra: Record<string, unknown> = { ...baseExtra }
|
||||
|
||||
// Add window cost limit settings
|
||||
if (windowCostEnabled.value && windowCostLimit.value != null && windowCostLimit.value > 0) {
|
||||
extra.window_cost_limit = windowCostLimit.value
|
||||
extra.window_cost_sticky_reserve = windowCostStickyReserve.value ?? 10
|
||||
}
|
||||
|
||||
// Add session limit settings
|
||||
if (sessionLimitEnabled.value && maxSessions.value != null && maxSessions.value > 0) {
|
||||
extra.max_sessions = maxSessions.value
|
||||
extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
|
||||
}
|
||||
|
||||
// Add TLS fingerprint settings
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
extra.enable_tls_fingerprint = true
|
||||
}
|
||||
|
||||
const credentials = {
|
||||
...tokenInfo,
|
||||
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
|
||||
@@ -2475,7 +2669,27 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
...proxyConfig
|
||||
})
|
||||
|
||||
const extra = oauth.buildExtraInfo(tokenInfo)
|
||||
// Build extra with quota control settings
|
||||
const baseExtra = oauth.buildExtraInfo(tokenInfo) || {}
|
||||
const extra: Record<string, unknown> = { ...baseExtra }
|
||||
|
||||
// Add window cost limit settings
|
||||
if (windowCostEnabled.value && windowCostLimit.value != null && windowCostLimit.value > 0) {
|
||||
extra.window_cost_limit = windowCostLimit.value
|
||||
extra.window_cost_sticky_reserve = windowCostStickyReserve.value ?? 10
|
||||
}
|
||||
|
||||
// Add session limit settings
|
||||
if (sessionLimitEnabled.value && maxSessions.value != null && maxSessions.value > 0) {
|
||||
extra.max_sessions = maxSessions.value
|
||||
extra.session_idle_timeout_minutes = sessionIdleTimeout.value ?? 5
|
||||
}
|
||||
|
||||
// Add TLS fingerprint settings
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
extra.enable_tls_fingerprint = true
|
||||
}
|
||||
|
||||
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
|
||||
|
||||
// Merge interceptWarmupRequests into credentials
|
||||
|
||||
@@ -566,7 +566,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
|
||||
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" />
|
||||
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.001" class="input" />
|
||||
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -732,6 +732,33 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TLS Fingerprint -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaControl.tlsFingerprint.label') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaControl.tlsFingerprint.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="tlsFingerprintEnabled = !tlsFingerprintEnabled"
|
||||
: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',
|
||||
tlsFingerprintEnabled ? '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',
|
||||
tlsFingerprintEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
@@ -904,6 +931,7 @@ const windowCostStickyReserve = ref<number | null>(null)
|
||||
const sessionLimitEnabled = ref(false)
|
||||
const maxSessions = ref<number | null>(null)
|
||||
const sessionIdleTimeout = ref<number | null>(null)
|
||||
const tlsFingerprintEnabled = ref(false)
|
||||
|
||||
// Computed: current preset mappings based on platform
|
||||
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
|
||||
@@ -1237,6 +1265,7 @@ function loadQuotaControlSettings(account: Account) {
|
||||
sessionLimitEnabled.value = false
|
||||
maxSessions.value = null
|
||||
sessionIdleTimeout.value = null
|
||||
tlsFingerprintEnabled.value = false
|
||||
|
||||
// Only applies to Anthropic OAuth/SetupToken accounts
|
||||
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
|
||||
@@ -1255,6 +1284,11 @@ function loadQuotaControlSettings(account: Account) {
|
||||
maxSessions.value = account.max_sessions
|
||||
sessionIdleTimeout.value = account.session_idle_timeout_minutes ?? 5
|
||||
}
|
||||
|
||||
// Load TLS fingerprint setting
|
||||
if (account.enable_tls_fingerprint === true) {
|
||||
tlsFingerprintEnabled.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function formatTempUnschedKeywords(value: unknown) {
|
||||
@@ -1407,6 +1441,13 @@ const handleSubmit = async () => {
|
||||
delete newExtra.session_idle_timeout_minutes
|
||||
}
|
||||
|
||||
// TLS fingerprint setting
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
newExtra.enable_tls_fingerprint = true
|
||||
} else {
|
||||
delete newExtra.enable_tls_fingerprint
|
||||
}
|
||||
|
||||
updatePayload.extra = newExtra
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<slot name="before"></slot>
|
||||
<button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary">
|
||||
<Icon name="refresh" size="md" :class="[loading ? 'animate-spin' : '']" />
|
||||
</button>
|
||||
|
||||
@@ -232,8 +232,11 @@ const loadAvailableModels = async () => {
|
||||
if (availableModels.value.length > 0) {
|
||||
if (props.account.platform === 'gemini') {
|
||||
const preferred =
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.0-flash') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.5-flash') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.5-pro') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-pro')
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-flash-preview') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-pro-preview')
|
||||
selectedModelId.value = preferred?.id || availableModels.value[0].id
|
||||
} else {
|
||||
// Try to select Sonnet as default, otherwise use first model
|
||||
|
||||
@@ -443,7 +443,7 @@ $env:ANTHROPIC_AUTH_TOKEN="${apiKey}"`
|
||||
}
|
||||
|
||||
function generateGeminiCliContent(baseUrl: string, apiKey: string): FileConfig {
|
||||
const model = 'gemini-2.5-pro'
|
||||
const model = 'gemini-2.0-flash'
|
||||
const modelComment = t('keys.useKeyModal.gemini.modelComment')
|
||||
let path: string
|
||||
let content: string
|
||||
@@ -548,14 +548,22 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
}
|
||||
}
|
||||
const geminiModels = {
|
||||
'gemini-3-pro-high': { name: 'Gemini 3 Pro High' },
|
||||
'gemini-3-pro-low': { name: 'Gemini 3 Pro Low' },
|
||||
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' },
|
||||
'gemini-3-pro-image': { name: 'Gemini 3 Pro Image' },
|
||||
'gemini-3-flash': { name: 'Gemini 3 Flash' },
|
||||
'gemini-2.5-flash-thinking': { name: 'Gemini 2.5 Flash Thinking' },
|
||||
'gemini-2.0-flash': { name: 'Gemini 2.0 Flash' },
|
||||
'gemini-2.5-flash': { name: 'Gemini 2.5 Flash' },
|
||||
'gemini-2.5-flash-lite': { name: 'Gemini 2.5 Flash Lite' }
|
||||
'gemini-2.5-pro': { name: 'Gemini 2.5 Pro' },
|
||||
'gemini-3-flash-preview': { name: 'Gemini 3 Flash Preview' },
|
||||
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' }
|
||||
}
|
||||
|
||||
const antigravityGeminiModels = {
|
||||
'gemini-2.5-flash': { name: 'Gemini 2.5 Flash' },
|
||||
'gemini-2.5-flash-lite': { name: 'Gemini 2.5 Flash Lite' },
|
||||
'gemini-2.5-flash-thinking': { name: 'Gemini 2.5 Flash Thinking' },
|
||||
'gemini-3-flash': { name: 'Gemini 3 Flash' },
|
||||
'gemini-3-pro-low': { name: 'Gemini 3 Pro Low' },
|
||||
'gemini-3-pro-high': { name: 'Gemini 3 Pro High' },
|
||||
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' },
|
||||
'gemini-3-pro-image': { name: 'Gemini 3 Pro Image' }
|
||||
}
|
||||
const claudeModels = {
|
||||
'claude-opus-4-5-thinking': { name: 'Claude Opus 4.5 Thinking' },
|
||||
@@ -575,7 +583,7 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
} else if (platform === 'antigravity-gemini') {
|
||||
provider[platform].npm = '@ai-sdk/google'
|
||||
provider[platform].name = 'Antigravity (Gemini)'
|
||||
provider[platform].models = geminiModels
|
||||
provider[platform].models = antigravityGeminiModels
|
||||
} else if (platform === 'openai') {
|
||||
provider[platform].models = openaiModels
|
||||
}
|
||||
|
||||
@@ -43,13 +43,13 @@ export const claudeModels = [
|
||||
|
||||
// Google Gemini
|
||||
const geminiModels = [
|
||||
'gemini-2.0-flash', 'gemini-2.0-flash-lite-preview', 'gemini-2.0-flash-exp',
|
||||
'gemini-2.0-pro-exp', 'gemini-2.0-flash-thinking-exp',
|
||||
'gemini-2.5-pro-exp-03-25', 'gemini-2.5-pro-preview-03-25',
|
||||
'gemini-3-pro-preview',
|
||||
'gemini-1.5-pro', 'gemini-1.5-pro-latest',
|
||||
'gemini-1.5-flash', 'gemini-1.5-flash-latest', 'gemini-1.5-flash-8b',
|
||||
'gemini-exp-1206'
|
||||
// Keep in sync with backend curated Gemini lists.
|
||||
// This list is intentionally conservative (models commonly available across OAuth/API key).
|
||||
'gemini-2.0-flash',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-3-pro-preview'
|
||||
]
|
||||
|
||||
// 智谱 GLM
|
||||
@@ -229,9 +229,8 @@ const openaiPresetMappings = [
|
||||
|
||||
const geminiPresetMappings = [
|
||||
{ label: 'Flash 2.0', from: 'gemini-2.0-flash', to: 'gemini-2.0-flash', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ label: 'Flash Lite', from: 'gemini-2.0-flash-lite-preview', to: 'gemini-2.0-flash-lite-preview', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: '1.5 Pro', from: 'gemini-1.5-pro', to: 'gemini-1.5-pro', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: '1.5 Flash', from: 'gemini-1.5-flash', to: 'gemini-1.5-flash', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }
|
||||
{ label: '2.5 Flash', from: 'gemini-2.5-flash', to: 'gemini-2.5-flash', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: '2.5 Pro', from: 'gemini-2.5-pro', to: 'gemini-2.5-pro', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }
|
||||
]
|
||||
|
||||
// =====================
|
||||
|
||||
@@ -673,6 +673,7 @@ export default {
|
||||
updating: 'Updating...',
|
||||
columns: {
|
||||
user: 'User',
|
||||
email: 'Email',
|
||||
username: 'Username',
|
||||
notes: 'Notes',
|
||||
role: 'Role',
|
||||
@@ -1093,6 +1094,7 @@ export default {
|
||||
todayStats: 'Today Stats',
|
||||
groups: 'Groups',
|
||||
usageWindows: 'Usage Windows',
|
||||
proxy: 'Proxy',
|
||||
lastUsed: 'Last Used',
|
||||
expiresAt: 'Expires At',
|
||||
actions: 'Actions'
|
||||
@@ -1283,6 +1285,10 @@ export default {
|
||||
idleTimeout: 'Idle Timeout',
|
||||
idleTimeoutPlaceholder: '5',
|
||||
idleTimeoutHint: 'Sessions will be released after idle timeout'
|
||||
},
|
||||
tlsFingerprint: {
|
||||
label: 'TLS Fingerprint Simulation',
|
||||
hint: 'Simulate Node.js/Claude Code client TLS fingerprint'
|
||||
}
|
||||
},
|
||||
expired: 'Expired',
|
||||
|
||||
@@ -1142,6 +1142,7 @@ export default {
|
||||
todayStats: '今日统计',
|
||||
groups: '分组',
|
||||
usageWindows: '用量窗口',
|
||||
proxy: '代理',
|
||||
lastUsed: '最近使用',
|
||||
expiresAt: '过期时间',
|
||||
actions: '操作'
|
||||
@@ -1416,6 +1417,10 @@ export default {
|
||||
idleTimeout: '空闲超时',
|
||||
idleTimeoutPlaceholder: '5',
|
||||
idleTimeoutHint: '会话空闲超时后自动释放'
|
||||
},
|
||||
tlsFingerprint: {
|
||||
label: 'TLS 指纹模拟',
|
||||
hint: '模拟 Node.js/Claude Code 客户端的 TLS 指纹'
|
||||
}
|
||||
},
|
||||
expired: '已过期',
|
||||
|
||||
@@ -480,6 +480,9 @@ export interface Account {
|
||||
max_sessions?: number | null
|
||||
session_idle_timeout_minutes?: number | null
|
||||
|
||||
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
enable_tls_fingerprint?: boolean | null
|
||||
|
||||
// 运行时状态(仅当启用对应限制时返回)
|
||||
current_window_cost?: number | null // 当前窗口费用
|
||||
active_sessions?: number | null // 当前活跃会话数
|
||||
|
||||
@@ -15,7 +15,40 @@
|
||||
@refresh="load"
|
||||
@sync="showSync = true"
|
||||
@create="showCreate = true"
|
||||
/>
|
||||
>
|
||||
<template #before>
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@click="showColumnDropdown = !showColumnDropdown"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.users.columnSettings')"
|
||||
>
|
||||
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
<span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="showColumnDropdown"
|
||||
class="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="max-h-80 overflow-y-auto p-2">
|
||||
<button
|
||||
v-for="col in toggleableColumns"
|
||||
:key="col.key"
|
||||
@click="toggleColumn(col.key)"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<Icon v-if="isColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AccountTableActions>
|
||||
</div>
|
||||
</template>
|
||||
<template #table>
|
||||
@@ -54,6 +87,15 @@
|
||||
<template #cell-usage="{ row }">
|
||||
<AccountUsageCell :account="row" />
|
||||
</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>
|
||||
<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">
|
||||
{{ (row.rate_multiplier ?? 1).toFixed(2) }}x
|
||||
@@ -143,6 +185,7 @@ import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vu
|
||||
import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue'
|
||||
import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
|
||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import type { Account, Proxy, Group } from '@/types'
|
||||
|
||||
@@ -171,12 +214,54 @@ const statsAcc = ref<Account | null>(null)
|
||||
const togglingSchedulable = ref<number | null>(null)
|
||||
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
|
||||
|
||||
// Column settings
|
||||
const showColumnDropdown = ref(false)
|
||||
const columnDropdownRef = ref<HTMLElement | null>(null)
|
||||
const hiddenColumns = reactive<Set<string>>(new Set())
|
||||
const DEFAULT_HIDDEN_COLUMNS = ['proxy', 'notes', 'priority', 'rate_multiplier']
|
||||
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
|
||||
|
||||
const loadSavedColumns = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as string[]
|
||||
parsed.forEach(key => hiddenColumns.add(key))
|
||||
} else {
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load saved columns:', e)
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||
}
|
||||
}
|
||||
|
||||
const saveColumnsToStorage = () => {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
|
||||
} catch (e) {
|
||||
console.error('Failed to save columns:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleColumn = (key: string) => {
|
||||
if (hiddenColumns.has(key)) {
|
||||
hiddenColumns.delete(key)
|
||||
} else {
|
||||
hiddenColumns.add(key)
|
||||
}
|
||||
saveColumnsToStorage()
|
||||
}
|
||||
|
||||
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
||||
|
||||
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange, handlePageSizeChange } = useTableLoader<Account, any>({
|
||||
fetchFn: adminAPI.accounts.list,
|
||||
initialParams: { platform: '', type: '', status: '', search: '' }
|
||||
})
|
||||
|
||||
const cols = computed(() => {
|
||||
// All available columns
|
||||
const allColumns = computed(() => {
|
||||
const c = [
|
||||
{ key: 'select', label: '', sortable: false },
|
||||
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
|
||||
@@ -189,11 +274,12 @@ const cols = computed(() => {
|
||||
if (!authStore.isSimpleMode) {
|
||||
c.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false })
|
||||
}
|
||||
c.push(
|
||||
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
|
||||
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
|
||||
{ key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true },
|
||||
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
|
||||
c.push(
|
||||
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
|
||||
{ key: 'proxy', label: t('admin.accounts.columns.proxy'), sortable: false },
|
||||
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
|
||||
{ key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), 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: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
|
||||
@@ -201,6 +287,18 @@ const cols = computed(() => {
|
||||
return c
|
||||
})
|
||||
|
||||
// Columns that can be toggled (exclude select, name, and actions)
|
||||
const toggleableColumns = computed(() =>
|
||||
allColumns.value.filter(col => col.key !== 'select' && col.key !== 'name' && col.key !== 'actions')
|
||||
)
|
||||
|
||||
// Filtered columns based on visibility
|
||||
const cols = computed(() =>
|
||||
allColumns.value.filter(col =>
|
||||
col.key === 'select' || col.key === 'name' || col.key === 'actions' || !hiddenColumns.has(col.key)
|
||||
)
|
||||
)
|
||||
|
||||
const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true }
|
||||
const openMenu = (a: Account, e: MouseEvent) => {
|
||||
menu.acc = a
|
||||
@@ -403,12 +501,21 @@ const isExpired = (value: number | null) => {
|
||||
return value * 1000 <= Date.now()
|
||||
}
|
||||
|
||||
// 滚动时关闭菜单
|
||||
// 滚动时关闭操作菜单(不关闭列设置下拉菜单)
|
||||
const handleScroll = () => {
|
||||
menu.show = false
|
||||
}
|
||||
|
||||
// 点击外部关闭列设置下拉菜单
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
||||
showColumnDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadSavedColumns()
|
||||
load()
|
||||
try {
|
||||
const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()])
|
||||
@@ -418,9 +525,11 @@ onMounted(async () => {
|
||||
console.error('Failed to load proxies/groups:', error)
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p>
|
||||
</div>
|
||||
<div v-if="createForm.subscription_type !== 'subscription'">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.rate_multiplier"
|
||||
@@ -680,7 +680,7 @@
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.platformNotEditable') }}</p>
|
||||
</div>
|
||||
<div v-if="editForm.subscription_type !== 'subscription'">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.rate_multiplier"
|
||||
@@ -1605,12 +1605,11 @@ const confirmDelete = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 subscription_type 变化,订阅模式时重置 rate_multiplier 为 1,is_exclusive 为 true
|
||||
// 监听 subscription_type 变化,订阅模式时 is_exclusive 默认为 true
|
||||
watch(
|
||||
() => createForm.subscription_type,
|
||||
(newVal) => {
|
||||
if (newVal === 'subscription') {
|
||||
createForm.rate_multiplier = 1.0
|
||||
createForm.is_exclusive = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,57 @@
|
||||
|
||||
<!-- Right: Actions -->
|
||||
<div class="ml-auto flex flex-wrap items-center justify-end gap-3">
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@click="showColumnDropdown = !showColumnDropdown"
|
||||
class="btn btn-secondary px-2 md:px-3"
|
||||
:title="t('admin.users.columnSettings')"
|
||||
>
|
||||
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
<span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="showColumnDropdown"
|
||||
class="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="p-2">
|
||||
<!-- User column mode selection -->
|
||||
<div class="mb-2 border-b border-gray-200 pb-2 dark:border-gray-700">
|
||||
<div class="px-3 py-1 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.subscriptions.columns.user') }}
|
||||
</div>
|
||||
<button
|
||||
@click="setUserColumnMode('email')"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ t('admin.users.columns.email') }}</span>
|
||||
<Icon v-if="userColumnMode === 'email'" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
<button
|
||||
@click="setUserColumnMode('username')"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ t('admin.users.columns.username') }}</span>
|
||||
<Icon v-if="userColumnMode === 'username'" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Other columns toggle -->
|
||||
<button
|
||||
v-for="col in toggleableColumns"
|
||||
:key="col.key"
|
||||
@click="toggleColumn(col.key)"
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<Icon v-if="isColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="loadSubscriptions"
|
||||
:disabled="loading"
|
||||
@@ -110,12 +161,18 @@
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
|
||||
>
|
||||
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">
|
||||
{{ row.user?.email?.charAt(0).toUpperCase() || '?' }}
|
||||
{{ userColumnMode === 'email'
|
||||
? (row.user?.email?.charAt(0).toUpperCase() || '?')
|
||||
: (row.user?.username?.charAt(0).toUpperCase() || '?')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.user?.email || t('admin.redeem.userPrefix', { id: row.user_id })
|
||||
}}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ userColumnMode === 'email'
|
||||
? (row.user?.email || t('admin.redeem.userPrefix', { id: row.user_id }))
|
||||
: (row.user?.username || '-')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -545,8 +602,43 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'user', label: t('admin.subscriptions.columns.user'), sortable: true },
|
||||
// User column display mode: 'email' or 'username'
|
||||
const userColumnMode = ref<'email' | 'username'>('email')
|
||||
const USER_COLUMN_MODE_KEY = 'subscription-user-column-mode'
|
||||
|
||||
const loadUserColumnMode = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(USER_COLUMN_MODE_KEY)
|
||||
if (saved === 'email' || saved === 'username') {
|
||||
userColumnMode.value = saved
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load user column mode:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const saveUserColumnMode = () => {
|
||||
try {
|
||||
localStorage.setItem(USER_COLUMN_MODE_KEY, userColumnMode.value)
|
||||
} catch (e) {
|
||||
console.error('Failed to save user column mode:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const setUserColumnMode = (mode: 'email' | 'username') => {
|
||||
userColumnMode.value = mode
|
||||
saveUserColumnMode()
|
||||
}
|
||||
|
||||
// All available columns
|
||||
const allColumns = computed<Column[]>(() => [
|
||||
{
|
||||
key: 'user',
|
||||
label: userColumnMode.value === 'email'
|
||||
? t('admin.subscriptions.columns.user')
|
||||
: t('admin.users.columns.username'),
|
||||
sortable: true
|
||||
},
|
||||
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: true },
|
||||
{ key: 'usage', label: t('admin.subscriptions.columns.usage'), sortable: false },
|
||||
{ key: 'expires_at', label: t('admin.subscriptions.columns.expires'), sortable: true },
|
||||
@@ -554,6 +646,69 @@ const columns = computed<Column[]>(() => [
|
||||
{ key: 'actions', label: t('admin.subscriptions.columns.actions'), sortable: false }
|
||||
])
|
||||
|
||||
// Columns that can be toggled (exclude user and actions which are always visible)
|
||||
const toggleableColumns = computed(() =>
|
||||
allColumns.value.filter(col => col.key !== 'user' && col.key !== 'actions')
|
||||
)
|
||||
|
||||
// Hidden columns set
|
||||
const hiddenColumns = reactive<Set<string>>(new Set())
|
||||
|
||||
// Default hidden columns
|
||||
const DEFAULT_HIDDEN_COLUMNS: string[] = []
|
||||
|
||||
// localStorage key
|
||||
const HIDDEN_COLUMNS_KEY = 'subscription-hidden-columns'
|
||||
|
||||
// Load saved column settings
|
||||
const loadSavedColumns = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as string[]
|
||||
parsed.forEach(key => hiddenColumns.add(key))
|
||||
} else {
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load saved columns:', e)
|
||||
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
|
||||
}
|
||||
}
|
||||
|
||||
// Save column settings to localStorage
|
||||
const saveColumnsToStorage = () => {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
|
||||
} catch (e) {
|
||||
console.error('Failed to save columns:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle column visibility
|
||||
const toggleColumn = (key: string) => {
|
||||
if (hiddenColumns.has(key)) {
|
||||
hiddenColumns.delete(key)
|
||||
} else {
|
||||
hiddenColumns.add(key)
|
||||
}
|
||||
saveColumnsToStorage()
|
||||
}
|
||||
|
||||
// Check if column is visible
|
||||
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
||||
|
||||
// Filtered columns for display
|
||||
const columns = computed<Column[]>(() =>
|
||||
allColumns.value.filter(col =>
|
||||
col.key === 'user' || col.key === 'actions' || !hiddenColumns.has(col.key)
|
||||
)
|
||||
)
|
||||
|
||||
// Column dropdown state
|
||||
const showColumnDropdown = ref(false)
|
||||
const columnDropdownRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Filter options
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.subscriptions.allStatus') },
|
||||
@@ -949,14 +1104,19 @@ const formatResetTime = (windowStart: string, period: 'daily' | 'weekly' | 'mont
|
||||
}
|
||||
}
|
||||
|
||||
// Handle click outside to close user dropdown
|
||||
// Handle click outside to close dropdowns
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('[data-assign-user-search]')) showUserDropdown.value = false
|
||||
if (!target.closest('[data-filter-user-search]')) showFilterUserDropdown.value = false
|
||||
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
||||
showColumnDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUserColumnMode()
|
||||
loadSavedColumns()
|
||||
loadSubscriptions()
|
||||
loadGroups()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/**", "src/**/*.spec.ts", "src/**/*.test.ts"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user