diff --git a/components/copy-input.vue b/components/copy-input.vue index 3ef4769..ac02040 100644 --- a/components/copy-input.vue +++ b/components/copy-input.vue @@ -1,11 +1,11 @@ - - + + {{ t('Copy') }} - + {{ t('Copy') }} diff --git a/composables/useAsyncOperation.ts b/composables/useAsyncOperation.ts new file mode 100644 index 0000000..c36f109 --- /dev/null +++ b/composables/useAsyncOperation.ts @@ -0,0 +1,136 @@ +/** + * 统一的异步操作管理 Composable + * 提供加载状态、错误处理和重试机制 + */ +import { ref, readonly, type Ref } from 'vue'; +import { useErrorHandler } from './useErrorHandler'; + +export interface AsyncOperationOptions { + /** + * 是否自动处理错误 + * @default true + */ + autoHandleError?: boolean; + /** + * 默认错误消息 + */ + defaultErrorMessage?: string; + /** + * 重试次数 + * @default 0 + */ + retries?: number; + /** + * 重试延迟(毫秒) + * @default 1000 + */ + retryDelay?: number; +} + +export interface UseAsyncOperationReturn Promise> { + /** + * 加载状态(只读) + */ + loading: Readonly>; + /** + * 错误信息(只读) + */ + error: Readonly>; + /** + * 执行异步操作 + */ + execute: (...args: Parameters) => Promise | null>; + /** + * 重置错误状态 + */ + resetError: () => void; +} + +/** + * 包装异步操作,提供加载状态和错误处理 + * @param operation 异步操作函数 + * @param options 配置选项 + * @returns 包含加载状态和执行函数的对象 + */ +export function useAsyncOperation Promise>( + operation: T, + options: AsyncOperationOptions = {} +): UseAsyncOperationReturn { + const { + autoHandleError = true, + defaultErrorMessage, + retries = 0, + retryDelay = 1000, + } = options; + + const loading = ref(false); + const error = ref(null); + const { handleError } = useErrorHandler(); + + /** + * 执行重试逻辑 + */ + const executeWithRetry = async ( + ...args: Parameters + ): Promise> => { + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await operation(...args); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + + // 如果不是最后一次尝试,等待后重试 + if (attempt < retries) { + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + continue; + } + } + } + + throw lastError || new Error('Operation failed'); + }; + + /** + * 执行异步操作 + */ + const execute = async ( + ...args: Parameters + ): Promise | null> => { + loading.value = true; + error.value = null; + + try { + const result = await executeWithRetry(...args); + return result; + } catch (err) { + const errorInstance = + err instanceof Error ? err : new Error(String(err)); + error.value = errorInstance; + + if (autoHandleError) { + handleError(errorInstance, defaultErrorMessage); + } + + return null; + } finally { + loading.value = false; + } + }; + + /** + * 重置错误状态 + */ + const resetError = () => { + error.value = null; + }; + + return { + loading: readonly(loading), + error: readonly(error), + execute, + resetError, + }; +} + diff --git a/composables/useAuth.ts b/composables/useAuth.ts index 9f6e3a3..76a034a 100644 --- a/composables/useAuth.ts +++ b/composables/useAuth.ts @@ -1,30 +1,31 @@ -import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@aws-sdk/types'; -import { getStsToken } from '~/lib/sts'; -import type { SiteConfig } from '~/types/config'; +import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@aws-sdk/types' +import { getStsToken } from '~/lib/sts' +import type { SiteConfig } from '~/types/config' +import { getLoginRoute } from '~/utils/routes' interface Credentials { - AccessKeyId?: string; - SecretAccessKey?: string; - SessionToken?: string; - Expiration?: string; + AccessKeyId?: string + SecretAccessKey?: string + SessionToken?: string + Expiration?: string } export function useAuth() { - const store = useLocalStorage('auth.credentials', {}); + const store = useLocalStorage('auth.credentials', {}) const setCredentials = (credentials: Credentials) => { - store.value = credentials; - }; + store.value = credentials + } const getCredentials = () => { if (!isValidCredentials(store.value)) { - return; + return } - return store.value; - }; + return store.value + } - const isExpired = (expiration: string) => (expiration ? new Date(expiration) < new Date() : false); + const isExpired = (expiration: string) => (expiration ? new Date(expiration) < new Date() : false) const isValidCredentials = (credentials: Credentials) => { return ( @@ -33,31 +34,31 @@ export function useAuth() { !!credentials?.SessionToken && credentials?.Expiration && !isExpired(credentials.Expiration) - ); - }; + ) + } const login = async ( credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider, customConfig?: SiteConfig ) => { - const credentialsResponse = await getStsToken(credentials, 'arn:aws:iam::*:role/Admin', customConfig); + const credentialsResponse = await getStsToken(credentials, 'arn:aws:iam::*:role/Admin', customConfig) setCredentials({ ...credentialsResponse, Expiration: credentialsResponse.Expiration?.toISOString(), - }); + }) - return credentialsResponse; - }; + return credentialsResponse + } const logout = () => { - store.value = {}; - }; + store.value = {} + } const logoutAndRedirect = () => { - logout(); - window.location.href = '/rustfs/console/auth/login'; - }; + logout() + window.location.href = getLoginRoute() + } return { login, @@ -65,5 +66,5 @@ export function useAuth() { logoutAndRedirect, credentials: ref(getCredentials()), isAuthenticated: ref(isValidCredentials(store.value)), - }; + } } diff --git a/composables/useErrorHandler.ts b/composables/useErrorHandler.ts new file mode 100644 index 0000000..c57deb1 --- /dev/null +++ b/composables/useErrorHandler.ts @@ -0,0 +1,106 @@ +/** + * 统一的错误处理 Composable + * 提供一致的错误处理和消息显示 + */ +import { useMessage } from '~/lib/ui/message'; +import { useI18n } from 'vue-i18n'; + +export interface ErrorHandlerOptions { + /** + * 是否记录错误到控制台 + * @default true + */ + logError?: boolean; + /** + * 默认错误消息的翻译键 + * @default 'Operation failed' + */ + defaultMessageKey?: string; +} + +/** + * 错误处理工具 + * @param options 配置选项 + * @returns 错误处理函数 + */ +export function useErrorHandler(options: ErrorHandlerOptions = {}) { + const { logError = true, defaultMessageKey = 'Operation failed' } = options; + const message = useMessage(); + const { t } = useI18n(); + + /** + * 处理错误 + * @param error 错误对象 + * @param defaultMessage 默认错误消息(可选) + */ + const handleError = (error: unknown, defaultMessage?: string): void => { + let errorMessage = defaultMessage || t(defaultMessageKey); + + // 提取错误消息 + if (error instanceof Error) { + errorMessage = error.message || errorMessage; + } else if (typeof error === 'string') { + errorMessage = error; + } else if (error && typeof error === 'object' && 'message' in error) { + errorMessage = String(error.message); + } + + // 记录错误 + if (logError) { + console.error('[ErrorHandler]', error); + } + + // 显示错误消息 + message.error(errorMessage); + }; + + /** + * 处理 API 错误 + * @param error 错误对象 + * @param defaultMessage 默认错误消息 + */ + const handleApiError = ( + error: unknown, + defaultMessage?: string + ): void => { + // API 错误可能有特定的格式 + if ( + error && + typeof error === 'object' && + 'response' in error && + 'data' in error + ) { + const apiError = error as { response?: { data?: { message?: string } } }; + const apiMessage = + apiError.response?.data?.message || + defaultMessage || + t('API request failed'); + handleError(apiMessage); + return; + } + + handleError(error, defaultMessage); + }; + + /** + * 静默处理错误(不显示消息) + * @param error 错误对象 + * @returns 错误消息 + */ + const handleErrorSilently = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return t(defaultMessageKey); + }; + + return { + handleError, + handleApiError, + handleErrorSilently, + }; +} + diff --git a/composables/useFormValidation.ts b/composables/useFormValidation.ts new file mode 100644 index 0000000..b5a7ae5 --- /dev/null +++ b/composables/useFormValidation.ts @@ -0,0 +1,102 @@ +/** + * 统一的表单验证 Composable + * 基于 Zod schema 提供类型安全的表单验证 + */ +import { z } from 'zod'; +import { useMessage } from '~/lib/ui/message'; +import { useI18n } from 'vue-i18n'; + +export interface ValidationResult { + success: boolean; + data?: T; + errors?: z.ZodError; +} + +/** + * 使用 Zod schema 进行表单验证 + * @param schema Zod schema 定义 + * @returns 验证函数和错误处理工具 + */ +export function useFormValidation(schema: T) { + const { t } = useI18n(); + const message = useMessage(); + + /** + * 验证数据是否符合 schema + * @param data 待验证的数据 + * @returns 验证结果 + */ + const validate = (data: unknown): ValidationResult> => { + const result = schema.safeParse(data); + + if (!result.success) { + return { + success: false, + errors: result.error, + }; + } + + return { + success: true, + data: result.data, + }; + }; + + /** + * 验证数据,失败时显示错误消息 + * @param data 待验证的数据 + * @param showMessage 是否显示错误消息 + * @returns 验证是否通过 + */ + const validateWithMessage = ( + data: unknown, + showMessage = true + ): data is z.infer => { + const result = validate(data); + + if (!result.success && showMessage) { + const firstError = result.errors?.errors[0]; + if (firstError) { + const fieldName = firstError.path.join('.'); + const errorMessage = firstError.message || t('Validation failed'); + message.error( + fieldName ? `${fieldName}: ${errorMessage}` : errorMessage + ); + } else { + message.error(t('Validation failed')); + } + } + + return result.success; + }; + + /** + * 获取字段错误消息 + * @param errors ZodError 实例 + * @param fieldPath 字段路径 + * @returns 错误消息或 undefined + */ + const getFieldError = ( + errors: z.ZodError | undefined, + fieldPath: (string | number)[] + ): string | undefined => { + if (!errors) return undefined; + + const error = errors.errors.find((e) => { + return ( + e.path.length === fieldPath.length && + e.path.every((p, i) => p === fieldPath[i]) + ); + }); + + return error?.message; + }; + + return { + validate, + validateWithMessage, + getFieldError, + schema, + }; +} + diff --git a/lib/api-client.ts b/lib/api-client.ts index 8b8a380..ef10b38 100644 --- a/lib/api-client.ts +++ b/lib/api-client.ts @@ -1,134 +1,144 @@ -import { joinURL } from 'ufo'; -import { parseApiError } from '~/utils/error-handler'; -import { logger } from '~/utils/logger'; -import type { AwsClient } from './aws4fetch'; +import { joinURL } from 'ufo' +import type { IApiErrorHandler } from '~/types/api' +import { parseApiError } from '~/utils/error-handler' +import { logger } from '~/utils/logger' +import type { AwsClient } from './aws4fetch' interface ApiClientOptions { - baseUrl?: string; - headers?: Record; + baseUrl?: string + headers?: Record + errorHandler?: IApiErrorHandler } interface RequestOptions { - method?: string; - headers?: Record; - body?: any; - params?: Record; + method?: string + headers?: Record + body?: any + params?: Record } class ApiClient { - private $api: AwsClient; - private config?: ApiClientOptions; + private $api: AwsClient + private config?: ApiClientOptions + private errorHandler?: IApiErrorHandler constructor(api: AwsClient, options?: ApiClientOptions) { - this.$api = api; - this.config = options; + this.$api = api + this.config = options + this.errorHandler = options?.errorHandler + } + + /** + * 设置错误处理器 + */ + setErrorHandler(handler: IApiErrorHandler): void { + this.errorHandler = handler } async request(url: string, options: RequestOptions = {}, parseJson: boolean = true) { - url = this.config?.baseUrl ? joinURL(this.config?.baseUrl, url) : url; - options.headers = { ...this.config?.headers, ...options.headers }; + url = this.config?.baseUrl ? joinURL(this.config?.baseUrl, url) : url + options.headers = { ...this.config?.headers, ...options.headers } // 处理body的数据格式,只对普通对象序列化 if (options.body && - !(options.body instanceof FormData) && - !(options.body instanceof Blob) && - !(options.body instanceof ArrayBuffer) && - !(options.body instanceof Uint8Array) && - !(options.body instanceof File)) { - options.body = JSON.stringify(options.body); + !(options.body instanceof FormData) && + !(options.body instanceof Blob) && + !(options.body instanceof ArrayBuffer) && + !(options.body instanceof Uint8Array) && + !(options.body instanceof File)) { + options.body = JSON.stringify(options.body) } if (options.params) { - const queryString = new URLSearchParams(options.params).toString(); - url += `?${queryString}`; // 拼接查询字符串到URL - delete options.params; // 删除params,以免影响fetch的options + const queryString = new URLSearchParams(options.params).toString() + url += `?${queryString}` // 拼接查询字符串到URL + delete options.params // 删除params,以免影响fetch的options } - logger.log('[request] url:', url); - logger.log('[request] options:', options); + logger.log('[request] url:', url) + logger.log('[request] options:', options) - const response = await this.$api.fetch(url, options); + const response = await this.$api.fetch(url, options) - logger.log('[request] response:', response); + logger.log('[request] response:', response) if (!response.ok) { - const errorMsg = await parseApiError(response); - throw new Error(errorMsg); + const errorMsg = await parseApiError(response) + throw new Error(errorMsg) } - // 处理401 + // 处理401未授权错误 if (response.status === 401) { - const message = useMessage(); - // 清除登录信息 - await useAuth().logout(); - window.location.href = '/rustfs/console/auth/login'; - return; + if (this.errorHandler) { + await this.errorHandler.handle401() + } + return } // 204 or body length is 0 if (response.status === 204 || response.headers.get('content-length') === '0' || !response.body) { - return null; + return null } if (parseJson) { - return await response.json(); + return await response.json() } else { - return response; + return response } } async *streamRequest(url: string, options: RequestOptions = {}) { - const response = await this.request(url, options, false); + const response = await this.request(url, options, false) if (!response.body) { - throw new Error('No response body'); + throw new Error('No response body') } - const reader = response.body.getReader(); - const decoder = new TextDecoder('utf-8'); + const reader = response.body.getReader() + const decoder = new TextDecoder('utf-8') while (true) { - const { done, value } = await reader.read(); - if (done) break; - const chunk = decoder.decode(value, { stream: true }); + const { done, value } = await reader.read() + if (done) break + const chunk = decoder.decode(value, { stream: true }) try { - const data = JSON.parse(chunk); - yield data; // 使用 yield 返回数据 + const data = JSON.parse(chunk) + yield data // 使用 yield 返回数据 } catch (error) { - logger.error('Failed to parse chunk:', error); + logger.error('Failed to parse chunk:', error) } } } async get(url: string, options?: RequestOptions) { - return this.request(url, { method: 'GET', ...options }); + return this.request(url, { method: 'GET', ...options }) } async post(url: string, body: any, options?: RequestOptions) { - return this.request(url, { method: 'POST', body, ...options }); + return this.request(url, { method: 'POST', body, ...options }) } async delete(url: string, options?: RequestOptions) { - return this.request(url, { method: 'DELETE', ...options }); + return this.request(url, { method: 'DELETE', ...options }) } async put(url: string, body: any, options?: RequestOptions) { - return this.request(url, { method: 'PUT', body, ...options }); + return this.request(url, { method: 'PUT', body, ...options }) } async patch(url: string, body: any, options?: RequestOptions) { - return this.request(url, { method: 'PATCH', body, ...options }); + return this.request(url, { method: 'PATCH', body, ...options }) } async head(url: string, options?: RequestOptions) { - return this.request(url, { method: 'HEAD', ...options }); + return this.request(url, { method: 'HEAD', ...options }) } async options(url: string, options?: RequestOptions) { - return this.request(url, { method: 'OPTIONS', ...options }); + return this.request(url, { method: 'OPTIONS', ...options }) } async trace(url: string, options?: RequestOptions) { - return this.request(url, { method: 'TRACE', ...options }); + return this.request(url, { method: 'TRACE', ...options }) } } -export default ApiClient; +export default ApiClient diff --git a/lib/utils/api-error-handler.ts b/lib/utils/api-error-handler.ts new file mode 100644 index 0000000..56793e7 --- /dev/null +++ b/lib/utils/api-error-handler.ts @@ -0,0 +1,70 @@ +/** + * API 错误处理器 + * 处理 API 请求中的错误,特别是认证错误 + */ +import type { ApiErrorHandlerCallback, IApiErrorHandler } from '~/types/api'; + +export class ApiErrorHandler implements IApiErrorHandler { + private onUnauthorized?: ApiErrorHandlerCallback; + private onForbidden?: ApiErrorHandlerCallback; + private onServerError?: ApiErrorHandlerCallback; + + constructor(callbacks?: { + onUnauthorized?: ApiErrorHandlerCallback; + onForbidden?: ApiErrorHandlerCallback; + onServerError?: ApiErrorHandlerCallback; + }) { + this.onUnauthorized = callbacks?.onUnauthorized; + this.onForbidden = callbacks?.onForbidden; + this.onServerError = callbacks?.onServerError; + } + + /** + * 处理 401 未授权错误 + */ + async handle401(): Promise { + if (this.onUnauthorized) { + await this.onUnauthorized(); + } + } + + /** + * 处理 403 禁止访问错误 + */ + async handle403(): Promise { + if (this.onForbidden) { + await this.onForbidden(); + } + } + + /** + * 处理 5xx 服务器错误 + */ + async handleServerError(): Promise { + if (this.onServerError) { + await this.onServerError(); + } + } + + /** + * 根据状态码处理错误 + * @param status HTTP 状态码 + */ + async handleByStatus(status: number): Promise { + switch (status) { + case 401: + await this.handle401(); + break; + case 403: + await this.handle403(); + break; + case 500: + case 502: + case 503: + case 504: + await this.handleServerError(); + break; + } + } +} + diff --git a/pages/config.vue b/pages/config.vue index 7e0bd87..4af5d0e 100644 --- a/pages/config.vue +++ b/pages/config.vue @@ -1,7 +1,8 @@