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 <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-12 23:09:04 +08:00
co-authored by Codex
parent de15722ee1
commit 322b602154
49 changed files with 450 additions and 404 deletions
+10
View File
@@ -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 与账号分组形成授权范围:
+23 -11
View File
@@ -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<unknown>) {
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<unknown>) {
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<string, unknown>
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(
+13 -6
View File
@@ -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<AccountGroupListResponse>({
url: '/api/admin/account-groups',
method: 'GET',
params: data,
...options,
})
}
export function createAccountGroup(data: AccountGroupCreateParam) {
export function createAccountGroup(data: AccountGroupCreateParam, options: RequestOptions = {}) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/create',
method: 'POST',
data,
...options,
})
}
export function updateAccountGroup(data: AccountGroupUpdateParam) {
export function updateAccountGroup(data: AccountGroupUpdateParam, options: RequestOptions = {}) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/update',
method: 'POST',
data,
...options,
})
}
export function enableAccountGroup(data: AccountGroupIdParam) {
export function enableAccountGroup(data: AccountGroupIdParam, options: RequestOptions = {}) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/enable',
method: 'POST',
data,
...options,
})
}
export function disableAccountGroup(data: AccountGroupIdParam) {
export function disableAccountGroup(data: AccountGroupIdParam, options: RequestOptions = {}) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/disable',
method: 'POST',
data,
...options,
})
}
export function deleteAccountGroup(data: AccountGroupIdParam) {
export function deleteAccountGroup(data: AccountGroupIdParam, options: RequestOptions = {}) {
return request<AccountGroupMutationResponse>({
url: '/api/admin/account-groups/delete',
method: 'POST',
data,
...options,
})
}
+33 -16
View File
@@ -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<AccountListResponse>({
url: '/api/admin/accounts',
method: 'GET',
params: data,
...options,
})
}
export function exportAccounts(data: AccountExportParam) {
export function exportAccounts(data: AccountExportParam, options: RequestOptions = {}) {
return request<unknown>({
url: '/api/admin/accounts/export',
method: 'GET',
params: data,
...options,
})
}
export function refreshAccount(data: AccountIdParam) {
export function refreshAccount(data: AccountIdParam, options: RequestOptions = {}) {
return request<AccountRefreshResponse>({
url: '/api/admin/accounts/refresh',
method: 'POST',
data,
...options,
})
}
export function recoverAccount(data: AccountIdParam) {
export function recoverAccount(data: AccountIdParam, options: RequestOptions = {}) {
return request<AccountRefreshResponse>({
url: '/api/admin/accounts/recover',
method: 'POST',
data,
...options,
})
}
export function getAccountProfileStatistics(data: AccountIdParam) {
export function getAccountProfileStatistics(data: AccountIdParam, options: RequestOptions = {}) {
return request<AccountProfileStatisticsResponse>({
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<AccountQuotaResponse>({
url: '/api/admin/accounts/quota/refresh',
method: 'POST',
data,
...options,
})
}
export function getAccountResetCredits(data: AccountIdParam) {
export function getAccountResetCredits(data: AccountIdParam, options: RequestOptions = {}) {
return request<AccountResetCreditsResponse>({
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<AccountResetCreditResultResponse>({
url: '/api/admin/accounts/reset-credits',
method: 'POST',
data,
...options,
})
}
export function getAccountModels(data: AccountIdParam) {
export function getAccountModels(data: AccountIdParam, options: RequestOptions = {}) {
return request<AccountModelsResponse>({
url: '/api/admin/accounts/models',
method: 'GET',
params: data,
...options,
})
}
export function refreshAccountModels(data: AccountIdParam) {
export function refreshAccountModels(data: AccountIdParam, options: RequestOptions = {}) {
return request<AccountModelsResponse>({
url: '/api/admin/accounts/models/refresh',
method: 'POST',
data,
...options,
})
}
export function importAccounts(data: AccountImportParam) {
export function importAccounts(data: AccountImportParam, options: RequestOptions = {}) {
return request<AccountImportResponse>({
url: '/api/admin/accounts/import',
method: 'POST',
data,
...options,
})
}
export function updateAccount(data: AccountUpdateParam) {
export function updateAccount(data: AccountUpdateParam, options: RequestOptions = {}) {
return request<AccountUpdateResponse>({
url: '/api/admin/accounts/update',
method: 'POST',
data,
...options,
})
}
export function batchUpdateAccounts(data: AccountBatchUpdateParam) {
export function batchUpdateAccounts(data: AccountBatchUpdateParam, options: RequestOptions = {}) {
return request<AccountBatchUpdateResponse>({
url: '/api/admin/accounts/batch-update',
method: 'POST',
data,
...options,
})
}
export function deleteAccounts(data: AccountDeleteParams) {
export function deleteAccounts(data: AccountDeleteParams, options: RequestOptions = {}) {
return request<AccountDeletionResponse>({
url: '/api/admin/accounts/delete',
method: 'POST',
data,
...options,
})
}
export function startAccountOAuth(data: AccountOAuthStartParam) {
export function startAccountOAuth(data: AccountOAuthStartParam, options: RequestOptions = {}) {
return request<AccountOAuthStartResponse>({
url: '/api/admin/accounts/oauth/start',
method: 'POST',
data,
...options,
})
}
export function completeAccountOAuth(data: AccountOAuthCompleteParam) {
export function completeAccountOAuth(data: AccountOAuthCompleteParam, options: RequestOptions = {}) {
return request<AccountOAuthCompleteResponse>({
url: '/api/admin/accounts/oauth/complete',
method: 'POST',
data,
...options,
})
}
+15 -7
View File
@@ -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<ApiKeyListResponse>({
url: '/api/admin/client-keys',
method: 'GET',
params: data,
...options,
})
}
export function createApiKey(data: ApiKeyWriteParam) {
export function createApiKey(data: ApiKeyWriteParam, options: RequestOptions = {}) {
return request<ApiKeyCreateResponse>({
url: '/api/admin/client-keys/create',
method: 'POST',
data,
...options,
})
}
export function updateApiKey(data: ApiKeyUpdateParam) {
export function updateApiKey(data: ApiKeyUpdateParam, options: RequestOptions = {}) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/update',
method: 'POST',
data,
...options,
})
}
export function revealApiKey(data: ApiKeyIdParam) {
export function revealApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
return request<ApiKeyRevealResponse>({
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<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/delete',
method: 'POST',
data,
...options,
})
}
export function disableApiKey(data: ApiKeyIdParam) {
export function disableApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/disable',
method: 'POST',
data,
...options,
})
}
export function enableApiKey(data: ApiKeyIdParam) {
export function enableApiKey(data: ApiKeyIdParam, options: RequestOptions = {}) {
return request<ApiKeyMutationResponse>({
url: '/api/admin/client-keys/enable',
method: 'POST',
data,
...options,
})
}
+7 -3
View File
@@ -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<LoginResponse>({
url: '/api/admin/auth/login',
method: 'POST',
data,
...options,
})
}
export function getAuthStatus() {
export function getAuthStatus(options: RequestOptions = {}) {
return request<AuthStatusResponse>({
url: '/api/admin/auth/status',
method: 'GET',
...options,
})
}
export function logout() {
export function logout(options: RequestOptions = {}) {
return request<LogoutResponse>({
url: '/api/admin/auth/logout',
method: 'POST',
...options,
})
}
+17 -8
View File
@@ -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<BackupSettingsView>({
url: '/api/admin/settings/backups',
method: 'GET',
...options,
})
}
export function updateBackupStorage(data: UpdateBackupStoragePayload) {
export function updateBackupStorage(data: UpdateBackupStoragePayload, options: RequestOptions = {}) {
return request<BackupSettingsView>({
url: '/api/admin/settings/backups/storage/update',
method: 'POST',
data,
...options,
})
}
export function testBackupStorage() {
export function testBackupStorage(options: RequestOptions = {}) {
return request<ConnectionTestResult>({
url: '/api/admin/settings/backups/storage/test',
method: 'POST',
...options,
})
}
export function updateBackupSchedule(data: UpdateBackupSchedulePayload) {
export function updateBackupSchedule(data: UpdateBackupSchedulePayload, options: RequestOptions = {}) {
return request<BackupSettingsView>({
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<BackupRecordPage>({
url: '/api/admin/settings/backups/records',
method: 'GET',
params,
...options,
})
}
export function createBackup() {
export function createBackup(options: RequestOptions = {}) {
return request<BackupRecord>({
url: '/api/admin/settings/backups/create',
method: 'POST',
...options,
})
}
export function getBackupDownloadUrl(backupId: string) {
export function getBackupDownloadUrl(backupId: string, options: RequestOptions = {}) {
return request<DownloadUrlResult>({
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<BackupRecord>({
url: '/api/admin/settings/backups/delete',
method: 'POST',
data: { backupId },
...options,
})
}
+5 -2
View File
@@ -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<DashboardSummaryResponse>({
url: '/api/admin/dashboard/summary',
method: 'GET',
params: data,
...options,
})
}
export function getDashboardTrend(data: DashboardQuery) {
export function getDashboardTrend(data: DashboardQuery, options: RequestOptions = {}) {
return request<DashboardTrendResponse>({
url: '/api/admin/dashboard/trend',
method: 'GET',
params: data,
...options,
})
}
+17 -8
View File
@@ -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<ProxyAccountPage>({
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<ProxyPage>({
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<ProxyMutation>({
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<ProxyMutation>({
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<OutboundProxyRecord>({
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<OutboundProxyTest>({
url: '/api/admin/proxies/probe',
method: 'POST',
data,
timeout: 25000,
...options,
})
}
+13 -6
View File
@@ -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<RuntimeSettings>({
url: '/api/admin/settings',
method: 'GET',
...options,
})
}
type UpdateSettingsParam = Omit<RuntimeSettings, 'updatedAt'>
export function updateSettings(data: UpdateSettingsParam) {
export function updateSettings(data: UpdateSettingsParam, options: RequestOptions = {}) {
return request<RuntimeSettings>({
url: '/api/admin/settings/update',
method: 'POST',
data,
...options,
})
}
export function getAdminApiKeyStatus() {
export function getAdminApiKeyStatus(options: RequestOptions = {}) {
return request<AdminApiKeyStatus>({
url: '/api/admin/settings/admin-api-key',
method: 'GET',
...options,
})
}
export function regenerateAdminApiKey() {
export function regenerateAdminApiKey(options: RequestOptions = {}) {
return request<RegeneratedAdminApiKey>({
url: '/api/admin/settings/admin-api-key/regenerate',
method: 'POST',
...options,
})
}
export function deleteAdminApiKey() {
export function deleteAdminApiKey(options: RequestOptions = {}) {
return request<DeletedAdminApiKey>({
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<CodexDesktopWindowsDownloads>({
url: '/api/admin/settings/client-downloads/codex-desktop/windows',
method: 'GET',
params: refresh ? { refresh: true } : undefined,
...options,
})
}
+9 -5
View File
@@ -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<SystemVersion>({
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<SystemUpdateDetail>({
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<SystemUpdateAccepted>({
url: '/api/admin/system/update',
method: 'POST',
data,
...options,
})
}
export function restartSystem() {
export function restartSystem(options: RequestOptions = {}) {
return request<SystemRestartAccepted>({
url: '/api/admin/system/restart',
method: 'POST',
...options,
})
}
+13 -6
View File
@@ -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<UsageRecordsResponse>({
url: '/api/admin/usage/records',
method: 'GET',
params: data,
...options,
})
}
export function getOpsErrors(data: OpsErrorPageQuery) {
export function getOpsErrors(data: OpsErrorPageQuery, options: RequestOptions = {}) {
return request<OpsErrorsResponse>({
url: '/api/admin/operations/errors',
method: 'GET',
params: data,
...options,
})
}
export function getUsageRecordDetail(data: UsageDetailQuery) {
export function getUsageRecordDetail(data: UsageDetailQuery, options: RequestOptions = {}) {
return request<UsageRecordDetail>({
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<UsageSummaryResponse>({
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<UsageInsightsOverviewResponse>({
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<UsageDiagnosticsResponse>({
url: '/api/admin/usage/insights/diagnostics',
method: 'GET',
params: data,
...options,
})
}
+34 -15
View File
@@ -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<unknown>) => {
const error = normalizeApiResponseError(response)
if (error)
return rejectRequest(error, response.config)
return response
},
(error: AxiosError<unknown>) => {
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<RequestOptions, 'silent'>) {
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<T = unknown>(config: AxiosRequestConfig): Promise<T> {
export default async function request<T = unknown>(config: RequestConfig): Promise<T> {
const response = await http.request<unknown>({
...config,
})
@@ -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<AccountGroup[]>([])
@@ -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 {
+6 -7
View File
@@ -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> = T | Promise<T>
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() {
+8 -5
View File
@@ -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<Result extends PageResult>(options: {
initialPageSize: number
load: (pagination: { page: number, pageSize: number }) => Promise<Result>
load: (pagination: { page: number, pageSize: number }, options: RequestOptions) => Promise<Result>
onSuccess?: (result: Result) => void
onError?: (error: unknown) => void
}) {
@@ -26,14 +27,16 @@ export function usePagedQuery<Result extends PageResult>(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<Result extends PageResult>(options: {
return true
}
catch (cause: unknown) {
request.fail(requestId, cause, execution.silent)
request.fail(requestId, cause, background)
return false
}
finally {
+1 -5
View File
@@ -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<OutboundProxyRecord[]>([])
@@ -20,9 +18,7 @@ export function useProxyCatalog() {
}
proxies.value = items
}
catch (error) {
toast.error(errorMessage(error, '代理列表加载失败'))
}
catch {}
finally {
loading.value = false
}
+20 -3
View File
@@ -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
},
}
}
@@ -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<Result extends PageResult>(options: {
initialPageSize: number
load: (pagination: PageRequest) => Promise<Result>
load: (pagination: PageRequest, options: RequestOptions) => Promise<Result>
onSuccess?: (result: Result) => void
onError?: (error: unknown) => void
}) {
@@ -34,7 +35,7 @@ export function useStablePagedQuery<Result extends PageResult>(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
@@ -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)
}
})
+3 -9
View File
@@ -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<string | null>(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<typeof apiLogin>[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,
+6 -5
View File
@@ -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
}
}
@@ -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)
@@ -61,9 +61,6 @@ const { profile, loading, error, load } = useAccountProfileStatistics(accountId,
</div>
<template #footer>
<span v-if="error && profile" class="mr-auto self-center text-cp-xs font-semibold text-cp-error-text">
{{ error }}
</span>
<BaseButton variant="secondary" @click="open = false">
关闭
</BaseButton>
@@ -67,7 +67,7 @@ export function useAccountBatchEditor(options: {
options.selectedIds.value = new Set()
await Promise.all([options.reloadAccounts(), options.reloadGroups()])
toast.success(`已更新 ${accountIds.length} 个账号`)
}, { errorText: '批量更新账号失败', onError: () => void options.reloadAccounts() })
}, { onError: () => void options.reloadAccounts() })
}
function selectedAccounts() {
@@ -407,7 +407,10 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
modelsRequestMode.value = refresh ? 'refresh' : 'load'
connectionTestError.value = ''
try {
const result = await (refresh ? refreshAccountModels : getAccountModels)({ accountId: account.id })
const result = await (refresh ? refreshAccountModels : getAccountModels)(
{ accountId: account.id },
{ signal: modelsRequest.signal },
)
if (!modelsRequest.isCurrent(requestId))
return
applyConnectionTestModels(result, refresh)
@@ -421,11 +424,8 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
catch (error: unknown) {
if (!modelsRequest.isCurrent(requestId))
return
connectionTestError.value = errorMessage(error, refresh ? '刷新上游模型失败' : '加载测试模型失败')
if (refresh) {
toast.error(connectionTestError.value)
}
else {
connectionTestError.value = errorMessage(error)
if (!refresh) {
connectionTestModelOptions.value = []
connectionTestSelectedModel.value = ''
}
@@ -68,7 +68,7 @@ export function useAccountEditor(options: {
showEditModal.value = false
await Promise.all([options.reloadAccounts(), options.reloadGroups()])
toast.success('账号已更新')
}, { errorText: '账号更新失败' })
})
}
watch([showEditModal, saving], ([open, isSaving]) => {
@@ -1,5 +1,6 @@
import type { Ref } from 'vue'
import type { getAccounts } from '@/api'
import type { RequestOptions } from '@/api/request'
import dayjs from 'dayjs'
import { ref, watch } from 'vue'
import {
@@ -83,7 +84,6 @@ export function useAccountMutations(options: {
await loadAccounts()
toast.success('账号已删除')
},
{ errorText: '删除失败' },
)
}
@@ -96,7 +96,7 @@ export function useAccountMutations(options: {
async () => {
const selected = accountsById([...options.selectedIds.value])
for (const accounts of accountDeletionGroups(selected)) {
await deleteAccountBatch(accounts)
await deleteAccountBatch(accounts, { silent: true })
deletedCount += accounts.length
const deletedIds = new Set(accounts.map(account => account.id))
const remaining = new Set(options.selectedIds.value)
@@ -164,9 +164,7 @@ export function useAccountMutations(options: {
}
toast.success('Token 已刷新')
}
catch (error: unknown) {
toast.error(errorMessage(error, '刷新失败'))
}
catch {}
})
}
@@ -182,9 +180,7 @@ export function useAccountMutations(options: {
}
toast.success('额度已刷新')
}
catch (error: unknown) {
toast.error(errorMessage(error, '额度刷新失败'))
}
catch {}
})
}
@@ -200,9 +196,7 @@ export function useAccountMutations(options: {
}
toast.success('账号状态已恢复')
}
catch (error: unknown) {
toast.error(errorMessage(error, '恢复状态失败'))
}
catch {}
})
}
@@ -217,7 +211,7 @@ export function useAccountMutations(options: {
return accounts
}
async function deleteAccountBatch(accounts: AccountRow[]) {
async function deleteAccountBatch(accounts: AccountRow[], options?: RequestOptions) {
const account = accounts[0]
if (!account)
return
@@ -225,7 +219,7 @@ export function useAccountMutations(options: {
provider: account.provider,
accountIds: accounts.map(account => account.id),
}
const result = await deleteAccounts(payload)
const result = await deleteAccounts(payload, options)
if (!result)
throw new Error(`不支持的 Provider:${account.provider}`)
}
@@ -65,7 +65,6 @@ export function useAccountOnboarding(options: {
: await importAccountDocument()
await finishCreate(message)
},
{ errorText: '导入失败' },
)
}
@@ -98,7 +97,6 @@ export function useAccountOnboarding(options: {
}
toast.success('授权链接已生成')
},
{ errorText: '授权链接生成失败' },
)
}
@@ -131,9 +129,6 @@ export function useAccountOnboarding(options: {
: 'OpenAI OAuth 账号已添加',
)
},
{
errorText: reauthorizingAccount.value ? '重新授权失败' : 'OAuth 授权导入失败',
},
)
}
@@ -200,7 +195,7 @@ export function useAccountOnboarding(options: {
settings: accountImportSettings(createForm.value),
outboundProxyId: createForm.value.proxyMode === 'proxy' ? createForm.value.proxyId.trim() : undefined,
data: entry.document,
})
}, { silent: true })
importedCount += result.importedCount
}
catch (error) {
@@ -2,14 +2,13 @@ import type { Ref } from 'vue'
import { shallowRef, watch } from 'vue'
import { getAccountProfileStatistics } from '@/api'
import { errorMessage } from '@/utils/async'
import { useRequestState } from '@/composables/useRequestState'
export function useAccountProfileStatistics(accountId: Ref<string>, open: Ref<boolean>) {
const profile = shallowRef<Awaited<ReturnType<typeof getAccountProfileStatistics>> | null>(null)
const loadedAccountId = shallowRef('')
const loading = shallowRef(false)
const error = shallowRef('')
let requestVersion = 0
const request = useRequestState()
const { loading, error } = request
async function load(force = false) {
const targetAccountId = accountId.value
@@ -18,31 +17,26 @@ export function useAccountProfileStatistics(accountId: Ref<string>, open: Ref<bo
if (!force && profile.value && loadedAccountId.value === targetAccountId)
return
const version = ++requestVersion
loading.value = true
error.value = ''
const version = request.start()
try {
const result = await getAccountProfileStatistics({ accountId: targetAccountId })
if (version !== requestVersion)
const result = await getAccountProfileStatistics({ accountId: targetAccountId }, { signal: request.signal })
if (!request.isCurrent(version))
return
profile.value = result
loadedAccountId.value = targetAccountId
}
catch (cause) {
if (version !== requestVersion)
return
error.value = errorMessage(cause, '官方个人资料加载失败')
request.fail(version, cause)
}
finally {
if (version === requestVersion)
loading.value = false
request.finish(version)
}
}
watch([open, accountId], ([isOpen, nextAccountId], previousValues) => {
const previousAccountId = previousValues?.[1]
if (nextAccountId !== previousAccountId) {
requestVersion += 1
request.invalidate()
profile.value = null
loadedAccountId.value = ''
loading.value = false
@@ -50,6 +44,8 @@ export function useAccountProfileStatistics(accountId: Ref<string>, open: Ref<bo
}
if (isOpen)
void load()
else
request.invalidate()
}, { immediate: true })
return {
@@ -31,6 +31,7 @@ interface ResetCreditsSession {
loading: boolean
loadError: string
loadSequence: number
loadController?: AbortController
accountUpdatedListeners: Set<(account: Account) => void>
}
@@ -55,12 +56,15 @@ function getResetCreditsSession(accountId: string) {
return session
}
async function loadSessionCredits(session: ResetCreditsSession) {
async function loadSessionCredits(session: ResetCreditsSession, silent = false) {
const sequence = ++session.loadSequence
session.loadController?.abort()
const controller = new AbortController()
session.loadController = controller
session.loading = true
session.loadError = ''
try {
const result = await getAccountResetCredits({ accountId: session.accountId })
const result = await getAccountResetCredits({ accountId: session.accountId }, { silent, signal: controller.signal })
if (sequence !== session.loadSequence)
return
session.snapshot = {
@@ -70,7 +74,7 @@ async function loadSessionCredits(session: ResetCreditsSession) {
}
catch (error: unknown) {
if (sequence === session.loadSequence)
session.loadError = errorMessage(error, '重置卡查询失败')
session.loadError = errorMessage(error)
}
finally {
if (sequence === session.loadSequence)
@@ -173,17 +177,18 @@ export function useAccountResetCredits(options: {
target.pendingOperation = operation
target.consuming = true
try {
// 不可逆消费由当前会话区分已确认失败与结果未知,不能先弹出可重试的通用错误。
const result = await consumeAccountResetCredit({
accountId: operation.accountId,
creditId: operation.creditId,
redeemRequestId: operation.redeemRequestId,
})
}, { silent: true })
const confirmed = result.code === 'reset'
|| (result.code === 'already_redeemed' && operation.hasTransportFailure)
target.pendingOperation = null
if (!confirmed) {
toast.error(resetResultMessage(result.code))
await loadSessionCredits(target)
await loadSessionCredits(target, true)
return false
}
@@ -191,9 +196,9 @@ export function useAccountResetCredits(options: {
? '上次重置已完成'
: '额度已重置'
applyConfirmedConsumption(target, operation)
await loadSessionCredits(target)
await loadSessionCredits(target, true)
try {
const quota = await refreshAccountQuota({ accountId: operation.accountId })
const quota = await refreshAccountQuota({ accountId: operation.accountId }, { silent: true })
// 原组件可能已经卸载或换号,只通知仍订阅该账号的实例。
for (const listener of target.accountUpdatedListeners)
listener(quota.account)
@@ -218,7 +223,7 @@ export function useAccountResetCredits(options: {
else {
target.pendingOperation = null
toast.error(errorMessage(error, '额度重置失败'))
await loadSessionCredits(target)
await loadSessionCredits(target, true)
}
return false
}
@@ -3,9 +3,7 @@ import { watchDebounced } from '@vueuse/core'
import { computed, onMounted, shallowRef, watch } from 'vue'
import { getAccounts } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { usePagedQuery } from '@/composables/usePagedQuery'
import { errorMessage } from '@/utils/async'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
@@ -26,7 +24,7 @@ export function useAccountsQuery() {
const query = usePagedQuery({
initialPageSize: 20,
load: ({ page, pageSize }) =>
load: ({ page, pageSize }, options) =>
getAccounts({
page,
pageSize,
@@ -36,13 +34,10 @@ export function useAccountsQuery() {
groupId: groupQuery.value || undefined,
sortBy: sort.value?.key,
sortDirection: sort.value?.direction,
}),
}, options),
onSuccess: (result) => {
accountSummary.value = result.summary
},
onError: (error) => {
toast.error(errorMessage(error, '账号加载失败'))
},
})
const accountPagination = computed(() => ({
@@ -13,7 +13,6 @@ import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { useCopyText } from '@/composables/useCopyText'
import { useIdSet } from '@/composables/useIdSet'
import { errorMessage } from '@/utils/async'
type ApiKeyRow = Awaited<ReturnType<typeof getApiKeys>>['items'][number]
@@ -125,7 +124,7 @@ export function useApiKeyMutations(options: {
toast.success('API Key 创建成功')
}
},
{ errorText: editingKey.value ? '更新失败' : '创建失败', onError: () => void options.reload() },
{ onError: () => void options.reload() },
)
}
@@ -176,7 +175,7 @@ export function useApiKeyMutations(options: {
await options.reload()
toast.success('删除成功')
},
{ errorText: '删除失败', onError: () => void options.reload() },
{ onError: () => void options.reload() },
)
}
@@ -197,7 +196,7 @@ export function useApiKeyMutations(options: {
await options.reload()
toast.success(`已删除 ${deleteCount} 个 API Key`)
},
{ errorText: '批量删除失败', onError: () => void options.reload() },
{ onError: () => void options.reload() },
)
}
@@ -209,9 +208,8 @@ export function useApiKeyMutations(options: {
await options.reload()
toast.success(key.enabled ? '已禁用' : '已启用')
}
catch (error: unknown) {
catch {
void options.reload()
toast.error(errorMessage(error, '状态更新失败'))
}
})
}
@@ -221,19 +219,25 @@ export function useApiKeyMutations(options: {
}
async function revealPlaintextKey(apiKey: ApiKeyRow) {
const result = await revealingKeys.run(apiKey.id, () => revealApiKey({ id: apiKey.id }))
if (!result?.plaintextKey)
throw new Error('完整 API Key 不可用')
return result.plaintextKey
try {
const result = await revealingKeys.run(apiKey.id, () => revealApiKey({ id: apiKey.id }))
if (!result)
return undefined
if (!result.plaintextKey) {
toast.error('完整 API Key 不可用')
return undefined
}
return result.plaintextKey
}
catch {
return undefined
}
}
async function copyApiKey(apiKey: ApiKeyRow) {
try {
await copyToClipboard(await revealPlaintextKey(apiKey))
}
catch (error: unknown) {
toast.error(errorMessage(error, '读取完整密钥失败'))
}
const key = await revealPlaintextKey(apiKey)
if (key)
await copyToClipboard(key)
}
watch(showKeyModal, (open) => {
@@ -3,8 +3,6 @@ import type { getApiKeys } from '@/api'
import { computed, shallowRef, watch } from 'vue'
import { API_BASE_URL } from '@/api/constants'
import { toast } from '@/components/base/BaseToast'
import { errorMessage } from '@/utils/async'
import { buildCodexCcSwitchImportDeeplink } from '../utils/ccswitchImport'
// “使用密钥”弹窗展示明文时,在列表行上补挂 reveal 得到的完整 key。
@@ -15,7 +13,7 @@ type ApiKeyRow = Awaited<ReturnType<typeof getApiKeys>>['items'][number] & { key
export function useApiKeyUse(options: {
createdKey: Readonly<Ref<string>>
createdKeyName: Readonly<Ref<string>>
revealPlaintextKey: (apiKey: ApiKeyRow) => Promise<string>
revealPlaintextKey: (apiKey: ApiKeyRow) => Promise<string | undefined>
}) {
const showUseKeyModal = shallowRef(false)
const selectedUseKey: ShallowRef<ApiKeyRow | null> = shallowRef(null)
@@ -54,28 +52,22 @@ export function useApiKeyUse(options: {
}
async function openUseKeyModal(apiKey: ApiKeyRow) {
try {
const key = await options.revealPlaintextKey(apiKey)
selectedUseKey.value = { ...apiKey, key }
showUseKeyModal.value = true
}
catch (error: unknown) {
toast.error(errorMessage(error, '读取完整密钥失败'))
}
const key = await options.revealPlaintextKey(apiKey)
if (!key)
return
selectedUseKey.value = { ...apiKey, key }
showUseKeyModal.value = true
}
async function importToCcs(apiKey: ApiKeyRow) {
try {
const key = await options.revealPlaintextKey(apiKey)
window.location.href = buildCodexCcSwitchImportDeeplink({
apiKey: key,
baseUrl: openAiBaseUrl.value,
providerName: apiKey.name || apiKey.prefix || 'codex-proxy-rs',
})
}
catch (error: unknown) {
toast.error(errorMessage(error, '读取完整密钥失败'))
}
const key = await options.revealPlaintextKey(apiKey)
if (!key)
return
window.location.href = buildCodexCcSwitchImportDeeplink({
apiKey: key,
baseUrl: openAiBaseUrl.value,
providerName: apiKey.name || apiKey.prefix || 'codex-proxy-rs',
})
}
watch(showUseKeyModal, (open) => {
@@ -1,10 +1,9 @@
import type { BaseTableSort } from '@/components/base/BaseTable/columns'
import { watchDebounced } from '@vueuse/core'
import { computed, onMounted, onScopeDispose, shallowRef } from 'vue'
import { computed, onMounted, shallowRef } from 'vue'
import { getApiKeys } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { errorMessage } from '@/utils/async'
import { useRequestState } from '@/composables/useRequestState'
import { formatDateTime } from '@/utils/date'
export function useApiKeysQuery() {
@@ -18,9 +17,9 @@ export function useApiKeysQuery() {
createdAtDisplay: string
}>
>([])
const loading = shallowRef(false)
const request = useRequestState()
const { loading } = request
const cursors = new Map<number, string | undefined>([[1, undefined]])
let requestSequence = 0
const apiKeyPagination = computed(() => ({
currentPage: page.value,
@@ -41,14 +40,14 @@ export function useApiKeysQuery() {
)
}
async function fetchPage(cursor: string | undefined, limit: number, search: string | undefined) {
async function fetchPage(cursor: string | undefined, limit: number, search: string | undefined, signal?: AbortSignal) {
return getApiKeys({
cursor,
limit,
search,
sortBy: sort.value?.key,
sortDirection: sort.value?.direction,
})
}, { signal })
}
function applyPage(result: Awaited<ReturnType<typeof getApiKeys>>, targetPage: number) {
@@ -61,19 +60,19 @@ export function useApiKeysQuery() {
}
async function execute(targetPage = page.value) {
const requestId = ++requestSequence
const requestId = request.start()
const signal = request.signal
const requestedPage = Math.max(1, targetPage)
const requestedPageSize = pageSize.value
const requestedSearch = searchQuery.value.trim() || undefined
loading.value = true
try {
let currentPage = knownPageBefore(requestedPage)
let result
while (currentPage < requestedPage) {
result = await fetchPage(cursors.get(currentPage), requestedPageSize, requestedSearch)
if (requestId !== requestSequence)
result = await fetchPage(cursors.get(currentPage), requestedPageSize, requestedSearch, signal)
if (!request.isCurrent(requestId))
return false
if (!result.nextCursor) {
@@ -85,8 +84,8 @@ export function useApiKeysQuery() {
currentPage += 1
}
result = await fetchPage(cursors.get(currentPage), requestedPageSize, requestedSearch)
if (requestId !== requestSequence)
result = await fetchPage(cursors.get(currentPage), requestedPageSize, requestedSearch, signal)
if (!request.isCurrent(requestId))
return false
if (result.nextCursor)
@@ -96,14 +95,11 @@ export function useApiKeysQuery() {
applyPage(result, currentPage)
return true
}
catch (error: unknown) {
if (requestId === requestSequence)
toast.error(errorMessage(error, 'API Key 加载失败'))
catch {
return false
}
finally {
if (requestId === requestSequence)
loading.value = false
request.finish(requestId)
}
}
@@ -137,9 +133,6 @@ export function useApiKeysQuery() {
onMounted(() => {
void execute()
})
onScopeDispose(() => {
requestSequence += 1
})
return {
loading,
@@ -19,6 +19,8 @@ export function useDashboard() {
const lastRefreshedAt = shallowRef('')
let trendRequestId = 0
let disposed = false
let summaryController: AbortController | undefined
let trendController: AbortController | undefined
const metrics = computed(() => snapshot.value.metrics)
const healthTimeline = computed(() => snapshot.value.healthTimeline)
@@ -36,18 +38,18 @@ export function useDashboard() {
const { resume: startAutoRefresh } = useIntervalFn(
() => {
void loadDashboardData()
void loadDashboardData(true)
},
30_000,
{ immediate: false },
)
async function loadDashboardData() {
async function loadDashboardData(silent = false) {
if (loading.value || refreshing.value)
return
try {
loading.value = true
await loadDashboardSnapshot()
await loadDashboardSnapshot(silent)
}
catch {
// 自动刷新会继续重试,保留最后一次成功快照。
@@ -76,16 +78,18 @@ export function useDashboard() {
const trendKind = normalizeDashboardTrendKind(kind)
activeTrendKind.value = trendKind
const requestId = ++trendRequestId
trendController?.abort()
trendController = new AbortController()
trendLoading.value = true
trendError.value = ''
try {
const result = await getDashboardTrend({ kind: trendKind })
const result = await getDashboardTrend({ kind: trendKind }, { signal: trendController.signal })
if (isCurrentTrendRequest(requestId, trendKind))
trend.value = result
}
catch (error: unknown) {
if (isCurrentTrendRequest(requestId, trendKind))
trendError.value = errorMessage(error, '趋势加载失败')
trendError.value = errorMessage(error)
}
finally {
if (isCurrentTrendRequest(requestId, trendKind))
@@ -93,13 +97,16 @@ export function useDashboard() {
}
}
async function loadDashboardSnapshot() {
async function loadDashboardSnapshot(silent = false) {
const trendKind = activeTrendKind.value
const requestId = ++trendRequestId
summaryController?.abort()
trendController?.abort()
summaryController = new AbortController()
trendLoading.value = true
trendError.value = ''
try {
const summary = await getDashboardSummary({ kind: trendKind })
const summary = await getDashboardSummary({ kind: trendKind }, { silent, signal: summaryController.signal })
if (disposed)
return
snapshot.value = dashboardSnapshotView(summary)
@@ -110,7 +117,7 @@ export function useDashboard() {
}
catch (error: unknown) {
if (isCurrentTrendRequest(requestId, trendKind))
trendError.value = errorMessage(error, '趋势加载失败')
trendError.value = errorMessage(error)
throw error
}
finally {
@@ -134,6 +141,8 @@ export function useDashboard() {
onScopeDispose(() => {
disposed = true
trendRequestId += 1
summaryController?.abort()
trendController?.abort()
})
return {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { CircleAlert, Eye, EyeOff, KeyRound, Mail, Moon, Sun } from '@lucide/vue'
import { Eye, EyeOff, KeyRound, Mail, Moon, Sun } from '@lucide/vue'
import { computed, shallowRef } from 'vue'
import AppBrandMark from '@/components/AppBrandMark.vue'
@@ -13,7 +13,6 @@ type ThemeName = 'light' | 'dark'
type PasswordInputType = 'password' | 'text'
const props = defineProps<{
error?: string | null
loading: boolean
submitDisabled: boolean
effectiveTheme: ThemeName
@@ -94,11 +93,6 @@ function togglePasswordVisible(): void {
</section>
<div class="grid gap-3">
<div v-if="props.error" class="login-error" role="alert">
<CircleAlert :size="16" />
<p>{{ props.error }}</p>
</div>
<div class="grid min-w-0 gap-2">
<span class="text-cp leading-[1.1] font-bold text-(--cp-login-label-color)">管理员账号</span>
<BaseInput
@@ -289,26 +283,6 @@ function togglePasswordVisible(): void {
transform: translateX(33px);
}
.login-error {
display: flex;
min-height: 38px;
align-items: center;
gap: 10px;
border-radius: 6px;
background: var(--cp-login-error-bg);
padding: 0 12px;
color: var(--cp-login-error-icon-color);
}
.login-error p {
min-width: 0;
margin: 0;
color: var(--cp-login-error-text-color);
font-size: 12px;
font-weight: 600;
line-height: 1.35;
}
.login-password-toggle {
--cp-color-fill-quaternary: color-mix(in srgb, var(--cp-input-hover-bg) 62%, transparent);
--cp-color-fill-tertiary: color-mix(in srgb, var(--cp-input-hover-bg) 88%, transparent);
-1
View File
@@ -61,7 +61,6 @@ async function handleSubmit(): Promise<void> {
<LoginPanel
v-model:username="username"
v-model:password="password"
:error="authStore.error"
:loading="loginLoading"
:submit-disabled="submitDisabled"
:effective-theme="effectiveTheme"
@@ -4,7 +4,6 @@ import { getProxyAccounts, removeProxyAccount } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { usePagedQuery } from '@/composables/usePagedQuery'
import { errorMessage } from '@/utils/async'
export function useProxyAccounts(options: {
isOpen: () => boolean
@@ -15,12 +14,11 @@ export function useProxyAccounts(options: {
const removeAction = useAsyncAction()
const query = usePagedQuery({
initialPageSize: 20,
load: pagination => getProxyAccounts({
load: (pagination, requestOptions) => getProxyAccounts({
...pagination,
proxyId: options.proxyId() ?? '',
search: search.value.trim() || undefined,
}),
onError: error => toast.error(errorMessage(error, '关联账号加载失败')),
}, requestOptions),
})
const pagination = computed(() => ({
currentPage: query.page.value,
@@ -60,7 +58,7 @@ export function useProxyAccounts(options: {
await query.execute()
}
return true
}, { errorText: '移除关联账号失败' }) ?? false
}) ?? false
}
watch([options.isOpen, options.proxyId], () => {
+5 -9
View File
@@ -16,7 +16,6 @@ import BaseTable from '@/components/base/BaseTable/index.vue'
import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { usePagedQuery } from '@/composables/usePagedQuery'
import { errorMessage } from '@/utils/async'
import { formatDateTime } from '@/utils/date'
import ProxyAccountsModal from './components/ProxyAccountsModal.vue'
import ProxyFormModal from './components/ProxyFormModal.vue'
@@ -24,8 +23,7 @@ import ProxyFormModal from './components/ProxyFormModal.vue'
const search = shallowRef('')
const query = usePagedQuery({
initialPageSize: 20,
load: pagination => getProxies({ ...pagination, search: search.value.trim() || undefined }),
onError: error => toast.error(errorMessage(error, '代理列表加载失败')),
load: (pagination, options) => getProxies({ ...pagination, search: search.value.trim() || undefined }, options),
})
const { items: proxies, loading } = query
const pagination = computed(() => ({ currentPage: query.page.value, pageSize: query.pageSize.value, total: query.total.value }))
@@ -70,9 +68,7 @@ async function checkProxy(proxy: OutboundProxyRecord) {
else
toast.error(result.lastTest?.message ?? '代理测试失败')
}
catch (error) {
toast.error(errorMessage(error, '代理测试失败'))
}
catch {}
finally {
testingIds.value.delete(proxy.id)
await query.execute({ silent: true })
@@ -98,7 +94,7 @@ async function testConnection() {
toast.success(`连接成功,耗时 ${result.latencyMs} ms`)
else
toast.error(result.message)
}, { errorText: '代理测试失败' })
})
}
async function save() {
@@ -121,7 +117,7 @@ async function save() {
search.value = ''
query.page.value = 1
await query.execute()
}, { errorText: '代理保存失败' })
})
}
function requestDelete(proxy: OutboundProxyRecord) {
@@ -138,7 +134,7 @@ async function confirmDelete() {
showDelete.value = false
await query.execute()
toast.success('代理已删除')
}, { errorText: '代理删除失败,请确认没有账号使用该代理' })
})
}
function setPage(page: number) {
@@ -19,7 +19,7 @@ export function useClientDownloads() {
downloads.value = await getCodexDesktopWindowsDownloads(refresh)
}
catch (cause: unknown) {
error.value = errorMessage(cause, 'Windows 离线安装包加载失败')
error.value = errorMessage(cause)
}
finally {
loading.value = false
@@ -8,7 +8,6 @@ import {
import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { useCopyText } from '@/composables/useCopyText'
import { errorMessage } from '@/utils/async'
export function useAdminApiKey() {
const loading = shallowRef(true)
@@ -26,9 +25,7 @@ export function useAdminApiKey() {
loading.value = true
status.exists = (await getAdminApiKeyStatus()).exists
}
catch (error: unknown) {
toast.error(errorMessage(error, '管理员 API Key 状态加载失败'))
}
catch {}
finally {
loading.value = false
}
@@ -45,7 +42,6 @@ export function useAdminApiKey() {
status.exists = true
toast.success(wasEnabled ? '管理员 API Key 已更新' : '管理员 API Key 已生成')
},
{ errorText: false, onError: error => toast.error(errorMessage(error, '生成失败')) },
)
}
@@ -61,7 +57,6 @@ export function useAdminApiKey() {
showDeleteModal.value = false
toast.success('管理员 API Key 已删除')
},
{ errorText: false, onError: error => toast.error(errorMessage(error, '删除失败')) },
)
}
@@ -7,6 +7,7 @@ import {
getBackupDownloadUrl,
getBackupRecords,
} from '@/api'
import { ApiError } from '@/api/request'
import { toast } from '@/components/base/BaseToast'
import { usePagedQuery } from '@/composables/usePagedQuery'
import { errorMessage } from '@/utils/async'
@@ -31,9 +32,8 @@ export function useBackupRecords() {
page: { page: number, pageSize: number, total: number, totalPages: number }
}>({
initialPageSize: 10,
load: ({ page, pageSize }) =>
getBackupRecords({ page, pageSize }),
onError: () => undefined,
load: ({ page, pageSize }, options) =>
getBackupRecords({ page, pageSize }, options),
})
const records = computed<BackupRecord[]>(() => paged.items.value as BackupRecord[])
@@ -92,7 +92,7 @@ export function useBackupRecords() {
return
refreshing.value = true
try {
await paged.execute({ silent: true })
await paged.execute({ background: true })
}
finally {
refreshing.value = false
@@ -119,9 +119,7 @@ export function useBackupRecords() {
toast.success('备份任务已创建')
await paged.execute({ silent: true })
}
catch (cause) {
toast.error(errorMessage(cause, '创建备份失败'))
}
catch {}
finally {
creating.value = false
}
@@ -142,7 +140,8 @@ export function useBackupRecords() {
link.remove()
}
catch (cause) {
toast.error(errorMessage(cause, '下载备份失败'))
if (!(cause instanceof ApiError))
toast.error(errorMessage(cause, '下载备份失败'))
}
finally {
downloadStates.value = { ...downloadStates.value, [record.id]: false }
@@ -163,9 +162,7 @@ export function useBackupRecords() {
toast.success('已请求删除备份')
await paged.execute({ silent: true })
}
catch (cause) {
toast.error(errorMessage(cause, '删除备份失败'))
}
catch {}
finally {
deleting.value = false
deleteTarget.value = null
@@ -52,7 +52,7 @@ export function useBackupSettings() {
loaded.value = true
}
catch (cause) {
error.value = errorMessage(cause, '加载备份配置失败')
error.value = errorMessage(cause)
}
finally {
loading.value = false
@@ -97,8 +97,7 @@ export function useBackupSettings() {
toast.success(schedulePaused ? '存储配置已保存,定时备份已暂停,请测试连接后重新启用' : '存储配置已保存')
return true
}
catch (cause) {
toast.error(errorMessage(cause, '保存存储配置失败'))
catch {
return false
}
finally {
@@ -119,9 +118,8 @@ export function useBackupSettings() {
toast.error(`${result.stage}: ${result.message}`)
}
}
catch (cause) {
catch {
verified.value = false
toast.error(errorMessage(cause, '连接测试失败'))
}
finally {
testing.value = false
@@ -142,8 +140,7 @@ export function useBackupSettings() {
toast.success('调度配置已保存')
return true
}
catch (cause) {
toast.error(errorMessage(cause, '保存调度配置失败'))
catch {
return false
}
finally {
@@ -2,14 +2,17 @@ import type { rotationOptions } from '../constants'
import { computed, reactive, ref, shallowRef } from 'vue'
import { getSettings, updateSettings } from '@/api'
import { ApiError } from '@/api/request'
import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { errorMessage } from '@/utils/async'
type RotationStrategy = (typeof rotationOptions)[number]['value']
export function useSettingsForm() {
const loading = shallowRef(true)
const saving = shallowRef(false)
const saveAction = useAsyncAction()
const saving = saveAction.loading
const error = shallowRef('')
const mappings = ref<Array<{ requestedModel: string, upstreamModel: string }>>([])
const form = reactive({
@@ -68,15 +71,14 @@ export function useSettingsForm() {
}))
}
async function loadSettings() {
async function loadSettings(silent = false) {
loading.value = true
error.value = ''
try {
applySettings(await getSettings())
applySettings(await getSettings({ silent }))
}
catch (cause: unknown) {
error.value = errorMessage(cause, '设置加载失败')
toast.error(error.value)
error.value = errorMessage(cause)
}
finally {
loading.value = false
@@ -127,8 +129,7 @@ export function useSettingsForm() {
toast.warning('请修正客户端最低版本格式')
return
}
try {
saving.value = true
await saveAction.run(async () => {
const result = await updateSettings({
modelMappings: mappingPayload(),
refreshMarginSeconds,
@@ -144,15 +145,12 @@ export function useSettingsForm() {
})
applySettings(result)
toast.success('设置已保存')
}
catch (cause: unknown) {
error.value = errorMessage(cause, '保存失败')
toast.error(error.value)
await loadSettings()
}
finally {
saving.value = false
}
}, {
onError: (cause) => {
if (cause instanceof ApiError)
void loadSettings(true)
},
})
}
return {
@@ -4,9 +4,8 @@ import { watchDebounced } from '@vueuse/core'
import { computed, onScopeDispose, shallowRef, watch } from 'vue'
import { getOpsErrors } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { useStablePagedQuery } from '@/composables/useStablePagedQuery'
import { errorMessage, withMinimumDuration } from '@/utils/async'
import { withMinimumDuration } from '@/utils/async'
import { usageSearchParam } from '../utils/search'
interface UseOpsErrorsTableOptions {
@@ -34,12 +33,11 @@ export function useOpsErrorsTable(options: UseOpsErrorsTableOptions) {
const query = useStablePagedQuery({
initialPageSize: 10,
load: ({ currentPage, pageSize }) => getOpsErrors({
load: ({ currentPage, pageSize }, requestOptions) => getOpsErrors({
currentPage,
pageSize,
...tableParams,
}),
onError: error => toast.error(errorMessage(error, '加载错误明细失败')),
}, requestOptions),
})
const pagination = computed(() => ({
currentPage: query.currentPage.value,
@@ -14,18 +14,22 @@ export function useRequestDiagnostics(requestId: () => string) {
watch(requestId, id => selectedId.value = id)
watch([selectedId, revision], async ([id], _previous, onCleanup) => {
let active = true
onCleanup(() => active = false)
const controller = new AbortController()
onCleanup(() => {
active = false
controller.abort()
})
detail.value = null
error.value = ''
loading.value = true
try {
const result = await getUsageRecordDetail({ id })
const result = await getUsageRecordDetail({ id }, { signal: controller.signal })
if (active)
detail.value = result
}
catch (cause: unknown) {
if (active)
error.value = errorMessage(cause, '诊断记录暂不可用,请稍后刷新')
error.value = errorMessage(cause)
}
finally {
if (active)
@@ -1,8 +1,6 @@
import type { UsageDisplayRecord, UsageViewModel } from '../utils/records'
import { shallowRef } from 'vue'
import { getUsageRecordDetail } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { errorMessage } from '@/utils/async'
import { normalizeUsageRecord } from '../utils/records'
export function useUsageRecordDetail() {
@@ -15,9 +13,7 @@ export function useUsageRecordDetail() {
selectedUsageRecord.value = normalizeUsageRecord(detail)
showDetailModal.value = true
}
catch (error: unknown) {
toast.error(errorMessage(error, '加载详情失败'))
}
catch {}
}
return {
@@ -10,9 +10,7 @@ import {
getUsageRecords,
getUsageRecordSummary,
} from '@/api'
import { toast } from '@/components/base/BaseToast'
import { errorMessage, withMinimumDuration } from '@/utils/async'
import { withMinimumDuration } from '@/utils/async'
import { usageSearchParam } from '../utils/search'
interface UseUsageRecordsTableOptions {
@@ -46,6 +44,9 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
let tableRequestId = 0
let analyticsRequestId = 0
let diagnosticRequestId = 0
let tableController: AbortController | undefined
let analyticsController: AbortController | undefined
let diagnosticController: AbortController | undefined
let disposed = false
const scopedParams = () => ({
...options.timeRangeParams.value,
@@ -86,13 +87,15 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
async function loadUsagePage(background: boolean) {
const requestId = ++tableRequestId
tableController?.abort()
tableController = new AbortController()
loading.value = !background
try {
const result = await getUsageRecords({
currentPage: currentPage.value,
pageSize: pageSize.value,
...tableParams,
})
}, { signal: tableController.signal })
if (requestId !== tableRequestId)
return
@@ -101,11 +104,7 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
totalRecords.value = result.total
currentPage.value = result.currentPage
}
catch (error: unknown) {
if (requestId !== tableRequestId)
return
toast.error(errorMessage(error, '加载失败'))
}
catch {}
finally {
if (requestId === tableRequestId) {
loading.value = false
@@ -116,16 +115,20 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
async function loadUsageAnalytics(globalParams: ReturnType<typeof scopedParams>, background: boolean) {
const requestId = ++analyticsRequestId
const diagnosticsId = ++diagnosticRequestId
analyticsController?.abort()
diagnosticController?.abort()
analyticsController = new AbortController()
const requestOptions = { signal: analyticsController.signal }
const dimension = diagnosticDimension.value
analyticsLoading.value = !background
try {
const [nextSummary, overview, diagnostics] = await Promise.all([
getUsageRecordSummary(globalParams),
getUsageRecordInsightsOverview(globalParams),
getUsageRecordSummary(globalParams, requestOptions),
getUsageRecordInsightsOverview(globalParams, requestOptions),
getUsageRecordInsightsDiagnostics({
...globalParams,
dimension,
}),
}, requestOptions),
])
if (requestId !== analyticsRequestId)
return
@@ -139,11 +142,7 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
: insights.value.diagnostics,
}
}
catch (error: unknown) {
if (requestId !== analyticsRequestId)
return
toast.error(errorMessage(error, '加载失败'))
}
catch {}
finally {
if (requestId === analyticsRequestId) {
analyticsLoading.value = false
@@ -153,13 +152,15 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
async function loadDiagnostics() {
const requestId = ++diagnosticRequestId
diagnosticController?.abort()
diagnosticController = new AbortController()
const dimension = diagnosticDimension.value
const params = scopedParams()
try {
const diagnostics = await getUsageRecordInsightsDiagnostics({
...params,
dimension,
})
}, { signal: diagnosticController.signal })
if (requestId !== diagnosticRequestId || dimension !== diagnosticDimension.value)
return
insights.value = {
@@ -167,11 +168,7 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
diagnostics,
}
}
catch (error: unknown) {
if (requestId !== diagnosticRequestId || dimension !== diagnosticDimension.value)
return
toast.error(errorMessage(error, '加载失败'))
}
catch {}
}
async function refreshUsageRecords() {
@@ -224,10 +221,13 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
})
watch(options.active, (active) => {
if (active)
if (active) {
void reloadLatestTable()
else
}
else {
tableRequestId += 1
tableController?.abort()
}
})
watchDebounced(
@@ -244,6 +244,9 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
tableRequestId += 1
analyticsRequestId += 1
diagnosticRequestId += 1
tableController?.abort()
analyticsController?.abort()
diagnosticController?.abort()
})
return {