-
-
-
-
-
-
diff --git a/frontend/src/components/admin/account/AccountTableActions.vue b/frontend/src/components/admin/account/AccountTableActions.vue
index 96fceaa0..8dffd6d1 100644
--- a/frontend/src/components/admin/account/AccountTableActions.vue
+++ b/frontend/src/components/admin/account/AccountTableActions.vue
@@ -1,8 +1,10 @@
+
+
{{ t('admin.accounts.syncFromCrs') }}
{{ t('admin.accounts.createAccount') }}
diff --git a/frontend/src/components/admin/account/AccountTestModal.vue b/frontend/src/components/admin/account/AccountTestModal.vue
index 2cb1c5a5..feb09654 100644
--- a/frontend/src/components/admin/account/AccountTestModal.vue
+++ b/frontend/src/components/admin/account/AccountTestModal.vue
@@ -232,8 +232,11 @@ const loadAvailableModels = async () => {
if (availableModels.value.length > 0) {
if (props.account.platform === 'gemini') {
const preferred =
+ availableModels.value.find((m) => m.id === 'gemini-2.0-flash') ||
+ availableModels.value.find((m) => m.id === 'gemini-2.5-flash') ||
availableModels.value.find((m) => m.id === 'gemini-2.5-pro') ||
- availableModels.value.find((m) => m.id === 'gemini-3-pro')
+ availableModels.value.find((m) => m.id === 'gemini-3-flash-preview') ||
+ availableModels.value.find((m) => m.id === 'gemini-3-pro-preview')
selectedModelId.value = preferred?.id || availableModels.value[0].id
} else {
// Try to select Sonnet as default, otherwise use first model
diff --git a/frontend/src/components/admin/usage/UsageCleanupDialog.vue b/frontend/src/components/admin/usage/UsageCleanupDialog.vue
new file mode 100644
index 00000000..d5e81e72
--- /dev/null
+++ b/frontend/src/components/admin/usage/UsageCleanupDialog.vue
@@ -0,0 +1,380 @@
+
+
+
+
+
+
+ {{ t('admin.usage.cleanup.warning') }}
+
+
+
+
+
+ {{ t('admin.usage.cleanup.recentTasks') }}
+
+
+ {{ t('common.refresh') }}
+
+
+
+
+
+ {{ t('admin.usage.cleanup.loadingTasks') }}
+
+
+ {{ t('admin.usage.cleanup.noTasks') }}
+
+
+
+
+
+
+ {{ statusLabel(task.status) }}
+
+ #{{ task.id }}
+
+ {{ t('admin.usage.cleanup.cancel') }}
+
+
+
+ {{ formatDateTime(task.created_at) }}
+
+
+
+ {{ t('admin.usage.cleanup.range') }}: {{ formatRange(task) }}
+ {{ t('admin.usage.cleanup.deletedRows') }}: {{ task.deleted_rows.toLocaleString() }}
+
+
+ {{ task.error_message }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+
+
+ {{ submitting ? t('admin.usage.cleanup.submitting') : t('admin.usage.cleanup.submit') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue
index 0926d83c..b17e0fdc 100644
--- a/frontend/src/components/admin/usage/UsageFilters.vue
+++ b/frontend/src/components/admin/usage/UsageFilters.vue
@@ -127,6 +127,12 @@
+
+
+
+
+
+
@@ -147,10 +153,13 @@
-
+
{{ t('common.reset') }}
+
+ {{ t('admin.usage.cleanup.button') }}
+
{{ t('usage.exportExcel') }}
@@ -174,16 +183,20 @@ interface Props {
exporting: boolean
startDate: string
endDate: string
+ showActions?: boolean
}
-const props = defineProps
()
+const props = withDefaults(defineProps(), {
+ showActions: true
+})
const emit = defineEmits([
'update:modelValue',
'update:startDate',
'update:endDate',
'change',
'reset',
- 'export'
+ 'export',
+ 'cleanup'
])
const { t } = useI18n()
@@ -221,6 +234,12 @@ const streamTypeOptions = ref([
{ value: false, label: t('usage.sync') }
])
+const billingTypeOptions = ref([
+ { value: null, label: t('admin.usage.allBillingTypes') },
+ { value: 0, label: t('admin.usage.billingTypeBalance') },
+ { value: 1, label: t('admin.usage.billingTypeSubscription') }
+])
+
const emitChange = () => emit('change')
const updateStartDate = (value: string) => {
diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue
index d2260c59..f6d1b1be 100644
--- a/frontend/src/components/admin/usage/UsageTable.vue
+++ b/frontend/src/components/admin/usage/UsageTable.vue
@@ -239,7 +239,7 @@ import { formatDateTime } from '@/utils/format'
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
-import type { UsageLog } from '@/types'
+import type { AdminUsageLog } from '@/types'
defineProps(['data', 'loading'])
const { t } = useI18n()
@@ -247,12 +247,12 @@ const { t } = useI18n()
// Tooltip state - cost
const tooltipVisible = ref(false)
const tooltipPosition = ref({ x: 0, y: 0 })
-const tooltipData = ref(null)
+const tooltipData = ref(null)
// Tooltip state - token
const tokenTooltipVisible = ref(false)
const tokenTooltipPosition = ref({ x: 0, y: 0 })
-const tokenTooltipData = ref(null)
+const tokenTooltipData = ref(null)
const cols = computed(() => [
{ key: 'user', label: t('admin.usage.user'), sortable: false },
@@ -296,7 +296,7 @@ const formatDuration = (ms: number | null | undefined): string => {
}
// Cost tooltip functions
-const showTooltip = (event: MouseEvent, row: UsageLog) => {
+const showTooltip = (event: MouseEvent, row: AdminUsageLog) => {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
tooltipData.value = row
@@ -311,7 +311,7 @@ const hideTooltip = () => {
}
// Token tooltip functions
-const showTokenTooltip = (event: MouseEvent, row: UsageLog) => {
+const showTokenTooltip = (event: MouseEvent, row: AdminUsageLog) => {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
tokenTooltipData.value = row
diff --git a/frontend/src/components/admin/user/UserAllowedGroupsModal.vue b/frontend/src/components/admin/user/UserAllowedGroupsModal.vue
index c1783fd2..825d2be5 100644
--- a/frontend/src/components/admin/user/UserAllowedGroupsModal.vue
+++ b/frontend/src/components/admin/user/UserAllowedGroupsModal.vue
@@ -39,10 +39,10 @@ import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
-import type { User, Group } from '@/types'
+import type { AdminUser, Group } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
-const props = defineProps<{ show: boolean, user: User | null }>()
+const props = defineProps<{ show: boolean, user: AdminUser | null }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
const groups = ref([]); const selectedIds = ref([]); const loading = ref(false); const submitting = ref(false)
@@ -56,4 +56,4 @@ const handleSave = async () => {
appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close')
} catch (error) { console.error('Failed to update allowed groups:', error) } finally { submitting.value = false }
}
-
\ No newline at end of file
+
diff --git a/frontend/src/components/admin/user/UserApiKeysModal.vue b/frontend/src/components/admin/user/UserApiKeysModal.vue
index ef098ba1..c2159ff4 100644
--- a/frontend/src/components/admin/user/UserApiKeysModal.vue
+++ b/frontend/src/components/admin/user/UserApiKeysModal.vue
@@ -32,10 +32,10 @@ import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
-import type { User, ApiKey } from '@/types'
+import type { AdminUser, ApiKey } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
-const props = defineProps<{ show: boolean, user: User | null }>()
+const props = defineProps<{ show: boolean, user: AdminUser | null }>()
defineEmits(['close']); const { t } = useI18n()
const apiKeys = ref([]); const loading = ref(false)
@@ -44,4 +44,4 @@ const load = async () => {
if (!props.user) return; loading.value = true
try { const res = await adminAPI.users.getUserApiKeys(props.user.id); apiKeys.value = res.items || [] } catch (error) { console.error('Failed to load API keys:', error) } finally { loading.value = false }
}
-
\ No newline at end of file
+
diff --git a/frontend/src/components/admin/user/UserBalanceModal.vue b/frontend/src/components/admin/user/UserBalanceModal.vue
index c669c2a5..143350bf 100644
--- a/frontend/src/components/admin/user/UserBalanceModal.vue
+++ b/frontend/src/components/admin/user/UserBalanceModal.vue
@@ -29,10 +29,10 @@ import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
-import type { User } from '@/types'
+import type { AdminUser } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
-const props = defineProps<{ show: boolean, user: User | null, operation: 'add' | 'subtract' }>()
+const props = defineProps<{ show: boolean, user: AdminUser | null, operation: 'add' | 'subtract' }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
const submitting = ref(false); const form = reactive({ amount: 0, notes: '' })
diff --git a/frontend/src/components/admin/user/UserEditModal.vue b/frontend/src/components/admin/user/UserEditModal.vue
index 2c4b117a..70ebd2d3 100644
--- a/frontend/src/components/admin/user/UserEditModal.vue
+++ b/frontend/src/components/admin/user/UserEditModal.vue
@@ -56,12 +56,12 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
-import type { User, UserAttributeValuesMap } from '@/types'
+import type { AdminUser, UserAttributeValuesMap } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
import Icon from '@/components/icons/Icon.vue'
-const props = defineProps<{ show: boolean, user: User | null }>()
+const props = defineProps<{ show: boolean, user: AdminUser | null }>()
const emit = defineEmits(['close', 'success'])
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
diff --git a/frontend/src/components/auth/TotpLoginModal.vue b/frontend/src/components/auth/TotpLoginModal.vue
new file mode 100644
index 00000000..03fa718d
--- /dev/null
+++ b/frontend/src/components/auth/TotpLoginModal.vue
@@ -0,0 +1,176 @@
+
+
+
+
+
diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue
index eab337ac..c1e4333d 100644
--- a/frontend/src/components/common/DataTable.vue
+++ b/frontend/src/components/common/DataTable.vue
@@ -181,6 +181,10 @@ import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
+const emit = defineEmits<{
+ sort: [key: string, order: 'asc' | 'desc']
+}>()
+
// 表格容器引用
const tableWrapperRef = ref(null)
const isScrollable = ref(false)
@@ -279,18 +283,149 @@ interface Props {
expandableActions?: boolean
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
rowKey?: string | ((row: any) => string | number)
+ /**
+ * Default sort configuration (only applied when there is no persisted sort state)
+ */
+ defaultSortKey?: string
+ defaultSortOrder?: 'asc' | 'desc'
+ /**
+ * Persist sort state (key + order) to localStorage using this key.
+ * If provided, DataTable will load the stored sort state on mount.
+ */
+ sortStorageKey?: string
+ /**
+ * Enable server-side sorting mode. When true, clicking sort headers
+ * will emit 'sort' events instead of performing client-side sorting.
+ */
+ serverSideSort?: boolean
}
const props = withDefaults(defineProps(), {
loading: false,
stickyFirstColumn: true,
stickyActionsColumn: true,
- expandableActions: true
+ expandableActions: true,
+ defaultSortOrder: 'asc',
+ serverSideSort: false
})
const sortKey = ref('')
const sortOrder = ref<'asc' | 'desc'>('asc')
const actionsExpanded = ref(false)
+
+type PersistedSortState = {
+ key: string
+ order: 'asc' | 'desc'
+}
+
+const collator = new Intl.Collator(undefined, {
+ numeric: true,
+ sensitivity: 'base'
+})
+
+const getSortableKeys = () => {
+ const keys = new Set()
+ 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
+ const key = normalizeSortKey(typeof parsed.key === 'string' ? parsed.key : '')
+ if (!key) return null
+ return { key, order: normalizeSortOrder(parsed.order) }
+ } catch (e) {
+ console.error('[DataTable] Failed to read persisted sort state:', e)
+ return null
+ }
+}
+
+const writePersistedSortState = (state: PersistedSortState) => {
+ if (!props.sortStorageKey) return
+ try {
+ localStorage.setItem(props.sortStorageKey, JSON.stringify(state))
+ } catch (e) {
+ console.error('[DataTable] Failed to persist sort state:', e)
+ }
+}
+
+const resolveInitialSortState = (): PersistedSortState | null => {
+ const persisted = readPersistedSortState()
+ if (persisted) return persisted
+
+ const key = normalizeSortKey(props.defaultSortKey || '')
+ if (!key) return null
+ return { key, order: normalizeSortOrder(props.defaultSortOrder) }
+}
+
+const applySortState = (state: PersistedSortState | null) => {
+ if (!state) return
+ sortKey.value = state.key
+ sortOrder.value = state.order
+}
+
+const isNullishOrEmpty = (value: any) => value === null || value === undefined || value === ''
+
+const toFiniteNumberOrNull = (value: any): number | null => {
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
+ if (typeof value === 'boolean') return value ? 1 : 0
+ if (typeof value === 'string') {
+ const trimmed = value.trim()
+ if (!trimmed) return null
+ const n = Number(trimmed)
+ return Number.isFinite(n) ? n : null
+ }
+ return null
+}
+
+const toSortableString = (value: any): string => {
+ if (value === null || value === undefined) return ''
+ if (typeof value === 'string') return value
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
+ if (value instanceof Date) return value.toISOString()
+ try {
+ return JSON.stringify(value)
+ } catch {
+ return String(value)
+ }
+}
+
+const compareSortValues = (a: any, b: any): number => {
+ const aEmpty = isNullishOrEmpty(a)
+ const bEmpty = isNullishOrEmpty(b)
+ if (aEmpty && bEmpty) return 0
+ if (aEmpty) return 1
+ if (bEmpty) return -1
+
+ const aNum = toFiniteNumberOrNull(a)
+ const bNum = toFiniteNumberOrNull(b)
+ if (aNum !== null && bNum !== null) {
+ if (aNum === bNum) return 0
+ return aNum < bNum ? -1 : 1
+ }
+
+ const aStr = toSortableString(a)
+ const bStr = toSortableString(b)
+ const res = collator.compare(aStr, bStr)
+ if (res === 0) return 0
+ return res < 0 ? -1 : 1
+}
const resolveRowKey = (row: any, index: number) => {
if (typeof props.rowKey === 'function') {
const key = props.rowKey(row)
@@ -323,26 +458,39 @@ watch(actionsExpanded, async () => {
})
const handleSort = (key: string) => {
+ let newOrder: 'asc' | 'desc' = 'asc'
if (sortKey.value === key) {
- sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
- } else {
+ 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 = 'asc'
+ sortOrder.value = newOrder
+ emit('sort', key, newOrder)
+ } else {
+ // Client-side sort mode: just update internal state
+ sortKey.value = key
+ sortOrder.value = newOrder
}
}
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 bVal = b[sortKey.value]
+ const key = sortKey.value
+ const order = sortOrder.value
- if (aVal === bVal) return 0
-
- const comparison = aVal > bVal ? 1 : -1
- return sortOrder.value === 'asc' ? comparison : -comparison
- })
+ // Stable sort (tie-break with original index) to avoid jitter when values are equal.
+ return props.data
+ .map((row, index) => ({ row, index }))
+ .sort((a, b) => {
+ const cmp = compareSortValues(a.row?.[key], b.row?.[key])
+ if (cmp !== 0) return order === 'asc' ? cmp : -cmp
+ return a.index - b.index
+ })
+ .map(item => item.row)
})
const hasActionsColumn = computed(() => {
@@ -396,6 +544,51 @@ const getAdaptivePaddingClass = () => {
return 'px-6' // 24px (原始值)
}
}
+
+// Init + keep persisted sort state consistent with current columns
+const didInitSort = ref(false)
+
+onMounted(() => {
+ const initial = resolveInitialSortState()
+ applySortState(initial)
+ didInitSort.value = true
+})
+
+watch(
+ () => props.columns,
+ () => {
+ // If current sort key is no longer sortable/visible, fall back to default/persisted.
+ const normalized = normalizeSortKey(sortKey.value)
+ if (!sortKey.value) {
+ const initial = resolveInitialSortState()
+ applySortState(initial)
+ return
+ }
+
+ if (!normalized) {
+ const fallback = resolveInitialSortState()
+ if (fallback) {
+ applySortState(fallback)
+ } else {
+ sortKey.value = ''
+ sortOrder.value = 'asc'
+ }
+ }
+ },
+ { deep: true }
+)
+
+watch(
+ [sortKey, sortOrder],
+ ([nextKey, nextOrder]) => {
+ if (!didInitSort.value) return
+ if (!props.sortStorageKey) return
+ const key = normalizeSortKey(nextKey)
+ if (!key) return
+ writePersistedSortState({ key, order: normalizeSortOrder(nextOrder) })
+ },
+ { flush: 'post' }
+)
diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue
index 6e6cee27..20370108 100644
--- a/frontend/src/views/auth/LoginView.vue
+++ b/frontend/src/views/auth/LoginView.vue
@@ -72,9 +72,19 @@
-
- {{ errors.password }}
-
+
+
+ {{ errors.password }}
+
+
+
+ {{ t('auth.forgotPassword') }}
+
+
@@ -153,6 +163,16 @@
+
+
+
diff --git a/frontend/src/views/user/KeysView.vue b/frontend/src/views/user/KeysView.vue
index 0787c467..b72ae9ad 100644
--- a/frontend/src/views/user/KeysView.vue
+++ b/frontend/src/views/user/KeysView.vue
@@ -133,6 +133,7 @@
diff --git a/frontend/src/views/user/ProfileView.vue b/frontend/src/views/user/ProfileView.vue
index 2d811fa5..0967e2b9 100644
--- a/frontend/src/views/user/ProfileView.vue
+++ b/frontend/src/views/user/ProfileView.vue
@@ -15,6 +15,7 @@