From 506cb21cb1d1aa7b94a6586e7da52c44104a1510 Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Sun, 28 Dec 2025 01:00:06 +0800 Subject: [PATCH] =?UTF-8?q?refactor(frontend):=20UI/UX=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E5=92=8C=E7=BB=84=E4=BB=B6=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DataTable组件操作列自适应 - 优化各种Modal弹窗 - 统一API调用方式(AbortSignal) - 添加全局订阅状态管理 - 优化各管理视图的交互和布局 - 修复国际化翻译问题 --- backend/internal/pkg/gemini/models.go | 4 +- backend/internal/pkg/geminicli/models.go | 6 +- frontend/src/App.vue | 22 +- frontend/src/api/admin/accounts.ts | 6 +- frontend/src/api/admin/groups.ts | 6 +- frontend/src/api/admin/proxies.ts | 6 +- frontend/src/api/admin/redeem.ts | 6 +- frontend/src/api/admin/subscriptions.ts | 6 +- frontend/src/api/admin/usage.ts | 8 +- frontend/src/api/admin/users.ts | 7 +- frontend/src/api/keys.ts | 9 +- frontend/src/api/usage.ts | 15 +- .../components/account/AccountStatsModal.vue | 11 +- .../components/account/AccountTestModal.vue | 8 +- .../account/BulkEditAccountModal.vue | 162 +++++-- .../components/account/CreateAccountModal.vue | 76 +-- .../components/account/EditAccountModal.vue | 32 +- .../account/OAuthAuthorizationFlow.vue | 2 +- .../components/account/ReAuthAccountModal.vue | 29 +- .../components/account/SyncFromCrsModal.vue | 21 +- .../src/components/common/ConfirmDialog.vue | 6 +- frontend/src/components/common/DataTable.vue | 10 +- frontend/src/components/common/Pagination.vue | 4 - frontend/src/components/common/Select.vue | 55 ++- .../common/SubscriptionProgressMini.vue | 39 +- frontend/src/components/common/index.ts | 1 + frontend/src/components/keys/UseKeyModal.vue | 8 +- frontend/src/i18n/locales/en.ts | 5 + frontend/src/i18n/locales/zh.ts | 5 + frontend/src/stores/index.ts | 1 + frontend/src/stores/subscriptions.ts | 135 ++++++ frontend/src/style.css | 29 ++ frontend/src/views/admin/AccountsView.vue | 257 +++++----- frontend/src/views/admin/GroupsView.vue | 74 ++- frontend/src/views/admin/ProxiesView.vue | 141 ++++-- frontend/src/views/admin/RedeemView.vue | 48 +- .../src/views/admin/SubscriptionsView.vue | 77 ++- frontend/src/views/admin/UsageView.vue | 92 +++- frontend/src/views/admin/UsersView.vue | 437 +++++++++++------- frontend/src/views/auth/LoginView.vue | 1 + frontend/src/views/auth/RegisterView.vue | 1 + frontend/src/views/setup/SetupWizardView.vue | 41 +- frontend/src/views/user/DashboardView.vue | 38 +- frontend/src/views/user/KeysView.vue | 57 ++- frontend/src/views/user/ProfileView.vue | 12 + frontend/src/views/user/UsageView.vue | 210 +++++++-- 46 files changed, 1582 insertions(+), 644 deletions(-) create mode 100644 frontend/src/stores/subscriptions.ts diff --git a/backend/internal/pkg/gemini/models.go b/backend/internal/pkg/gemini/models.go index 0af6003d..2be13c44 100644 --- a/backend/internal/pkg/gemini/models.go +++ b/backend/internal/pkg/gemini/models.go @@ -18,8 +18,10 @@ func DefaultModels() []Model { methods := []string{"generateContent", "streamGenerateContent"} return []Model{ {Name: "models/gemini-3-pro-preview", SupportedGenerationMethods: methods}, + {Name: "models/gemini-3-flash-preview", SupportedGenerationMethods: methods}, + {Name: "models/gemini-2.5-pro", SupportedGenerationMethods: methods}, + {Name: "models/gemini-2.5-flash", SupportedGenerationMethods: methods}, {Name: "models/gemini-2.0-flash", SupportedGenerationMethods: methods}, - {Name: "models/gemini-2.0-flash-lite", SupportedGenerationMethods: methods}, {Name: "models/gemini-1.5-pro", SupportedGenerationMethods: methods}, {Name: "models/gemini-1.5-flash", SupportedGenerationMethods: methods}, {Name: "models/gemini-1.5-flash-8b", SupportedGenerationMethods: methods}, diff --git a/backend/internal/pkg/geminicli/models.go b/backend/internal/pkg/geminicli/models.go index 065c7a10..f09bef90 100644 --- a/backend/internal/pkg/geminicli/models.go +++ b/backend/internal/pkg/geminicli/models.go @@ -11,11 +11,11 @@ type Model struct { // DefaultModels is the curated Gemini model list used by the admin UI "test account" flow. var DefaultModels = []Model{ - {ID: "gemini-3-pro", Type: "model", DisplayName: "Gemini 3 Pro", CreatedAt: ""}, - {ID: "gemini-3-flash", Type: "model", DisplayName: "Gemini 3 Flash", CreatedAt: ""}, + {ID: "gemini-3-pro-preview", Type: "model", DisplayName: "Gemini 3 Pro Preview", CreatedAt: ""}, + {ID: "gemini-3-flash-preview", Type: "model", DisplayName: "Gemini 3 Flash Preview", CreatedAt: ""}, {ID: "gemini-2.5-pro", Type: "model", DisplayName: "Gemini 2.5 Pro", CreatedAt: ""}, {ID: "gemini-2.5-flash", Type: "model", DisplayName: "Gemini 2.5 Flash", CreatedAt: ""}, } // DefaultTestModel is the default model to preselect in test flows. -const DefaultTestModel = "gemini-2.5-pro" +const DefaultTestModel = "gemini-3-pro-preview" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 89aa91bc..8bae7b74 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,12 +2,14 @@ import { RouterView, useRouter, useRoute } from 'vue-router' import { onMounted, watch } from 'vue' import Toast from '@/components/common/Toast.vue' -import { useAppStore } from '@/stores' +import { useAppStore, useAuthStore, useSubscriptionStore } from '@/stores' import { getSetupStatus } from '@/api/setup' const router = useRouter() const route = useRoute() const appStore = useAppStore() +const authStore = useAuthStore() +const subscriptionStore = useSubscriptionStore() /** * Update favicon dynamically @@ -46,6 +48,24 @@ watch( { immediate: true } ) +// Watch for authentication state and manage subscription data +watch( + () => authStore.isAuthenticated, + (isAuthenticated) => { + if (isAuthenticated) { + // User logged in: preload subscriptions and start polling + subscriptionStore.fetchActiveSubscriptions().catch((error) => { + console.error('Failed to preload subscriptions:', error) + }) + subscriptionStore.startPolling() + } else { + // User logged out: clear data and stop polling + subscriptionStore.clear() + } + }, + { immediate: true } +) + onMounted(async () => { // Check if setup is needed try { diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index cac50232..dbd4ff15 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -30,6 +30,9 @@ export async function list( type?: string status?: string search?: string + }, + options?: { + signal?: AbortSignal } ): Promise> { const { data } = await apiClient.get>('/admin/accounts', { @@ -37,7 +40,8 @@ export async function list( page, page_size: pageSize, ...filters - } + }, + signal: options?.signal }) return data } diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index d48792e7..23db9104 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -26,6 +26,9 @@ export async function list( platform?: GroupPlatform status?: 'active' | 'inactive' is_exclusive?: boolean + }, + options?: { + signal?: AbortSignal } ): Promise> { const { data } = await apiClient.get>('/admin/groups', { @@ -33,7 +36,8 @@ export async function list( page, page_size: pageSize, ...filters - } + }, + signal: options?.signal }) return data } diff --git a/frontend/src/api/admin/proxies.ts b/frontend/src/api/admin/proxies.ts index 273e1f8a..fe20a205 100644 --- a/frontend/src/api/admin/proxies.ts +++ b/frontend/src/api/admin/proxies.ts @@ -20,6 +20,9 @@ export async function list( protocol?: string status?: 'active' | 'inactive' search?: string + }, + options?: { + signal?: AbortSignal } ): Promise> { const { data } = await apiClient.get>('/admin/proxies', { @@ -27,7 +30,8 @@ export async function list( page, page_size: pageSize, ...filters - } + }, + signal: options?.signal }) return data } diff --git a/frontend/src/api/admin/redeem.ts b/frontend/src/api/admin/redeem.ts index 738b1519..a53c3566 100644 --- a/frontend/src/api/admin/redeem.ts +++ b/frontend/src/api/admin/redeem.ts @@ -25,6 +25,9 @@ export async function list( type?: RedeemCodeType status?: 'active' | 'used' | 'expired' | 'unused' search?: string + }, + options?: { + signal?: AbortSignal } ): Promise> { const { data } = await apiClient.get>('/admin/redeem-codes', { @@ -32,7 +35,8 @@ export async function list( page, page_size: pageSize, ...filters - } + }, + signal: options?.signal }) return data } diff --git a/frontend/src/api/admin/subscriptions.ts b/frontend/src/api/admin/subscriptions.ts index ceabd4ee..54b448e2 100644 --- a/frontend/src/api/admin/subscriptions.ts +++ b/frontend/src/api/admin/subscriptions.ts @@ -27,6 +27,9 @@ export async function list( status?: 'active' | 'expired' | 'revoked' user_id?: number group_id?: number + }, + options?: { + signal?: AbortSignal } ): Promise> { const { data } = await apiClient.get>( @@ -36,7 +39,8 @@ export async function list( page, page_size: pageSize, ...filters - } + }, + signal: options?.signal } ) return data diff --git a/frontend/src/api/admin/usage.ts b/frontend/src/api/admin/usage.ts index 5d4896d3..42c23a87 100644 --- a/frontend/src/api/admin/usage.ts +++ b/frontend/src/api/admin/usage.ts @@ -41,9 +41,13 @@ export interface AdminUsageQueryParams extends UsageQueryParams { * @param params - Query parameters for filtering and pagination * @returns Paginated list of usage logs */ -export async function list(params: AdminUsageQueryParams): Promise> { +export async function list( + params: AdminUsageQueryParams, + options?: { signal?: AbortSignal } +): Promise> { const { data } = await apiClient.get>('/admin/usage', { - params + params, + signal: options?.signal }) return data } diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 9ba58e8b..2901f4ce 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -11,6 +11,7 @@ import type { User, UpdateUserRequest, PaginatedResponse } from '@/types' * @param page - Page number (default: 1) * @param pageSize - Items per page (default: 20) * @param filters - Optional filters (status, role, search) + * @param options - Optional request options (signal) * @returns Paginated list of users */ export async function list( @@ -20,6 +21,9 @@ export async function list( status?: 'active' | 'disabled' role?: 'admin' | 'user' search?: string + }, + options?: { + signal?: AbortSignal } ): Promise> { const { data } = await apiClient.get>('/admin/users', { @@ -27,7 +31,8 @@ export async function list( page, page_size: pageSize, ...filters - } + }, + signal: options?.signal }) return data } diff --git a/frontend/src/api/keys.ts b/frontend/src/api/keys.ts index 5bedbf2c..caa339e4 100644 --- a/frontend/src/api/keys.ts +++ b/frontend/src/api/keys.ts @@ -10,14 +10,19 @@ import type { ApiKey, CreateApiKeyRequest, UpdateApiKeyRequest, PaginatedRespons * List all API keys for current user * @param page - Page number (default: 1) * @param pageSize - Items per page (default: 10) + * @param options - Optional request options * @returns Paginated list of API keys */ export async function list( page: number = 1, - pageSize: number = 10 + pageSize: number = 10, + options?: { + signal?: AbortSignal + } ): Promise> { const { data } = await apiClient.get>('/keys', { - params: { page, page_size: pageSize } + params: { page, page_size: pageSize }, + signal: options?.signal }) return data } diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 20581603..f0aec5fb 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -90,8 +90,12 @@ export async function list( * @param params - Query parameters for filtering and pagination * @returns Paginated list of usage logs */ -export async function query(params: UsageQueryParams): Promise> { +export async function query( + params: UsageQueryParams, + config: { signal?: AbortSignal } = {} +): Promise> { const { data } = await apiClient.get>('/usage', { + ...config, params }) return data @@ -232,15 +236,22 @@ export interface BatchApiKeysUsageResponse { /** * Get batch usage stats for user's own API keys * @param apiKeyIds - Array of API key IDs + * @param options - Optional request options * @returns Usage stats map keyed by API key ID */ export async function getDashboardApiKeysUsage( - apiKeyIds: number[] + apiKeyIds: number[], + options?: { + signal?: AbortSignal + } ): Promise { const { data } = await apiClient.post( '/usage/dashboard/api-keys-usage', { api_key_ids: apiKeyIds + }, + { + signal: options?.signal } ) return data diff --git a/frontend/src/components/account/AccountStatsModal.vue b/frontend/src/components/account/AccountStatsModal.vue index a82bbfb2..93f38a83 100644 --- a/frontend/src/components/account/AccountStatsModal.vue +++ b/frontend/src/components/account/AccountStatsModal.vue @@ -1,5 +1,10 @@ - + diff --git a/frontend/src/components/common/Select.vue b/frontend/src/components/common/Select.vue index d0e52541..71a41431 100644 --- a/frontend/src/components/common/Select.vue +++ b/frontend/src/components/common/Select.vue @@ -30,7 +30,11 @@ -
+
+ + + + { }) const loadGroups = async () => { + if (abortController) { + abortController.abort() + } + const currentController = new AbortController() + abortController = currentController + const { signal } = currentController loading.value = true try { const response = await adminAPI.groups.list(pagination.page, pagination.page_size, { platform: (filters.platform as GroupPlatform) || undefined, status: filters.status as any, is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined - }) + }, { signal }) + if (signal.aborted) return groups.value = response.items pagination.total = response.total pagination.pages = response.pages - } catch (error) { + } catch (error: any) { + if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') { + return + } appStore.showError(t('admin.groups.failedToLoad')) console.error('Error loading groups:', error) } finally { - loading.value = false + if (abortController === currentController && !signal.aborted) { + loading.value = false + } } } @@ -683,6 +719,12 @@ const handlePageChange = (page: number) => { loadGroups() } +const handlePageSizeChange = (pageSize: number) => { + pagination.page_size = pageSize + pagination.page = 1 + loadGroups() +} + const closeCreateModal = () => { showCreateModal.value = false createForm.name = '' diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue index f5d39ef2..a5df9bd0 100644 --- a/frontend/src/views/admin/ProxiesView.vue +++ b/frontend/src/views/admin/ProxiesView.vue @@ -209,15 +209,16 @@ :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" + @update:pageSize="handlePageSizeChange" /> - @@ -271,7 +272,12 @@
-
+
-
- - -
@@ -435,11 +413,44 @@
-
+
+ + + - -
+
@@ -526,11 +542,20 @@

{{ t('admin.subscriptions.validityHint') }}

- -
+ + + -
@@ -417,17 +429,23 @@
- -
+ + + ([]) const groups = ref([]) const users = ref([]) const loading = ref(false) +let abortController: AbortController | null = null const filters = reactive({ status: '', group_id: '' @@ -530,20 +549,36 @@ const subscriptionGroupOptions = computed(() => const userOptions = computed(() => users.value.map((u) => ({ value: u.id, label: u.email }))) const loadSubscriptions = async () => { + if (abortController) { + abortController.abort() + } + const requestController = new AbortController() + abortController = requestController + const { signal } = requestController + loading.value = true try { const response = await adminAPI.subscriptions.list(pagination.page, pagination.page_size, { status: (filters.status as any) || undefined, group_id: filters.group_id ? parseInt(filters.group_id) : undefined + }, { + signal }) + if (signal.aborted || abortController !== requestController) return subscriptions.value = response.items pagination.total = response.total pagination.pages = response.pages - } catch (error) { + } catch (error: any) { + if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') { + return + } appStore.showError(t('admin.subscriptions.failedToLoad')) console.error('Error loading subscriptions:', error) } finally { - loading.value = false + if (abortController === requestController) { + loading.value = false + abortController = null + } } } @@ -569,6 +604,12 @@ const handlePageChange = (page: number) => { loadSubscriptions() } +const handlePageSizeChange = (pageSize: number) => { + pagination.page_size = pageSize + pagination.page = 1 + loadSubscriptions() +} + const closeAssignModal = () => { showAssignModal.value = false assignForm.user_id = null diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index bdd9f68f..2165bf2a 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -224,7 +224,7 @@ v-model="filters.api_key_id" :options="apiKeyOptions" :placeholder="t('usage.allApiKeys')" - :disabled="!selectedUser && apiKeys.length === 0" + searchable @change="applyFilters" />
@@ -236,6 +236,7 @@ v-model="filters.model" :options="modelOptions" :placeholder="t('admin.usage.allModels')" + searchable @change="applyFilters" /> @@ -534,6 +535,7 @@ :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" + @update:pageSize="handlePageSizeChange" /> @@ -666,6 +668,7 @@ const models = ref([]) const accounts = ref([]) const groups = ref([]) const loading = ref(false) +let abortController: AbortController | null = null // User search state const userSearchKeyword = ref('') @@ -675,7 +678,7 @@ const showUserDropdown = ref(false) const selectedUser = ref(null) let searchTimeout: ReturnType | null = null -// API Key options computed from selected user's keys +// API Key options computed from loaded keys const apiKeyOptions = computed(() => { return [ { value: null, label: t('usage.allApiKeys') }, @@ -796,7 +799,7 @@ const selectUser = async (user: SimpleUser) => { filters.value.api_key_id = undefined // Load API keys for selected user - await loadApiKeysForUser(user.id) + await loadApiKeys(user.id) applyFilters() } @@ -807,10 +810,11 @@ const clearUserFilter = () => { filters.value.user_id = undefined filters.value.api_key_id = undefined apiKeys.value = [] + loadApiKeys() applyFilters() } -const loadApiKeysForUser = async (userId: number) => { +const loadApiKeys = async (userId?: number) => { try { apiKeys.value = await adminAPI.usage.searchApiKeys(userId) } catch (error) { @@ -863,7 +867,24 @@ const formatCacheTokens = (value: number): string => { return value.toLocaleString() } +const isAbortError = (error: unknown): boolean => { + if (error instanceof DOMException && error.name === 'AbortError') { + return true + } + if (typeof error === 'object' && error !== null) { + const maybeError = error as { code?: string; name?: string } + return maybeError.code === 'ERR_CANCELED' || maybeError.name === 'CanceledError' + } + return false +} + const loadUsageLogs = async () => { + if (abortController) { + abortController.abort() + } + const controller = new AbortController() + abortController = controller + const { signal } = controller loading.value = true try { const params: AdminUsageQueryParams = { @@ -872,17 +893,23 @@ const loadUsageLogs = async () => { ...filters.value } - const response = await adminAPI.usage.list(params) + const response = await adminAPI.usage.list(params, { signal }) + if (signal.aborted) { + return + } usageLogs.value = response.items pagination.value.total = response.total pagination.value.pages = response.pages - // Extract models from loaded logs for filter options - extractModelsFromLogs() } catch (error) { + if (signal.aborted || isAbortError(error)) { + return + } appStore.showError(t('usage.failedToLoad')) } finally { - loading.value = false + if (!signal.aborted && abortController === controller) { + loading.value = false + } } } @@ -944,27 +971,37 @@ const applyFilters = () => { // Load filter options const loadFilterOptions = async () => { try { - // Load accounts - const accountsResponse = await adminAPI.accounts.list(1, 1000) + const [accountsResponse, groupsResponse] = await Promise.all([ + adminAPI.accounts.list(1, 1000), + adminAPI.groups.list(1, 1000) + ]) accounts.value = accountsResponse.items || [] - - // Load groups - const groupsResponse = await adminAPI.groups.list(1, 1000) groups.value = groupsResponse.items || [] } catch (error) { console.error('Failed to load filter options:', error) } + await loadModelOptions() } -// Extract unique models from usage logs -const extractModelsFromLogs = () => { - const uniqueModels = new Set() - usageLogs.value.forEach(log => { - if (log.model) { - uniqueModels.add(log.model) - } - }) - models.value = Array.from(uniqueModels).sort() +const loadModelOptions = async () => { + try { + const endDate = new Date() + const startDateRange = new Date(endDate) + startDateRange.setDate(startDateRange.getDate() - 29) + const response = await adminAPI.dashboard.getModelStats({ + start_date: startDateRange.toISOString().split('T')[0], + end_date: endDate.toISOString().split('T')[0] + }) + const uniqueModels = new Set() + response.models?.forEach((stat) => { + if (stat.model) { + uniqueModels.add(stat.model) + } + }) + models.value = Array.from(uniqueModels).sort() + } catch (error) { + console.error('Failed to load model options:', error) + } } const resetFilters = () => { @@ -987,6 +1024,7 @@ const resetFilters = () => { // Reset date range to default (last 7 days) initializeDateRange() pagination.value.page = 1 + loadApiKeys() loadUsageLogs() loadUsageStats() loadChartData() @@ -997,6 +1035,12 @@ const handlePageChange = (page: number) => { loadUsageLogs() } +const handlePageSizeChange = (pageSize: number) => { + pagination.value.page_size = pageSize + pagination.value.page = 1 + loadUsageLogs() +} + const exportToCSV = () => { if (usageLogs.value.length === 0) { appStore.showWarning(t('usage.noDataToExport')) @@ -1072,6 +1116,7 @@ const hideTooltip = () => { onMounted(() => { initializeDateRange() loadFilterOptions() + loadApiKeys() loadUsageLogs() loadUsageStats() loadChartData() @@ -1083,5 +1128,8 @@ onUnmounted(() => { if (searchTimeout) { clearTimeout(searchTimeout) } + if (abortController) { + abortController.abort() + } }) diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index d9ce2036..9288650d 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -198,12 +198,13 @@ {{ formatDateTime(value) }} - + + +
+
+ +
+
+
+ - -
+
+
-
+ + - -
+
@@ -664,11 +664,19 @@
-
+ + + + -
@@ -828,13 +836,13 @@
-
+ -
@@ -994,16 +1002,21 @@
-
+ - -
+
-
+ + + - -
+
- -
+ + + (null) const dropdownRef = ref(null) const dropdownPosition = ref<{ top: number; left: number } | null>(null) const groupButtonRefs = ref>(new Map()) +let abortController: AbortController | null = null // Get the currently selected key for group change const selectedKeyForGroup = computed(() => { @@ -623,14 +627,27 @@ const copyToClipboard = async (text: string, keyId: number) => { copiedKeyId.value = keyId setTimeout(() => { copiedKeyId.value = null - }, 2000) + }, 800) } } +const isAbortError = (error: unknown) => { + if (!error || typeof error !== 'object') return false + const { name, code } = error as { name?: string; code?: string } + return name === 'AbortError' || code === 'ERR_CANCELED' +} + const loadApiKeys = async () => { + abortController?.abort() + const controller = new AbortController() + abortController = controller + const { signal } = controller loading.value = true try { - const response = await keysAPI.list(pagination.value.page, pagination.value.page_size) + const response = await keysAPI.list(pagination.value.page, pagination.value.page_size, { + signal + }) + if (signal.aborted) return apiKeys.value = response.items pagination.value.total = response.total pagination.value.pages = response.pages @@ -639,16 +656,24 @@ const loadApiKeys = async () => { if (response.items.length > 0) { const keyIds = response.items.map((k) => k.id) try { - const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds) + const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds, { signal }) + if (signal.aborted) return usageStats.value = usageResponse.stats } catch (e) { - console.error('Failed to load usage stats:', e) + if (!isAbortError(e)) { + console.error('Failed to load usage stats:', e) + } } } } catch (error) { + if (isAbortError(error)) { + return + } appStore.showError(t('keys.failedToLoad')) } finally { - loading.value = false + if (abortController === controller) { + loading.value = false + } } } @@ -683,6 +708,12 @@ const handlePageChange = (page: number) => { loadApiKeys() } +const handlePageSizeChange = (pageSize: number) => { + pagination.value.page_size = pageSize + pagination.value.page = 1 + loadApiKeys() +} + const editKey = (key: ApiKey) => { selectedKey.value = key formData.value = { diff --git a/frontend/src/views/user/ProfileView.vue b/frontend/src/views/user/ProfileView.vue index ebb079f6..e1b72380 100644 --- a/frontend/src/views/user/ProfileView.vue +++ b/frontend/src/views/user/ProfileView.vue @@ -244,6 +244,12 @@ autocomplete="new-password" class="input" /> +

+ {{ t('profile.passwordsNotMatch') }} +

@@ -392,6 +398,12 @@ const handleChangePassword = async () => { } const handleUpdateProfile = async () => { + // Basic validation + if (!profileForm.value.username.trim()) { + appStore.showError(t('profile.usernameRequired')) + return + } + updatingProfile.value = true try { const updatedUser = await userAPI.updateProfile({ diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index f9a628e8..b326b4c5 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -164,8 +164,28 @@ -
@@ -366,6 +386,7 @@ :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" + @update:pageSize="handlePageSizeChange" /> @@ -412,7 +433,7 @@