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 1/6] =?UTF-8?q?refactor(frontend):=20UI/UX=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=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 @@ From 4e3499c0d7b66edaf099f8881e4c570b376ca32f Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Sun, 28 Dec 2025 10:32:04 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(frontend):=20=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E7=8A=B6=E6=80=81=E5=AE=9E=E6=97=B6=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 Dashboard 页面加载时强制刷新订阅状态 - 在兑换订阅卡密后立即刷新订阅状态 - 清理订阅轮询相关注释 --- frontend/src/stores/subscriptions.ts | 4 ++-- frontend/src/views/user/DashboardView.vue | 7 +++++++ frontend/src/views/user/RedeemView.vue | 7 +++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/src/stores/subscriptions.ts b/frontend/src/stores/subscriptions.ts index e63707f7..2bda1e1a 100644 --- a/frontend/src/stores/subscriptions.ts +++ b/frontend/src/stores/subscriptions.ts @@ -79,7 +79,7 @@ export const useSubscriptionStore = defineStore('subscriptions', () => { } /** - * Start auto-refresh polling (every 5 minutes) + * Start auto-refresh polling */ function startPolling() { if (pollerInterval) return @@ -88,7 +88,7 @@ export const useSubscriptionStore = defineStore('subscriptions', () => { fetchActiveSubscriptions(true).catch((error) => { console.error('Subscription polling failed:', error) }) - }, 5 * 60 * 1000) // 5 minutes + }, 5 * 60 * 1000) } /** diff --git a/frontend/src/views/user/DashboardView.vue b/frontend/src/views/user/DashboardView.vue index f9d5ec42..1288ac48 100644 --- a/frontend/src/views/user/DashboardView.vue +++ b/frontend/src/views/user/DashboardView.vue @@ -661,6 +661,7 @@ import { ref, computed, onMounted, watch } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { useAuthStore } from '@/stores/auth' +import { useSubscriptionStore } from '@/stores/subscriptions' import { formatDateTime } from '@/utils/format' const { t } = useI18n() @@ -701,6 +702,7 @@ ChartJS.register( const router = useRouter() const authStore = useAuthStore() +const subscriptionStore = useSubscriptionStore() const user = computed(() => authStore.user) const stats = ref(null) @@ -1018,6 +1020,11 @@ onMounted(async () => { // Load critical data first await loadDashboardStats() + // Force refresh subscription status when entering dashboard (bypass cache) + subscriptionStore.fetchActiveSubscriptions(true).catch((error) => { + console.error('Failed to refresh subscription status:', error) + }) + // Initialize date range (synchronous) initializeDateRange() diff --git a/frontend/src/views/user/RedeemView.vue b/frontend/src/views/user/RedeemView.vue index 6e6c1600..7e35916d 100644 --- a/frontend/src/views/user/RedeemView.vue +++ b/frontend/src/views/user/RedeemView.vue @@ -445,6 +445,7 @@ import { ref, computed, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { useAuthStore } from '@/stores/auth' import { useAppStore } from '@/stores/app' +import { useSubscriptionStore } from '@/stores/subscriptions' import { redeemAPI, authAPI, type RedeemHistoryItem } from '@/api' import AppLayout from '@/components/layout/AppLayout.vue' import { formatDateTime } from '@/utils/format' @@ -452,6 +453,7 @@ import { formatDateTime } from '@/utils/format' const { t } = useI18n() const authStore = useAuthStore() const appStore = useAppStore() +const subscriptionStore = useSubscriptionStore() const user = computed(() => authStore.user) @@ -544,6 +546,11 @@ const handleRedeem = async () => { // Refresh user data to get updated balance/concurrency await authStore.refreshUser() + // If subscription type, immediately refresh subscription status + if (result.type === 'subscription') { + await subscriptionStore.fetchActiveSubscriptions(true) // force refresh + } + // Clear the input redeemCode.value = '' From 5f2d81d154478884fa723ae381bc202fd71d17b9 Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Sun, 28 Dec 2025 13:20:30 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8DUI?= =?UTF-8?q?=E6=94=B9=E8=BF=9B=E5=88=86=E6=94=AF=E4=B8=AD=E7=9A=84=E5=85=B3?= =?UTF-8?q?=E9=94=AE=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复RedeemView订阅刷新失败导致流程中断的问题 将订阅刷新隔离到独立try/catch,失败时仅显示警告 - 修复DataTable resize事件监听器泄漏问题 确保添加和移除使用同一个回调引用 - 修复订阅状态缓存导致强制刷新失效的问题 force=true时绕过activePromise缓存,clear()清空缓存 - 修复图表主题切换后颜色不更新的问题 添加图表ref并在主题切换时调用update()方法 --- frontend/src/components/common/DataTable.vue | 10 +++++++--- frontend/src/i18n/locales/en.ts | 3 ++- frontend/src/i18n/locales/zh.ts | 3 ++- frontend/src/stores/subscriptions.ts | 13 +++++++++---- frontend/src/views/user/DashboardView.vue | 19 ++++++++++++++++--- frontend/src/views/user/RedeemView.vue | 7 ++++++- 6 files changed, 42 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index 8abeee0c..9c250bc2 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -211,6 +211,7 @@ const checkActionsColumnWidth = () => { // 监听尺寸变化 let resizeObserver: ResizeObserver | null = null +let resizeHandler: (() => void) | null = null onMounted(() => { checkScrollable() @@ -223,17 +224,20 @@ onMounted(() => { resizeObserver.observe(tableWrapperRef.value) } else { // 降级方案:不支持 ResizeObserver 时使用 window resize - const handleResize = () => { + resizeHandler = () => { checkScrollable() checkActionsColumnWidth() } - window.addEventListener('resize', handleResize) + window.addEventListener('resize', resizeHandler) } }) onUnmounted(() => { resizeObserver?.disconnect() - window.removeEventListener('resize', checkScrollable) + if (resizeHandler) { + window.removeEventListener('resize', resizeHandler) + resizeHandler = null + } }) interface Props { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 3eb2fef3..285bb199 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -410,7 +410,8 @@ export default { subscriptionDays: '{days} days', days: ' days', codeRedeemSuccess: 'Code redeemed successfully!', - failedToRedeem: 'Failed to redeem code. Please check the code and try again.' + failedToRedeem: 'Failed to redeem code. Please check the code and try again.', + subscriptionRefreshFailed: 'Redeemed successfully, but failed to refresh subscription status.' }, // Profile diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ca4ea7ac..1231ce54 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -406,7 +406,8 @@ export default { subscriptionDays: '{days} 天', days: '天', codeRedeemSuccess: '兑换成功!', - failedToRedeem: '兑换失败,请检查兑换码后重试。' + failedToRedeem: '兑换失败,请检查兑换码后重试。', + subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。' }, // Profile diff --git a/frontend/src/stores/subscriptions.ts b/frontend/src/stores/subscriptions.ts index 2bda1e1a..58965914 100644 --- a/frontend/src/stores/subscriptions.ts +++ b/frontend/src/stores/subscriptions.ts @@ -48,7 +48,7 @@ export const useSubscriptionStore = defineStore('subscriptions', () => { } // Return in-flight request if exists (deduplication) - if (activePromise) { + if (activePromise && !force) { return activePromise } @@ -56,7 +56,7 @@ export const useSubscriptionStore = defineStore('subscriptions', () => { // Start new request loading.value = true - activePromise = subscriptionsAPI + const requestPromise = subscriptionsAPI .getActiveSubscriptions() .then((data) => { if (currentGeneration === requestGeneration) { @@ -71,10 +71,14 @@ export const useSubscriptionStore = defineStore('subscriptions', () => { throw error }) .finally(() => { - loading.value = false - activePromise = null + if (activePromise === requestPromise) { + loading.value = false + activePromise = null + } }) + activePromise = requestPromise + return activePromise } @@ -106,6 +110,7 @@ export const useSubscriptionStore = defineStore('subscriptions', () => { */ function clear() { requestGeneration++ + activePromise = null activeSubscriptions.value = [] loaded.value = false lastFetchedAt.value = null diff --git a/frontend/src/views/user/DashboardView.vue b/frontend/src/views/user/DashboardView.vue index 1288ac48..d660e1a0 100644 --- a/frontend/src/views/user/DashboardView.vue +++ b/frontend/src/views/user/DashboardView.vue @@ -336,6 +336,7 @@
@@ -400,7 +401,12 @@ {{ t('dashboard.tokenUsageTrend') }}
- +
diff --git a/frontend/src/views/user/RedeemView.vue b/frontend/src/views/user/RedeemView.vue index 7e35916d..6fa29c5b 100644 --- a/frontend/src/views/user/RedeemView.vue +++ b/frontend/src/views/user/RedeemView.vue @@ -548,7 +548,12 @@ const handleRedeem = async () => { // If subscription type, immediately refresh subscription status if (result.type === 'subscription') { - await subscriptionStore.fetchActiveSubscriptions(true) // force refresh + try { + await subscriptionStore.fetchActiveSubscriptions(true) // force refresh + } catch (error) { + console.error('Failed to refresh subscriptions after redeem:', error) + appStore.showWarning(t('redeem.subscriptionRefreshFailed')) + } } // Clear the input From d895a2c46952dc3856ec83fa56774f14dcf79103 Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Sun, 28 Dec 2025 14:45:40 +0800 Subject: [PATCH 5/6] =?UTF-8?q?refactor(frontend):=20=E7=A7=BB=E9=99=A4Dat?= =?UTF-8?q?aTable=E8=A1=A8=E5=A4=B4=E4=B8=AD=E5=BA=9F=E5=BC=83=E7=9A=84?= =?UTF-8?q?=E5=B1=95=E5=BC=80/=E6=8A=98=E5=8F=A0=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除操作列表头的展开/折叠按钮和图标 - 该功能已被操作列内的'更多'按钮替代 - 保留底层的展开/收起逻辑供'更多'按钮使用 --- frontend/src/components/common/DataTable.vue | 31 -------------------- 1 file changed, 31 deletions(-) diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index 9c250bc2..27eb61cc 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -24,37 +24,6 @@ >
{{ column.label }} - - Date: Sun, 28 Dec 2025 14:47:55 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(frontend):=20=E7=A7=BB=E9=99=A4DataTabl?= =?UTF-8?q?e=E4=B8=AD=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E5=92=8C=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除未使用的 hasExpandableActions 计算属性 - 移除未使用的 toggleActionsExpanded 函数 - 修复 TypeScript 类型检查错误 --- frontend/src/components/common/DataTable.vue | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index 27eb61cc..c160da26 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -271,26 +271,6 @@ const sortedData = computed(() => { }) }) -// 检查是否有可展开的操作列 -const hasExpandableActions = computed(() => { - // 如果明确指定了actionsCount,使用它来判断 - if (props.actionsCount !== undefined) { - return props.expandableActions && props.columns.some((col) => col.key === 'actions') && props.actionsCount > 2 - } - - // 否则使用原来的检测逻辑 - return ( - props.expandableActions && - props.columns.some((col) => col.key === 'actions') && - actionsColumnNeedsExpanding.value - ) -}) - -// 切换操作列展开/折叠状态 -const toggleActionsExpanded = () => { - actionsExpanded.value = !actionsExpanded.value -} - // 检查第一列是否为勾选列 const hasSelectColumn = computed(() => { return props.columns.length > 0 && props.columns[0].key === 'select'