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,