From 322b602154e2b17ada5012105a71a1915a8a5f06 Mon Sep 17 00:00:00 2001 From: Zyy Date: Sat, 12 Sep 2026 23:08:31 +0800 Subject: [PATCH] fix(frontend): centralize admin API error notifications Show server envelope messages from the response interceptor, with shared transport fallbacks, cancellation handling, and explicit silent requests. Remove duplicate page notifications while preserving contextual batch and irreversible-operation feedback. Propagate request options through API modules and document notification ownership. Verify the notification-only tree with ESLint, vue-tsc, and a Vite build. Co-authored-by: Codex --- docs/architecture.md | 10 ++++ frontend/src/api/error.ts | 34 ++++++++---- frontend/src/api/modules/account-groups.ts | 19 ++++--- frontend/src/api/modules/accounts.ts | 49 +++++++++++------ frontend/src/api/modules/api-keys.ts | 22 +++++--- frontend/src/api/modules/auth.ts | 10 ++-- frontend/src/api/modules/backups.ts | 25 ++++++--- frontend/src/api/modules/dashboard.ts | 7 ++- frontend/src/api/modules/proxies.ts | 25 ++++++--- frontend/src/api/modules/settings.ts | 19 ++++--- frontend/src/api/modules/system.ts | 14 +++-- frontend/src/api/modules/usage.ts | 19 ++++--- frontend/src/api/request.ts | 49 +++++++++++------ .../src/composables/useAccountGroupCatalog.ts | 5 +- frontend/src/composables/useAsyncAction.ts | 13 +++-- frontend/src/composables/usePagedQuery.ts | 13 +++-- frontend/src/composables/useProxyCatalog.ts | 6 +-- frontend/src/composables/useRequestState.ts | 23 ++++++-- .../src/composables/useStablePagedQuery.ts | 5 +- .../components/SystemUpdateModal/index.vue | 16 ++---- frontend/src/stores/modules/auth.ts | 12 ++--- frontend/src/stores/modules/system-update.ts | 11 ++-- .../composables/useAccountGroups.ts | 17 +++--- .../components/AccountProfileModal/index.vue | 3 -- .../composables/useAccountBatchEditor.ts | 2 +- .../composables/useAccountConnectionTest.ts | 12 ++--- .../accounts/composables/useAccountEditor.ts | 2 +- .../composables/useAccountMutations.ts | 20 +++---- .../composables/useAccountOnboarding.ts | 7 +-- .../useAccountProfileStatistics.ts | 26 ++++----- .../composables/useAccountResetCredits.ts | 21 +++++--- .../accounts/composables/useAccountsQuery.ts | 9 +--- .../composables/useApiKeyMutations.ts | 36 +++++++------ .../api-keys/composables/useApiKeyUse.ts | 36 +++++-------- .../api-keys/composables/useApiKeysQuery.ts | 35 +++++------- .../dashboard/composables/useDashboard.ts | 25 ++++++--- .../src/views/login/components/LoginPanel.vue | 28 +--------- frontend/src/views/login/index.vue | 1 - .../proxies/composables/useProxyAccounts.ts | 8 ++- frontend/src/views/proxies/index.vue | 14 ++--- .../composables/useClientDownloads.ts | 2 +- .../settings/composables/useAdminApiKey.ts | 7 +-- .../settings/composables/useBackupRecords.ts | 19 +++---- .../settings/composables/useBackupSettings.ts | 11 ++-- .../settings/composables/useSettingsForm.ts | 30 +++++------ .../usage/composables/useOpsErrorsTable.ts | 8 ++- .../composables/useRequestDiagnostics.ts | 10 ++-- .../usage/composables/useUsageRecordDetail.ts | 6 +-- .../usage/composables/useUsageRecordsTable.ts | 53 ++++++++++--------- 49 files changed, 450 insertions(+), 404 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 7f18c4ca0..c06d40f4e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -259,6 +259,16 @@ Provider 管理适配在仍持有结构化失败事实时选择静态 `public_me 连接测试的 `gateway` / `provider` / `upstream` 来源以及 `not_sent` / `sent` / `ambiguous` 发送状态由 Core 在 仍持有完整执行错误时一次判定;Vue 只能根据稳定字段生成摘要,不能匹配英文错误句子反推来源。 +Vue 普通管理请求的错误提示由 `api/request.ts` 响应拦截器统一负责:优先展示安全信封的 `message`, +缺失或空白时才使用请求层的 HTTP、网络或超时兜底;成功业务码为 `200`,HTTP 成功但业务码失败也会拒绝。 +规范化异常保留 `status`、`code`、`requestId` 与 `kind`。页面和 `useAsyncAction` 不再重复弹出接口错误; +查询可以保留失败状态与重试入口,本地校验、文件操作、SSE 诊断和成功响应中的业务结果仍归各自 owner。 + +API 模块通过统一的 `RequestOptions` 传递 `signal`、`timeout` 和 `silent`。取消或已被新查询取代的请求 +不弹提示;后台轮询、重启探测等显式使用 `silent`,它只关闭提示,不吞异常,也不跳过会话失效处理。 +批量操作的部分成功汇总、不可逆操作的结果未知等必要业务处理先将对应请求静默,再由业务 owner 提供 +一次有上下文的反馈;不得为普通失败重新维护一套消息或业务码映射。 + ## 6. 路由、账号范围与 continuation Client Key 与账号分组形成授权范围: diff --git a/frontend/src/api/error.ts b/frontend/src/api/error.ts index 51106ae4e..ef894e7dc 100644 --- a/frontend/src/api/error.ts +++ b/frontend/src/api/error.ts @@ -1,4 +1,4 @@ -import type { AxiosError, AxiosResponseHeaders, RawAxiosResponseHeaders } from 'axios' +import type { AxiosError, AxiosResponse, AxiosResponseHeaders, RawAxiosResponseHeaders } from 'axios' export type ApiErrorKind = 'api' | 'timeout' | 'network' | 'http' | 'cancelled' @@ -23,31 +23,43 @@ export class ApiError extends Error { export function normalizeApiError(error: AxiosError) { const status = error.response?.status ?? 0 const requestId = responseHeader(error.response?.headers, 'x-request-id') - const envelope = adminErrorEnvelope(error.response?.data) - if (envelope) { - return new ApiError(envelope.message, status, envelope.code, requestId, 'api') - } - if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT' || error.code === 'ERR_TIMEOUT') { - return new ApiError('请求超时,请稍后重试', status, undefined, requestId, 'timeout') - } if (error.code === 'ERR_CANCELED') { return new ApiError('请求已取消', status, undefined, requestId, 'cancelled') } + const apiError = error.response && normalizeApiResponseError(error.response) + if (apiError) + return apiError + if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT' || error.code === 'ERR_TIMEOUT') { + return new ApiError('请求超时,请稍后重试', status, undefined, requestId, 'timeout') + } if (!error.response) { return new ApiError('网络连接失败,请检查网络后重试', 0, undefined, requestId, 'network') } return new ApiError(httpFallback(status), status, undefined, requestId, 'http') } +export function normalizeApiResponseError(response: AxiosResponse) { + const envelope = adminErrorEnvelope(response.data) + if (!envelope || envelope.code === 200) + return undefined + return new ApiError( + envelope.message || httpFallback(response.status >= 400 ? response.status : 0), + response.status, + envelope.code, + responseHeader(response.headers, 'x-request-id'), + 'api', + ) +} + function adminErrorEnvelope(value: unknown): AdminErrorEnvelope | null { if (!value || typeof value !== 'object') return null const record = value as Record - if (typeof record.code !== 'number' || typeof record.message !== 'string') + if (typeof record.code !== 'number' || !Number.isSafeInteger(record.code)) return null - const message = record.message.trim() - return message ? { code: record.code, message } : null + const message = typeof record.message === 'string' ? record.message.trim() : '' + return { code: record.code, message } } function responseHeader( diff --git a/frontend/src/api/modules/account-groups.ts b/frontend/src/api/modules/account-groups.ts index 4bfa26c6a..ec5b729ca 100644 --- a/frontend/src/api/modules/account-groups.ts +++ b/frontend/src/api/modules/account-groups.ts @@ -1,3 +1,4 @@ +import type { RequestOptions } from '../request' import request from '../request' export interface AccountGroupRef { @@ -78,50 +79,56 @@ interface AccountGroupIdParam { id: string } -export function getAccountGroups(data: AccountGroupListParams) { +export function getAccountGroups(data: AccountGroupListParams, options: RequestOptions = {}) { return request({ url: '/api/admin/account-groups', method: 'GET', params: data, + ...options, }) } -export function createAccountGroup(data: AccountGroupCreateParam) { +export function createAccountGroup(data: AccountGroupCreateParam, options: RequestOptions = {}) { return request({ url: '/api/admin/account-groups/create', method: 'POST', data, + ...options, }) } -export function updateAccountGroup(data: AccountGroupUpdateParam) { +export function updateAccountGroup(data: AccountGroupUpdateParam, options: RequestOptions = {}) { return request({ url: '/api/admin/account-groups/update', method: 'POST', data, + ...options, }) } -export function enableAccountGroup(data: AccountGroupIdParam) { +export function enableAccountGroup(data: AccountGroupIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/account-groups/enable', method: 'POST', data, + ...options, }) } -export function disableAccountGroup(data: AccountGroupIdParam) { +export function disableAccountGroup(data: AccountGroupIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/account-groups/disable', method: 'POST', data, + ...options, }) } -export function deleteAccountGroup(data: AccountGroupIdParam) { +export function deleteAccountGroup(data: AccountGroupIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/account-groups/delete', method: 'POST', data, + ...options, }) } diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index b48f7a98e..27da0c246 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -1,3 +1,4 @@ +import type { RequestOptions } from '../request' import type { AccountGroupRef } from './account-groups' import request from '../request' @@ -349,43 +350,48 @@ interface AccountExportParam { confirm: string } -export function getAccounts(data: AccountListParams) { +export function getAccounts(data: AccountListParams, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts', method: 'GET', params: data, + ...options, }) } -export function exportAccounts(data: AccountExportParam) { +export function exportAccounts(data: AccountExportParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/export', method: 'GET', params: data, + ...options, }) } -export function refreshAccount(data: AccountIdParam) { +export function refreshAccount(data: AccountIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/refresh', method: 'POST', data, + ...options, }) } -export function recoverAccount(data: AccountIdParam) { +export function recoverAccount(data: AccountIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/recover', method: 'POST', data, + ...options, }) } -export function getAccountProfileStatistics(data: AccountIdParam) { +export function getAccountProfileStatistics(data: AccountIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/profile-statistics', method: 'GET', params: data, + ...options, }) } @@ -404,90 +410,101 @@ function stableAvatarVersion(value: string) { return hash.toString(36) } -export function refreshAccountQuota(data: AccountIdParam) { +export function refreshAccountQuota(data: AccountIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/quota/refresh', method: 'POST', data, + ...options, }) } -export function getAccountResetCredits(data: AccountIdParam) { +export function getAccountResetCredits(data: AccountIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/reset-credits', method: 'GET', params: data, + ...options, }) } -export function consumeAccountResetCredit(data: AccountResetCreditConsumeParam) { +export function consumeAccountResetCredit(data: AccountResetCreditConsumeParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/reset-credits', method: 'POST', data, + ...options, }) } -export function getAccountModels(data: AccountIdParam) { +export function getAccountModels(data: AccountIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/models', method: 'GET', params: data, + ...options, }) } -export function refreshAccountModels(data: AccountIdParam) { +export function refreshAccountModels(data: AccountIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/models/refresh', method: 'POST', data, + ...options, }) } -export function importAccounts(data: AccountImportParam) { +export function importAccounts(data: AccountImportParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/import', method: 'POST', data, + ...options, }) } -export function updateAccount(data: AccountUpdateParam) { +export function updateAccount(data: AccountUpdateParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/update', method: 'POST', data, + ...options, }) } -export function batchUpdateAccounts(data: AccountBatchUpdateParam) { +export function batchUpdateAccounts(data: AccountBatchUpdateParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/batch-update', method: 'POST', data, + ...options, }) } -export function deleteAccounts(data: AccountDeleteParams) { +export function deleteAccounts(data: AccountDeleteParams, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/delete', method: 'POST', data, + ...options, }) } -export function startAccountOAuth(data: AccountOAuthStartParam) { +export function startAccountOAuth(data: AccountOAuthStartParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/oauth/start', method: 'POST', data, + ...options, }) } -export function completeAccountOAuth(data: AccountOAuthCompleteParam) { +export function completeAccountOAuth(data: AccountOAuthCompleteParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/oauth/complete', method: 'POST', data, + ...options, }) } diff --git a/frontend/src/api/modules/api-keys.ts b/frontend/src/api/modules/api-keys.ts index 60e55d98f..bb2b4d77a 100644 --- a/frontend/src/api/modules/api-keys.ts +++ b/frontend/src/api/modules/api-keys.ts @@ -1,3 +1,4 @@ +import type { RequestOptions } from '../request' import type { AccountGroupRef } from './account-groups' import request from '../request' @@ -73,58 +74,65 @@ interface ApiKeyIdParam { id: string } -export function getApiKeys(data: ApiKeyListParams) { +export function getApiKeys(data: ApiKeyListParams, options: RequestOptions = {}) { return request({ url: '/api/admin/client-keys', method: 'GET', params: data, + ...options, }) } -export function createApiKey(data: ApiKeyWriteParam) { +export function createApiKey(data: ApiKeyWriteParam, options: RequestOptions = {}) { return request({ url: '/api/admin/client-keys/create', method: 'POST', data, + ...options, }) } -export function updateApiKey(data: ApiKeyUpdateParam) { +export function updateApiKey(data: ApiKeyUpdateParam, options: RequestOptions = {}) { return request({ url: '/api/admin/client-keys/update', method: 'POST', data, + ...options, }) } -export function revealApiKey(data: ApiKeyIdParam) { +export function revealApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/client-keys/reveal', method: 'GET', params: data, + ...options, }) } -export function deleteApiKey(data: ApiKeyIdParam) { +export function deleteApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/client-keys/delete', method: 'POST', data, + ...options, }) } -export function disableApiKey(data: ApiKeyIdParam) { +export function disableApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/client-keys/disable', method: 'POST', data, + ...options, }) } -export function enableApiKey(data: ApiKeyIdParam) { +export function enableApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/client-keys/enable', method: 'POST', data, + ...options, }) } diff --git a/frontend/src/api/modules/auth.ts b/frontend/src/api/modules/auth.ts index 37be3a25a..86c34d739 100644 --- a/frontend/src/api/modules/auth.ts +++ b/frontend/src/api/modules/auth.ts @@ -1,3 +1,4 @@ +import type { RequestOptions } from '../request' import request from '../request' export interface LoginResponse { @@ -17,24 +18,27 @@ interface LoginParam { password: string } -export function login(data: LoginParam) { +export function login(data: LoginParam, options: RequestOptions = {}) { return request({ url: '/api/admin/auth/login', method: 'POST', data, + ...options, }) } -export function getAuthStatus() { +export function getAuthStatus(options: RequestOptions = {}) { return request({ url: '/api/admin/auth/status', method: 'GET', + ...options, }) } -export function logout() { +export function logout(options: RequestOptions = {}) { return request({ url: '/api/admin/auth/logout', method: 'POST', + ...options, }) } diff --git a/frontend/src/api/modules/backups.ts b/frontend/src/api/modules/backups.ts index 1eb2858d2..4f313ac38 100644 --- a/frontend/src/api/modules/backups.ts +++ b/frontend/src/api/modules/backups.ts @@ -1,3 +1,4 @@ +import type { RequestOptions } from '../request' import request from '../request' export interface BackupSettingsView { @@ -95,63 +96,71 @@ export interface DownloadUrlResult { expiresInSeconds: number } -export function getBackupSettings() { +export function getBackupSettings(options: RequestOptions = {}) { return request({ url: '/api/admin/settings/backups', method: 'GET', + ...options, }) } -export function updateBackupStorage(data: UpdateBackupStoragePayload) { +export function updateBackupStorage(data: UpdateBackupStoragePayload, options: RequestOptions = {}) { return request({ url: '/api/admin/settings/backups/storage/update', method: 'POST', data, + ...options, }) } -export function testBackupStorage() { +export function testBackupStorage(options: RequestOptions = {}) { return request({ url: '/api/admin/settings/backups/storage/test', method: 'POST', + ...options, }) } -export function updateBackupSchedule(data: UpdateBackupSchedulePayload) { +export function updateBackupSchedule(data: UpdateBackupSchedulePayload, options: RequestOptions = {}) { return request({ url: '/api/admin/settings/backups/schedule/update', method: 'POST', data, + ...options, }) } -export function getBackupRecords(params: BackupRecordsParams) { +export function getBackupRecords(params: BackupRecordsParams, options: RequestOptions = {}) { return request({ url: '/api/admin/settings/backups/records', method: 'GET', params, + ...options, }) } -export function createBackup() { +export function createBackup(options: RequestOptions = {}) { return request({ url: '/api/admin/settings/backups/create', method: 'POST', + ...options, }) } -export function getBackupDownloadUrl(backupId: string) { +export function getBackupDownloadUrl(backupId: string, options: RequestOptions = {}) { return request({ url: '/api/admin/settings/backups/download-url', method: 'POST', data: { backupId }, + ...options, }) } -export function deleteBackup(backupId: string) { +export function deleteBackup(backupId: string, options: RequestOptions = {}) { return request({ url: '/api/admin/settings/backups/delete', method: 'POST', data: { backupId }, + ...options, }) } diff --git a/frontend/src/api/modules/dashboard.ts b/frontend/src/api/modules/dashboard.ts index adc8e441d..978cd6b97 100644 --- a/frontend/src/api/modules/dashboard.ts +++ b/frontend/src/api/modules/dashboard.ts @@ -1,3 +1,4 @@ +import type { RequestOptions } from '../request' import type { AccountQuotaWindow } from './accounts' import type { UsageListRecord } from './usage' import request from '../request' @@ -185,18 +186,20 @@ interface DashboardQuery { kind: string } -export function getDashboardSummary(data: DashboardQuery) { +export function getDashboardSummary(data: DashboardQuery, options: RequestOptions = {}) { return request({ url: '/api/admin/dashboard/summary', method: 'GET', params: data, + ...options, }) } -export function getDashboardTrend(data: DashboardQuery) { +export function getDashboardTrend(data: DashboardQuery, options: RequestOptions = {}) { return request({ url: '/api/admin/dashboard/trend', method: 'GET', params: data, + ...options, }) } diff --git a/frontend/src/api/modules/proxies.ts b/frontend/src/api/modules/proxies.ts index e9b299fe5..34f84eaab 100644 --- a/frontend/src/api/modules/proxies.ts +++ b/frontend/src/api/modules/proxies.ts @@ -1,3 +1,4 @@ +import type { RequestOptions } from '../request' import type { AccountGroupRef } from './account-groups' import request from '../request' @@ -43,19 +44,21 @@ interface ProxyAccountPage { page: ProxyPage['page'] } -export function getProxyAccounts(params: { proxyId: string, page: number, pageSize: number, search?: string }) { +export function getProxyAccounts(params: { proxyId: string, page: number, pageSize: number, search?: string }, options: RequestOptions = {}) { return request({ url: '/api/admin/proxies/accounts', method: 'GET', params, + ...options, }) } -export function removeProxyAccount(data: { proxyId: string, accountId: string }) { +export function removeProxyAccount(data: { proxyId: string, accountId: string }, options: RequestOptions = {}) { return request<{ configRevision: number }>({ url: '/api/admin/proxies/accounts/remove', method: 'POST', data, + ...options, }) } @@ -64,52 +67,58 @@ interface ProxyMutation { configRevision: number } -export function getProxies(params: { page: number, pageSize: number, search?: string }) { +export function getProxies(params: { page: number, pageSize: number, search?: string }, options: RequestOptions = {}) { return request({ url: '/api/admin/proxies', method: 'GET', params, + ...options, }) } -export function createProxy(data: { name: string, proxyUrl: string }) { +export function createProxy(data: { name: string, proxyUrl: string }, options: RequestOptions = {}) { return request({ url: '/api/admin/proxies/create', method: 'POST', data, + ...options, }) } -export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string }) { +export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string }, options: RequestOptions = {}) { return request({ url: '/api/admin/proxies/update', method: 'POST', data, + ...options, }) } -export function deleteProxy(data: { id: string, revision: number }) { +export function deleteProxy(data: { id: string, revision: number }, options: RequestOptions = {}) { return request<{ configRevision: number }>({ url: '/api/admin/proxies/delete', method: 'POST', data, + ...options, }) } -export function testProxy(data: { id: string, revision: number }) { +export function testProxy(data: { id: string, revision: number }, options: RequestOptions = {}) { return request({ url: '/api/admin/proxies/test', method: 'POST', data, timeout: 25000, + ...options, }) } -export function probeProxy(data: { proxyUrl: string }) { +export function probeProxy(data: { proxyUrl: string }, options: RequestOptions = {}) { return request({ url: '/api/admin/proxies/probe', method: 'POST', data, timeout: 25000, + ...options, }) } diff --git a/frontend/src/api/modules/settings.ts b/frontend/src/api/modules/settings.ts index 68bed568d..b0cc12ae9 100644 --- a/frontend/src/api/modules/settings.ts +++ b/frontend/src/api/modules/settings.ts @@ -1,3 +1,4 @@ +import type { RequestOptions } from '../request' import request from '../request' export type RotationStrategy = 'smart' | 'quota_reset_priority' | 'round_robin' | 'sticky' @@ -49,48 +50,54 @@ export interface DeletedAdminApiKey { message: string } -export function getSettings() { +export function getSettings(options: RequestOptions = {}) { return request({ url: '/api/admin/settings', method: 'GET', + ...options, }) } type UpdateSettingsParam = Omit -export function updateSettings(data: UpdateSettingsParam) { +export function updateSettings(data: UpdateSettingsParam, options: RequestOptions = {}) { return request({ url: '/api/admin/settings/update', method: 'POST', data, + ...options, }) } -export function getAdminApiKeyStatus() { +export function getAdminApiKeyStatus(options: RequestOptions = {}) { return request({ url: '/api/admin/settings/admin-api-key', method: 'GET', + ...options, }) } -export function regenerateAdminApiKey() { +export function regenerateAdminApiKey(options: RequestOptions = {}) { return request({ url: '/api/admin/settings/admin-api-key/regenerate', method: 'POST', + ...options, }) } -export function deleteAdminApiKey() { +export function deleteAdminApiKey(options: RequestOptions = {}) { return request({ url: '/api/admin/settings/admin-api-key/delete', method: 'POST', + ...options, }) } -export function getCodexDesktopWindowsDownloads(refresh = false) { +export function getCodexDesktopWindowsDownloads(refresh = false, options: RequestOptions = {}) { return request({ url: '/api/admin/settings/client-downloads/codex-desktop/windows', method: 'GET', params: refresh ? { refresh: true } : undefined, + ...options, }) } diff --git a/frontend/src/api/modules/system.ts b/frontend/src/api/modules/system.ts index bd1b3d9bd..be5d68e7a 100644 --- a/frontend/src/api/modules/system.ts +++ b/frontend/src/api/modules/system.ts @@ -1,3 +1,4 @@ +import type { RequestOptions } from '../request' import request from '../request' export interface SystemVersion { @@ -42,11 +43,11 @@ export interface SystemRestartAccepted { operationId: string } -export function getSystemVersion(timeout = 0) { +export function getSystemVersion(options: RequestOptions = {}) { return request({ url: '/api/admin/system/version', method: 'GET', - ...(timeout ? { timeout } : {}), + ...options, }) } @@ -58,25 +59,28 @@ interface SystemUpdateTarget { targetVersion?: string } -export function getSystemUpdateDetail(data: SystemUpdateDetailQuery) { +export function getSystemUpdateDetail(data: SystemUpdateDetailQuery, options: RequestOptions = {}) { return request({ url: '/api/admin/system/update/detail', method: 'GET', params: data, + ...options, }) } -export function performSystemUpdate(data: SystemUpdateTarget) { +export function performSystemUpdate(data: SystemUpdateTarget, options: RequestOptions = {}) { return request({ url: '/api/admin/system/update', method: 'POST', data, + ...options, }) } -export function restartSystem() { +export function restartSystem(options: RequestOptions = {}) { return request({ url: '/api/admin/system/restart', method: 'POST', + ...options, }) } diff --git a/frontend/src/api/modules/usage.ts b/frontend/src/api/modules/usage.ts index ff713ecae..a83b6fb61 100644 --- a/frontend/src/api/modules/usage.ts +++ b/frontend/src/api/modules/usage.ts @@ -1,3 +1,4 @@ +import type { RequestOptions } from '../request' import request from '../request' export interface UsageTokenDetails { @@ -471,50 +472,56 @@ interface UsageDetailQuery { type UsageDiagnosticsQuery = UsageRangeQuery & { dimension: string } -export function getUsageRecords(data: UsagePageQuery) { +export function getUsageRecords(data: UsagePageQuery, options: RequestOptions = {}) { return request({ url: '/api/admin/usage/records', method: 'GET', params: data, + ...options, }) } -export function getOpsErrors(data: OpsErrorPageQuery) { +export function getOpsErrors(data: OpsErrorPageQuery, options: RequestOptions = {}) { return request({ url: '/api/admin/operations/errors', method: 'GET', params: data, + ...options, }) } -export function getUsageRecordDetail(data: UsageDetailQuery) { +export function getUsageRecordDetail(data: UsageDetailQuery, options: RequestOptions = {}) { return request({ url: '/api/admin/usage/records/detail', method: 'GET', params: data, + ...options, }) } -export function getUsageRecordSummary(data: UsageRangeQuery) { +export function getUsageRecordSummary(data: UsageRangeQuery, options: RequestOptions = {}) { return request({ url: '/api/admin/usage/records/summary', method: 'GET', params: data, + ...options, }) } -export function getUsageRecordInsightsOverview(data: UsageRangeQuery) { +export function getUsageRecordInsightsOverview(data: UsageRangeQuery, options: RequestOptions = {}) { return request({ url: '/api/admin/usage/insights/overview', method: 'GET', params: data, + ...options, }) } -export function getUsageRecordInsightsDiagnostics(data: UsageDiagnosticsQuery) { +export function getUsageRecordInsightsDiagnostics(data: UsageDiagnosticsQuery, options: RequestOptions = {}) { return request({ url: '/api/admin/usage/insights/diagnostics', method: 'GET', params: data, + ...options, }) } diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index a0d954f46..56da8efa4 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -4,13 +4,24 @@ import type { AxiosRequestConfig, AxiosResponse, } from 'axios' -import axios from 'axios' +import type { ApiError } from './error' +import axios from 'axios' +import { toast } from '@/components/base/BaseToast' import { API_BASE_URL, API_TIMEOUT_MS } from './constants' -import { normalizeApiError } from './error' +import { normalizeApiError, normalizeApiResponseError } from './error' export { ApiError } from './error' +export interface RequestOptions { + // 静默只关闭全局提示,不吞掉异常,也不跳过会话失效处理。 + silent?: boolean + signal?: AbortSignal + timeout?: number +} + +type RequestConfig = AxiosRequestConfig & RequestOptions + const http: AxiosInstance = axios.create({ baseURL: API_BASE_URL, timeout: API_TIMEOUT_MS, @@ -42,22 +53,30 @@ function handleUnauthorizedOnce() { } http.interceptors.response.use( - (response: AxiosResponse) => { + (response: AxiosResponse) => { + const error = normalizeApiResponseError(response) + if (error) + return rejectRequest(error, response.config) return response }, (error: AxiosError) => { - const { response } = error - - const status = response?.status ?? 0 - - if (status === 401 && !isAuthenticationRequest(error.config?.url)) { - handleUnauthorizedOnce() - } - - return Promise.reject(normalizeApiError(error)) + return rejectRequest(normalizeApiError(error), error.config) }, ) +function rejectRequest(error: ApiError, config?: AxiosRequestConfig & Pick) { + if (error.kind === 'cancelled' || config?.signal?.aborted) + return Promise.reject(error) + + const sessionExpired = error.status === 401 && !isAuthenticationRequest(config?.url) + const alreadyHandled = sessionExpired && unauthorizedHandled + if (sessionExpired) + handleUnauthorizedOnce() + if (!config?.silent && !alreadyHandled) + toast.error(error.message) + return Promise.reject(error) +} + interface ApiEnvelope { code: number message: string @@ -69,12 +88,12 @@ function isApiEnvelope(value: unknown): value is ApiEnvelope { typeof value === 'object' && value !== null && 'data' in value - && 'code' in value - && 'message' in value + && 'code' in value && typeof value.code === 'number' + && 'message' in value && typeof value.message === 'string' ) } -export default async function request(config: AxiosRequestConfig): Promise { +export default async function request(config: RequestConfig): Promise { const response = await http.request({ ...config, }) diff --git a/frontend/src/composables/useAccountGroupCatalog.ts b/frontend/src/composables/useAccountGroupCatalog.ts index 0b2011a50..0115c79c2 100644 --- a/frontend/src/composables/useAccountGroupCatalog.ts +++ b/frontend/src/composables/useAccountGroupCatalog.ts @@ -2,8 +2,6 @@ import type { AccountGroup } from '@/api' import { onMounted, shallowRef } from 'vue' import { getAccountGroups } from '@/api' -import { toast } from '@/components/base/BaseToast' -import { errorMessage } from '@/utils/async' export function useAccountGroupCatalog(options: { immediate?: boolean } = {}) { const groups = shallowRef([]) @@ -21,8 +19,7 @@ export function useAccountGroupCatalog(options: { immediate?: boolean } = {}) { groups.value = items return items } - catch (error: unknown) { - toast.error(errorMessage(error, '账号分组加载失败')) + catch { return [] } finally { diff --git a/frontend/src/composables/useAsyncAction.ts b/frontend/src/composables/useAsyncAction.ts index 4ea630cd3..03586192a 100644 --- a/frontend/src/composables/useAsyncAction.ts +++ b/frontend/src/composables/useAsyncAction.ts @@ -1,25 +1,24 @@ import { shallowRef } from 'vue' +import { ApiError } from '@/api/request' import { toast } from '@/components/base/BaseToast' import { errorMessage, withMinimumDuration } from '@/utils/async' type MaybePromise = T | Promise -type AsyncActionErrorText = string | false | ((error: unknown) => string | false) - interface AsyncActionRunOptions { - errorText?: AsyncActionErrorText + // 仅用于本地校验、文件与浏览器操作;接口错误由请求层负责提示。 + errorText?: string | false minimumMs?: number onError?: (error: unknown) => void rethrow?: boolean } -function resolveErrorText(error: unknown, errorText: AsyncActionErrorText | undefined) { - const fallback = typeof errorText === 'function' ? errorText(error) : errorText - if (fallback === false) { +function resolveErrorText(error: unknown, errorText: string | false | undefined) { + if (error instanceof ApiError || errorText === false) { return '' } - return errorMessage(error, '') || fallback || '' + return errorMessage(error, errorText || '操作失败') } export function useAsyncAction() { diff --git a/frontend/src/composables/usePagedQuery.ts b/frontend/src/composables/usePagedQuery.ts index 0f6c064f3..9deaf0af8 100644 --- a/frontend/src/composables/usePagedQuery.ts +++ b/frontend/src/composables/usePagedQuery.ts @@ -1,3 +1,4 @@ +import type { RequestOptions } from '@/api/request' import { clamp } from 'es-toolkit' import { shallowRef } from 'vue' @@ -15,7 +16,7 @@ interface PageResult { export function usePagedQuery(options: { initialPageSize: number - load: (pagination: { page: number, pageSize: number }) => Promise + load: (pagination: { page: number, pageSize: number }, options: RequestOptions) => Promise onSuccess?: (result: Result) => void onError?: (error: unknown) => void }) { @@ -26,14 +27,16 @@ export function usePagedQuery(options: { const request = useRequestState(options.onError) const { loading, error, invalidate } = request - async function execute(execution: { silent?: boolean } = {}) { - const requestId = request.start(execution.silent) + async function execute(execution: { silent?: boolean, background?: boolean } = {}) { + // 保留当前列表的刷新不一定静默;手动刷新仍需由请求层提示失败。 + const background = execution.background || execution.silent + const requestId = request.start(background) try { const result = await options.load({ page: page.value, pageSize: pageSize.value, - }) + }, { silent: execution.silent, signal: request.signal }) if (!request.isCurrent(requestId)) return false @@ -50,7 +53,7 @@ export function usePagedQuery(options: { return true } catch (cause: unknown) { - request.fail(requestId, cause, execution.silent) + request.fail(requestId, cause, background) return false } finally { diff --git a/frontend/src/composables/useProxyCatalog.ts b/frontend/src/composables/useProxyCatalog.ts index 4b362c4b2..43b5afac1 100644 --- a/frontend/src/composables/useProxyCatalog.ts +++ b/frontend/src/composables/useProxyCatalog.ts @@ -1,8 +1,6 @@ import type { OutboundProxyRecord } from '@/api' import { onMounted, shallowRef } from 'vue' import { getProxies } from '@/api' -import { toast } from '@/components/base/BaseToast' -import { errorMessage } from '@/utils/async' export function useProxyCatalog() { const proxies = shallowRef([]) @@ -20,9 +18,7 @@ export function useProxyCatalog() { } proxies.value = items } - catch (error) { - toast.error(errorMessage(error, '代理列表加载失败')) - } + catch {} finally { loading.value = false } diff --git a/frontend/src/composables/useRequestState.ts b/frontend/src/composables/useRequestState.ts index 99c301cfa..3c039a300 100644 --- a/frontend/src/composables/useRequestState.ts +++ b/frontend/src/composables/useRequestState.ts @@ -1,14 +1,18 @@ import { onScopeDispose, shallowRef } from 'vue' +import { ApiError } from '@/api/request' import { errorMessage } from '@/utils/async' export function useRequestState(onError?: (error: unknown) => void) { const loading = shallowRef(false) const error = shallowRef('') let sequence = 0 + let controller: AbortController | undefined function start(silent = false) { const requestId = ++sequence + controller?.abort() + controller = new AbortController() if (!silent) { loading.value = true error.value = '' @@ -21,9 +25,9 @@ export function useRequestState(onError?: (error: unknown) => void) { } function fail(requestId: number, cause: unknown, silent = false) { - if (!isCurrent(requestId) || silent) + if (!isCurrent(requestId) || silent || (cause instanceof ApiError && cause.kind === 'cancelled')) return - error.value = errorMessage(cause, '加载失败') + error.value = errorMessage(cause) onError?.(cause) } @@ -34,10 +38,23 @@ export function useRequestState(onError?: (error: unknown) => void) { function invalidate() { sequence += 1 + controller?.abort() + controller = undefined loading.value = false } onScopeDispose(invalidate) - return { loading, error, start, isCurrent, fail, finish, invalidate } + return { + loading, + error, + start, + isCurrent, + fail, + finish, + invalidate, + get signal() { + return controller?.signal + }, + } } diff --git a/frontend/src/composables/useStablePagedQuery.ts b/frontend/src/composables/useStablePagedQuery.ts index 744be4294..d4ae36f14 100644 --- a/frontend/src/composables/useStablePagedQuery.ts +++ b/frontend/src/composables/useStablePagedQuery.ts @@ -1,3 +1,4 @@ +import type { RequestOptions } from '@/api/request' import { shallowRef } from 'vue' import { useRequestState } from './useRequestState' @@ -16,7 +17,7 @@ interface PageRequest { export function useStablePagedQuery(options: { initialPageSize: number - load: (pagination: PageRequest) => Promise + load: (pagination: PageRequest, options: RequestOptions) => Promise onSuccess?: (result: Result) => void onError?: (error: unknown) => void }) { @@ -34,7 +35,7 @@ export function useStablePagedQuery(options: { const result = await options.load({ currentPage: Math.max(1, targetPage), pageSize: pageSize.value, - }) + }, { silent: execution.silent, signal: request.signal }) if (!request.isCurrent(requestId)) return false diff --git a/frontend/src/layout/components/SystemUpdateModal/index.vue b/frontend/src/layout/components/SystemUpdateModal/index.vue index b241d661b..27b744c3c 100644 --- a/frontend/src/layout/components/SystemUpdateModal/index.vue +++ b/frontend/src/layout/components/SystemUpdateModal/index.vue @@ -99,9 +99,7 @@ async function handleCheckUpdates(force = true) { const data = await checkUpdates(force) toast.success(data?.hasUpdate ? '发现可用更新' : '当前已是最新版本') } - catch (error: unknown) { - toast.error(errorMessage(error, '检查更新失败')) - } + catch {} } async function handleUpdateRequest() { @@ -129,9 +127,7 @@ async function handleUpdateRequest() { } await runConfirmedUpdate(remoteTargetVersion) } - catch (error: unknown) { - toast.error(errorMessage(error, '检查更新失败')) - } + catch {} finally { preparingUpdate.value = false } @@ -144,9 +140,7 @@ async function runConfirmedUpdate(targetVersion: string) { toast.success('更新完成,请重启服务') } } - catch (error: unknown) { - toast.error(errorMessage(error, '更新失败')) - } + catch {} } async function handleConfirmUpdate() { @@ -170,9 +164,7 @@ async function handleRestart() { watch(open, (visible) => { if (visible && !loadedOnce.value) { - void loadSystem(false).catch((error: unknown) => { - toast.error(errorMessage(error, '加载系统更新信息失败')) - }) + void loadSystem(false).catch(() => undefined) } }) diff --git a/frontend/src/stores/modules/auth.ts b/frontend/src/stores/modules/auth.ts index f2230e45f..5e64199be 100644 --- a/frontend/src/stores/modules/auth.ts +++ b/frontend/src/stores/modules/auth.ts @@ -3,17 +3,15 @@ import { ref } from 'vue' import { login as apiLogin, logout as apiLogout, getAuthStatus } from '@/api' import { resetUnauthorizedHandling } from '@/api/request' -import { errorMessage } from '@/utils/async' export const useAuthStore = defineStore('auth', () => { const isAuthenticated = ref(false) const sessionChecked = ref(false) const loading = ref(false) - const error = ref(null) async function checkAuth() { try { - const status = await getAuthStatus() + const status = await getAuthStatus({ silent: true }) isAuthenticated.value = status.authenticated if (status.authenticated) resetUnauthorizedHandling() @@ -31,7 +29,6 @@ export const useAuthStore = defineStore('auth', () => { async function login(payload: Parameters[0]) { try { loading.value = true - error.value = null await apiLogin(payload) isAuthenticated.value = true @@ -40,8 +37,7 @@ export const useAuthStore = defineStore('auth', () => { return true } - catch (cause: unknown) { - error.value = errorMessage(cause, '登录失败') + catch { isAuthenticated.value = false return false } @@ -52,7 +48,7 @@ export const useAuthStore = defineStore('auth', () => { async function logout() { try { - await apiLogout() + await apiLogout({ silent: true }) } catch { // 忽略登出错误 @@ -67,14 +63,12 @@ export const useAuthStore = defineStore('auth', () => { isAuthenticated.value = false sessionChecked.value = true loading.value = false - error.value = null } return { isAuthenticated, sessionChecked, loading, - error, checkAuth, login, logout, diff --git a/frontend/src/stores/modules/system-update.ts b/frontend/src/stores/modules/system-update.ts index 2b435d2ad..872bd6971 100644 --- a/frontend/src/stores/modules/system-update.ts +++ b/frontend/src/stores/modules/system-update.ts @@ -197,7 +197,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => { if (loadVersionPromise) return loadVersionPromise - loadVersionPromise = getSystemVersion() + loadVersionPromise = getSystemVersion({ silent: true }) try { const versionData = await loadVersionPromise version.value = versionData @@ -252,7 +252,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => { return result } catch (error: unknown) { - updateError.value = errorMessage(error, '更新失败') + updateError.value = errorMessage(error) appendUpdateLog({ id: `update-client-error-${Date.now()}`, level: 'error', @@ -270,7 +270,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => { while (Date.now() < deadline) { try { - const readyVersion = await getSystemVersion(restartProbeTimeoutMs) + const readyVersion = await getSystemVersion({ timeout: restartProbeTimeoutMs, silent: true }) if (normalizeSystemVersion(readyVersion.version) === expectedVersion) { window.location.reload() return @@ -306,12 +306,13 @@ export const useSystemUpdateStore = defineStore('system-update', () => { disconnectUpdateEvents() try { - await restartSystem() + // 进程可能在返回响应前退出,由下面的目标版本探测判定是否完成。 + await restartSystem({ silent: true }) } catch (error: unknown) { if (error instanceof ApiError && error.status > 0) { setPhase({ kind: 'failed' }) - updateError.value = errorMessage(error, '重启失败') + updateError.value = errorMessage(error) throw error } } diff --git a/frontend/src/views/account-groups/composables/useAccountGroups.ts b/frontend/src/views/account-groups/composables/useAccountGroups.ts index 0ca39e80f..4729d1029 100644 --- a/frontend/src/views/account-groups/composables/useAccountGroups.ts +++ b/frontend/src/views/account-groups/composables/useAccountGroups.ts @@ -47,14 +47,13 @@ export function useAccountGroups() { const query = usePagedQuery({ initialPageSize: 20, - load: ({ page, pageSize }) => + load: ({ page, pageSize }, options) => getAccountGroups({ page, pageSize, search: searchQuery.value.trim() || undefined, enabled: statusQuery.value ? statusQuery.value === 'enabled' : undefined, - }), - onError: error => toast.error(errorMessage(error, '账号分组加载失败')), + }, options), }) const groups = computed(() => query.items.value.map(group => ({ @@ -148,8 +147,6 @@ export function useAccountGroups() { form.value = emptyForm() await Promise.all([query.execute(), loadReferenceKeys()]) toast.success(updating ? '分组已更新' : '分组已创建') - }, { - errorText: editingGroup.value ? '分组更新失败' : '分组创建失败', }) } @@ -172,7 +169,7 @@ export function useAccountGroups() { pendingDisableGroup.value = null await query.execute() toast.success('分组已禁用') - }, { errorText: '禁用分组失败' }) + }) } async function toggleStatus(group: AccountGroup) { @@ -183,9 +180,7 @@ export function useAccountGroups() { await query.execute() toast.success(group.enabled ? '分组已禁用' : '分组已启用') } - catch (error: unknown) { - toast.error(errorMessage(error, '分组状态更新失败')) - } + catch {} }) } @@ -207,7 +202,7 @@ export function useAccountGroups() { pendingDeleteGroup.value = null await query.execute() toast.success('分组已删除') - }, { errorText: '删除分组失败', onError: () => void query.execute() }) + }, { onError: () => void query.execute() }) } async function confirmBatchDelete() { @@ -219,7 +214,7 @@ export function useAccountGroups() { const failures: unknown[] = [] for (const groupId of [...selectedIds.value]) { try { - await deleteAccountGroup({ id: groupId }) + await deleteAccountGroup({ id: groupId }, { silent: true }) deletedCount += 1 const remaining = new Set(selectedIds.value) remaining.delete(groupId) diff --git a/frontend/src/views/accounts/components/AccountProfileModal/index.vue b/frontend/src/views/accounts/components/AccountProfileModal/index.vue index 1fcf91806..df3d644e1 100644 --- a/frontend/src/views/accounts/components/AccountProfileModal/index.vue +++ b/frontend/src/views/accounts/components/AccountProfileModal/index.vue @@ -61,9 +61,6 @@ const { profile, loading, error, load } = useAccountProfileStatistics(accountId,