refactor: 修复 API 客户端 composables 滥用和硬编码路径问题

- 修复 lib/api-client.ts 中在类方法中直接调用 composables 的问题
- 添加 ApiErrorHandler 错误处理器,通过依赖注入方式处理 401 错误
- 创建路由工具函数 utils/routes.ts,统一管理路由路径
- 修复 composables/useAuth.ts 中的硬编码路径,使用工具函数
- 修复 components/copy-input.vue 的生命周期问题,在 onMounted 中创建 ClipboardJS 实例
- 修复 plugins/03.s3.ts 中的错误处理,使用 navigateTo 替代直接操作 window.location
- 修复 pages/config.vue 中的硬编码路径
- 添加 composables/useFormValidation.ts、useAsyncOperation.ts、useErrorHandler.ts 工具函数(供后续使用)
- 添加 types/api.ts 类型定义
This commit is contained in:
overtrue
2025-10-31 22:50:59 +08:00
parent 572a2e0150
commit 0598f8065f
12 changed files with 732 additions and 168 deletions
+32 -37
View File
@@ -1,11 +1,11 @@
<script lang="ts" setup>
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Icon } from '#components'
import ClipboardJS from 'clipboard'
import { useMessage } from '@/lib/ui/message'
import { onUnmounted } from 'vue'
import ClipboardJS from 'clipboard'
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -31,51 +31,46 @@ const props = defineProps({
const model = defineModel<string>()
const message = useMessage()
const clipboard = ref<ClipboardJS | null>(null)
const clipboard = new ClipboardJS(props.copyIcon ? `#${props.id}-btn-icon` : `#${props.id}-btn`)
clipboard.on('success', function (e) {
message.success(t('Copy Success'))
e.clearSelection()
onMounted(() => {
clipboard.value = new ClipboardJS(
props.copyIcon ? `#${props.id}-btn-icon` : `#${props.id}-btn`
)
clipboard.value.on('success', function (e) {
message.success(t('Copy Success'))
e.clearSelection()
})
clipboard.value.on('error', async function () {
// 如果复制失败,则使用 navigator.clipboard.writeText 进行复制
try {
if (navigator.clipboard && model.value) {
await navigator.clipboard.writeText(model.value)
message.success(t('Copy Success'))
} else {
message.error(t('Copy Failed'))
}
} catch {
message.error(t('Copy Failed'))
}
})
})
// 这里成功的时候也响应error,所以这里也加上
clipboard.on('error', function (e) {
// 如果复制失败,则使用navigator.clipboard.writeText进行复制
if (navigator.clipboard) {
navigator.clipboard.writeText(model.value || '')
}
message.success(t('Copy Success'))
})
onUnmounted(() => {
clipboard.destroy()
clipboard.value?.destroy()
clipboard.value = null
})
</script>
<template>
<div class="flex h-full items-center gap-2">
<Input
v-model="model"
:readonly="props.readonly"
:id="props.id"
class="flex-1"
/>
<Button
v-if="!props.copyIcon"
:id="`${props.id}-btn`"
:data-clipboard-target="`#${props.id}`"
variant="default"
>
<Input v-model="model" :readonly="props.readonly" :id="props.id" class="flex-1" />
<Button v-if="!props.copyIcon" :id="`${props.id}-btn`" :data-clipboard-target="`#${props.id}`" variant="default">
{{ t('Copy') }}
</Button>
<Button
v-else
:id="`${props.id}-btn-icon`"
variant="ghost"
size="sm"
class="shrink-0"
:data-clipboard-target="`#${props.id}`"
:title="t('Copy')"
>
<Button v-else :id="`${props.id}-btn-icon`" variant="ghost" size="sm" class="shrink-0" :data-clipboard-target="`#${props.id}`" :title="t('Copy')">
<Icon :size="18" name="ri:file-copy-line" />
<span class="sr-only">{{ t('Copy') }}</span>
</Button>
+136
View File
@@ -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<T extends (...args: any[]) => Promise<any>> {
/**
* 加载状态(只读)
*/
loading: Readonly<Ref<boolean>>;
/**
* 错误信息(只读)
*/
error: Readonly<Ref<Error | null>>;
/**
* 执行异步操作
*/
execute: (...args: Parameters<T>) => Promise<ReturnType<T> | null>;
/**
* 重置错误状态
*/
resetError: () => void;
}
/**
* 包装异步操作,提供加载状态和错误处理
* @param operation 异步操作函数
* @param options 配置选项
* @returns 包含加载状态和执行函数的对象
*/
export function useAsyncOperation<T extends (...args: any[]) => Promise<any>>(
operation: T,
options: AsyncOperationOptions = {}
): UseAsyncOperationReturn<T> {
const {
autoHandleError = true,
defaultErrorMessage,
retries = 0,
retryDelay = 1000,
} = options;
const loading = ref(false);
const error = ref<Error | null>(null);
const { handleError } = useErrorHandler();
/**
* 执行重试逻辑
*/
const executeWithRetry = async (
...args: Parameters<T>
): Promise<ReturnType<T>> => {
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<T>
): Promise<ReturnType<T> | 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,
};
}
+27 -26
View File
@@ -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<Credentials | undefined>(getCredentials()),
isAuthenticated: ref(isValidCredentials(store.value)),
};
}
}
+106
View File
@@ -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,
};
}
+102
View File
@@ -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<T> {
success: boolean;
data?: T;
errors?: z.ZodError;
}
/**
* 使用 Zod schema 进行表单验证
* @param schema Zod schema 定义
* @returns 验证函数和错误处理工具
*/
export function useFormValidation<T extends z.ZodTypeAny>(schema: T) {
const { t } = useI18n();
const message = useMessage();
/**
* 验证数据是否符合 schema
* @param data 待验证的数据
* @returns 验证结果
*/
const validate = (data: unknown): ValidationResult<z.infer<T>> => {
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<T> => {
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,
};
}
+69 -59
View File
@@ -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<string, string>;
baseUrl?: string
headers?: Record<string, string>
errorHandler?: IApiErrorHandler
}
interface RequestOptions {
method?: string;
headers?: Record<string, string>;
body?: any;
params?: Record<string, string>;
method?: string
headers?: Record<string, string>
body?: any
params?: Record<string, string>
}
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
+70
View File
@@ -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<void> {
if (this.onUnauthorized) {
await this.onUnauthorized();
}
}
/**
* 处理 403 禁止访问错误
*/
async handle403(): Promise<void> {
if (this.onForbidden) {
await this.onForbidden();
}
}
/**
* 处理 5xx 服务器错误
*/
async handleServerError(): Promise<void> {
if (this.onServerError) {
await this.onServerError();
}
}
/**
* 根据状态码处理错误
* @param status HTTP 状态码
*/
async handleByStatus(status: number): Promise<void> {
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;
}
}
}
+5 -4
View File
@@ -1,7 +1,8 @@
<script lang="ts" setup>
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { getLoginRoute } from '~/utils/routes'
await setPageLayout('plain')
@@ -51,7 +52,7 @@ const validateAndSave = async () => {
// 延迟后刷新页面并跳转,确保配置完全生效
setTimeout(() => {
window.location.href = '/rustfs/console/auth/login'
window.location.href = getLoginRoute()
}, 200)
} catch (error) {
console.error('URL validation error:', error) // 调试信息
@@ -74,7 +75,7 @@ const resetToCurrentHost = async () => {
// 延迟后跳转到登录页面,确保配置完全生效
setTimeout(() => {
window.location.href = '/rustfs/console/auth/login'
window.location.href = getLoginRoute()
}, 200)
}
@@ -151,7 +152,7 @@ onMounted(() => {
{{ t('Need help?') }}
<NuxtLink to="https://docs.rustfs.com" class="text-blue-600 hover:underline">{{
t('View Documentation')
}}</NuxtLink>
}}</NuxtLink>
</p>
</div>
+34 -20
View File
@@ -1,22 +1,24 @@
import ApiClient from '~/lib/api-client';
import { AwsClient } from '~/lib/aws4fetch';
import type { SiteConfig } from '~/types/config'; // Updated import
import ApiClient from '~/lib/api-client'
import { AwsClient } from '~/lib/aws4fetch'
import { ApiErrorHandler } from '~/lib/utils/api-error-handler'
import type { SiteConfig } from '~/types/config'
import { navigateToRoute } from '~/utils/routes'
export default defineNuxtPlugin({
name: 'admin-api-client',
setup(nuxtApp) {
const siteConfig = nuxtApp.$siteConfig as SiteConfig;
const { isAuthenticated, credentials } = useAuth();
const siteConfig = nuxtApp.$siteConfig as SiteConfig
const { isAuthenticated, credentials } = useAuth()
if (!isAuthenticated.value) {
return;
return
}
const accessKeyId = credentials.value?.AccessKeyId || '';
const secretAccessKey = credentials.value?.SecretAccessKey || '';
const sessionToken = credentials.value?.SessionToken || '';
const region: string = siteConfig.s3.region || 'us-east-1';
const service = 's3';
const accessKeyId = credentials.value?.AccessKeyId || ''
const secretAccessKey = credentials.value?.SecretAccessKey || ''
const sessionToken = credentials.value?.SessionToken || ''
const region: string = siteConfig.s3.region || 'us-east-1'
const service = 's3'
const adminApiClient: AwsClient = new AwsClient({
accessKeyId,
@@ -24,17 +26,29 @@ export default defineNuxtPlugin({
sessionToken,
region,
service,
});
})
// 创建错误处理器
const errorHandler = new ApiErrorHandler({
onUnauthorized: async () => {
const { logout } = useAuth()
logout()
await navigateToRoute('/auth/login', { external: true })
},
})
const apiClient = new ApiClient(adminApiClient, {
baseUrl: siteConfig.api.baseURL,
headers: {
'Content-Type': 'application/json',
},
errorHandler,
})
return {
provide: {
api: new ApiClient(adminApiClient, {
baseUrl: siteConfig.api.baseURL,
headers: {
'Content-Type': 'application/json',
},
}),
api: apiClient,
},
};
}
},
});
})
+33 -22
View File
@@ -1,22 +1,22 @@
import { S3Client } from '@aws-sdk/client-s3';
import type { SiteConfig } from '~/types/config';
import type { DeserializeHandler, DeserializeHandlerArguments, DeserializeHandlerOutput } from '@aws-sdk/types';
import { S3Client } from '@aws-sdk/client-s3'
import type { DeserializeHandler, DeserializeHandlerArguments, DeserializeHandlerOutput } from '@aws-sdk/types'
import type { SiteConfig } from '~/types/config'
interface S3Response {
response?: {
body?: string;
};
[key: string]: any;
body?: string
}
[key: string]: any
}
export default defineNuxtPlugin({
name: 's3-client',
setup(nuxtApp) {
const { credentials, isAuthenticated } = useAuth();
const siteConfig = nuxtApp.$siteConfig as SiteConfig;
const { credentials, isAuthenticated } = useAuth()
const siteConfig = nuxtApp.$siteConfig as SiteConfig
if (!isAuthenticated || !credentials.value) {
return;
return
}
const client = new S3Client({
@@ -30,58 +30,69 @@ export default defineNuxtPlugin({
secretAccessKey: credentials.value?.SecretAccessKey || '',
sessionToken: credentials.value?.SessionToken || '',
},
});
})
// 添加中间件处理XML响应和401错误
client.middlewareStack.add(
(next: DeserializeHandler<any, any>) =>
async (args: DeserializeHandlerArguments<any>): Promise<DeserializeHandlerOutput<any>> => {
try {
const response = (await next(args)) as S3Response;
const response = (await next(args)) as S3Response
// 检查响应是否为XML格式
if (response.response?.body && typeof response.response.body === 'string') {
const body = response.response.body.trim();
const body = response.response.body.trim()
// 检查是否是空的XML响应
if (body.match(/^<\?xml[^>]*\?><[^>]*><\/[^>]*>$/)) {
// 从XML标签名提取属性名
const tagName = body.match(/<([^>]*)><\/\1>/)?.[1];
const tagName = body.match(/<([^>]*)><\/\1>/)?.[1]
if (tagName) {
// 将XML标签名转换为驼峰命名
const propertyName = tagName.replace(/(?:^|_)([a-z])/g, (_, letter) => letter.toUpperCase());
const propertyName = tagName.replace(/(?:^|_)([a-z])/g, (_, letter) => letter.toUpperCase())
// 返回null,保持response结构
return {
response: response.response,
[propertyName]: null,
} as unknown as DeserializeHandlerOutput<any>;
} as unknown as DeserializeHandlerOutput<any>
}
}
}
return response as DeserializeHandlerOutput<any>;
return response as DeserializeHandlerOutput<any>
} catch (error: any) {
// 处理401未授权错误
if (error?.$metadata?.httpStatusCode === 401) {
await useAuth().logoutAndRedirect();
const { logout } = useAuth()
logout()
// 使用 navigateTo 而不是直接操作 window.location
await navigateTo('/auth/login')
// 返回一个空响应以符合类型要求
return {
response: {
statusCode: 401,
headers: {},
},
} as DeserializeHandlerOutput<any>
}
// 处理 S3 客户端错误,优先抛出 error.message 作为新的 Error
if (error?.Code) {
throw new Error(error.Code);
throw new Error(error.Code)
}
throw error;
throw error
}
},
{
step: 'deserialize',
name: 'handleXmlResponse',
}
);
)
return {
provide: {
s3Client: client,
},
};
}
},
});
})
+57
View File
@@ -0,0 +1,57 @@
/**
* API 相关类型定义
*/
/**
* API 错误处理器回调函数类型
*/
export type ApiErrorHandlerCallback = () => void | Promise<void>;
/**
* API 客户端错误处理器接口
*/
export interface IApiErrorHandler {
handle401(): Promise<void>;
handle403(): Promise<void>;
handleServerError(): Promise<void>;
handleByStatus(status: number): Promise<void>;
}
/**
* API 请求基础配置
*/
export interface ApiRequestConfig {
baseURL?: string;
headers?: Record<string, string>;
timeout?: number;
}
/**
* API 响应基础结构
*/
export interface ApiResponse<T = any> {
data?: T;
message?: string;
code?: number;
}
/**
* 分页请求参数
*/
export interface PaginationParams {
page?: number;
pageSize?: number;
offset?: number;
limit?: number;
}
/**
* 分页响应数据
*/
export interface PaginatedResponse<T> {
items: T[];
total: number;
page?: number;
pageSize?: number;
}
+61
View File
@@ -0,0 +1,61 @@
/**
* 路由工具函数
* 统一管理路由路径和导航逻辑
*/
import { joinURL } from 'ufo';
/**
* 获取应用的 baseURL
* @returns baseURL 路径
*/
function getBaseURL(): string {
// 在客户端,从 window.location 推断 baseURL
if (process.client) {
const pathname = window.location.pathname;
// 如果路径以 /rustfs/console 开头,提取基础路径
const match = pathname.match(/^(\/rustfs\/console)/);
if (match) {
return match[1] + '/';
}
}
// 默认值
return '/rustfs/console/';
}
/**
* 构建完整路由路径(包含 baseURL)
* @param path 路由路径
* @returns 完整路径
*/
export function buildRoute(path: string): string {
const baseURL = getBaseURL();
return joinURL(baseURL.replace(/\/$/, ''), path.replace(/^\//, ''));
}
/**
* 导航到指定路由
* @param path 路由路径
* @param options 导航选项
*/
export async function navigateToRoute(
path: string,
options?: { external?: boolean; replace?: boolean }
): Promise<void> {
const fullPath = buildRoute(path);
if (options?.external) {
window.location.href = fullPath;
} else {
await navigateTo(fullPath, { replace: options?.replace });
}
}
/**
* 获取登录页面路径
* @returns 登录页面完整路径
*/
export function getLoginRoute(): string {
return buildRoute('/auth/login');
}