Unverified Commit 2fe8932c authored by Call White's avatar Call White Committed by GitHub
Browse files

Merge pull request #3 from cyhhao/main

merge to main
parents 2f2e76f9 adb77af1
...@@ -566,7 +566,7 @@ ...@@ -566,7 +566,7 @@
</div> </div>
<div> <div>
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label> <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> <p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
</div> </div>
</div> </div>
...@@ -732,6 +732,60 @@ ...@@ -732,6 +732,60 @@
</div> </div>
</div> </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>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
...@@ -829,7 +883,7 @@ import { useI18n } from 'vue-i18n' ...@@ -829,7 +883,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin' 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 BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
...@@ -847,7 +901,7 @@ interface Props { ...@@ -847,7 +901,7 @@ interface Props {
show: boolean show: boolean
account: Account | null account: Account | null
proxies: Proxy[] proxies: Proxy[]
groups: Group[] groups: AdminGroup[]
} }
const props = defineProps<Props>() const props = defineProps<Props>()
...@@ -904,6 +958,8 @@ const windowCostStickyReserve = ref<number | null>(null) ...@@ -904,6 +958,8 @@ const windowCostStickyReserve = ref<number | null>(null)
const sessionLimitEnabled = ref(false) const sessionLimitEnabled = ref(false)
const maxSessions = ref<number | null>(null) const maxSessions = ref<number | null>(null)
const sessionIdleTimeout = 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 // Computed: current preset mappings based on platform
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic')) const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
...@@ -1237,6 +1293,8 @@ function loadQuotaControlSettings(account: Account) { ...@@ -1237,6 +1293,8 @@ function loadQuotaControlSettings(account: Account) {
sessionLimitEnabled.value = false sessionLimitEnabled.value = false
maxSessions.value = null maxSessions.value = null
sessionIdleTimeout.value = null sessionIdleTimeout.value = null
tlsFingerprintEnabled.value = false
sessionIdMaskingEnabled.value = false
// Only applies to Anthropic OAuth/SetupToken accounts // Only applies to Anthropic OAuth/SetupToken accounts
if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) { if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) {
...@@ -1255,6 +1313,16 @@ function loadQuotaControlSettings(account: Account) { ...@@ -1255,6 +1313,16 @@ function loadQuotaControlSettings(account: Account) {
maxSessions.value = account.max_sessions maxSessions.value = account.max_sessions
sessionIdleTimeout.value = account.session_idle_timeout_minutes ?? 5 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) { function formatTempUnschedKeywords(value: unknown) {
...@@ -1407,6 +1475,20 @@ const handleSubmit = async () => { ...@@ -1407,6 +1475,20 @@ const handleSubmit = async () => {
delete newExtra.session_idle_timeout_minutes 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 updatePayload.extra = newExtra
} }
......
<template> <template>
<Teleport to="body"> <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 v-if="show && position">
<div class="py-1"> <!-- Backdrop: click anywhere outside to close -->
<template v-if="account"> <div class="fixed inset-0 z-[9998]" @click="emit('close')"></div>
<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"> <div
<Icon name="play" size="sm" class="text-green-500" :stroke-width="2" /> 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"
{{ t('admin.accounts.testConnection') }} :style="{ top: position.top + 'px', left: position.left + 'px' }"
</button> @click.stop
<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" /> <div class="py-1">
{{ t('admin.accounts.viewStats') }} <template v-if="account">
</button> <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">
<template v-if="account.type === 'oauth' || account.type === 'setup-token'"> <Icon name="play" size="sm" class="text-green-500" :stroke-width="2" />
<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"> {{ t('admin.accounts.testConnection') }}
<Icon name="link" size="sm" />
{{ t('admin.accounts.reAuthorize') }}
</button> </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"> <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="refresh" size="sm" /> <Icon name="chart" size="sm" class="text-indigo-500" />
{{ t('admin.accounts.refreshToken') }} {{ 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> </button>
</template> </template>
<div v-if="account.status === 'error' || isRateLimited || isOverloaded" class="my-1 border-t border-gray-100 dark:border-dark-700"></div> </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> </div>
</Teleport> </Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Icon } from '@/components/icons' import { Icon } from '@/components/icons'
import type { Account } from '@/types' import type { Account } from '@/types'
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>() 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 { t } = useI18n()
const isRateLimited = computed(() => props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date()) 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 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> </script>
<template> <template>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<slot name="before"></slot>
<button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary"> <button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary">
<Icon name="refresh" size="md" :class="[loading ? 'animate-spin' : '']" /> <Icon name="refresh" size="md" :class="[loading ? 'animate-spin' : '']" />
</button> </button>
<slot name="after"></slot>
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button> <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> <button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
</div> </div>
......
...@@ -232,8 +232,11 @@ const loadAvailableModels = async () => { ...@@ -232,8 +232,11 @@ const loadAvailableModels = async () => {
if (availableModels.value.length > 0) { if (availableModels.value.length > 0) {
if (props.account.platform === 'gemini') { if (props.account.platform === 'gemini') {
const preferred = 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-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 selectedModelId.value = preferred?.id || availableModels.value[0].id
} else { } else {
// Try to select Sonnet as default, otherwise use first model // Try to select Sonnet as default, otherwise use first model
......
<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 @@ ...@@ -127,6 +127,12 @@
<Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" /> <Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" />
</div> </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 --> <!-- Group Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]"> <div class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.group') }}</label> <label class="input-label">{{ t('admin.usage.group') }}</label>
...@@ -147,10 +153,13 @@ ...@@ -147,10 +153,13 @@
</div> </div>
<!-- Right: actions --> <!-- 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"> <button type="button" @click="$emit('reset')" class="btn btn-secondary">
{{ t('common.reset') }} {{ t('common.reset') }}
</button> </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"> <button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
{{ t('usage.exportExcel') }} {{ t('usage.exportExcel') }}
</button> </button>
...@@ -174,16 +183,20 @@ interface Props { ...@@ -174,16 +183,20 @@ interface Props {
exporting: boolean exporting: boolean
startDate: string startDate: string
endDate: string endDate: string
showActions?: boolean
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), {
showActions: true
})
const emit = defineEmits([ const emit = defineEmits([
'update:modelValue', 'update:modelValue',
'update:startDate', 'update:startDate',
'update:endDate', 'update:endDate',
'change', 'change',
'reset', 'reset',
'export' 'export',
'cleanup'
]) ])
const { t } = useI18n() const { t } = useI18n()
...@@ -221,6 +234,12 @@ const streamTypeOptions = ref<SelectOption[]>([ ...@@ -221,6 +234,12 @@ const streamTypeOptions = ref<SelectOption[]>([
{ value: false, label: t('usage.sync') } { 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 emitChange = () => emit('change')
const updateStartDate = (value: string) => { const updateStartDate = (value: string) => {
......
...@@ -239,7 +239,7 @@ import { formatDateTime } from '@/utils/format' ...@@ -239,7 +239,7 @@ import { formatDateTime } from '@/utils/format'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import type { UsageLog } from '@/types' import type { AdminUsageLog } from '@/types'
defineProps(['data', 'loading']) defineProps(['data', 'loading'])
const { t } = useI18n() const { t } = useI18n()
...@@ -247,12 +247,12 @@ const { t } = useI18n() ...@@ -247,12 +247,12 @@ const { t } = useI18n()
// Tooltip state - cost // Tooltip state - cost
const tooltipVisible = ref(false) const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 }) const tooltipPosition = ref({ x: 0, y: 0 })
const tooltipData = ref<UsageLog | null>(null) const tooltipData = ref<AdminUsageLog | null>(null)
// Tooltip state - token // Tooltip state - token
const tokenTooltipVisible = ref(false) const tokenTooltipVisible = ref(false)
const tokenTooltipPosition = ref({ x: 0, y: 0 }) const tokenTooltipPosition = ref({ x: 0, y: 0 })
const tokenTooltipData = ref<UsageLog | null>(null) const tokenTooltipData = ref<AdminUsageLog | null>(null)
const cols = computed(() => [ const cols = computed(() => [
{ key: 'user', label: t('admin.usage.user'), sortable: false }, { key: 'user', label: t('admin.usage.user'), sortable: false },
...@@ -296,7 +296,7 @@ const formatDuration = (ms: number | null | undefined): string => { ...@@ -296,7 +296,7 @@ const formatDuration = (ms: number | null | undefined): string => {
} }
// Cost tooltip functions // Cost tooltip functions
const showTooltip = (event: MouseEvent, row: UsageLog) => { const showTooltip = (event: MouseEvent, row: AdminUsageLog) => {
const target = event.currentTarget as HTMLElement const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect() const rect = target.getBoundingClientRect()
tooltipData.value = row tooltipData.value = row
...@@ -311,7 +311,7 @@ const hideTooltip = () => { ...@@ -311,7 +311,7 @@ const hideTooltip = () => {
} }
// Token tooltip functions // Token tooltip functions
const showTokenTooltip = (event: MouseEvent, row: UsageLog) => { const showTokenTooltip = (event: MouseEvent, row: AdminUsageLog) => {
const target = event.currentTarget as HTMLElement const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect() const rect = target.getBoundingClientRect()
tokenTooltipData.value = row tokenTooltipData.value = row
......
...@@ -39,10 +39,10 @@ import { ref, watch } from 'vue' ...@@ -39,10 +39,10 @@ import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { User, Group } from '@/types' import type { AdminUser, Group } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue' 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 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) const groups = ref<Group[]>([]); const selectedIds = ref<number[]>([]); const loading = ref(false); const submitting = ref(false)
...@@ -56,4 +56,4 @@ const handleSave = async () => { ...@@ -56,4 +56,4 @@ const handleSave = async () => {
appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close') appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close')
} catch (error) { console.error('Failed to update allowed groups:', error) } finally { submitting.value = false } } catch (error) { console.error('Failed to update allowed groups:', error) } finally { submitting.value = false }
} }
</script> </script>
\ No newline at end of file
...@@ -32,10 +32,10 @@ import { ref, watch } from 'vue' ...@@ -32,10 +32,10 @@ import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
import type { User, ApiKey } from '@/types' import type { AdminUser, ApiKey } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue' 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() defineEmits(['close']); const { t } = useI18n()
const apiKeys = ref<ApiKey[]>([]); const loading = ref(false) const apiKeys = ref<ApiKey[]>([]); const loading = ref(false)
...@@ -44,4 +44,4 @@ const load = async () => { ...@@ -44,4 +44,4 @@ const load = async () => {
if (!props.user) return; loading.value = true 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 } 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> </script>
\ No newline at end of file
...@@ -29,10 +29,10 @@ import { reactive, ref, watch } from 'vue' ...@@ -29,10 +29,10 @@ import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { User } from '@/types' import type { AdminUser } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue' 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 emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
const submitting = ref(false); const form = reactive({ amount: 0, notes: '' }) const submitting = ref(false); const form = reactive({ amount: 0, notes: '' })
......
...@@ -56,12 +56,12 @@ import { useI18n } from 'vue-i18n' ...@@ -56,12 +56,12 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin' 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 BaseDialog from '@/components/common/BaseDialog.vue'
import UserAttributeForm from '@/components/user/UserAttributeForm.vue' import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
import Icon from '@/components/icons/Icon.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 emit = defineEmits(['close', 'success'])
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard() 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>
...@@ -181,6 +181,10 @@ import Icon from '@/components/icons/Icon.vue' ...@@ -181,6 +181,10 @@ import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n() const { t } = useI18n()
const emit = defineEmits<{
sort: [key: string, order: 'asc' | 'desc']
}>()
// 表格容器引用 // 表格容器引用
const tableWrapperRef = ref<HTMLElement | null>(null) const tableWrapperRef = ref<HTMLElement | null>(null)
const isScrollable = ref(false) const isScrollable = ref(false)
...@@ -279,18 +283,149 @@ interface Props { ...@@ -279,18 +283,149 @@ interface Props {
expandableActions?: boolean expandableActions?: boolean
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能 actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
rowKey?: string | ((row: any) => string | 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>(), { const props = withDefaults(defineProps<Props>(), {
loading: false, loading: false,
stickyFirstColumn: true, stickyFirstColumn: true,
stickyActionsColumn: true, stickyActionsColumn: true,
expandableActions: true expandableActions: true,
defaultSortOrder: 'asc',
serverSideSort: false
}) })
const sortKey = ref<string>('') const sortKey = ref<string>('')
const sortOrder = ref<'asc' | 'desc'>('asc') const sortOrder = ref<'asc' | 'desc'>('asc')
const actionsExpanded = ref(false) 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) => { const resolveRowKey = (row: any, index: number) => {
if (typeof props.rowKey === 'function') { if (typeof props.rowKey === 'function') {
const key = props.rowKey(row) const key = props.rowKey(row)
...@@ -323,26 +458,39 @@ watch(actionsExpanded, async () => { ...@@ -323,26 +458,39 @@ watch(actionsExpanded, async () => {
}) })
const handleSort = (key: string) => { const handleSort = (key: string) => {
let newOrder: 'asc' | 'desc' = 'asc'
if (sortKey.value === key) { 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 { } else {
// Client-side sort mode: just update internal state
sortKey.value = key sortKey.value = key
sortOrder.value = 'asc' sortOrder.value = newOrder
} }
} }
const sortedData = computed(() => { const sortedData = computed(() => {
if (!sortKey.value || !props.data) return props.data // Server-side sort mode: return data as-is (server handles sorting)
if (props.serverSideSort || !sortKey.value || !props.data) return props.data
return [...props.data].sort((a, b) => {
const aVal = a[sortKey.value] const key = sortKey.value
const bVal = b[sortKey.value] const order = sortOrder.value
if (aVal === bVal) return 0 // Stable sort (tie-break with original index) to avoid jitter when values are equal.
return props.data
const comparison = aVal > bVal ? 1 : -1 .map((row, index) => ({ row, index }))
return sortOrder.value === 'asc' ? comparison : -comparison .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(() => { const hasActionsColumn = computed(() => {
...@@ -396,6 +544,51 @@ const getAdaptivePaddingClass = () => { ...@@ -396,6 +544,51 @@ const getAdaptivePaddingClass = () => {
return 'px-6' // 24px (原始值) 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> </script>
<style scoped> <style scoped>
......
...@@ -42,13 +42,13 @@ ...@@ -42,13 +42,13 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import GroupBadge from './GroupBadge.vue' import GroupBadge from './GroupBadge.vue'
import type { Group, GroupPlatform } from '@/types' import type { AdminGroup, GroupPlatform } from '@/types'
const { t } = useI18n() const { t } = useI18n()
interface Props { interface Props {
modelValue: number[] modelValue: number[]
groups: Group[] groups: AdminGroup[]
platform?: GroupPlatform // Optional platform filter platform?: GroupPlatform // Optional platform filter
mixedScheduling?: boolean // For antigravity accounts: allow anthropic/gemini groups mixedScheduling?: boolean // For antigravity accounts: allow anthropic/gemini groups
} }
......
...@@ -37,7 +37,7 @@ ...@@ -37,7 +37,7 @@
</p> </p>
<!-- Page size selector --> <!-- Page size selector -->
<div class="flex items-center space-x-2"> <div v-if="showPageSizeSelector" class="flex items-center space-x-2">
<span class="text-sm text-gray-700 dark:text-gray-300" <span class="text-sm text-gray-700 dark:text-gray-300"
>{{ t('pagination.perPage') }}:</span >{{ t('pagination.perPage') }}:</span
> >
...@@ -49,6 +49,22 @@ ...@@ -49,6 +49,22 @@
/> />
</div> </div>
</div> </div>
<div v-if="showJump" class="flex items-center space-x-2">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('pagination.jumpTo') }}</span>
<input
v-model="jumpPage"
type="number"
min="1"
:max="totalPages"
class="input w-20 text-sm"
:placeholder="t('pagination.jumpPlaceholder')"
@keyup.enter="submitJump"
/>
<button type="button" class="btn btn-ghost btn-sm" @click="submitJump">
{{ t('pagination.jumpAction') }}
</button>
</div>
</div> </div>
<!-- Desktop pagination buttons --> <!-- Desktop pagination buttons -->
...@@ -102,7 +118,7 @@ ...@@ -102,7 +118,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import Select from './Select.vue' import Select from './Select.vue'
...@@ -114,6 +130,8 @@ interface Props { ...@@ -114,6 +130,8 @@ interface Props {
page: number page: number
pageSize: number pageSize: number
pageSizeOptions?: number[] pageSizeOptions?: number[]
showPageSizeSelector?: boolean
showJump?: boolean
} }
interface Emits { interface Emits {
...@@ -122,7 +140,9 @@ interface Emits { ...@@ -122,7 +140,9 @@ interface Emits {
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
pageSizeOptions: () => [10, 20, 50, 100] pageSizeOptions: () => [10, 20, 50, 100],
showPageSizeSelector: true,
showJump: false
}) })
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
...@@ -146,6 +166,8 @@ const pageSizeSelectOptions = computed(() => { ...@@ -146,6 +166,8 @@ const pageSizeSelectOptions = computed(() => {
})) }))
}) })
const jumpPage = ref('')
const visiblePages = computed(() => { const visiblePages = computed(() => {
const pages: (number | string)[] = [] const pages: (number | string)[] = []
const maxVisible = 7 const maxVisible = 7
...@@ -196,6 +218,16 @@ const handlePageSizeChange = (value: string | number | boolean | null) => { ...@@ -196,6 +218,16 @@ const handlePageSizeChange = (value: string | number | boolean | null) => {
const newPageSize = typeof value === 'string' ? parseInt(value) : value const newPageSize = typeof value === 'string' ? parseInt(value) : value
emit('update:pageSize', newPageSize) emit('update:pageSize', newPageSize)
} }
const submitJump = () => {
const value = jumpPage.value.trim()
if (!value) return
const pageNum = Number.parseInt(value, 10)
if (Number.isNaN(pageNum)) return
const nextPage = Math.min(Math.max(pageNum, 1), totalPages.value)
jumpPage.value = ''
goToPage(nextPage)
}
</script> </script>
<style scoped> <style scoped>
......
...@@ -13,6 +13,9 @@ A generic data table component with sorting, loading states, and custom cell ren ...@@ -13,6 +13,9 @@ A generic data table component with sorting, loading states, and custom cell ren
- `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter - `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter
- `data: any[]` - Array of data objects to display - `data: any[]` - Array of data objects to display
- `loading?: boolean` - Show loading skeleton - `loading?: boolean` - Show loading skeleton
- `defaultSortKey?: string` - Default sort key (only used if no persisted sort state)
- `defaultSortOrder?: 'asc' | 'desc'` - Default sort order (default: `asc`)
- `sortStorageKey?: string` - Persist sort state (key + order) to localStorage
- `rowKey?: string | (row: any) => string | number` - Row key field or resolver (defaults to `row.id`, falls back to index) - `rowKey?: string | (row: any) => string | number` - Row key field or resolver (defaults to `row.id`, falls back to index)
**Slots:** **Slots:**
......
...@@ -443,7 +443,7 @@ $env:ANTHROPIC_AUTH_TOKEN="${apiKey}"` ...@@ -443,7 +443,7 @@ $env:ANTHROPIC_AUTH_TOKEN="${apiKey}"`
} }
function generateGeminiCliContent(baseUrl: string, apiKey: string): FileConfig { function generateGeminiCliContent(baseUrl: string, apiKey: string): FileConfig {
const model = 'gemini-2.5-pro' const model = 'gemini-2.0-flash'
const modelComment = t('keys.useKeyModal.gemini.modelComment') const modelComment = t('keys.useKeyModal.gemini.modelComment')
let path: string let path: string
let content: string let content: string
...@@ -548,14 +548,22 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin ...@@ -548,14 +548,22 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
} }
} }
const geminiModels = { const geminiModels = {
'gemini-3-pro-high': { name: 'Gemini 3 Pro High' }, 'gemini-2.0-flash': { name: 'Gemini 2.0 Flash' },
'gemini-2.5-flash': { name: 'Gemini 2.5 Flash' },
'gemini-2.5-pro': { name: 'Gemini 2.5 Pro' },
'gemini-3-flash-preview': { name: 'Gemini 3 Flash Preview' },
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' }
}
const antigravityGeminiModels = {
'gemini-2.5-flash': { name: 'Gemini 2.5 Flash' },
'gemini-2.5-flash-lite': { name: 'Gemini 2.5 Flash Lite' },
'gemini-2.5-flash-thinking': { name: 'Gemini 2.5 Flash Thinking' },
'gemini-3-flash': { name: 'Gemini 3 Flash' },
'gemini-3-pro-low': { name: 'Gemini 3 Pro Low' }, 'gemini-3-pro-low': { name: 'Gemini 3 Pro Low' },
'gemini-3-pro-high': { name: 'Gemini 3 Pro High' },
'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' }, 'gemini-3-pro-preview': { name: 'Gemini 3 Pro Preview' },
'gemini-3-pro-image': { name: 'Gemini 3 Pro Image' }, 'gemini-3-pro-image': { name: 'Gemini 3 Pro Image' }
'gemini-3-flash': { name: 'Gemini 3 Flash' },
'gemini-2.5-flash-thinking': { name: 'Gemini 2.5 Flash Thinking' },
'gemini-2.5-flash': { name: 'Gemini 2.5 Flash' },
'gemini-2.5-flash-lite': { name: 'Gemini 2.5 Flash Lite' }
} }
const claudeModels = { const claudeModels = {
'claude-opus-4-5-thinking': { name: 'Claude Opus 4.5 Thinking' }, 'claude-opus-4-5-thinking': { name: 'Claude Opus 4.5 Thinking' },
...@@ -575,7 +583,7 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin ...@@ -575,7 +583,7 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
} else if (platform === 'antigravity-gemini') { } else if (platform === 'antigravity-gemini') {
provider[platform].npm = '@ai-sdk/google' provider[platform].npm = '@ai-sdk/google'
provider[platform].name = 'Antigravity (Gemini)' provider[platform].name = 'Antigravity (Gemini)'
provider[platform].models = geminiModels provider[platform].models = antigravityGeminiModels
} else if (platform === 'openai') { } else if (platform === 'openai') {
provider[platform].models = openaiModels provider[platform].models = openaiModels
} }
......
...@@ -21,8 +21,20 @@ ...@@ -21,8 +21,20 @@
</div> </div>
</div> </div>
<!-- Right: Language + Subscriptions + Balance + User Dropdown --> <!-- Right: Docs + Language + Subscriptions + Balance + User Dropdown -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<!-- Docs Link -->
<a
v-if="docUrl"
:href="docUrl"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white"
>
<Icon name="book" size="sm" />
<span class="hidden sm:inline">{{ t('nav.docs') }}</span>
</a>
<!-- Language Switcher --> <!-- Language Switcher -->
<LocaleSwitcher /> <LocaleSwitcher />
...@@ -211,6 +223,7 @@ const user = computed(() => authStore.user) ...@@ -211,6 +223,7 @@ const user = computed(() => authStore.user)
const dropdownOpen = ref(false) const dropdownOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null) const dropdownRef = ref<HTMLElement | null>(null)
const contactInfo = computed(() => appStore.contactInfo) const contactInfo = computed(() => appStore.contactInfo)
const docUrl = computed(() => appStore.docUrl)
// 只在标准模式的管理员下显示新手引导按钮 // 只在标准模式的管理员下显示新手引导按钮
const showOnboardingButton = computed(() => { const showOnboardingButton = computed(() => {
......
...@@ -421,6 +421,16 @@ const userNavItems = computed(() => { ...@@ -421,6 +421,16 @@ const userNavItems = computed(() => {
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
? [
{
path: '/purchase',
label: t('nav.buySubscription'),
icon: CreditCardIcon,
hideInSimpleMode: true
}
]
: []),
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon } { path: '/profile', label: t('nav.profile'), icon: UserIcon }
] ]
...@@ -433,6 +443,16 @@ const personalNavItems = computed(() => { ...@@ -433,6 +443,16 @@ const personalNavItems = computed(() => {
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
? [
{
path: '/purchase',
label: t('nav.buySubscription'),
icon: CreditCardIcon,
hideInSimpleMode: true
}
]
: []),
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon } { path: '/profile', label: t('nav.profile'), icon: UserIcon }
] ]
......
<template>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
{{ t('profile.totp.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.totp.description') }}
</p>
</div>
<div class="px-6 py-6">
<!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
</div>
<!-- Feature disabled globally -->
<div v-else-if="status && !status.feature_enabled" class="flex items-center gap-4 py-4">
<div class="flex-shrink-0 rounded-full bg-gray-100 p-3 dark:bg-dark-700">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">
{{ t('profile.totp.featureDisabled') }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.totp.featureDisabledHint') }}
</p>
</div>
</div>
<!-- 2FA Enabled -->
<div v-else-if="status?.enabled" class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 rounded-full bg-green-100 p-3 dark:bg-green-900/30">
<svg class="h-6 w-6 text-green-600 dark:text-green-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>
<div>
<p class="font-medium text-gray-900 dark:text-white">
{{ t('profile.totp.enabled') }}
</p>
<p v-if="status.enabled_at" class="text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.totp.enabledAt') }}: {{ formatDate(status.enabled_at) }}
</p>
</div>
</div>
<button
type="button"
class="btn btn-outline-danger"
@click="showDisableDialog = true"
>
{{ t('profile.totp.disable') }}
</button>
</div>
<!-- 2FA Not Enabled -->
<div v-else class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 rounded-full bg-gray-100 p-3 dark:bg-dark-700">
<svg class="h-6 w-6 text-gray-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>
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">
{{ t('profile.totp.notEnabled') }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.totp.notEnabledHint') }}
</p>
</div>
</div>
<button
type="button"
class="btn btn-primary"
@click="showSetupModal = true"
>
{{ t('profile.totp.enable') }}
</button>
</div>
</div>
<!-- Setup Modal -->
<TotpSetupModal
v-if="showSetupModal"
@close="showSetupModal = false"
@success="handleSetupSuccess"
/>
<!-- Disable Dialog -->
<TotpDisableDialog
v-if="showDisableDialog"
@close="showDisableDialog = false"
@success="handleDisableSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { totpAPI } from '@/api'
import type { TotpStatus } from '@/types'
import TotpSetupModal from './TotpSetupModal.vue'
import TotpDisableDialog from './TotpDisableDialog.vue'
const { t } = useI18n()
const loading = ref(true)
const status = ref<TotpStatus | null>(null)
const showSetupModal = ref(false)
const showDisableDialog = ref(false)
const loadStatus = async () => {
loading.value = true
try {
status.value = await totpAPI.getStatus()
} catch (error) {
console.error('Failed to load TOTP status:', error)
} finally {
loading.value = false
}
}
const handleSetupSuccess = () => {
showSetupModal.value = false
loadStatus()
}
const handleDisableSuccess = () => {
showDisableDialog.value = false
loadStatus()
}
const formatDate = (timestamp: number) => {
// Backend returns Unix timestamp in seconds, convert to milliseconds
const date = new Date(timestamp * 1000)
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
loadStatus()
})
</script>
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