"frontend/git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "a413fa3b1715c9ddb01be707e0fba24188d1d33d"
Commit c8e2f614 authored by cyhhao's avatar cyhhao
Browse files

Merge branch 'main' of github.com:Wei-Shaw/sub2api

parents c0347cde c95a8649
...@@ -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>
<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()
......
...@@ -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>
......
...@@ -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(() => {
......
...@@ -43,13 +43,13 @@ export const claudeModels = [ ...@@ -43,13 +43,13 @@ export const claudeModels = [
// Google Gemini // Google Gemini
const geminiModels = [ const geminiModels = [
'gemini-2.0-flash', 'gemini-2.0-flash-lite-preview', 'gemini-2.0-flash-exp', // Keep in sync with backend curated Gemini lists.
'gemini-2.0-pro-exp', 'gemini-2.0-flash-thinking-exp', // This list is intentionally conservative (models commonly available across OAuth/API key).
'gemini-2.5-pro-exp-03-25', 'gemini-2.5-pro-preview-03-25', 'gemini-2.0-flash',
'gemini-3-pro-preview', 'gemini-2.5-flash',
'gemini-1.5-pro', 'gemini-1.5-pro-latest', 'gemini-2.5-pro',
'gemini-1.5-flash', 'gemini-1.5-flash-latest', 'gemini-1.5-flash-8b', 'gemini-3-flash-preview',
'gemini-exp-1206' 'gemini-3-pro-preview'
] ]
// 智谱 GLM // 智谱 GLM
...@@ -229,9 +229,8 @@ const openaiPresetMappings = [ ...@@ -229,9 +229,8 @@ const openaiPresetMappings = [
const geminiPresetMappings = [ const geminiPresetMappings = [
{ label: 'Flash 2.0', from: 'gemini-2.0-flash', to: 'gemini-2.0-flash', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' }, { label: 'Flash 2.0', from: 'gemini-2.0-flash', to: 'gemini-2.0-flash', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: 'Flash Lite', from: 'gemini-2.0-flash-lite-preview', to: 'gemini-2.0-flash-lite-preview', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, { label: '2.5 Flash', from: 'gemini-2.5-flash', to: 'gemini-2.5-flash', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: '1.5 Pro', from: 'gemini-1.5-pro', to: 'gemini-1.5-pro', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: '2.5 Pro', from: 'gemini-2.5-pro', to: 'gemini-2.5-pro', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }
{ label: '1.5 Flash', from: 'gemini-1.5-flash', to: 'gemini-1.5-flash', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }
] ]
// ===================== // =====================
......
...@@ -196,7 +196,8 @@ export default { ...@@ -196,7 +196,8 @@ export default {
expand: 'Expand', expand: 'Expand',
logout: 'Logout', logout: 'Logout',
github: 'GitHub', github: 'GitHub',
mySubscriptions: 'My Subscriptions' mySubscriptions: 'My Subscriptions',
docs: 'Docs'
}, },
// Auth // Auth
...@@ -572,7 +573,10 @@ export default { ...@@ -572,7 +573,10 @@ export default {
previous: 'Previous', previous: 'Previous',
next: 'Next', next: 'Next',
perPage: 'Per page', perPage: 'Per page',
goToPage: 'Go to page {page}' goToPage: 'Go to page {page}',
jumpTo: 'Jump to',
jumpPlaceholder: 'Page',
jumpAction: 'Go'
}, },
// Errors // Errors
...@@ -673,6 +677,7 @@ export default { ...@@ -673,6 +677,7 @@ export default {
updating: 'Updating...', updating: 'Updating...',
columns: { columns: {
user: 'User', user: 'User',
email: 'Email',
username: 'Username', username: 'Username',
notes: 'Notes', notes: 'Notes',
role: 'Role', role: 'Role',
...@@ -945,7 +950,7 @@ export default { ...@@ -945,7 +950,7 @@ export default {
title: 'Subscription Management', title: 'Subscription Management',
description: 'Manage user subscriptions and quota limits', description: 'Manage user subscriptions and quota limits',
assignSubscription: 'Assign Subscription', assignSubscription: 'Assign Subscription',
extendSubscription: 'Extend Subscription', adjustSubscription: 'Adjust Subscription',
revokeSubscription: 'Revoke Subscription', revokeSubscription: 'Revoke Subscription',
allStatus: 'All Status', allStatus: 'All Status',
allGroups: 'All Groups', allGroups: 'All Groups',
...@@ -960,6 +965,7 @@ export default { ...@@ -960,6 +965,7 @@ export default {
resetInHoursMinutes: 'Resets in {hours}h {minutes}m', resetInHoursMinutes: 'Resets in {hours}h {minutes}m',
resetInDaysHours: 'Resets in {days}d {hours}h', resetInDaysHours: 'Resets in {days}d {hours}h',
daysRemaining: 'days remaining', daysRemaining: 'days remaining',
remainingDays: 'Remaining days',
noExpiration: 'No expiration', noExpiration: 'No expiration',
status: { status: {
active: 'Active', active: 'Active',
...@@ -978,28 +984,32 @@ export default { ...@@ -978,28 +984,32 @@ export default {
user: 'User', user: 'User',
group: 'Subscription Group', group: 'Subscription Group',
validityDays: 'Validity (Days)', validityDays: 'Validity (Days)',
extendDays: 'Extend by (Days)' adjustDays: 'Adjust by (Days)'
}, },
selectUser: 'Select a user', selectUser: 'Select a user',
selectGroup: 'Select a subscription group', selectGroup: 'Select a subscription group',
groupHint: 'Only groups with subscription billing type are shown', groupHint: 'Only groups with subscription billing type are shown',
validityHint: 'Number of days the subscription will be valid', validityHint: 'Number of days the subscription will be valid',
extendingFor: 'Extending subscription for', adjustingFor: 'Adjusting subscription for',
currentExpiration: 'Current expiration', currentExpiration: 'Current expiration',
adjustDaysPlaceholder: 'Positive to extend, negative to shorten',
adjustHint: 'Enter positive number to extend, negative to shorten (remaining days must be > 0)',
assign: 'Assign', assign: 'Assign',
assigning: 'Assigning...', assigning: 'Assigning...',
extend: 'Extend', adjust: 'Adjust',
extending: 'Extending...', adjusting: 'Adjusting...',
revoke: 'Revoke', revoke: 'Revoke',
noSubscriptionsYet: 'No subscriptions yet', noSubscriptionsYet: 'No subscriptions yet',
assignFirstSubscription: 'Assign a subscription to get started.', assignFirstSubscription: 'Assign a subscription to get started.',
subscriptionAssigned: 'Subscription assigned successfully', subscriptionAssigned: 'Subscription assigned successfully',
subscriptionExtended: 'Subscription extended successfully', subscriptionAdjusted: 'Subscription adjusted successfully',
subscriptionRevoked: 'Subscription revoked successfully', subscriptionRevoked: 'Subscription revoked successfully',
failedToLoad: 'Failed to load subscriptions', failedToLoad: 'Failed to load subscriptions',
failedToAssign: 'Failed to assign subscription', failedToAssign: 'Failed to assign subscription',
failedToExtend: 'Failed to extend subscription', failedToAdjust: 'Failed to adjust subscription',
failedToRevoke: 'Failed to revoke subscription', failedToRevoke: 'Failed to revoke subscription',
adjustWouldExpire: 'Remaining days after adjustment must be greater than 0',
adjustOutOfRange: 'Adjustment days must be between -36500 and 36500',
pleaseSelectUser: 'Please select a user', pleaseSelectUser: 'Please select a user',
pleaseSelectGroup: 'Please select a group', pleaseSelectGroup: 'Please select a group',
validityDaysRequired: 'Please enter a valid number of days (at least 1)', validityDaysRequired: 'Please enter a valid number of days (at least 1)',
...@@ -1093,6 +1103,7 @@ export default { ...@@ -1093,6 +1103,7 @@ export default {
todayStats: 'Today Stats', todayStats: 'Today Stats',
groups: 'Groups', groups: 'Groups',
usageWindows: 'Usage Windows', usageWindows: 'Usage Windows',
proxy: 'Proxy',
lastUsed: 'Last Used', lastUsed: 'Last Used',
expiresAt: 'Expires At', expiresAt: 'Expires At',
actions: 'Actions' actions: 'Actions'
...@@ -1283,6 +1294,14 @@ export default { ...@@ -1283,6 +1294,14 @@ export default {
idleTimeout: 'Idle Timeout', idleTimeout: 'Idle Timeout',
idleTimeoutPlaceholder: '5', idleTimeoutPlaceholder: '5',
idleTimeoutHint: 'Sessions will be released after idle timeout' idleTimeoutHint: 'Sessions will be released after idle timeout'
},
tlsFingerprint: {
label: 'TLS Fingerprint Simulation',
hint: 'Simulate Node.js/Claude Code client TLS fingerprint'
},
sessionIdMasking: {
label: 'Session ID Masking',
hint: 'When enabled, fixes the session ID in metadata.user_id for 15 minutes, making upstream think requests come from the same session'
} }
}, },
expired: 'Expired', expired: 'Expired',
...@@ -1931,7 +1950,43 @@ export default { ...@@ -1931,7 +1950,43 @@ export default {
cacheCreationTokens: 'Cache Creation Tokens', cacheCreationTokens: 'Cache Creation Tokens',
cacheReadTokens: 'Cache Read Tokens', cacheReadTokens: 'Cache Read Tokens',
failedToLoad: 'Failed to load usage records', failedToLoad: 'Failed to load usage records',
ipAddress: 'IP' billingType: 'Billing Type',
allBillingTypes: 'All Billing Types',
billingTypeBalance: 'Balance',
billingTypeSubscription: 'Subscription',
ipAddress: 'IP',
cleanup: {
button: 'Cleanup',
title: 'Cleanup Usage Records',
warning: 'Cleanup is irreversible and will affect historical stats.',
submit: 'Submit Cleanup',
submitting: 'Submitting...',
confirmTitle: 'Confirm Cleanup',
confirmMessage: 'Are you sure you want to submit this cleanup task? This action cannot be undone.',
confirmSubmit: 'Confirm Cleanup',
cancel: 'Cancel',
cancelConfirmTitle: 'Confirm Cancel',
cancelConfirmMessage: 'Are you sure you want to cancel this cleanup task?',
cancelConfirm: 'Confirm Cancel',
cancelSuccess: 'Cleanup task canceled',
cancelFailed: 'Failed to cancel cleanup task',
recentTasks: 'Recent Cleanup Tasks',
loadingTasks: 'Loading tasks...',
noTasks: 'No cleanup tasks yet',
range: 'Range',
deletedRows: 'Deleted',
missingRange: 'Please select a date range',
submitSuccess: 'Cleanup task created',
submitFailed: 'Failed to create cleanup task',
loadFailed: 'Failed to load cleanup tasks',
status: {
pending: 'Pending',
running: 'Running',
succeeded: 'Succeeded',
failed: 'Failed',
canceled: 'Canceled'
}
}
}, },
// Ops Monitoring // Ops Monitoring
...@@ -2741,7 +2796,9 @@ export default { ...@@ -2741,7 +2796,9 @@ export default {
homeContent: 'Home Page Content', homeContent: 'Home Page Content',
homeContentPlaceholder: 'Enter custom content for the home page. Supports Markdown & HTML. If a URL is entered, it will be displayed as an iframe.', homeContentPlaceholder: 'Enter custom content for the home page. Supports Markdown & HTML. If a URL is entered, it will be displayed as an iframe.',
homeContentHint: 'Customize the home page content. Supports Markdown/HTML. If you enter a URL (starting with http:// or https://), it will be used as an iframe src to embed an external page. When set, the default status information will no longer be displayed.', homeContentHint: 'Customize the home page content. Supports Markdown/HTML. If you enter a URL (starting with http:// or https://), it will be used as an iframe src to embed an external page. When set, the default status information will no longer be displayed.',
homeContentIframeWarning: '⚠️ iframe mode note: Some websites have X-Frame-Options or CSP security policies that prevent embedding in iframes. If the page appears blank or shows an error, please verify the target website allows embedding, or consider using HTML mode to build your own content.' homeContentIframeWarning: '⚠️ iframe mode note: Some websites have X-Frame-Options or CSP security policies that prevent embedding in iframes. If the page appears blank or shows an error, please verify the target website allows embedding, or consider using HTML mode to build your own content.',
hideCcsImportButton: 'Hide CCS Import Button',
hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page'
}, },
smtp: { smtp: {
title: 'SMTP Settings', title: 'SMTP Settings',
......
...@@ -193,7 +193,8 @@ export default { ...@@ -193,7 +193,8 @@ export default {
expand: '展开', expand: '展开',
logout: '退出登录', logout: '退出登录',
github: 'GitHub', github: 'GitHub',
mySubscriptions: '我的订阅' mySubscriptions: '我的订阅',
docs: '文档'
}, },
// Auth // Auth
...@@ -568,7 +569,10 @@ export default { ...@@ -568,7 +569,10 @@ export default {
previous: '上一页', previous: '上一页',
next: '下一页', next: '下一页',
perPage: '每页', perPage: '每页',
goToPage: '跳转到第 {page} 页' goToPage: '跳转到第 {page} 页',
jumpTo: '跳转页',
jumpPlaceholder: '页码',
jumpAction: '跳转'
}, },
// Errors // Errors
...@@ -1021,7 +1025,7 @@ export default { ...@@ -1021,7 +1025,7 @@ export default {
title: '订阅管理', title: '订阅管理',
description: '管理用户订阅和配额限制', description: '管理用户订阅和配额限制',
assignSubscription: '分配订阅', assignSubscription: '分配订阅',
extendSubscription: '延长订阅', adjustSubscription: '调整订阅',
revokeSubscription: '撤销订阅', revokeSubscription: '撤销订阅',
allStatus: '全部状态', allStatus: '全部状态',
allGroups: '全部分组', allGroups: '全部分组',
...@@ -1036,6 +1040,7 @@ export default { ...@@ -1036,6 +1040,7 @@ export default {
resetInHoursMinutes: '{hours} 小时 {minutes} 分钟后重置', resetInHoursMinutes: '{hours} 小时 {minutes} 分钟后重置',
resetInDaysHours: '{days} 天 {hours} 小时后重置', resetInDaysHours: '{days} 天 {hours} 小时后重置',
daysRemaining: '天剩余', daysRemaining: '天剩余',
remainingDays: '剩余天数',
noExpiration: '无过期时间', noExpiration: '无过期时间',
status: { status: {
active: '生效中', active: '生效中',
...@@ -1054,28 +1059,32 @@ export default { ...@@ -1054,28 +1059,32 @@ export default {
user: '用户', user: '用户',
group: '订阅分组', group: '订阅分组',
validityDays: '有效期(天)', validityDays: '有效期(天)',
extendDays: '延长天数' adjustDays: '调整天数'
}, },
selectUser: '选择用户', selectUser: '选择用户',
selectGroup: '选择订阅分组', selectGroup: '选择订阅分组',
groupHint: '仅显示订阅计费类型的分组', groupHint: '仅显示订阅计费类型的分组',
validityHint: '订阅的有效天数', validityHint: '订阅的有效天数',
extendingFor: '为以下用户延长订阅', adjustingFor: '为以下用户调整订阅',
currentExpiration: '当前到期时间', currentExpiration: '当前到期时间',
adjustDaysPlaceholder: '正数延长,负数缩短',
adjustHint: '输入正数延长订阅,负数缩短订阅(缩短后剩余天数需大于0)',
assign: '分配', assign: '分配',
assigning: '分配中...', assigning: '分配中...',
extend: '延长', adjust: '调整',
extending: '延长中...', adjusting: '调整中...',
revoke: '撤销', revoke: '撤销',
noSubscriptionsYet: '暂无订阅', noSubscriptionsYet: '暂无订阅',
assignFirstSubscription: '分配一个订阅以开始使用。', assignFirstSubscription: '分配一个订阅以开始使用。',
subscriptionAssigned: '订阅分配成功', subscriptionAssigned: '订阅分配成功',
subscriptionExtended: '订阅延长成功', subscriptionAdjusted: '订阅调整成功',
subscriptionRevoked: '订阅撤销成功', subscriptionRevoked: '订阅撤销成功',
failedToLoad: '加载订阅列表失败', failedToLoad: '加载订阅列表失败',
failedToAssign: '分配订阅失败', failedToAssign: '分配订阅失败',
failedToExtend: '延长订阅失败', failedToAdjust: '调整订阅失败',
failedToRevoke: '撤销订阅失败', failedToRevoke: '撤销订阅失败',
adjustWouldExpire: '调整后剩余天数必须大于0',
adjustOutOfRange: '调整天数必须在 -36500 到 36500 之间',
pleaseSelectUser: '请选择用户', pleaseSelectUser: '请选择用户',
pleaseSelectGroup: '请选择分组', pleaseSelectGroup: '请选择分组',
validityDaysRequired: '请输入有效的天数(至少1天)', validityDaysRequired: '请输入有效的天数(至少1天)',
...@@ -1142,6 +1151,7 @@ export default { ...@@ -1142,6 +1151,7 @@ export default {
todayStats: '今日统计', todayStats: '今日统计',
groups: '分组', groups: '分组',
usageWindows: '用量窗口', usageWindows: '用量窗口',
proxy: '代理',
lastUsed: '最近使用', lastUsed: '最近使用',
expiresAt: '过期时间', expiresAt: '过期时间',
actions: '操作' actions: '操作'
...@@ -1416,6 +1426,14 @@ export default { ...@@ -1416,6 +1426,14 @@ export default {
idleTimeout: '空闲超时', idleTimeout: '空闲超时',
idleTimeoutPlaceholder: '5', idleTimeoutPlaceholder: '5',
idleTimeoutHint: '会话空闲超时后自动释放' idleTimeoutHint: '会话空闲超时后自动释放'
},
tlsFingerprint: {
label: 'TLS 指纹模拟',
hint: '模拟 Node.js/Claude Code 客户端的 TLS 指纹'
},
sessionIdMasking: {
label: '会话 ID 伪装',
hint: '启用后将在 15 分钟内固定 metadata.user_id 中的 session ID,使上游认为请求来自同一会话'
} }
}, },
expired: '已过期', expired: '已过期',
...@@ -2079,7 +2097,43 @@ export default { ...@@ -2079,7 +2097,43 @@ export default {
cacheCreationTokens: '缓存创建 Token', cacheCreationTokens: '缓存创建 Token',
cacheReadTokens: '缓存读取 Token', cacheReadTokens: '缓存读取 Token',
failedToLoad: '加载使用记录失败', failedToLoad: '加载使用记录失败',
ipAddress: 'IP' billingType: '计费类型',
allBillingTypes: '全部计费类型',
billingTypeBalance: '钱包余额',
billingTypeSubscription: '订阅套餐',
ipAddress: 'IP',
cleanup: {
button: '清理',
title: '清理使用记录',
warning: '清理不可恢复,且会影响历史统计回看。',
submit: '提交清理',
submitting: '提交中...',
confirmTitle: '确认清理',
confirmMessage: '确定要提交清理任务吗?清理不可恢复。',
confirmSubmit: '确认清理',
cancel: '取消任务',
cancelConfirmTitle: '确认取消',
cancelConfirmMessage: '确定要取消该清理任务吗?',
cancelConfirm: '确认取消',
cancelSuccess: '清理任务已取消',
cancelFailed: '取消清理任务失败',
recentTasks: '最近清理任务',
loadingTasks: '正在加载任务...',
noTasks: '暂无清理任务',
range: '时间范围',
deletedRows: '删除数量',
missingRange: '请选择时间范围',
submitSuccess: '清理任务已创建',
submitFailed: '创建清理任务失败',
loadFailed: '加载清理任务失败',
status: {
pending: '待执行',
running: '执行中',
succeeded: '已完成',
failed: '失败',
canceled: '已取消'
}
}
}, },
// Ops Monitoring // Ops Monitoring
...@@ -2893,7 +2947,9 @@ export default { ...@@ -2893,7 +2947,9 @@ export default {
homeContent: '首页内容', homeContent: '首页内容',
homeContentPlaceholder: '在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。', homeContentPlaceholder: '在此输入首页内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性。',
homeContentHint: '自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。', homeContentHint: '自定义首页内容,支持 Markdown/HTML。如果输入的是链接(以 http:// 或 https:// 开头),则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。设置后首页的状态信息将不再显示。',
homeContentIframeWarning: '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。' homeContentIframeWarning: '⚠️ iframe 模式提示:部分网站设置了 X-Frame-Options 或 CSP 安全策略,禁止被嵌入到 iframe 中。如果页面显示空白或报错,请确认目标网站允许被嵌入,或考虑使用 HTML 模式自行构建页面内容。',
hideCcsImportButton: '隐藏 CCS 导入按钮',
hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮'
}, },
smtp: { smtp: {
title: 'SMTP 设置', title: 'SMTP 设置',
......
...@@ -321,6 +321,7 @@ export const useAppStore = defineStore('app', () => { ...@@ -321,6 +321,7 @@ export const useAppStore = defineStore('app', () => {
contact_info: contactInfo.value, contact_info: contactInfo.value,
doc_url: docUrl.value, doc_url: docUrl.value,
home_content: '', home_content: '',
hide_ccs_import_button: false,
linuxdo_oauth_enabled: false, linuxdo_oauth_enabled: false,
version: siteVersion.value version: siteVersion.value
} }
......
...@@ -27,7 +27,6 @@ export interface FetchOptions { ...@@ -27,7 +27,6 @@ export interface FetchOptions {
export interface User { export interface User {
id: number id: number
username: string username: string
notes: string
email: string email: string
role: 'admin' | 'user' // User role for authorization role: 'admin' | 'user' // User role for authorization
balance: number // User balance for API usage balance: number // User balance for API usage
...@@ -39,6 +38,11 @@ export interface User { ...@@ -39,6 +38,11 @@ export interface User {
updated_at: string updated_at: string
} }
export interface AdminUser extends User {
// 管理员备注(普通用户接口不返回)
notes: string
}
export interface LoginRequest { export interface LoginRequest {
email: string email: string
password: string password: string
...@@ -75,6 +79,7 @@ export interface PublicSettings { ...@@ -75,6 +79,7 @@ export interface PublicSettings {
contact_info: string contact_info: string
doc_url: string doc_url: string
home_content: string home_content: string
hide_ccs_import_button: boolean
linuxdo_oauth_enabled: boolean linuxdo_oauth_enabled: boolean
version: string version: string
} }
...@@ -269,12 +274,17 @@ export interface Group { ...@@ -269,12 +274,17 @@ export interface Group {
// Claude Code 客户端限制 // Claude Code 客户端限制
claude_code_only: boolean claude_code_only: boolean
fallback_group_id: number | null fallback_group_id: number | null
// 模型路由配置(仅 anthropic 平台使用) created_at: string
updated_at: string
}
export interface AdminGroup extends Group {
// 模型路由配置(仅管理员可见,内部信息)
model_routing: Record<string, number[]> | null model_routing: Record<string, number[]> | null
model_routing_enabled: boolean model_routing_enabled: boolean
// 分组下账号数量(仅管理员可见)
account_count?: number account_count?: number
created_at: string
updated_at: string
} }
export interface ApiKey { export interface ApiKey {
...@@ -480,6 +490,13 @@ export interface Account { ...@@ -480,6 +490,13 @@ export interface Account {
max_sessions?: number | null max_sessions?: number | null
session_idle_timeout_minutes?: number | null session_idle_timeout_minutes?: number | null
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
enable_tls_fingerprint?: boolean | null
// 会话ID伪装(仅 Anthropic OAuth/SetupToken 账号有效)
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID
session_id_masking_enabled?: boolean | null
// 运行时状态(仅当启用对应限制时返回) // 运行时状态(仅当启用对应限制时返回)
current_window_cost?: number | null // 当前窗口费用 current_window_cost?: number | null // 当前窗口费用
active_sessions?: number | null // 当前活跃会话数 active_sessions?: number | null // 当前活跃会话数
...@@ -629,7 +646,7 @@ export interface UsageLog { ...@@ -629,7 +646,7 @@ export interface UsageLog {
total_cost: number total_cost: number
actual_cost: number actual_cost: number
rate_multiplier: number rate_multiplier: number
account_rate_multiplier?: number | null billing_type: number
stream: boolean stream: boolean
duration_ms: number duration_ms: number
...@@ -642,18 +659,57 @@ export interface UsageLog { ...@@ -642,18 +659,57 @@ export interface UsageLog {
// User-Agent // User-Agent
user_agent: string | null user_agent: string | null
// IP 地址(仅管理员可见)
ip_address: string | null
created_at: string created_at: string
user?: User user?: User
api_key?: ApiKey api_key?: ApiKey
account?: Account
group?: Group group?: Group
subscription?: UserSubscription subscription?: UserSubscription
} }
export interface UsageLogAccountSummary {
id: number
name: string
}
export interface AdminUsageLog extends UsageLog {
// 账号计费倍率(仅管理员可见)
account_rate_multiplier?: number | null
// 用户请求 IP(仅管理员可见)
ip_address?: string | null
// 最小账号信息(仅管理员接口返回)
account?: UsageLogAccountSummary
}
export interface UsageCleanupFilters {
start_time: string
end_time: string
user_id?: number
api_key_id?: number
account_id?: number
group_id?: number
model?: string | null
stream?: boolean | null
billing_type?: number | null
}
export interface UsageCleanupTask {
id: number
status: string
filters: UsageCleanupFilters
created_by: number
deleted_rows: number
error_message?: string | null
canceled_by?: number | null
canceled_at?: string | null
started_at?: string | null
finished_at?: string | null
created_at: string
updated_at: string
}
export interface RedeemCode { export interface RedeemCode {
id: number id: number
code: string code: string
...@@ -877,6 +933,7 @@ export interface UsageQueryParams { ...@@ -877,6 +933,7 @@ export interface UsageQueryParams {
group_id?: number group_id?: number
model?: string model?: string
stream?: boolean stream?: boolean
billing_type?: number | null
start_date?: string start_date?: string
end_date?: string end_date?: string
} }
......
...@@ -15,7 +15,40 @@ ...@@ -15,7 +15,40 @@
@refresh="load" @refresh="load"
@sync="showSync = true" @sync="showSync = true"
@create="showCreate = true" @create="showCreate = true"
/> >
<template #after>
<!-- Column Settings Dropdown -->
<div class="relative" ref="columnDropdownRef">
<button
@click="showColumnDropdown = !showColumnDropdown"
class="btn btn-secondary px-2 md:px-3"
:title="t('admin.users.columnSettings')"
>
<svg class="h-4 w-4 md:mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4.5v15m6-15v15m-10.875 0h15.75c.621 0 1.125-.504 1.125-1.125V5.625c0-.621-.504-1.125-1.125-1.125H4.125C3.504 4.5 3 5.004 3 5.625v12.75c0 .621.504 1.125 1.125 1.125z" />
</svg>
<span class="hidden md:inline">{{ t('admin.users.columnSettings') }}</span>
</button>
<!-- Dropdown menu -->
<div
v-if="showColumnDropdown"
class="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
>
<div class="max-h-80 overflow-y-auto p-2">
<button
v-for="col in toggleableColumns"
:key="col.key"
@click="toggleColumn(col.key)"
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
>
<span>{{ col.label }}</span>
<Icon v-if="isColumnVisible(col.key)" name="check" size="sm" class="text-primary-500" />
</button>
</div>
</div>
</div>
</template>
</AccountTableActions>
</div> </div>
</template> </template>
<template #table> <template #table>
...@@ -54,6 +87,15 @@ ...@@ -54,6 +87,15 @@
<template #cell-usage="{ row }"> <template #cell-usage="{ row }">
<AccountUsageCell :account="row" /> <AccountUsageCell :account="row" />
</template> </template>
<template #cell-proxy="{ row }">
<div v-if="row.proxy" class="flex items-center gap-2">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ row.proxy.name }}</span>
<span v-if="row.proxy.country_code" class="text-xs text-gray-500 dark:text-gray-400">
({{ row.proxy.country_code }})
</span>
</div>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
</template>
<template #cell-rate_multiplier="{ row }"> <template #cell-rate_multiplier="{ row }">
<span class="text-sm font-mono text-gray-700 dark:text-gray-300"> <span class="text-sm font-mono text-gray-700 dark:text-gray-300">
{{ (row.rate_multiplier ?? 1).toFixed(2) }}x {{ (row.rate_multiplier ?? 1).toFixed(2) }}x
...@@ -143,15 +185,16 @@ import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vu ...@@ -143,15 +185,16 @@ import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vu
import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue' import AccountGroupsCell from '@/components/account/AccountGroupsCell.vue'
import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue' import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue' import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import Icon from '@/components/icons/Icon.vue'
import { formatDateTime, formatRelativeTime } from '@/utils/format' import { formatDateTime, formatRelativeTime } from '@/utils/format'
import type { Account, Proxy, Group } from '@/types' import type { Account, Proxy, AdminGroup } from '@/types'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const proxies = ref<Proxy[]>([]) const proxies = ref<Proxy[]>([])
const groups = ref<Group[]>([]) const groups = ref<AdminGroup[]>([])
const selIds = ref<number[]>([]) const selIds = ref<number[]>([])
const showCreate = ref(false) const showCreate = ref(false)
const showEdit = ref(false) const showEdit = ref(false)
...@@ -171,12 +214,54 @@ const statsAcc = ref<Account | null>(null) ...@@ -171,12 +214,54 @@ const statsAcc = ref<Account | null>(null)
const togglingSchedulable = ref<number | null>(null) const togglingSchedulable = ref<number | null>(null)
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null }) const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
// Column settings
const showColumnDropdown = ref(false)
const columnDropdownRef = ref<HTMLElement | null>(null)
const hiddenColumns = reactive<Set<string>>(new Set())
const DEFAULT_HIDDEN_COLUMNS = ['proxy', 'notes', 'priority', 'rate_multiplier']
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
const loadSavedColumns = () => {
try {
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
if (saved) {
const parsed = JSON.parse(saved) as string[]
parsed.forEach(key => hiddenColumns.add(key))
} else {
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
}
} catch (e) {
console.error('Failed to load saved columns:', e)
DEFAULT_HIDDEN_COLUMNS.forEach(key => hiddenColumns.add(key))
}
}
const saveColumnsToStorage = () => {
try {
localStorage.setItem(HIDDEN_COLUMNS_KEY, JSON.stringify([...hiddenColumns]))
} catch (e) {
console.error('Failed to save columns:', e)
}
}
const toggleColumn = (key: string) => {
if (hiddenColumns.has(key)) {
hiddenColumns.delete(key)
} else {
hiddenColumns.add(key)
}
saveColumnsToStorage()
}
const isColumnVisible = (key: string) => !hiddenColumns.has(key)
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange, handlePageSizeChange } = useTableLoader<Account, any>({ const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange, handlePageSizeChange } = useTableLoader<Account, any>({
fetchFn: adminAPI.accounts.list, fetchFn: adminAPI.accounts.list,
initialParams: { platform: '', type: '', status: '', search: '' } initialParams: { platform: '', type: '', status: '', search: '' }
}) })
const cols = computed(() => { // All available columns
const allColumns = computed(() => {
const c = [ const c = [
{ key: 'select', label: '', sortable: false }, { key: 'select', label: '', sortable: false },
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true }, { key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
...@@ -189,11 +274,12 @@ const cols = computed(() => { ...@@ -189,11 +274,12 @@ const cols = computed(() => {
if (!authStore.isSimpleMode) { if (!authStore.isSimpleMode) {
c.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false }) c.push({ key: 'groups', label: t('admin.accounts.columns.groups'), sortable: false })
} }
c.push( c.push(
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false }, { key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true }, { key: 'proxy', label: t('admin.accounts.columns.proxy'), sortable: false },
{ key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true }, { key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true }, { key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true },
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
{ key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true }, { key: 'expires_at', label: t('admin.accounts.columns.expiresAt'), sortable: true },
{ key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false }, { key: 'notes', label: t('admin.accounts.columns.notes'), sortable: false },
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false } { key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
...@@ -201,6 +287,18 @@ const cols = computed(() => { ...@@ -201,6 +287,18 @@ const cols = computed(() => {
return c return c
}) })
// Columns that can be toggled (exclude select, name, and actions)
const toggleableColumns = computed(() =>
allColumns.value.filter(col => col.key !== 'select' && col.key !== 'name' && col.key !== 'actions')
)
// Filtered columns based on visibility
const cols = computed(() =>
allColumns.value.filter(col =>
col.key === 'select' || col.key === 'name' || col.key === 'actions' || !hiddenColumns.has(col.key)
)
)
const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true } const handleEdit = (a: Account) => { edAcc.value = a; showEdit.value = true }
const openMenu = (a: Account, e: MouseEvent) => { const openMenu = (a: Account, e: MouseEvent) => {
menu.acc = a menu.acc = a
...@@ -403,12 +501,21 @@ const isExpired = (value: number | null) => { ...@@ -403,12 +501,21 @@ const isExpired = (value: number | null) => {
return value * 1000 <= Date.now() return value * 1000 <= Date.now()
} }
// 滚动时关闭菜单 // 滚动时关闭操作菜单(不关闭列设置下拉菜单)
const handleScroll = () => { const handleScroll = () => {
menu.show = false menu.show = false
} }
// 点击外部关闭列设置下拉菜单
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
showColumnDropdown.value = false
}
}
onMounted(async () => { onMounted(async () => {
loadSavedColumns()
load() load()
try { try {
const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()]) const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()])
...@@ -418,9 +525,11 @@ onMounted(async () => { ...@@ -418,9 +525,11 @@ onMounted(async () => {
console.error('Failed to load proxies/groups:', error) console.error('Failed to load proxies/groups:', error)
} }
window.addEventListener('scroll', handleScroll, true) window.addEventListener('scroll', handleScroll, true)
document.addEventListener('click', handleClickOutside)
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('scroll', handleScroll, true) window.removeEventListener('scroll', handleScroll, true)
document.removeEventListener('click', handleClickOutside)
}) })
</script> </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