Commit 0170d19f authored by song's avatar song
Browse files

merge upstream main

parent 7ade9baa
<template>
<div class="flex items-center gap-2">
<!-- Main Status Badge -->
<button
v-if="isTempUnschedulable"
type="button"
:class="['badge text-xs', statusClass, 'cursor-pointer']"
:title="t('admin.accounts.status.viewTempUnschedDetails')"
@click="handleTempUnschedClick"
>
{{ statusText }}
</button>
<span v-else :class="['badge text-xs', statusClass]">
{{ statusText }}
</span>
<!-- Rate Limit Display (429) - Two-line layout -->
<div v-if="isRateLimited" class="flex flex-col items-center gap-1">
<span class="badge text-xs badge-warning">{{ t('admin.accounts.status.rateLimited') }}</span>
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ rateLimitCountdown }}</span>
</div>
<!-- Overload Display (529) - Two-line layout -->
<div v-else-if="isOverloaded" class="flex flex-col items-center gap-1">
<span class="badge text-xs badge-danger">{{ t('admin.accounts.status.overloaded') }}</span>
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{ overloadCountdown }}</span>
</div>
<!-- Main Status Badge (shown when not rate limited/overloaded) -->
<template v-else>
<button
v-if="isTempUnschedulable"
type="button"
:class="['badge text-xs', statusClass, 'cursor-pointer']"
:title="t('admin.accounts.status.viewTempUnschedDetails')"
@click="handleTempUnschedClick"
>
{{ statusText }}
</button>
<span v-else :class="['badge text-xs', statusClass]">
{{ statusText }}
</span>
</template>
<!-- Error Info Indicator -->
<div v-if="hasError && account.error_message" class="group/error relative">
......@@ -42,7 +56,6 @@
></div>
</div>
</div>
<!-- Rate Limit Indicator (429) -->
<div v-if="isRateLimited" class="group relative">
<span
......@@ -108,8 +121,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account } from '@/types'
import { formatTime } from '@/utils/format'
import Icon from '@/components/icons/Icon.vue'
import { formatCountdownWithSuffix } from '@/utils/format'
const { t } = useI18n()
......@@ -163,6 +175,16 @@ const hasError = computed(() => {
return props.account.status === 'error'
})
// Computed: countdown text for rate limit (429)
const rateLimitCountdown = computed(() => {
return formatCountdownWithSuffix(props.account.rate_limit_reset_at)
})
// Computed: countdown text for overload (529)
const overloadCountdown = computed(() => {
return formatCountdownWithSuffix(props.account.overload_until)
})
// Computed: status badge class
const statusClass = computed(() => {
if (hasError.value) {
......@@ -171,7 +193,7 @@ const statusClass = computed(() => {
if (isTempUnschedulable.value) {
return 'badge-warning'
}
if (!props.account.schedulable || isRateLimited.value || isOverloaded.value) {
if (!props.account.schedulable) {
return 'badge-gray'
}
switch (props.account.status) {
......@@ -197,9 +219,6 @@ const statusText = computed(() => {
if (!props.account.schedulable) {
return t('admin.accounts.status.paused')
}
if (isRateLimited.value || isOverloaded.value) {
return t('admin.accounts.status.limited')
}
return t(`admin.accounts.status.${props.account.status}`)
})
......@@ -207,5 +226,4 @@ const handleTempUnschedClick = () => {
if (!isTempUnschedulable.value) return
emit('show-temp-unsched', props.account)
}
</script>
......@@ -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
......
......@@ -648,7 +648,7 @@ import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { Proxy, Group } from '@/types'
import type { Proxy, AdminGroup } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
......@@ -659,7 +659,7 @@ interface Props {
show: boolean
accountIds: number[]
proxies: Proxy[]
groups: Group[]
groups: AdminGroup[]
}
const props = defineProps<Props>()
......
......@@ -1159,9 +1159,9 @@
</div>
</div>
<!-- Intercept Warmup Requests (Anthropic/Antigravity) -->
<!-- Intercept Warmup Requests (Anthropic only) -->
<div
v-if="form.platform === 'anthropic' || form.platform === 'antigravity'"
v-if="form.platform === 'anthropic'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
......@@ -1191,6 +1191,190 @@
</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>
<!-- Session ID Masking -->
<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.sessionIdMasking.label') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaControl.sessionIdMasking.hint') }}
</p>
</div>
<button
type="button"
@click="sessionIdMaskingEnabled = !sessionIdMaskingEnabled"
: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',
sessionIdMaskingEnabled ? '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',
sessionIdMaskingEnabled ? '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 +1398,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>
......@@ -1632,7 +1816,7 @@ import {
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
import type { Proxy, AdminGroup, AccountPlatform, AccountType } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
......@@ -1678,7 +1862,7 @@ const apiKeyHint = computed(() => {
interface Props {
show: boolean
proxies: Proxy[]
groups: Group[]
groups: AdminGroup[]
}
const props = defineProps<Props>()
......@@ -1763,6 +1947,16 @@ 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)
const sessionIdMaskingEnabled = 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 +2334,15 @@ 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
sessionIdMaskingEnabled.value = false
tempUnschedEnabled.value = false
tempUnschedRules.value = []
geminiOAuthType.value = 'code_assist'
......@@ -2407,7 +2610,32 @@ 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
}
// Add session ID masking settings
if (sessionIdMaskingEnabled.value) {
extra.session_id_masking_enabled = true
}
const credentials = {
...tokenInfo,
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
......@@ -2475,7 +2703,32 @@ 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
}
// Add session ID masking settings
if (sessionIdMaskingEnabled.value) {
extra.session_id_masking_enabled = true
}
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
// Merge interceptWarmupRequests into credentials
......
......@@ -512,9 +512,9 @@
</div>
</div>
<!-- Intercept Warmup Requests (Anthropic/Antigravity) -->
<!-- Intercept Warmup Requests (Anthropic only) -->
<div
v-if="account?.platform === 'anthropic' || account?.platform === 'antigravity'"
v-if="account?.platform === 'anthropic'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
......@@ -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,60 @@
</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>
<!-- Session ID Masking -->
<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.sessionIdMasking.label') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaControl.sessionIdMasking.hint') }}
</p>
</div>
<button
type="button"
@click="sessionIdMaskingEnabled = !sessionIdMaskingEnabled"
: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',
sessionIdMaskingEnabled ? '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',
sessionIdMaskingEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
......@@ -829,7 +883,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin'
import type { Account, Proxy, Group } from '@/types'
import type { Account, Proxy, AdminGroup } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
......@@ -847,7 +901,7 @@ interface Props {
show: boolean
account: Account | null
proxies: Proxy[]
groups: Group[]
groups: AdminGroup[]
}
const props = defineProps<Props>()
......@@ -904,6 +958,8 @@ 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)
const sessionIdMaskingEnabled = ref(false)
// Computed: current preset mappings based on platform
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
......@@ -1237,6 +1293,8 @@ function loadQuotaControlSettings(account: Account) {
sessionLimitEnabled.value = false
maxSessions.value = null
sessionIdleTimeout.value = null
tlsFingerprintEnabled.value = false
sessionIdMaskingEnabled.value = false
// Only applies to Anthropic OAuth/SetupToken accounts
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
......@@ -1255,6 +1313,16 @@ 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
}
// Load session ID masking setting
if (account.session_id_masking_enabled === true) {
sessionIdMaskingEnabled.value = true
}
}
function formatTempUnschedKeywords(value: unknown) {
......@@ -1407,6 +1475,20 @@ 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
}
// Session ID masking setting
if (sessionIdMaskingEnabled.value) {
newExtra.session_id_masking_enabled = true
} else {
delete newExtra.session_id_masking_enabled
}
updatePayload.extra = newExtra
}
......
<template>
<Teleport to="body">
<div v-if="show && position" class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800" :style="{ top: position.top + 'px', left: position.left + 'px' }">
<div class="py-1">
<template v-if="account">
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="play" size="sm" class="text-green-500" :stroke-width="2" />
{{ t('admin.accounts.testConnection') }}
</button>
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="chart" size="sm" class="text-indigo-500" />
{{ t('admin.accounts.viewStats') }}
</button>
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="link" size="sm" />
{{ t('admin.accounts.reAuthorize') }}
<div v-if="show && position">
<!-- Backdrop: click anywhere outside to close -->
<div class="fixed inset-0 z-[9998]" @click="emit('close')"></div>
<div
class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800"
:style="{ top: position.top + 'px', left: position.left + 'px' }"
@click.stop
>
<div class="py-1">
<template v-if="account">
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="play" size="sm" class="text-green-500" :stroke-width="2" />
{{ t('admin.accounts.testConnection') }}
</button>
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="refresh" size="sm" />
{{ t('admin.accounts.refreshToken') }}
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="chart" size="sm" class="text-indigo-500" />
{{ t('admin.accounts.viewStats') }}
</button>
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="link" size="sm" />
{{ t('admin.accounts.reAuthorize') }}
</button>
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="refresh" size="sm" />
{{ t('admin.accounts.refreshToken') }}
</button>
</template>
<div v-if="account.status === 'error' || isRateLimited || isOverloaded" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
<button v-if="account.status === 'error'" @click="$emit('reset-status', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="sync" size="sm" />
{{ t('admin.accounts.resetStatus') }}
</button>
<button v-if="isRateLimited || isOverloaded" @click="$emit('clear-rate-limit', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="clock" size="sm" />
{{ t('admin.accounts.clearRateLimit') }}
</button>
</template>
<div v-if="account.status === 'error' || isRateLimited || isOverloaded" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
<button v-if="account.status === 'error'" @click="$emit('reset-status', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="sync" size="sm" />
{{ t('admin.accounts.resetStatus') }}
</button>
<button v-if="isRateLimited || isOverloaded" @click="$emit('clear-rate-limit', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="clock" size="sm" />
{{ t('admin.accounts.clearRateLimit') }}
</button>
</template>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Icon } from '@/components/icons'
import type { Account } from '@/types'
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit'])
const emit = defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit'])
const { t } = useI18n()
const isRateLimited = computed(() => props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date())
const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date())
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') emit('close')
}
watch(
() => props.show,
(visible) => {
if (visible) {
window.addEventListener('keydown', handleKeydown)
} else {
window.removeEventListener('keydown', handleKeydown)
}
},
{ immediate: true }
)
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<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>
<slot name="after"></slot>
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
</div>
......
......@@ -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
......
<template>
<BaseDialog
:show="show"
:title="t('admin.announcements.readStatus')"
width="extra-wide"
@close="handleClose"
>
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex-1">
<input
v-model="search"
type="text"
class="input"
:placeholder="t('admin.announcements.searchUsers')"
@input="handleSearch"
/>
</div>
<button @click="load" :disabled="loading" class="btn btn-secondary" :title="t('common.refresh')">
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
</div>
<DataTable :columns="columns" :data="items" :loading="loading">
<template #cell-email="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
<template #cell-balance="{ value }">
<span class="font-medium text-gray-900 dark:text-white">${{ Number(value ?? 0).toFixed(2) }}</span>
</template>
<template #cell-eligible="{ value }">
<span :class="['badge', value ? 'badge-success' : 'badge-gray']">
{{ value ? t('admin.announcements.eligible') : t('common.no') }}
</span>
</template>
<template #cell-read_at="{ value }">
<span class="text-sm text-gray-500 dark:text-dark-400">
{{ value ? formatDateTime(value) : t('admin.announcements.unread') }}
</span>
</template>
</DataTable>
<Pagination
v-if="pagination.total > 0"
:page="pagination.page"
:total="pagination.total"
:page-size="pagination.page_size"
@update:page="handlePageChange"
@update:pageSize="handlePageSizeChange"
/>
</div>
<template #footer>
<div class="flex justify-end">
<button type="button" class="btn btn-secondary" @click="handleClose">{{ t('common.close') }}</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
import type { AnnouncementUserReadStatus } from '@/types'
import type { Column } from '@/components/common/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const appStore = useAppStore()
const props = defineProps<{
show: boolean
announcementId: number | null
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const loading = ref(false)
const search = ref('')
const pagination = reactive({
page: 1,
page_size: 20,
total: 0,
pages: 0
})
const items = ref<AnnouncementUserReadStatus[]>([])
const columns = computed<Column[]>(() => [
{ key: 'email', label: t('common.email') },
{ key: 'username', label: t('admin.users.columns.username') },
{ key: 'balance', label: t('common.balance') },
{ key: 'eligible', label: t('admin.announcements.eligible') },
{ key: 'read_at', label: t('admin.announcements.readAt') }
])
let currentController: AbortController | null = null
async function load() {
if (!props.show || !props.announcementId) return
if (currentController) currentController.abort()
currentController = new AbortController()
try {
loading.value = true
const res = await adminAPI.announcements.getReadStatus(
props.announcementId,
pagination.page,
pagination.page_size,
search.value
)
items.value = res.items
pagination.total = res.total
pagination.pages = res.pages
pagination.page = res.page
pagination.page_size = res.page_size
} catch (error: any) {
if (currentController.signal.aborted || error?.name === 'AbortError') return
console.error('Failed to load read status:', error)
appStore.showError(error.response?.data?.detail || t('admin.announcements.failedToLoadReadStatus'))
} finally {
loading.value = false
}
}
function handlePageChange(page: number) {
pagination.page = page
load()
}
function handlePageSizeChange(pageSize: number) {
pagination.page_size = pageSize
pagination.page = 1
load()
}
let searchDebounceTimer: number | null = null
function handleSearch() {
if (searchDebounceTimer) window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = window.setTimeout(() => {
pagination.page = 1
load()
}, 300)
}
function handleClose() {
emit('close')
}
watch(
() => props.show,
(v) => {
if (!v) return
pagination.page = 1
load()
}
)
watch(
() => props.announcementId,
() => {
if (!props.show) return
pagination.page = 1
load()
}
)
onMounted(() => {
// noop
})
</script>
<template>
<div class="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800/50">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.announcements.form.targetingMode') }}
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-dark-400">
{{ mode === 'all' ? t('admin.announcements.form.targetingAll') : t('admin.announcements.form.targetingCustom') }}
</div>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="radio"
name="announcement-targeting-mode"
value="all"
:checked="mode === 'all'"
@change="setMode('all')"
class="h-4 w-4"
/>
{{ t('admin.announcements.form.targetingAll') }}
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="radio"
name="announcement-targeting-mode"
value="custom"
:checked="mode === 'custom'"
@change="setMode('custom')"
class="h-4 w-4"
/>
{{ t('admin.announcements.form.targetingCustom') }}
</label>
</div>
</div>
<div v-if="mode === 'custom'" class="mt-4 space-y-4">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-gray-900 dark:text-white">
OR
<span class="ml-1 text-xs font-normal text-gray-500 dark:text-dark-400">
({{ anyOf.length }}/50)
</span>
</div>
<button
type="button"
class="btn btn-secondary"
:disabled="anyOf.length >= 50"
@click="addOrGroup"
>
<Icon name="plus" size="sm" class="mr-1" />
{{ t('admin.announcements.form.addOrGroup') }}
</button>
</div>
<div v-if="anyOf.length === 0" class="rounded-xl border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-dark-600 dark:text-dark-400">
{{ t('admin.announcements.form.targetingCustom') }}: {{ t('admin.announcements.form.addOrGroup') }}
</div>
<div
v-for="(group, groupIndex) in anyOf"
:key="groupIndex"
class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-800"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.announcements.form.targetingCustom') }} #{{ groupIndex + 1 }}
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-dark-400">AND ({{ (group.all_of?.length || 0) }}/50)</span>
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-dark-400">
{{ t('admin.announcements.form.addAndCondition') }}
</div>
</div>
<button
type="button"
class="btn btn-secondary"
@click="removeOrGroup(groupIndex)"
>
<Icon name="trash" size="sm" class="mr-1" />
{{ t('common.delete') }}
</button>
</div>
<div class="mt-4 space-y-3">
<div
v-for="(cond, condIndex) in (group.all_of || [])"
:key="condIndex"
class="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-900/30"
>
<div class="flex flex-col gap-3 md:flex-row md:items-end">
<div class="w-full md:w-52">
<label class="input-label">{{ t('admin.announcements.form.conditionType') }}</label>
<Select
:model-value="cond.type"
:options="conditionTypeOptions"
@update:model-value="(v) => setConditionType(groupIndex, condIndex, v as any)"
/>
</div>
<div v-if="cond.type === 'subscription'" class="flex-1">
<label class="input-label">{{ t('admin.announcements.form.selectPackages') }}</label>
<GroupSelector
v-model="subscriptionSelections[groupIndex][condIndex]"
:groups="groups"
/>
</div>
<div v-else class="flex flex-1 flex-col gap-3 sm:flex-row">
<div class="w-full sm:w-44">
<label class="input-label">{{ t('admin.announcements.form.operator') }}</label>
<Select
:model-value="cond.operator"
:options="balanceOperatorOptions"
@update:model-value="(v) => setOperator(groupIndex, condIndex, v as any)"
/>
</div>
<div class="w-full sm:flex-1">
<label class="input-label">{{ t('admin.announcements.form.balanceValue') }}</label>
<input
:value="String(cond.value ?? '')"
type="number"
step="any"
class="input"
@input="(e) => setBalanceValue(groupIndex, condIndex, (e.target as HTMLInputElement).value)"
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="btn btn-secondary"
@click="removeAndCondition(groupIndex, condIndex)"
>
<Icon name="trash" size="sm" class="mr-1" />
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="btn btn-secondary"
:disabled="(group.all_of?.length || 0) >= 50"
@click="addAndCondition(groupIndex)"
>
<Icon name="plus" size="sm" class="mr-1" />
{{ t('admin.announcements.form.addAndCondition') }}
</button>
</div>
</div>
</div>
<div v-if="validationError" class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/30 dark:bg-red-900/10 dark:text-red-300">
{{ validationError }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
AdminGroup,
AnnouncementTargeting,
AnnouncementCondition,
AnnouncementConditionGroup,
AnnouncementConditionType,
AnnouncementOperator
} from '@/types'
import Select from '@/components/common/Select.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const props = defineProps<{
modelValue: AnnouncementTargeting
groups: AdminGroup[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: AnnouncementTargeting): void
}>()
const anyOf = computed(() => props.modelValue?.any_of ?? [])
type Mode = 'all' | 'custom'
const mode = computed<Mode>(() => (anyOf.value.length === 0 ? 'all' : 'custom'))
const conditionTypeOptions = computed(() => [
{ value: 'subscription', label: t('admin.announcements.form.conditionSubscription') },
{ value: 'balance', label: t('admin.announcements.form.conditionBalance') }
])
const balanceOperatorOptions = computed(() => [
{ value: 'gt', label: t('admin.announcements.operators.gt') },
{ value: 'gte', label: t('admin.announcements.operators.gte') },
{ value: 'lt', label: t('admin.announcements.operators.lt') },
{ value: 'lte', label: t('admin.announcements.operators.lte') },
{ value: 'eq', label: t('admin.announcements.operators.eq') }
])
function setMode(next: Mode) {
if (next === 'all') {
emit('update:modelValue', { any_of: [] })
return
}
if (anyOf.value.length === 0) {
emit('update:modelValue', { any_of: [{ all_of: [defaultSubscriptionCondition()] }] })
}
}
function defaultSubscriptionCondition(): AnnouncementCondition {
return {
type: 'subscription' as AnnouncementConditionType,
operator: 'in' as AnnouncementOperator,
group_ids: []
}
}
function defaultBalanceCondition(): AnnouncementCondition {
return {
type: 'balance' as AnnouncementConditionType,
operator: 'gte' as AnnouncementOperator,
value: 0
}
}
type TargetingDraft = {
any_of: AnnouncementConditionGroup[]
}
function updateTargeting(mutator: (draft: TargetingDraft) => void) {
const draft: TargetingDraft = JSON.parse(JSON.stringify(props.modelValue ?? { any_of: [] }))
if (!draft.any_of) draft.any_of = []
mutator(draft)
emit('update:modelValue', draft)
}
function addOrGroup() {
updateTargeting((draft) => {
if (draft.any_of.length >= 50) return
draft.any_of.push({ all_of: [defaultSubscriptionCondition()] })
})
}
function removeOrGroup(groupIndex: number) {
updateTargeting((draft) => {
draft.any_of.splice(groupIndex, 1)
})
}
function addAndCondition(groupIndex: number) {
updateTargeting((draft) => {
const group = draft.any_of[groupIndex]
if (!group.all_of) group.all_of = []
if (group.all_of.length >= 50) return
group.all_of.push(defaultSubscriptionCondition())
})
}
function removeAndCondition(groupIndex: number, condIndex: number) {
updateTargeting((draft) => {
const group = draft.any_of[groupIndex]
if (!group?.all_of) return
group.all_of.splice(condIndex, 1)
})
}
function setConditionType(groupIndex: number, condIndex: number, nextType: AnnouncementConditionType) {
updateTargeting((draft) => {
const group = draft.any_of[groupIndex]
if (!group?.all_of) return
if (nextType === 'subscription') {
group.all_of[condIndex] = defaultSubscriptionCondition()
} else {
group.all_of[condIndex] = defaultBalanceCondition()
}
})
}
function setOperator(groupIndex: number, condIndex: number, op: AnnouncementOperator) {
updateTargeting((draft) => {
const group = draft.any_of[groupIndex]
if (!group?.all_of) return
const cond = group.all_of[condIndex]
if (!cond) return
cond.operator = op
})
}
function setBalanceValue(groupIndex: number, condIndex: number, raw: string) {
const n = raw === '' ? 0 : Number(raw)
updateTargeting((draft) => {
const group = draft.any_of[groupIndex]
if (!group?.all_of) return
const cond = group.all_of[condIndex]
if (!cond) return
cond.value = Number.isFinite(n) ? n : 0
})
}
// We keep group_ids selection in a parallel reactive map because GroupSelector is numeric list.
// Then we mirror it back to targeting.group_ids via a watcher.
const subscriptionSelections = reactive<Record<number, Record<number, number[]>>>({})
function ensureSelectionPath(groupIndex: number, condIndex: number) {
if (!subscriptionSelections[groupIndex]) subscriptionSelections[groupIndex] = {}
if (!subscriptionSelections[groupIndex][condIndex]) subscriptionSelections[groupIndex][condIndex] = []
}
// Sync from modelValue to subscriptionSelections (one-way: model -> local state)
watch(
() => props.modelValue,
(v) => {
const groups = v?.any_of ?? []
for (let gi = 0; gi < groups.length; gi++) {
const allOf = groups[gi]?.all_of ?? []
for (let ci = 0; ci < allOf.length; ci++) {
const c = allOf[ci]
if (c?.type === 'subscription') {
ensureSelectionPath(gi, ci)
// Only update if different to avoid triggering unnecessary updates
const newIds = (c.group_ids ?? []).slice()
const currentIds = subscriptionSelections[gi]?.[ci] ?? []
if (JSON.stringify(newIds.sort()) !== JSON.stringify(currentIds.sort())) {
subscriptionSelections[gi][ci] = newIds
}
}
}
}
},
{ immediate: true }
)
// Sync from subscriptionSelections to modelValue (one-way: local state -> model)
// Use a debounced approach to avoid infinite loops
let syncTimeout: ReturnType<typeof setTimeout> | null = null
watch(
() => subscriptionSelections,
() => {
// Debounce the sync to avoid rapid fire updates
if (syncTimeout) clearTimeout(syncTimeout)
syncTimeout = setTimeout(() => {
// Build the new targeting state
const newTargeting: TargetingDraft = JSON.parse(JSON.stringify(props.modelValue ?? { any_of: [] }))
if (!newTargeting.any_of) newTargeting.any_of = []
const groups = newTargeting.any_of ?? []
for (let gi = 0; gi < groups.length; gi++) {
const allOf = groups[gi]?.all_of ?? []
for (let ci = 0; ci < allOf.length; ci++) {
const c = allOf[ci]
if (c?.type === 'subscription') {
ensureSelectionPath(gi, ci)
c.operator = 'in' as AnnouncementOperator
c.group_ids = (subscriptionSelections[gi]?.[ci] ?? []).slice()
}
}
}
// Only emit if there's an actual change (deep comparison)
if (JSON.stringify(props.modelValue) !== JSON.stringify(newTargeting)) {
emit('update:modelValue', newTargeting)
}
}, 0)
},
{ deep: true }
)
const validationError = computed(() => {
if (mode.value !== 'custom') return ''
const groups = anyOf.value
if (groups.length === 0) return t('admin.announcements.form.addOrGroup')
if (groups.length > 50) return 'any_of > 50'
for (const g of groups) {
const allOf = g?.all_of ?? []
if (allOf.length === 0) return t('admin.announcements.form.addAndCondition')
if (allOf.length > 50) return 'all_of > 50'
for (const c of allOf) {
if (c.type === 'subscription') {
if (!c.group_ids || c.group_ids.length === 0) return t('admin.announcements.form.selectPackages')
}
}
}
return ''
})
</script>
<template>
<BaseDialog :show="show" :title="t('admin.usage.cleanup.title')" width="wide" @close="handleClose">
<div class="space-y-4">
<UsageFilters
v-model="localFilters"
v-model:startDate="localStartDate"
v-model:endDate="localEndDate"
:exporting="false"
:show-actions="false"
@change="noop"
/>
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
{{ t('admin.usage.cleanup.warning') }}
</div>
<div class="rounded-xl border border-gray-200 p-4 dark:border-dark-700">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200">
{{ t('admin.usage.cleanup.recentTasks') }}
</h4>
<button type="button" class="btn btn-ghost btn-sm" @click="loadTasks">
{{ t('common.refresh') }}
</button>
</div>
<div class="mt-3 space-y-2">
<div v-if="tasksLoading" class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.usage.cleanup.loadingTasks') }}
</div>
<div v-else-if="tasks.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.usage.cleanup.noTasks') }}
</div>
<div v-else class="space-y-2">
<div
v-for="task in tasks"
:key="task.id"
class="flex flex-col gap-2 rounded-lg border border-gray-100 px-3 py-2 text-sm text-gray-600 dark:border-dark-700 dark:text-gray-300"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2">
<span :class="statusClass(task.status)" class="rounded-full px-2 py-0.5 text-xs font-semibold">
{{ statusLabel(task.status) }}
</span>
<span class="text-xs text-gray-400">#{{ task.id }}</span>
<button
v-if="canCancel(task)"
type="button"
class="btn btn-ghost btn-xs text-rose-600 hover:text-rose-700 dark:text-rose-300"
@click="openCancelConfirm(task)"
>
{{ t('admin.usage.cleanup.cancel') }}
</button>
</div>
<div class="text-xs text-gray-400">
{{ formatDateTime(task.created_at) }}
</div>
</div>
<div class="flex flex-wrap items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span>{{ t('admin.usage.cleanup.range') }}: {{ formatRange(task) }}</span>
<span>{{ t('admin.usage.cleanup.deletedRows') }}: {{ task.deleted_rows.toLocaleString() }}</span>
</div>
<div v-if="task.error_message" class="text-xs text-rose-500">
{{ task.error_message }}
</div>
</div>
</div>
</div>
<Pagination
v-if="tasksTotal > tasksPageSize"
class="mt-4"
:total="tasksTotal"
:page="tasksPage"
:page-size="tasksPageSize"
:page-size-options="[5]"
:show-page-size-selector="false"
:show-jump="true"
@update:page="handleTaskPageChange"
@update:pageSize="handleTaskPageSizeChange"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="handleClose">
{{ t('common.cancel') }}
</button>
<button type="button" class="btn btn-danger" :disabled="submitting" @click="openConfirm">
{{ submitting ? t('admin.usage.cleanup.submitting') : t('admin.usage.cleanup.submit') }}
</button>
</div>
</template>
</BaseDialog>
<ConfirmDialog
:show="confirmVisible"
:title="t('admin.usage.cleanup.confirmTitle')"
:message="t('admin.usage.cleanup.confirmMessage')"
:confirm-text="t('admin.usage.cleanup.confirmSubmit')"
danger
@confirm="submitCleanup"
@cancel="confirmVisible = false"
/>
<ConfirmDialog
:show="cancelConfirmVisible"
:title="t('admin.usage.cleanup.cancelConfirmTitle')"
:message="t('admin.usage.cleanup.cancelConfirmMessage')"
:confirm-text="t('admin.usage.cleanup.cancelConfirm')"
danger
@confirm="cancelTask"
@cancel="cancelConfirmVisible = false"
/>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Pagination from '@/components/common/Pagination.vue'
import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
import { adminUsageAPI } from '@/api/admin/usage'
import type { AdminUsageQueryParams, UsageCleanupTask, CreateUsageCleanupTaskRequest } from '@/api/admin/usage'
interface Props {
show: boolean
filters: AdminUsageQueryParams
startDate: string
endDate: string
}
const props = defineProps<Props>()
const emit = defineEmits(['close'])
const { t } = useI18n()
const appStore = useAppStore()
const localFilters = ref<AdminUsageQueryParams>({})
const localStartDate = ref('')
const localEndDate = ref('')
const tasks = ref<UsageCleanupTask[]>([])
const tasksLoading = ref(false)
const tasksPage = ref(1)
const tasksPageSize = ref(5)
const tasksTotal = ref(0)
const submitting = ref(false)
const confirmVisible = ref(false)
const cancelConfirmVisible = ref(false)
const canceling = ref(false)
const cancelTarget = ref<UsageCleanupTask | null>(null)
let pollTimer: number | null = null
const noop = () => {}
const resetFilters = () => {
localFilters.value = { ...props.filters }
localStartDate.value = props.startDate
localEndDate.value = props.endDate
localFilters.value.start_date = localStartDate.value
localFilters.value.end_date = localEndDate.value
tasksPage.value = 1
tasksTotal.value = 0
}
const startPolling = () => {
stopPolling()
pollTimer = window.setInterval(() => {
loadTasks()
}, 10000)
}
const stopPolling = () => {
if (pollTimer !== null) {
window.clearInterval(pollTimer)
pollTimer = null
}
}
const handleClose = () => {
stopPolling()
confirmVisible.value = false
cancelConfirmVisible.value = false
canceling.value = false
cancelTarget.value = null
submitting.value = false
emit('close')
}
const statusLabel = (status: string) => {
const map: Record<string, string> = {
pending: t('admin.usage.cleanup.status.pending'),
running: t('admin.usage.cleanup.status.running'),
succeeded: t('admin.usage.cleanup.status.succeeded'),
failed: t('admin.usage.cleanup.status.failed'),
canceled: t('admin.usage.cleanup.status.canceled')
}
return map[status] || status
}
const statusClass = (status: string) => {
const map: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
running: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-200',
succeeded: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200',
failed: 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200',
canceled: 'bg-gray-200 text-gray-600 dark:bg-dark-600 dark:text-gray-300'
}
return map[status] || 'bg-gray-100 text-gray-600'
}
const formatDateTime = (value?: string | null) => {
if (!value) return '--'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
const formatRange = (task: UsageCleanupTask) => {
const start = formatDateTime(task.filters.start_time)
const end = formatDateTime(task.filters.end_time)
return `${start} ~ ${end}`
}
const getUserTimezone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone
} catch {
return 'UTC'
}
}
const loadTasks = async () => {
if (!props.show) return
tasksLoading.value = true
try {
const res = await adminUsageAPI.listCleanupTasks({
page: tasksPage.value,
page_size: tasksPageSize.value
})
tasks.value = res.items || []
tasksTotal.value = res.total || 0
if (res.page) {
tasksPage.value = res.page
}
if (res.page_size) {
tasksPageSize.value = res.page_size
}
} catch (error) {
console.error('Failed to load cleanup tasks:', error)
appStore.showError(t('admin.usage.cleanup.loadFailed'))
} finally {
tasksLoading.value = false
}
}
const handleTaskPageChange = (page: number) => {
tasksPage.value = page
loadTasks()
}
const handleTaskPageSizeChange = (size: number) => {
if (!Number.isFinite(size) || size <= 0) return
tasksPageSize.value = size
tasksPage.value = 1
loadTasks()
}
const openConfirm = () => {
confirmVisible.value = true
}
const canCancel = (task: UsageCleanupTask) => {
return task.status === 'pending' || task.status === 'running'
}
const openCancelConfirm = (task: UsageCleanupTask) => {
cancelTarget.value = task
cancelConfirmVisible.value = true
}
const buildPayload = (): CreateUsageCleanupTaskRequest | null => {
if (!localStartDate.value || !localEndDate.value) {
appStore.showError(t('admin.usage.cleanup.missingRange'))
return null
}
const payload: CreateUsageCleanupTaskRequest = {
start_date: localStartDate.value,
end_date: localEndDate.value,
timezone: getUserTimezone()
}
if (localFilters.value.user_id && localFilters.value.user_id > 0) {
payload.user_id = localFilters.value.user_id
}
if (localFilters.value.api_key_id && localFilters.value.api_key_id > 0) {
payload.api_key_id = localFilters.value.api_key_id
}
if (localFilters.value.account_id && localFilters.value.account_id > 0) {
payload.account_id = localFilters.value.account_id
}
if (localFilters.value.group_id && localFilters.value.group_id > 0) {
payload.group_id = localFilters.value.group_id
}
if (localFilters.value.model) {
payload.model = localFilters.value.model
}
if (localFilters.value.stream !== null && localFilters.value.stream !== undefined) {
payload.stream = localFilters.value.stream
}
if (localFilters.value.billing_type !== null && localFilters.value.billing_type !== undefined) {
payload.billing_type = localFilters.value.billing_type
}
return payload
}
const submitCleanup = async () => {
const payload = buildPayload()
if (!payload) {
confirmVisible.value = false
return
}
submitting.value = true
confirmVisible.value = false
try {
await adminUsageAPI.createCleanupTask(payload)
appStore.showSuccess(t('admin.usage.cleanup.submitSuccess'))
loadTasks()
} catch (error) {
console.error('Failed to create cleanup task:', error)
appStore.showError(t('admin.usage.cleanup.submitFailed'))
} finally {
submitting.value = false
}
}
const cancelTask = async () => {
const task = cancelTarget.value
if (!task) {
cancelConfirmVisible.value = false
return
}
canceling.value = true
cancelConfirmVisible.value = false
try {
await adminUsageAPI.cancelCleanupTask(task.id)
appStore.showSuccess(t('admin.usage.cleanup.cancelSuccess'))
loadTasks()
} catch (error) {
console.error('Failed to cancel cleanup task:', error)
appStore.showError(t('admin.usage.cleanup.cancelFailed'))
} finally {
canceling.value = false
cancelTarget.value = null
}
}
watch(
() => props.show,
(show) => {
if (show) {
resetFilters()
loadTasks()
startPolling()
} else {
stopPolling()
}
}
)
onUnmounted(() => {
stopPolling()
})
</script>
......@@ -127,6 +127,12 @@
<Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" />
</div>
<!-- Billing Type Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.billingType') }}</label>
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
</div>
<!-- Group Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.group') }}</label>
......@@ -147,10 +153,13 @@
</div>
<!-- Right: actions -->
<div class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
<div v-if="showActions" class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
{{ t('common.reset') }}
</button>
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
{{ t('admin.usage.cleanup.button') }}
</button>
<button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
{{ t('usage.exportExcel') }}
</button>
......@@ -174,16 +183,20 @@ interface Props {
exporting: boolean
startDate: string
endDate: string
showActions?: boolean
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
showActions: true
})
const emit = defineEmits([
'update:modelValue',
'update:startDate',
'update:endDate',
'change',
'reset',
'export'
'export',
'cleanup'
])
const { t } = useI18n()
......@@ -221,6 +234,12 @@ const streamTypeOptions = ref<SelectOption[]>([
{ value: false, label: t('usage.sync') }
])
const billingTypeOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allBillingTypes') },
{ value: 0, label: t('admin.usage.billingTypeBalance') },
{ value: 1, label: t('admin.usage.billingTypeSubscription') }
])
const emitChange = () => emit('change')
const updateStartDate = (value: string) => {
......
......@@ -239,7 +239,7 @@ import { formatDateTime } from '@/utils/format'
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
import type { UsageLog } from '@/types'
import type { AdminUsageLog } from '@/types'
defineProps(['data', 'loading'])
const { t } = useI18n()
......@@ -247,12 +247,12 @@ const { t } = useI18n()
// Tooltip state - cost
const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 })
const tooltipData = ref<UsageLog | null>(null)
const tooltipData = ref<AdminUsageLog | null>(null)
// Tooltip state - token
const tokenTooltipVisible = ref(false)
const tokenTooltipPosition = ref({ x: 0, y: 0 })
const tokenTooltipData = ref<UsageLog | null>(null)
const tokenTooltipData = ref<AdminUsageLog | null>(null)
const cols = computed(() => [
{ key: 'user', label: t('admin.usage.user'), sortable: false },
......@@ -296,7 +296,7 @@ const formatDuration = (ms: number | null | undefined): string => {
}
// Cost tooltip functions
const showTooltip = (event: MouseEvent, row: UsageLog) => {
const showTooltip = (event: MouseEvent, row: AdminUsageLog) => {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
tooltipData.value = row
......@@ -311,7 +311,7 @@ const hideTooltip = () => {
}
// Token tooltip functions
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
const showTokenTooltip = (event: MouseEvent, row: AdminUsageLog) => {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
tokenTooltipData.value = row
......
......@@ -39,10 +39,10 @@ import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { User, Group } from '@/types'
import type { AdminUser, Group } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
const props = defineProps<{ show: boolean, user: User | null }>()
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
const groups = ref<Group[]>([]); const selectedIds = ref<number[]>([]); const loading = ref(false); const submitting = ref(false)
......@@ -56,4 +56,4 @@ const handleSave = async () => {
appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close')
} catch (error) { console.error('Failed to update allowed groups:', error) } finally { submitting.value = false }
}
</script>
\ No newline at end of file
</script>
......@@ -32,10 +32,10 @@ import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
import type { User, ApiKey } from '@/types'
import type { AdminUser, ApiKey } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
const props = defineProps<{ show: boolean, user: User | null }>()
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
defineEmits(['close']); const { t } = useI18n()
const apiKeys = ref<ApiKey[]>([]); const loading = ref(false)
......@@ -44,4 +44,4 @@ const load = async () => {
if (!props.user) return; loading.value = true
try { const res = await adminAPI.users.getUserApiKeys(props.user.id); apiKeys.value = res.items || [] } catch (error) { console.error('Failed to load API keys:', error) } finally { loading.value = false }
}
</script>
\ No newline at end of file
</script>
......@@ -29,10 +29,10 @@ import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { User } from '@/types'
import type { AdminUser } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
const props = defineProps<{ show: boolean, user: User | null, operation: 'add' | 'subtract' }>()
const props = defineProps<{ show: boolean, user: AdminUser | null, operation: 'add' | 'subtract' }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
const submitting = ref(false); const form = reactive({ amount: 0, notes: '' })
......
......@@ -56,12 +56,12 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
import type { User, UserAttributeValuesMap } from '@/types'
import type { AdminUser, UserAttributeValuesMap } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
import Icon from '@/components/icons/Icon.vue'
const props = defineProps<{ show: boolean, user: User | null }>()
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
const emit = defineEmits(['close', 'success'])
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
......
<template>
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<div class="fixed inset-0 bg-black/50 transition-opacity"></div>
<div class="relative w-full max-w-md transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-dark-800">
<!-- Header -->
<div class="mb-6 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
<svg class="h-6 w-6 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<h3 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">
{{ t('profile.totp.loginTitle') }}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.totp.loginHint') }}
</p>
<p v-if="userEmailMasked" class="mt-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ userEmailMasked }}
</p>
</div>
<!-- Code Input -->
<div class="mb-6">
<div class="flex justify-center gap-2">
<input
v-for="(_, index) in 6"
:key="index"
:ref="(el) => setInputRef(el, index)"
type="text"
maxlength="1"
inputmode="numeric"
pattern="[0-9]"
class="h-12 w-10 rounded-lg border border-gray-300 text-center text-lg font-semibold focus:border-primary-500 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
:disabled="verifying"
@input="handleCodeInput($event, index)"
@keydown="handleKeydown($event, index)"
@paste="handlePaste"
/>
</div>
<!-- Loading indicator -->
<div v-if="verifying" class="mt-3 flex items-center justify-center gap-2 text-sm text-gray-500">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-500"></div>
{{ t('common.verifying') }}
</div>
</div>
<!-- Error -->
<div v-if="error" class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
{{ error }}
</div>
<!-- Cancel button only -->
<button
type="button"
class="btn btn-secondary w-full"
:disabled="verifying"
@click="$emit('cancel')"
>
{{ t('common.cancel') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
defineProps<{
tempToken: string
userEmailMasked?: string
}>()
const emit = defineEmits<{
verify: [code: string]
cancel: []
}>()
const { t } = useI18n()
const verifying = ref(false)
const error = ref('')
const code = ref<string[]>(['', '', '', '', '', ''])
const inputRefs = ref<(HTMLInputElement | null)[]>([])
// Watch for code changes and auto-submit when 6 digits are entered
watch(
() => code.value.join(''),
(newCode) => {
if (newCode.length === 6 && !verifying.value) {
emit('verify', newCode)
}
}
)
defineExpose({
setVerifying: (value: boolean) => { verifying.value = value },
setError: (message: string) => {
error.value = message
code.value = ['', '', '', '', '', '']
// Clear input DOM values
inputRefs.value.forEach(input => {
if (input) input.value = ''
})
nextTick(() => {
inputRefs.value[0]?.focus()
})
}
})
const setInputRef = (el: any, index: number) => {
inputRefs.value[index] = el as HTMLInputElement | null
}
const handleCodeInput = (event: Event, index: number) => {
const input = event.target as HTMLInputElement
const value = input.value.replace(/[^0-9]/g, '')
code.value[index] = value
if (value && index < 5) {
nextTick(() => {
inputRefs.value[index + 1]?.focus()
})
}
}
const handleKeydown = (event: KeyboardEvent, index: number) => {
if (event.key === 'Backspace') {
const input = event.target as HTMLInputElement
// If current cell is empty and not the first, move to previous cell
if (!input.value && index > 0) {
event.preventDefault()
inputRefs.value[index - 1]?.focus()
}
// Otherwise, let the browser handle the backspace naturally
// The input event will sync code.value via handleCodeInput
}
}
const handlePaste = (event: ClipboardEvent) => {
event.preventDefault()
const pastedData = event.clipboardData?.getData('text') || ''
const digits = pastedData.replace(/[^0-9]/g, '').slice(0, 6).split('')
// Update both the ref and the input elements
digits.forEach((digit, index) => {
code.value[index] = digit
if (inputRefs.value[index]) {
inputRefs.value[index]!.value = digit
}
})
// Clear remaining inputs if pasted less than 6 digits
for (let i = digits.length; i < 6; i++) {
code.value[i] = ''
if (inputRefs.value[i]) {
inputRefs.value[i]!.value = ''
}
}
const focusIndex = Math.min(digits.length, 5)
nextTick(() => {
inputRefs.value[focusIndex]?.focus()
})
}
onMounted(() => {
nextTick(() => {
inputRefs.value[0]?.focus()
})
})
</script>
<template>
<div>
<!-- 铃铛按钮 -->
<button
@click="openModal"
class="relative flex h-9 w-9 items-center justify-center rounded-lg text-gray-600 transition-all hover:bg-gray-100 hover:scale-105 dark:text-gray-400 dark:hover:bg-dark-800"
:class="{ 'text-blue-600 dark:text-blue-400': unreadCount > 0 }"
:aria-label="t('announcements.title')"
>
<Icon name="bell" size="md" />
<!-- 未读红点 -->
<span
v-if="unreadCount > 0"
class="absolute right-1 top-1 flex h-2 w-2"
>
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75"></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</span>
</button>
<!-- 公告列表 Modal -->
<Teleport to="body">
<Transition name="modal-fade">
<div
v-if="isModalOpen"
class="fixed inset-0 z-[100] flex items-start justify-center overflow-y-auto bg-gradient-to-br from-black/70 via-black/60 to-black/70 p-4 pt-[8vh] backdrop-blur-md"
@click="closeModal"
>
<div
class="w-full max-w-[620px] overflow-hidden rounded-3xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
@click.stop
>
<!-- Header with Gradient -->
<div class="relative overflow-hidden border-b border-gray-100/80 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 px-6 py-5 dark:border-dark-700/50 dark:from-blue-900/10 dark:to-indigo-900/5">
<div class="relative z-10 flex items-start justify-between">
<div>
<div class="flex items-center gap-2">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-500/30">
<Icon name="bell" size="sm" />
</div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('announcements.title') }}
</h2>
</div>
<p v-if="unreadCount > 0" class="mt-2 text-sm text-gray-600 dark:text-gray-400">
<span class="font-medium text-blue-600 dark:text-blue-400">{{ unreadCount }}</span>
{{ t('announcements.unread') }}
</p>
</div>
<div class="flex items-center gap-2">
<button
v-if="unreadCount > 0"
@click="markAllAsRead"
:disabled="loading"
class="rounded-lg bg-blue-600 px-4 py-2 text-xs font-medium text-white shadow-lg shadow-blue-500/30 transition-all hover:bg-blue-700 hover:shadow-xl disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
>
{{ t('announcements.markAllRead') }}
</button>
<button
@click="closeModal"
class="flex h-9 w-9 items-center justify-center rounded-lg bg-white/50 text-gray-500 backdrop-blur-sm transition-all hover:bg-white hover:text-gray-700 dark:bg-dark-700/50 dark:text-gray-400 dark:hover:bg-dark-700 dark:hover:text-gray-300"
:aria-label="t('common.close')"
>
<Icon name="x" size="sm" />
</button>
</div>
</div>
<!-- Decorative gradient -->
<div class="absolute right-0 top-0 h-full w-48 bg-gradient-to-l from-indigo-100/20 to-transparent dark:from-indigo-900/10"></div>
</div>
<!-- Body -->
<div class="max-h-[65vh] overflow-y-auto">
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16">
<div class="relative">
<div class="h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-blue-600 dark:border-dark-600 dark:border-t-blue-400"></div>
<div class="absolute inset-0 h-12 w-12 animate-pulse rounded-full border-4 border-blue-400/30"></div>
</div>
</div>
<!-- Announcements List -->
<div v-else-if="announcements.length > 0">
<div
v-for="item in announcements"
:key="item.id"
class="group relative flex items-center gap-4 border-b border-gray-100 px-6 py-4 transition-all hover:bg-gray-50 dark:border-dark-700 dark:hover:bg-dark-700/30"
:class="{ 'bg-blue-50/30 dark:bg-blue-900/5': !item.read_at }"
style="min-height: 72px"
@click="openDetail(item)"
>
<!-- Status Indicator -->
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center">
<div
v-if="!item.read_at"
class="relative flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-500/30"
>
<!-- Pulse ring -->
<span class="absolute inline-flex h-full w-full animate-ping rounded-xl bg-blue-400 opacity-75"></span>
<!-- Icon -->
<svg class="relative z-10 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div
v-else
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gray-100 text-gray-400 dark:bg-dark-700 dark:text-gray-600"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<!-- Content -->
<div class="flex min-w-0 flex-1 items-center justify-between gap-4">
<div class="min-w-0 flex-1">
<h3 class="truncate text-sm font-medium text-gray-900 dark:text-white">
{{ item.title }}
</h3>
<div class="mt-1 flex items-center gap-2">
<time class="text-xs text-gray-500 dark:text-gray-400">
{{ formatRelativeTime(item.created_at) }}
</time>
<span
v-if="!item.read_at"
class="inline-flex items-center gap-1 rounded-md bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
>
<span class="relative flex h-1.5 w-1.5">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-500 opacity-75"></span>
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-blue-600"></span>
</span>
{{ t('announcements.unread') }}
</span>
</div>
</div>
<!-- Arrow -->
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-gray-400 transition-transform group-hover:translate-x-1 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
<!-- Unread indicator bar -->
<div
v-if="!item.read_at"
class="absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-blue-500 to-indigo-600"
></div>
</div>
</div>
<!-- Empty State -->
<div v-else class="flex flex-col items-center justify-center py-16">
<div class="relative mb-4">
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600">
<Icon name="inbox" size="xl" class="text-gray-400 dark:text-gray-500" />
</div>
<div class="absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-white">
<svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</div>
</div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('announcements.empty') }}</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('announcements.emptyDescription') }}</p>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- 公告详情 Modal -->
<Teleport to="body">
<Transition name="modal-fade">
<div
v-if="detailModalOpen && selectedAnnouncement"
class="fixed inset-0 z-[110] flex items-start justify-center overflow-y-auto bg-gradient-to-br from-black/70 via-black/60 to-black/70 p-4 pt-[6vh] backdrop-blur-md"
@click="closeDetail"
>
<div
class="w-full max-w-[780px] overflow-hidden rounded-3xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
@click.stop
>
<!-- Header with Decorative Elements -->
<div class="relative overflow-hidden border-b border-gray-100 bg-gradient-to-br from-blue-50/80 via-indigo-50/50 to-purple-50/30 px-8 py-6 dark:border-dark-700 dark:from-blue-900/20 dark:via-indigo-900/10 dark:to-purple-900/5">
<!-- Decorative background elements -->
<div class="absolute right-0 top-0 h-full w-64 bg-gradient-to-l from-indigo-100/30 to-transparent dark:from-indigo-900/20"></div>
<div class="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-gradient-to-br from-blue-400/20 to-indigo-500/20 blur-3xl"></div>
<div class="absolute -left-4 -bottom-4 h-24 w-24 rounded-full bg-gradient-to-tr from-purple-400/20 to-pink-500/20 blur-2xl"></div>
<div class="relative z-10 flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<!-- Icon and Category -->
<div class="mb-3 flex items-center gap-2">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-500/30">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="flex items-center gap-2">
<span class="rounded-lg bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
{{ t('announcements.title') }}
</span>
<span
v-if="!selectedAnnouncement.read_at"
class="inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-600 px-2.5 py-1 text-xs font-medium text-white shadow-lg shadow-blue-500/30"
>
<span class="relative flex h-2 w-2">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-white opacity-75"></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-white"></span>
</span>
{{ t('announcements.unread') }}
</span>
</div>
</div>
<!-- Title -->
<h2 class="mb-3 text-2xl font-bold leading-tight text-gray-900 dark:text-white">
{{ selectedAnnouncement.title }}
</h2>
<!-- Meta Info -->
<div class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<div class="flex items-center gap-1.5">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<time>{{ formatRelativeWithDateTime(selectedAnnouncement.created_at) }}</time>
</div>
<div class="flex items-center gap-1.5">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span>{{ selectedAnnouncement.read_at ? t('announcements.read') : t('announcements.unread') }}</span>
</div>
</div>
</div>
<!-- Close button -->
<button
@click="closeDetail"
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-white/50 text-gray-500 backdrop-blur-sm transition-all hover:bg-white hover:text-gray-700 hover:shadow-lg dark:bg-dark-700/50 dark:text-gray-400 dark:hover:bg-dark-700 dark:hover:text-gray-300"
:aria-label="t('common.close')"
>
<Icon name="x" size="md" />
</button>
</div>
</div>
<!-- Body with Enhanced Markdown -->
<div class="max-h-[60vh] overflow-y-auto bg-white px-8 py-8 dark:bg-dark-800">
<!-- Content with decorative border -->
<div class="relative">
<!-- Decorative left border -->
<div class="absolute left-0 top-0 bottom-0 w-1 rounded-full bg-gradient-to-b from-blue-500 via-indigo-500 to-purple-500"></div>
<div class="pl-6">
<div
class="markdown-body prose prose-sm max-w-none dark:prose-invert"
v-html="renderMarkdown(selectedAnnouncement.content)"
></div>
</div>
</div>
</div>
<!-- Footer with Actions -->
<div class="border-t border-gray-100 bg-gray-50/50 px-8 py-5 dark:border-dark-700 dark:bg-dark-900/30">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ selectedAnnouncement.read_at ? t('announcements.readStatus') : t('announcements.markReadHint') }}</span>
</div>
<div class="flex items-center gap-3">
<button
@click="closeDetail"
class="rounded-xl border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-gray-700 shadow-sm transition-all hover:bg-gray-50 hover:shadow dark:border-dark-600 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
>
{{ t('common.close') }}
</button>
<button
v-if="!selectedAnnouncement.read_at"
@click="markAsReadAndClose(selectedAnnouncement.id)"
class="rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 px-5 py-2.5 text-sm font-medium text-white shadow-lg shadow-blue-500/30 transition-all hover:shadow-xl hover:scale-105"
>
<span class="flex items-center gap-2">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{{ t('announcements.markRead') }}
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { announcementsAPI } from '@/api'
import { useAppStore } from '@/stores/app'
import { formatRelativeTime, formatRelativeWithDateTime } from '@/utils/format'
import type { UserAnnouncement } from '@/types'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const appStore = useAppStore()
// Configure marked
marked.setOptions({
breaks: true,
gfm: true,
})
// State
const announcements = ref<UserAnnouncement[]>([])
const isModalOpen = ref(false)
const detailModalOpen = ref(false)
const selectedAnnouncement = ref<UserAnnouncement | null>(null)
const loading = ref(false)
// Computed
const unreadCount = computed(() =>
announcements.value.filter((a) => !a.read_at).length
)
// Methods
function renderMarkdown(content: string): string {
if (!content) return ''
const html = marked.parse(content) as string
return DOMPurify.sanitize(html)
}
async function loadAnnouncements() {
try {
loading.value = true
const allAnnouncements = await announcementsAPI.list(false)
announcements.value = allAnnouncements.slice(0, 20)
} catch (err: any) {
console.error('Failed to load announcements:', err)
appStore.showError(err?.message || t('common.unknownError'))
} finally {
loading.value = false
}
}
function openModal() {
isModalOpen.value = true
if (announcements.value.length === 0) {
loadAnnouncements()
}
}
function closeModal() {
isModalOpen.value = false
}
function openDetail(announcement: UserAnnouncement) {
selectedAnnouncement.value = announcement
detailModalOpen.value = true
if (!announcement.read_at) {
markAsRead(announcement.id)
}
}
function closeDetail() {
detailModalOpen.value = false
selectedAnnouncement.value = null
}
async function markAsRead(id: number) {
try {
await announcementsAPI.markRead(id)
const announcement = announcements.value.find((a) => a.id === id)
if (announcement) {
announcement.read_at = new Date().toISOString()
}
if (selectedAnnouncement.value?.id === id) {
selectedAnnouncement.value.read_at = new Date().toISOString()
}
} catch (err: any) {
appStore.showError(err?.message || t('common.unknownError'))
}
}
async function markAsReadAndClose(id: number) {
await markAsRead(id)
appStore.showSuccess(t('announcements.markedAsRead'))
closeDetail()
}
async function markAllAsRead() {
try {
loading.value = true
const unreadAnnouncements = announcements.value.filter((a) => !a.read_at)
await Promise.all(unreadAnnouncements.map((a) => announcementsAPI.markRead(a.id)))
announcements.value.forEach((a) => {
if (!a.read_at) {
a.read_at = new Date().toISOString()
}
})
appStore.showSuccess(t('announcements.allMarkedAsRead'))
} catch (err: any) {
appStore.showError(err?.message || t('common.unknownError'))
} finally {
loading.value = false
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (detailModalOpen.value) {
closeDetail()
} else if (isModalOpen.value) {
closeModal()
}
}
}
onMounted(() => {
document.addEventListener('keydown', handleEscape)
loadAnnouncements()
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleEscape)
// Restore body overflow in case component is unmounted while modals are open
document.body.style.overflow = ''
})
watch([isModalOpen, detailModalOpen], ([modal, detail]) => {
if (modal || detail) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
</script>
<style scoped>
/* Modal Animations */
.modal-fade-enter-active {
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.modal-fade-leave-active {
transition: all 0.2s cubic-bezier(0.4, 0, 1, 1);
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.modal-fade-enter-from > div {
transform: scale(0.94) translateY(-12px);
opacity: 0;
}
.modal-fade-leave-to > div {
transform: scale(0.96) translateY(-8px);
opacity: 0;
}
/* Scrollbar Styling */
.overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, #cbd5e1, #94a3b8);
border-radius: 4px;
}
.dark .overflow-y-auto::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, #4b5563, #374151);
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom, #94a3b8, #64748b);
}
.dark .overflow-y-auto::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom, #6b7280, #4b5563);
}
</style>
<style>
/* Enhanced Markdown Styles */
.markdown-body {
@apply text-[15px] leading-[1.75];
@apply text-gray-700 dark:text-gray-300;
}
.markdown-body h1 {
@apply mb-6 mt-8 border-b border-gray-200 pb-3 text-3xl font-bold text-gray-900 dark:border-dark-600 dark:text-white;
}
.markdown-body h2 {
@apply mb-4 mt-7 border-b border-gray-100 pb-2 text-2xl font-bold text-gray-900 dark:border-dark-700 dark:text-white;
}
.markdown-body h3 {
@apply mb-3 mt-6 text-xl font-semibold text-gray-900 dark:text-white;
}
.markdown-body h4 {
@apply mb-2 mt-5 text-lg font-semibold text-gray-900 dark:text-white;
}
.markdown-body p {
@apply mb-4 leading-relaxed;
}
.markdown-body a {
@apply font-medium text-blue-600 underline decoration-blue-600/30 decoration-2 underline-offset-2 transition-all hover:decoration-blue-600 dark:text-blue-400 dark:decoration-blue-400/30 dark:hover:decoration-blue-400;
}
.markdown-body ul,
.markdown-body ol {
@apply mb-4 ml-6 space-y-2;
}
.markdown-body ul {
@apply list-disc;
}
.markdown-body ol {
@apply list-decimal;
}
.markdown-body li {
@apply leading-relaxed;
@apply pl-2;
}
.markdown-body li::marker {
@apply text-blue-600 dark:text-blue-400;
}
.markdown-body blockquote {
@apply relative my-5 border-l-4 border-blue-500 bg-blue-50/50 py-3 pl-5 pr-4 italic text-gray-700 dark:border-blue-400 dark:bg-blue-900/10 dark:text-gray-300;
}
.markdown-body blockquote::before {
content: '"';
@apply absolute -left-1 top-0 text-5xl font-serif text-blue-500/20 dark:text-blue-400/20;
}
.markdown-body code {
@apply rounded-lg bg-gray-100 px-2 py-1 text-[13px] font-mono text-pink-600 dark:bg-dark-700 dark:text-pink-400;
}
.markdown-body pre {
@apply my-5 overflow-x-auto rounded-xl border border-gray-200 bg-gray-50 p-5 dark:border-dark-600 dark:bg-dark-900/50;
}
.markdown-body pre code {
@apply bg-transparent p-0 text-[13px] text-gray-800 dark:text-gray-200;
}
.markdown-body hr {
@apply my-8 border-0 border-t-2 border-gray-200 dark:border-dark-700;
}
.markdown-body table {
@apply mb-5 w-full overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600;
}
.markdown-body th,
.markdown-body td {
@apply border-r border-b border-gray-200 px-4 py-3 text-left dark:border-dark-600;
}
.markdown-body th:last-child,
.markdown-body td:last-child {
@apply border-r-0;
}
.markdown-body tr:last-child td {
@apply border-b-0;
}
.markdown-body th {
@apply bg-gradient-to-br from-blue-50 to-indigo-50 font-semibold text-gray-900 dark:from-blue-900/20 dark:to-indigo-900/10 dark:text-white;
}
.markdown-body tbody tr {
@apply transition-colors hover:bg-gray-50 dark:hover:bg-dark-700/30;
}
.markdown-body img {
@apply my-5 max-w-full rounded-xl border border-gray-200 shadow-md dark:border-dark-600;
}
.markdown-body strong {
@apply font-semibold text-gray-900 dark:text-white;
}
.markdown-body em {
@apply italic text-gray-600 dark:text-gray-400;
}
</style>
......@@ -181,6 +181,10 @@ import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const emit = defineEmits<{
sort: [key: string, order: 'asc' | 'desc']
}>()
// 表格容器引用
const tableWrapperRef = ref<HTMLElement | null>(null)
const isScrollable = ref(false)
......@@ -279,18 +283,149 @@ interface Props {
expandableActions?: boolean
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
rowKey?: string | ((row: any) => string | number)
/**
* Default sort configuration (only applied when there is no persisted sort state)
*/
defaultSortKey?: string
defaultSortOrder?: 'asc' | 'desc'
/**
* Persist sort state (key + order) to localStorage using this key.
* If provided, DataTable will load the stored sort state on mount.
*/
sortStorageKey?: string
/**
* Enable server-side sorting mode. When true, clicking sort headers
* will emit 'sort' events instead of performing client-side sorting.
*/
serverSideSort?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
stickyFirstColumn: true,
stickyActionsColumn: true,
expandableActions: true
expandableActions: true,
defaultSortOrder: 'asc',
serverSideSort: false
})
const sortKey = ref<string>('')
const sortOrder = ref<'asc' | 'desc'>('asc')
const actionsExpanded = ref(false)
type PersistedSortState = {
key: string
order: 'asc' | 'desc'
}
const collator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base'
})
const getSortableKeys = () => {
const keys = new Set<string>()
for (const col of props.columns) {
if (col.sortable) keys.add(col.key)
}
return keys
}
const normalizeSortKey = (candidate: string) => {
if (!candidate) return ''
const sortableKeys = getSortableKeys()
return sortableKeys.has(candidate) ? candidate : ''
}
const normalizeSortOrder = (candidate: any): 'asc' | 'desc' => {
return candidate === 'desc' ? 'desc' : 'asc'
}
const readPersistedSortState = (): PersistedSortState | null => {
if (!props.sortStorageKey) return null
try {
const raw = localStorage.getItem(props.sortStorageKey)
if (!raw) return null
const parsed = JSON.parse(raw) as Partial<PersistedSortState>
const key = normalizeSortKey(typeof parsed.key === 'string' ? parsed.key : '')
if (!key) return null
return { key, order: normalizeSortOrder(parsed.order) }
} catch (e) {
console.error('[DataTable] Failed to read persisted sort state:', e)
return null
}
}
const writePersistedSortState = (state: PersistedSortState) => {
if (!props.sortStorageKey) return
try {
localStorage.setItem(props.sortStorageKey, JSON.stringify(state))
} catch (e) {
console.error('[DataTable] Failed to persist sort state:', e)
}
}
const resolveInitialSortState = (): PersistedSortState | null => {
const persisted = readPersistedSortState()
if (persisted) return persisted
const key = normalizeSortKey(props.defaultSortKey || '')
if (!key) return null
return { key, order: normalizeSortOrder(props.defaultSortOrder) }
}
const applySortState = (state: PersistedSortState | null) => {
if (!state) return
sortKey.value = state.key
sortOrder.value = state.order
}
const isNullishOrEmpty = (value: any) => value === null || value === undefined || value === ''
const toFiniteNumberOrNull = (value: any): number | null => {
if (typeof value === 'number') return Number.isFinite(value) ? value : null
if (typeof value === 'boolean') return value ? 1 : 0
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return null
const n = Number(trimmed)
return Number.isFinite(n) ? n : null
}
return null
}
const toSortableString = (value: any): string => {
if (value === null || value === undefined) return ''
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
if (value instanceof Date) return value.toISOString()
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
const compareSortValues = (a: any, b: any): number => {
const aEmpty = isNullishOrEmpty(a)
const bEmpty = isNullishOrEmpty(b)
if (aEmpty && bEmpty) return 0
if (aEmpty) return 1
if (bEmpty) return -1
const aNum = toFiniteNumberOrNull(a)
const bNum = toFiniteNumberOrNull(b)
if (aNum !== null && bNum !== null) {
if (aNum === bNum) return 0
return aNum < bNum ? -1 : 1
}
const aStr = toSortableString(a)
const bStr = toSortableString(b)
const res = collator.compare(aStr, bStr)
if (res === 0) return 0
return res < 0 ? -1 : 1
}
const resolveRowKey = (row: any, index: number) => {
if (typeof props.rowKey === 'function') {
const key = props.rowKey(row)
......@@ -323,26 +458,39 @@ watch(actionsExpanded, async () => {
})
const handleSort = (key: string) => {
let newOrder: 'asc' | 'desc' = 'asc'
if (sortKey.value === key) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
newOrder = sortOrder.value === 'asc' ? 'desc' : 'asc'
}
if (props.serverSideSort) {
// Server-side sort mode: emit event and update internal state for UI feedback
sortKey.value = key
sortOrder.value = newOrder
emit('sort', key, newOrder)
} else {
// Client-side sort mode: just update internal state
sortKey.value = key
sortOrder.value = 'asc'
sortOrder.value = newOrder
}
}
const sortedData = computed(() => {
if (!sortKey.value || !props.data) return props.data
return [...props.data].sort((a, b) => {
const aVal = a[sortKey.value]
const bVal = b[sortKey.value]
if (aVal === bVal) return 0
const comparison = aVal > bVal ? 1 : -1
return sortOrder.value === 'asc' ? comparison : -comparison
})
// Server-side sort mode: return data as-is (server handles sorting)
if (props.serverSideSort || !sortKey.value || !props.data) return props.data
const key = sortKey.value
const order = sortOrder.value
// Stable sort (tie-break with original index) to avoid jitter when values are equal.
return props.data
.map((row, index) => ({ row, index }))
.sort((a, b) => {
const cmp = compareSortValues(a.row?.[key], b.row?.[key])
if (cmp !== 0) return order === 'asc' ? cmp : -cmp
return a.index - b.index
})
.map(item => item.row)
})
const hasActionsColumn = computed(() => {
......@@ -396,6 +544,51 @@ const getAdaptivePaddingClass = () => {
return 'px-6' // 24px (原始值)
}
}
// Init + keep persisted sort state consistent with current columns
const didInitSort = ref(false)
onMounted(() => {
const initial = resolveInitialSortState()
applySortState(initial)
didInitSort.value = true
})
watch(
() => props.columns,
() => {
// If current sort key is no longer sortable/visible, fall back to default/persisted.
const normalized = normalizeSortKey(sortKey.value)
if (!sortKey.value) {
const initial = resolveInitialSortState()
applySortState(initial)
return
}
if (!normalized) {
const fallback = resolveInitialSortState()
if (fallback) {
applySortState(fallback)
} else {
sortKey.value = ''
sortOrder.value = 'asc'
}
}
},
{ deep: true }
)
watch(
[sortKey, sortOrder],
([nextKey, nextOrder]) => {
if (!didInitSort.value) return
if (!props.sortStorageKey) return
const key = normalizeSortKey(nextKey)
if (!key) return
writePersistedSortState({ key, order: normalizeSortOrder(nextOrder) })
},
{ flush: 'post' }
)
</script>
<style scoped>
......
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