diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 66a6eef..e5e08c9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2499,12 +2499,13 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nix" -version = "0.27.1" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.11.0", "cfg-if", + "cfg_aliases", "libc", ] diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 595f0eb..92e16de 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -137,6 +137,17 @@ fn check_res_bin() { println!("cargo:warning=Using default OpenList version (set NETMOUNT_OPENLIST_VERSION to override)"); } + // 获取 OpenList 版本 + let openlist_version = get_openlist_version(); + println!("cargo:warning=Building with OpenList version: {}", openlist_version); + + // 检查环境变量覆盖 + if env::var("NETMOUNT_OPENLIST_VERSION").is_ok() { + println!("cargo:warning=Using OpenList version from environment variable NETMOUNT_OPENLIST_VERSION"); + } else { + println!("cargo:warning=Using default OpenList version (set NETMOUNT_OPENLIST_VERSION to override)"); + } + let res_bin_urls = match OS_TYPE { "windows" => match arch { "aarch64"|"arm" |"arm64"=> ResBinUrls { diff --git a/src/services/openlist.ts b/src/services/openlist.ts index e6dc126..c91896b 100644 --- a/src/services/openlist.ts +++ b/src/services/openlist.ts @@ -1,10 +1,6 @@ -import { Child, Command } from "@tauri-apps/plugin-shell"; -import { formatPath, randomString } from "../utils/utils"; import { OpenlistInfo } from "../type/openlist/openlistInfo"; -import { nmConfig, osInfo } from "./config"; - -let openlistInfo: OpenlistInfo = { +const openlistInfo: OpenlistInfo = { markInRclone: '.netmount-openlist.', endpoint: { url: '', @@ -20,7 +16,43 @@ let openlistInfo: OpenlistInfo = { scheme: { http_port: 9751//随机 }, - temp_dir: 'data\\temp' + temp_dir: 'data\\temp', + // v4 常用字段默认值 + site_url: '', + cdn: '', + jwt_secret: '', + token_expires_in: 48, + database: { + type: 'sqlite3', + host: '', + port: 0, + user: '', + password: '', + name: '', + db_file: 'data/data.db', // 相对路径,会在 modifyOpenlistConfig 中转为绝对路径 + table_prefix: 'x_', + ssl_mode: '' + }, + bleve_dir: 'bleve', + log: { + enable: true, + name: 'log/log.log', // 相对路径,会在 modifyOpenlistConfig 中转为绝对路径 + max_size: 50, + max_backups: 30, + max_age: 28, + compress: false + }, + tasks: { + download: { workers: 5, max_retry: 1, expire_seconds: 0 }, + transfer: { workers: 5, max_retry: 2, expire_seconds: 0 }, + upload: { workers: 5, max_retry: 0, expire_seconds: 0 }, + copy: { workers: 5, max_retry: 2, expire_seconds: 0 } + }, + cors: { + allow_origins: ['*'], + allow_methods: ['*'], + allow_headers: ['*'] + } }, version: { version: '' diff --git a/src/services/rclone.ts b/src/services/rclone.ts index 38a1614..fe357d1 100644 --- a/src/services/rclone.ts +++ b/src/services/rclone.ts @@ -1,11 +1,8 @@ -import { t } from "i18next" import { RcloneInfo } from "../type/rclone/rcloneInfo" import { RcloneStats } from "../type/rclone/stats" -import { nmConfig, osInfo } from "./config" -import { formatPath } from "../utils/utils" -let rcloneInfo: RcloneInfo = { +const rcloneInfo: RcloneInfo = { process:{ }, @@ -63,6 +60,6 @@ let rcloneInfo: RcloneInfo = { } } -let rcloneStatsHistory: RcloneStats[] = [] +const rcloneStatsHistory: RcloneStats[] = [] export { rcloneInfo, rcloneStatsHistory } \ No newline at end of file diff --git a/src/type/controller/storage/info.d.ts b/src/type/controller/storage/info.d.ts index 1fe4034..59934b2 100644 --- a/src/type/controller/storage/info.d.ts +++ b/src/type/controller/storage/info.d.ts @@ -1,8 +1,35 @@ +// Rclone Provider 选项接口 +interface RcloneProviderOption { + Name: string; + Help: string; + Type: string; + Default: string | number | boolean; + DefaultStr: string; + ValueStr: string; + Required: boolean; + Advanced: boolean; + IsPassword: boolean; + Provider?: string; + ShortOpt?: string; + Examples?: Array<{ + Value: string; + Help: string; + }>; +} + +// Rclone Provider 接口 +interface RcloneProvider { + Name: string; + Prefix: string; + Description: string; + Options: RcloneProviderOption[]; +} + //过滤器 interface FilterType { name: string,//匹配参数名 - value: any,//匹配值 + value: string | number | boolean,//匹配值 state: boolean,//是否过滤 } @@ -64,4 +91,4 @@ interface StorageInfoType { } } -export { StorageInfoType, StorageParamsType, StorageParamItemType, ParamItemOptionType, FilterType } \ No newline at end of file +export { StorageInfoType, StorageParamsType, StorageParamItemType, ParamItemOptionType, FilterType, RcloneProvider, RcloneProviderOption } \ No newline at end of file diff --git a/src/type/openlist/openlistInfo.d.ts b/src/type/openlist/openlistInfo.d.ts index 4ddc812..deffeeb 100644 --- a/src/type/openlist/openlistInfo.d.ts +++ b/src/type/openlist/openlistInfo.d.ts @@ -13,10 +13,64 @@ interface OpenlistInfo { }; openlistConfig: {//https://docs.openlist.team/zh/config/configuration.html force?: boolean; + site_url?: string; + cdn?: string; + jwt_secret?: string; + token_expires_in?: number; + database?: { + type?: string; + host?: string; + port?: number; + user?: string; + password?: string; + name?: string; + db_file?: string; + table_prefix?: string; + ssl_mode?: string; + }; scheme?: { http_port?: number; + https_port?: number; + cert_file?: string; + key_file?: string; }; temp_dir?: string; + bleve_dir?: string; + log?: { + enable?: boolean; + name?: string; + max_size?: number; + max_backups?: number; + max_age?: number; + compress?: boolean; + }; + tasks?: { + download?: { + workers?: number; + max_retry?: number; + expire_seconds?: number; + }; + transfer?: { + workers?: number; + max_retry?: number; + expire_seconds?: number; + }; + upload?: { + workers?: number; + max_retry?: number; + expire_seconds?: number; + }; + copy?: { + workers?: number; + max_retry?: number; + expire_seconds?: number; + }; + }; + cors?: { + allow_origins?: string[]; + allow_methods?: string[]; + allow_headers?: string[]; + }; }; version:{ version: string; @@ -28,4 +82,41 @@ interface OpenlistInfo { }, } -export { OpenlistInfo }; \ No newline at end of file +// OpenList 存储项接口 +interface OpenlistStorageItem { + id: number; + mount_path: string; + driver: string; + order: number; + status: 'work' | string; + addition: string | Record; + remark: string; + modified: string; + disabled: boolean; + enable_sign: boolean; + order_by: string; + order_direction: string; + extract_folder: string; + web_proxy: boolean; + webdav_policy: string; + down_proxy_url: string; +} + +// OpenList 存储列表响应 +interface OpenlistStorageListResponse { + code: number; + message: string; + data: { + content: OpenlistStorageItem[]; + total: number; + }; +} + +// OpenList 存储详情响应 +interface OpenlistStorageGetResponse { + code: number; + message: string; + data: OpenlistStorageItem; +} + +export { OpenlistInfo, OpenlistStorageItem, OpenlistStorageListResponse, OpenlistStorageGetResponse }; \ No newline at end of file diff --git a/src/type/rclone/rcloneInfo.d.ts b/src/type/rclone/rcloneInfo.d.ts index eb157de..4f6047d 100644 --- a/src/type/rclone/rcloneInfo.d.ts +++ b/src/type/rclone/rcloneInfo.d.ts @@ -81,4 +81,4 @@ interface FileInfo { isDir: boolean; } -export { RcloneInfo, FileInfo, StorageSpace,StorageList } \ No newline at end of file +export { RcloneInfo, FileInfo, StorageSpace,StorageList,RcloneVersion,MountList } \ No newline at end of file diff --git a/src/type/rclone/storage/mount/parameters.d.ts b/src/type/rclone/storage/mount/parameters.d.ts index 6eb530e..fbd5ed1 100644 --- a/src/type/rclone/storage/mount/parameters.d.ts +++ b/src/type/rclone/storage/mount/parameters.d.ts @@ -64,7 +64,19 @@ interface MountOptions { -export { VfsOptions, MountOptions } +// Rclone mount point 接口 +interface RcloneMountPoint { + Fs: string; + MountPoint: string; + MountedOn: string; +} + +// Rclone mount list 响应接口 +interface RcloneMountListResponse { + mountPoints: RcloneMountPoint[]; +} + +export { VfsOptions, MountOptions, RcloneMountPoint, RcloneMountListResponse } diff --git a/src/utils/aria2/aria2.ts b/src/utils/aria2/aria2.ts index 4fee831..1a9ccba 100644 --- a/src/utils/aria2/aria2.ts +++ b/src/utils/aria2/aria2.ts @@ -44,7 +44,7 @@ class Aria2 { // 解析aria2的命令行输出 private parseOutput(output: string): Aria2Attrib { - let tempAria2Attrib: Aria2Attrib = { + const tempAria2Attrib: Aria2Attrib = { state: 'request', speed: '', percentage: 0, diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000..89175c5 --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,230 @@ +/** + * 应用常量集中化管理 + * 消除 Magic Strings 和 Magic Numbers + */ + +// ============================================ +// 时间常量 (毫秒) +// ============================================ + +export const TIME_MS = { + SECOND: 1_000, + MINUTE: 60_000, + HOUR: 3_600_000, + DAY: 86_400_000, +} as const; + +export const TIME_S = { + MINUTE: 60, + HOUR: 3600, + DAY: 86400, +} as const; + +// ============================================ +// API 超时配置 +// ============================================ + +export const API_TIMEOUT = { + DEFAULT: 10_000, // 默认超时 10秒 + PING: 5_000, // Ping 超时 5秒 + LONG: 30_000, // 长请求超时 30秒 + VERSION_CHECK: 5_000, // 版本检查超时 5秒 + OPENLIST_VERSION: 10_000, // OpenList 版本检查 10秒 +} as const; + +// ============================================ +// 重试配置 +// ============================================ + +export const RETRY_CONFIG = { + MAX_ATTEMPTS: 3, // 最大重试次数 + INITIAL_DELAY: 1_000, // 初始延迟 1秒 + MAX_DELAY: 10_000, // 最大延迟 10秒 + BACKOFF_MULTIPLIER: 2, // 退避倍数 + JITTER_RANGE: 500, // 抖动范围 (±ms) +} as const; + +// ============================================ +// 存储配置 +// ============================================ + +export const STORAGE_CONFIG = { + NAME_MAX_LENGTH: 128, + INVALID_CHARS: ['<', '>', ':', '"', '|', '?', '*', '/', '\\'] as const, + CACHE_DIR_SUFFIX: '.cache/netmount', + DEFAULT_PAGE_SIZE: 50, +} as const; + +// ============================================ +// 任务配置 +// ============================================ + +export const TASK_CONFIG = { + RUN_MODES: ['start', 'time', 'interval', 'disposable'] as const, + TASK_TYPES: ['copy', 'move', 'delete', 'sync', 'bisync'] as const, + DATE_MULTIPLIERS: [ + { name: 'day', value: 1 }, + { name: 'week', value: 7 }, + { name: 'month', value: 30 }, + ] as const, + INTERVAL_MULTIPLIERS: [ + { name: 'hour', value: 60 * 60 }, + { name: 'minute', value: 60 }, + { name: 'second', value: 1 }, + ] as const, + DEFAULT_WORKERS: 5, + DEFAULT_MAX_RETRY: 2, +} as const; + +// ============================================ +// OpenList 配置 +// ============================================ + +export const OPENLIST_CONFIG = { + DEFAULT_PORT: 9751, + DEFAULT_TOKEN_EXPIRES_HOURS: 48, + MARK_IN_RCLONE: '.netmount-openlist.', + DEFAULT_TABLE_PREFIX: 'x_', + LOG_MAX_SIZE: 50, + LOG_MAX_BACKUPS: 30, + LOG_MAX_AGE: 28, + CORS_ALLOW_ALL: true, + DOWNLOAD_WORKERS: 5, + TRANSFER_WORKERS: 5, + UPLOAD_WORKERS: 5, + COPY_WORKERS: 5, +} as const; + +// ============================================ +// Rclone 配置 +// ============================================ + +export const RCLONE_CONFIG = { + DEFAULT_PORT: 6434, + DEFAULT_TEMP_DIR: 'rclone-temp', +} as const; + +// ============================================ +// 主题和语言配置 +// ============================================ + +export const THEME_MODES = ['auto', 'light', 'dark'] as const; + +export const LANGUAGE_OPTIONS = [ + { name: '简体中文', value: 'cn', langCode: 'zh-cn' }, + { name: '繁體中文', value: 'ct', langCode: 'zh-tw' }, + { name: 'English', value: 'en', langCode: 'en-us' }, +] as const; + +// ============================================ +// 错误码配置 +// ============================================ + +export const ERROR_CODES = { + // 通用错误 + UNKNOWN: 'UNKNOWN_ERROR', + VALIDATION: 'VALIDATION_ERROR', + API: 'API_ERROR', + NETWORK: 'NETWORK_ERROR', + TIMEOUT: 'TIMEOUT_ERROR', + NOT_FOUND: 'NOT_FOUND', + PERMISSION: 'PERMISSION_DENIED', + CONFIG: 'CONFIG_ERROR', + + // 存储相关 + STORAGE_NOT_FOUND: 'STORAGE_NOT_FOUND', + STORAGE_EXISTS: 'STORAGE_EXISTS', + STORAGE_CREATE_FAILED: 'STORAGE_CREATE_FAILED', + STORAGE_DELETE_FAILED: 'STORAGE_DELETE_FAILED', + + // 挂载相关 + MOUNT_FAILED: 'MOUNT_FAILED', + UNMOUNT_FAILED: 'UNMOUNT_FAILED', + MOUNT_POINT_INVALID: 'MOUNT_POINT_INVALID', + + // 任务相关 + TASK_NOT_FOUND: 'TASK_NOT_FOUND', + TASK_CREATE_FAILED: 'TASK_CREATE_FAILED', + TASK_EXECUTION_FAILED: 'TASK_EXECUTION_FAILED', + + // 框架相关 + RCLONE_START_FAILED: 'RCLONE_START_FAILED', + OPENLIST_START_FAILED: 'OPENLIST_START_FAILED', + FRAMEWORK_VERSION_FAILED: 'FRAMEWORK_VERSION_FAILED', +} as const; + +// ============================================ +// 消息配置 +// ============================================ + +export const MESSAGES = { + // 成功消息 + SUCCESS: 'success', + INSTALL_SUCCESS: '安装成功', + + // 错误消息 + ERROR: 'error', + ERROR_TIPS: '发生错误', + NETWORK_DISCONNECTED: '网络连接已断开', + OPERATION_TIMEOUT: '操作超时', + + // 存储消息 + STORAGE_CREATED: '存储创建成功', + STORAGE_DELETED: '存储删除成功', + STORAGE_UPDATED: '存储更新成功', + + // 挂载消息 + MOUNT_SUCCESS: '挂载成功', + UNMOUNT_SUCCESS: '卸载成功', +} as const; + +// ============================================ +// 路径配置 +// ============================================ + +export const PATHS = { + HOME_DIR_PLACEHOLDER: '~', + DATA_DIR: 'data', + LOG_DIR: 'log', + TEMP_DIR: 'temp', + DB_FILE: 'data/data.db', + LOG_FILE: 'log/log.log', + BLEVE_DIR: 'bleve', +} as const; + +// ============================================ +// 日志配置 +// ============================================ + +export const LOG_CONFIG = { + LEVEL_DEBUG: 'debug', + LEVEL_INFO: 'info', + LEVEL_WARN: 'warn', + LEVEL_ERROR: 'error', + DEFAULT_LEVEL: 'info', +} as const; + +// ============================================ +// 窗口配置 +// ============================================ + +export const WINDOW_CONFIG = { + DEFAULT_WIDTH: 1200, + DEFAULT_HEIGHT: 800, + MIN_WIDTH: 800, + MIN_HEIGHT: 600, +} as const; + +// ============================================ +// 国际化键名 +// ============================================ + +export const I18N_KEYS = { + INIT: 'init', + READ_CONFIG: 'read_config', + START_FRAMEWORK: 'start_framework', + GET_NOTICE: 'get_notice', + ERROR: 'error', + SUCCESS: 'success', + ERROR_TIPS: 'error_tips', +} as const; diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000..af8d86d --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,427 @@ +/** + * 错误处理系统 - 提供统一的错误类型和处理机制 + * 增强应用的健壮性和可维护性 + */ + +import { Message, Notification } from "@arco-design/web-react"; +import { t } from "i18next"; + +/** + * 错误类型枚举 + */ +export enum ErrorType { + VALIDATION = 'VALIDATION', // 输入验证错误 + API = 'API', // API调用错误 + NETWORK = 'NETWORK', // 网络错误 + TIMEOUT = 'TIMEOUT', // 超时错误 + NOT_FOUND = 'NOT_FOUND', // 资源未找到 + PERMISSION = 'PERMISSION', // 权限错误 + CONFIG = 'CONFIG', // 配置错误 + UNKNOWN = 'UNKNOWN', // 未知错误 +} + +/** + * 错误严重级别 + */ +export enum ErrorSeverity { + INFO = 'info', + WARNING = 'warning', + ERROR = 'error', + CRITICAL = 'critical', +} + +/** + * 应用错误类 - 提供结构化的错误信息 + */ +export class AppError extends Error { + public readonly type: ErrorType; + public readonly severity: ErrorSeverity; + public readonly code: string; + public readonly details: Record | undefined; + public readonly timestamp: Date; + public readonly recoverable: boolean; + public readonly originalCause: Error | undefined; + + constructor(options: { + message: string; + type?: ErrorType | undefined; + severity?: ErrorSeverity | undefined; + code?: string | undefined; + details?: Record | undefined; + recoverable?: boolean | undefined; + cause?: Error | undefined; + }) { + super(options.message); + this.name = 'AppError'; + this.type = options.type ?? ErrorType.UNKNOWN; + this.severity = options.severity ?? ErrorSeverity.ERROR; + this.code = options.code ?? 'UNKNOWN_ERROR'; + this.details = options.details; + this.timestamp = new Date(); + this.recoverable = options.recoverable ?? false; + + // 保留原始错误 + if (options.cause) { + this.originalCause = options.cause; + } + + // 确保 instanceof 正确工作 + Object.setPrototypeOf(this, AppError.prototype); + } + + /** + * 创建验证错误 + */ + static validation( + message: string, + details?: Record + ): AppError { + return new AppError({ + message, + type: ErrorType.VALIDATION, + severity: ErrorSeverity.WARNING, + code: 'VALIDATION_ERROR', + details, + recoverable: true, + }); + } + + /** + * 创建API错误 + */ + static api( + message: string, + code?: string, + details?: Record, + cause?: Error + ): AppError { + return new AppError({ + message, + type: ErrorType.API, + severity: ErrorSeverity.ERROR, + code: code ?? 'API_ERROR', + details, + recoverable: true, + cause, + }); + } + + /** + * 创建网络错误 + */ + static network(message: string, cause?: Error): AppError { + return new AppError({ + message, + type: ErrorType.NETWORK, + severity: ErrorSeverity.ERROR, + code: 'NETWORK_ERROR', + recoverable: true, + cause, + }); + } + + /** + * 创建超时错误 + */ + static timeout(operation: string, timeoutMs: number): AppError { + return new AppError({ + message: `操作 "${operation}" 超时 (${timeoutMs}ms)`, + type: ErrorType.TIMEOUT, + severity: ErrorSeverity.WARNING, + code: 'TIMEOUT_ERROR', + details: { operation, timeoutMs }, + recoverable: true, + }); + } + + /** + * 创建未找到错误 + */ + static notFound(resource: string, identifier?: string): AppError { + return new AppError({ + message: identifier + ? `未找到 ${resource}: ${identifier}` + : `未找到 ${resource}`, + type: ErrorType.NOT_FOUND, + severity: ErrorSeverity.WARNING, + code: 'NOT_FOUND', + details: { resource, identifier }, + recoverable: true, + }); + } + + /** + * 创建配置错误 + */ + static config(message: string, details?: Record): AppError { + return new AppError({ + message, + type: ErrorType.CONFIG, + severity: ErrorSeverity.CRITICAL, + code: 'CONFIG_ERROR', + details, + recoverable: false, + }); + } + + /** + * 序列化错误信息 + */ + toJSON(): Record { + return { + name: this.name, + message: this.message, + type: this.type, + severity: this.severity, + code: this.code, + details: this.details, + timestamp: this.timestamp.toISOString(), + recoverable: this.recoverable, + stack: this.stack, + cause: this.originalCause?.message, + }; + } + + /** + * 获取用户友好的错误消息 + */ + getUserMessage(): string { + // 如果有国际化键,使用国际化消息 + const i18nKey = `errors.${this.code}`; + const i18nMessage = t(i18nKey, { defaultValue: '' }); + + if (i18nMessage) { + return i18nMessage; + } + + // 否则返回原始消息 + return this.message; + } +} + +/** + * 错误处理器配置 + */ +interface ErrorHandlerConfig { + showNotification: boolean; + showMessage: boolean; + logToConsole: boolean; + rethrow: boolean; +} + +const DEFAULT_CONFIG: ErrorHandlerConfig = { + showNotification: false, + showMessage: true, + logToConsole: true, + rethrow: false, +}; + +/** + * 错误处理器 - 统一处理错误的中心点 + */ +export class ErrorHandler { + private static errorLog: AppError[] = []; + private static maxLogSize = 100; + + /** + * 处理错误 + */ + static handle( + error: unknown, + config: Partial = {} + ): AppError { + const mergedConfig = { ...DEFAULT_CONFIG, ...config }; + + // 转换为 AppError + const appError = this.normalizeError(error); + + // 记录错误 + this.logError(appError, mergedConfig.logToConsole); + + // 显示通知 + if (mergedConfig.showNotification && appError.severity === ErrorSeverity.CRITICAL) { + this.showNotification(appError); + } + + // 显示消息 + if (mergedConfig.showMessage) { + this.showMessage(appError); + } + + // 是否重新抛出 + if (mergedConfig.rethrow) { + throw appError; + } + + return appError; + } + + /** + * 将任意错误转换为 AppError + */ + private static normalizeError(error: unknown): AppError { + if (error instanceof AppError) { + return error; + } + + if (error instanceof Error) { + return new AppError({ + message: error.message, + type: ErrorType.UNKNOWN, + severity: ErrorSeverity.ERROR, + code: 'UNKNOWN_ERROR', + cause: error, + }); + } + + if (typeof error === 'string') { + return new AppError({ + message: error, + type: ErrorType.UNKNOWN, + severity: ErrorSeverity.ERROR, + code: 'UNKNOWN_ERROR', + }); + } + + return new AppError({ + message: '发生未知错误', + type: ErrorType.UNKNOWN, + severity: ErrorSeverity.ERROR, + code: 'UNKNOWN_ERROR', + details: { originalError: error }, + }); + } + + /** + * 记录错误 + */ + private static logError(error: AppError, logToConsole: boolean): void { + // 添加到内存日志 + this.errorLog.unshift(error); + if (this.errorLog.length > this.maxLogSize) { + this.errorLog.pop(); + } + + // 控制台输出 + if (logToConsole) { + const logMethod = error.severity === ErrorSeverity.CRITICAL + ? console.error + : error.severity === ErrorSeverity.WARNING + ? console.warn + : console.log; + + logMethod(`[${error.type}] ${error.code}:`, error.message, error.details); + } + } + + /** + * 显示通知 + */ + private static showNotification(error: AppError): void { + Notification.error({ + title: t('error'), + content: error.getUserMessage(), + duration: 5000, + }); + } + + /** + * 显示消息提示 + */ + private static showMessage(error: AppError): void { + if (error.severity === ErrorSeverity.CRITICAL || error.severity === ErrorSeverity.ERROR) { + Message.error(error.getUserMessage()); + } else if (error.severity === ErrorSeverity.WARNING) { + Message.warning(error.getUserMessage()); + } + } + + /** + * 获取错误日志 + */ + static getErrorLog(): AppError[] { + return [...this.errorLog]; + } + + /** + * 清除错误日志 + */ + static clearErrorLog(): void { + this.errorLog = []; + } + + /** + * 创建安全执行包装器 + */ + static safe( + fn: () => T | Promise, + fallback?: T, + config?: Partial + ): Promise { + return Promise.resolve() + .then(() => fn()) + .catch((error) => { + this.handle(error, config); + return fallback; + }); + } +} + +/** + * 便捷函数 - 快速处理错误 + */ +export function handleError( + error: unknown, + config?: Partial +): AppError { + return ErrorHandler.handle(error, config); +} + +/** + * 便捷函数 - 安全执行 + */ +export function safeExecute( + fn: () => T | Promise, + fallback?: T, + config?: Partial +): Promise { + return ErrorHandler.safe(fn, fallback, config); +} + +/** + * 全局错误监听器设置 + */ +export function setupGlobalErrorHandlers(): void { + // 处理未捕获的 Promise 错误 + window.addEventListener('unhandledrejection', (event) => { + event.preventDefault(); + + // 排除 ResizeObserver 错误 (已知的 Chrome bug) + if (event.reason?.message?.includes('ResizeObserver')) { + return; + } + + ErrorHandler.handle(event.reason, { + showNotification: true, + showMessage: true, + }); + }); + + // 处理全局错误 + window.addEventListener('error', (event) => { + event.preventDefault(); + + // 排除 ResizeObserver 错误 + if (event.message?.includes('ResizeObserver')) { + return; + } + + ErrorHandler.handle( + new Error(`${event.message} at ${event.filename}:${event.lineno}:${event.colno}`), + { + showNotification: true, + showMessage: true, + } + ); + }); +} diff --git a/src/utils/openlist/openlist.ts b/src/utils/openlist/openlist.ts index 6d383a6..930d60a 100644 --- a/src/utils/openlist/openlist.ts +++ b/src/utils/openlist/openlist.ts @@ -49,14 +49,42 @@ async function modifyOpenlistConfig(rewriteData: { await invoke('write_json_file',{configData:newOpenlistConfig,path:path}) } -async function addOpenlistInRclone(){ - //await delStorage(openlistInfo.markInRclone) - await createStorage(openlistInfo.markInRclone,'webdav',{ - 'url':openlistInfo.endpoint.url+'/dav', - 'vendor':'other', - 'user':nmConfig.framework.openlist.user, - 'pass':nmConfig.framework.openlist.password, - }) +async function addOpenlistInRclone() { + const webdavUrl = openlistInfo.endpoint.url + '/dav'; + const username = nmConfig.framework.openlist.user; + + console.log('=== OpenList WebDAV Configuration ==='); + console.log('WebDAV URL:', webdavUrl); + console.log('WebDAV Username:', username); + console.log('Note: WebDAV password is the same as Web UI login password'); + + // 可选:探测 WebDAV 端点以提供诊断信息 + try { + const probeRes = await fetch(webdavUrl, { + method: 'OPTIONS', + headers: { + 'Authorization': 'Basic ' + btoa(username + ':' + nmConfig.framework.openlist.password) + } + }); + console.log('WebDAV probe HTTP status:', probeRes.status); + if (probeRes.status === 401) { + console.warn('WebDAV returned 401 - Please check if user has WebDAV Read/Management permissions enabled'); + } else if (probeRes.status === 403) { + console.warn('WebDAV returned 403 - Please check if user has necessary file permissions'); + } else if (probeRes.ok || probeRes.status === 207) { + console.log('WebDAV endpoint appears to be accessible'); + } + } catch (probeError) { + console.warn('WebDAV probe failed (this is normal if server is still starting):', probeError); + } + console.log('====================================='); + + await createStorage(openlistInfo.markInRclone, 'webdav', { + 'url': webdavUrl, + 'vendor': 'other', + 'user': username, + 'pass': nmConfig.framework.openlist.password, + }); } diff --git a/src/utils/openlist/process.ts b/src/utils/openlist/process.ts index 91a0a13..7dbc1aa 100644 --- a/src/utils/openlist/process.ts +++ b/src/utils/openlist/process.ts @@ -23,7 +23,7 @@ async function startOpenlist() { openlistInfo.openlistConfig.temp_dir = formatPath(nmConfig.settings.path.cacheDir + '/openlist/', osInfo.osType === "windows") //自动分配端口 - openlistInfo.openlistConfig.scheme!.http_port != (await getAvailablePorts(2))[1] + openlistInfo.openlistConfig.scheme!.http_port = (await getAvailablePorts(2))[1] openlistInfo.endpoint.url = 'http://localhost:' + (openlistInfo.openlistConfig.scheme?.http_port || 5573) await setOpenlistPass(nmConfig.framework.openlist.password) diff --git a/src/utils/openlist/request.ts b/src/utils/openlist/request.ts index 81b4551..aaf0b82 100644 --- a/src/utils/openlist/request.ts +++ b/src/utils/openlist/request.ts @@ -1,64 +1,135 @@ import { openlistInfo } from "../../services/openlist"; -import runCmd from "../tauri/cmd"; -import { addParams } from "./process"; -async function openlist_api_ping(){ +// API 响应接口 +interface ApiResponse { + code?: number; + message?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +/** + * 构建完整 URL(包含查询参数) + */ +function buildFullPath(path: string, queryData?: object): string { + const searchParams = new URLSearchParams(); + if (queryData) { + Object.entries(queryData).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + searchParams.append(key, String(value)); + } + }); + } + const queryString = searchParams.toString(); + return `${openlistInfo.endpoint.url}${path}${queryString ? '?' + queryString : ''}`; +} + +/** + * 统一处理 API 响应 + */ +async function handleApiResponse( + res: Response, + fullPath: string, + method: string +): Promise { + // 检查 HTTP 状态 + if (!res.ok) { + console.error(`OpenList API HTTP error [${method}]: ${res.status} ${res.statusText} for ${fullPath}`); + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + + // 解析 JSON + let data: ApiResponse; try { - return await fetch(openlistInfo.endpoint.url+'/ping',{method: 'GET'} ).then((res) => res.ok) - }catch (e) { - console.log(e) - return false - } - } - - -async function openlist_api_get(path: string, queryData?: object, bodyData?: object) { - - // 将queryData对象转换为URLSearchParams对象以便于构建查询字符串 - const searchParams = new URLSearchParams(); - if (queryData) { - Object.entries(queryData).forEach(([key, value]) => { - searchParams.append(key, String(value)); - }); + data = await res.json(); + } catch (parseError) { + console.error(`OpenList API JSON parse error [${method}] for ${fullPath}:`, parseError); + throw new Error('JSON parse error'); } - // 将查询参数附加到路径上 - const fullPath = `${openlistInfo.endpoint.url}${path}${searchParams.toString() ? '?' + searchParams.toString() : ''}`; + // 检查业务状态码 + // 注意:业务错误(code != 200)仅记录日志,不抛出异常 + // 调用方应根据返回的 code 自行处理业务错误 + if (data.code !== undefined && data.code !== 200) { + console.error(`OpenList API business error [${method}]: code=${data.code}, message=${data.message}, path=${fullPath}`); + } - return fetch(fullPath, { - method: 'GET', - redirect: 'follow', - headers: { - 'Authorization': openlistInfo.endpoint.auth.token, - }, - body: bodyData ? JSON.stringify(bodyData) : undefined, - }).then((res) => { - return res.json(); - }); + return data; } - -function openlist_api_post(path: string, bodyData?: object, queryData?: object,) { - // 将queryData对象转换为URLSearchParams对象以便于构建查询字符串 - const searchParams = new URLSearchParams(); - if (queryData) { - Object.entries(queryData).forEach(([key, value]) => { - searchParams.append(key, String(value)); - }); +/** + * 统一错误处理包装器 + */ +async function wrapApiCall( + operation: () => Promise, + fullPath: string, + method: string +): Promise { + try { + return await operation(); + } catch (error) { + console.error(`OpenList API call failed [${method}] for ${fullPath}:`, error); + throw error; } - const fullPath = `${openlistInfo.endpoint.url}${path}${searchParams.toString() ? '?' + searchParams.toString() : ''}`; - - return fetch(fullPath, { - method: 'POST', - redirect: 'follow', - headers: { - 'Authorization': openlistInfo.endpoint.auth.token, - 'Content-Type': 'application/json' - }, - body: bodyData ? JSON.stringify(bodyData) : undefined, - }).then((res) => { - return res.json(); - }); } -export { openlist_api_get, openlist_api_post ,openlist_api_ping} \ No newline at end of file +/** + * OpenList API Ping 检查 + */ +async function openlist_api_ping(): Promise { + try { + const url = openlistInfo.endpoint.url + '/ping'; + const res = await fetch(url, { method: 'GET' }); + return res.ok; + } catch (e) { + console.log('OpenList ping failed:', e); + return false; + } +} + +/** + * OpenList API GET 请求 + */ +async function openlist_api_get(path: string, queryData?: object): Promise { + const fullPath = buildFullPath(path, queryData); + + return wrapApiCall(async () => { + const res = await fetch(fullPath, { + method: 'GET', + redirect: 'follow', + headers: { + 'Authorization': openlistInfo.endpoint.auth.token, + }, + }); + return handleApiResponse(res, fullPath, 'GET'); + }, fullPath, 'GET'); +} + +/** + * OpenList API POST 请求 + */ +async function openlist_api_post( + path: string, + bodyData?: object, + queryData?: object +): Promise { + const fullPath = buildFullPath(path, queryData); + + return wrapApiCall(async () => { + const res = await fetch(fullPath, { + method: 'POST', + redirect: 'follow', + headers: { + 'Authorization': openlistInfo.endpoint.auth.token, + 'Content-Type': 'application/json' + }, + body: bodyData ? JSON.stringify(bodyData) : undefined, + }); + return handleApiResponse(res, fullPath, 'POST'); + }, fullPath, 'POST'); +} + +export { openlist_api_get, openlist_api_post, openlist_api_ping }; +export type { ApiResponse }; \ No newline at end of file diff --git a/src/utils/rclone/request.ts b/src/utils/rclone/request.ts index f41840d..635bb11 100644 --- a/src/utils/rclone/request.ts +++ b/src/utils/rclone/request.ts @@ -2,14 +2,45 @@ import { Message } from "@arco-design/web-react"; import { rcloneInfo } from "../../services/rclone"; import { nmConfig } from "../../services/config"; -let getRcloneApiHeaders = () => { +// API 响应接口 +interface RcloneApiResponse { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +/** + * 获取 Rclone API 请求头 + */ +const getRcloneApiHeaders = () => { return { Authorization: `Basic ${btoa(`${nmConfig.framework.rclone.user}:${nmConfig.framework.rclone.password}`)}`, 'Content-Type': 'application/json' } }; -async function rclone_api_noop(): Promise { +/** + * 构建完整 API URL + */ +function buildApiUrl(path: string): string { + return `${rcloneInfo.endpoint.url}${path}`; +} + +/** + * 统一处理 API 响应 + */ +async function handleApiResponse( + res: Response, + fullPath: string, + method: string +): Promise { + // 检查 HTTP 状态 + if (!res.ok) { + console.error(`Rclone API HTTP error [${method}]: ${res.status} ${res.statusText} for ${fullPath}`); + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + + // 解析 JSON + let data: RcloneApiResponse; try { return await fetch(rcloneInfo.endpoint.url + '/rc/noop', { method: 'POST', @@ -20,52 +51,109 @@ async function rclone_api_noop(): Promise { console.log(e) return false; } + + return data; } -function rclone_api_post(path: string, bodyData: object = {}, ignoreError?: boolean) { +/** + * 打印错误信息 + */ +async function printError(error: Error | Response): Promise { + console.error('Rclone API Error:', error); - return fetch(rcloneInfo.endpoint.url + path, { - method: 'POST', - headers: getRcloneApiHeaders(), - body: JSON.stringify(bodyData) - }).then((response) => { - if (!response.ok && !ignoreError) { - printError(response); + let errorMessage = ''; + + if (error instanceof Response) { + if (error.status) { + errorMessage += `HTTP ${error.status} - ${error.statusText}\n`; } - return response.json(); - }).then((jsonResponse) => { - return jsonResponse; - }).catch((error) => { - if (ignoreError) { return } - printError(error); - }); -} - -async function printError(error: Response) { - console.log(error); - - let str = '' - - if (error.status) { - str += `HTTP ${error.status} - ${error.statusText}\n` + try { + const errorData = await error.json(); + if (errorData.error) { + errorMessage += `\n${errorData.error}`; + } + } catch { + // 忽略 JSON 解析错误 + } + } else if (error instanceof Error) { + errorMessage = error.message; + } else { + errorMessage = String(error); } - if (error.body) { - str += "\n" + (await error.json()).error; - } - if (str) { - Message.error('Error:' + str); + + if (errorMessage) { + Message.error(`Error: ${errorMessage}`); } } +/** + * Rclone API Ping 检查 + */ +async function rclone_api_noop(): Promise { + try { + const url = buildApiUrl('/rc/noop'); + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: getRcloneApiHeaders().Authorization } + }); + return res.ok; + } catch (e) { + console.log('Rclone ping failed:', e); + return false; + } +} +/** + * Rclone API POST 请求 + */ +async function rclone_api_post( + path: string, + bodyData: object = {}, + ignoreError?: boolean +): Promise { + const fullPath = buildApiUrl(path); -/* export function rclone_api_get(path:string){ - return fetch(rcloneApiEndpoint + path,{ - method: 'GET', - headers -}).then((res)=>{ - return res.json() - }) -} */ + try { + const res = await fetch(fullPath, { + method: 'POST', + headers: getRcloneApiHeaders(), + body: JSON.stringify(bodyData) + }); -export { rclone_api_post, getRcloneApiHeaders, rclone_api_noop } \ No newline at end of file + const data = await handleApiResponse(res, fullPath, 'POST'); + return data; + } catch (error) { + if (!ignoreError) { + await printError(error as Error | Response); + } + return undefined; + } +} + +/** + * Rclone API GET 请求 + */ +async function rclone_api_get( + path: string, + ignoreError?: boolean +): Promise { + const fullPath = buildApiUrl(path); + + try { + const res = await fetch(fullPath, { + method: 'GET', + headers: getRcloneApiHeaders() + }); + + const data = await handleApiResponse(res, fullPath, 'GET'); + return data; + } catch (error) { + if (!ignoreError) { + await printError(error as Error | Response); + } + return undefined; + } +} + +export { rclone_api_post, rclone_api_get, getRcloneApiHeaders, rclone_api_noop }; +export type { RcloneApiResponse }; \ No newline at end of file diff --git a/src/utils/request.ts b/src/utils/request.ts new file mode 100644 index 0000000..c40a314 --- /dev/null +++ b/src/utils/request.ts @@ -0,0 +1,395 @@ +/** + * HTTP 请求工具 - 提供超时、重试、请求拦截等功能 + * 增强网络请求的健壮性 + */ + +import { AppError, ErrorType } from './error'; +import { + API_TIMEOUT, + RETRY_CONFIG, +} from './constants'; + +// ============================================ +// 类型定义 +// ============================================ + +/** + * 请求配置 + */ +export interface RequestConfig { + timeout?: number; + retries?: number; + retryDelay?: number; + retryOnTimeout?: boolean; + abortSignal?: AbortSignal; + headers?: Record; +} + +/** + * 请求响应 + */ +export interface RequestResult { + success: boolean; + data?: T; + error?: AppError; + attempts: number; + duration: number; +} + +/** + * 重试策略类型 + */ +export type RetryStrategy = 'fixed' | 'exponential' | 'linear'; + +// ============================================ +// 工具函数 +// ============================================ + +/** + * 计算重试延迟 (带抖动) + */ +function calculateRetryDelay( + attempt: number, + baseDelay: number, + multiplier: number, + maxDelay: number, + jitterRange: number, + strategy: RetryStrategy +): number { + let delay: number; + + switch (strategy) { + case 'exponential': + delay = baseDelay * Math.pow(multiplier, attempt); + break; + case 'linear': + delay = baseDelay * (attempt + 1); + break; + case 'fixed': + default: + delay = baseDelay; + } + + // 限制最大延迟 + delay = Math.min(delay, maxDelay); + + // 添加随机抖动 (±jitterRange/2) + const jitter = (Math.random() - 0.5) * jitterRange; + delay = Math.max(0, Math.round(delay + jitter)); + + return delay; +} + +/** + * 判断是否应该重试 + */ +function shouldRetry(error: unknown, retryOnTimeout: boolean, attempt: number, maxAttempts: number): boolean { + if (attempt >= maxAttempts) { + return false; + } + + // 超时错误且不允许重试 + if (!retryOnTimeout && error instanceof AppError && error.type === ErrorType.TIMEOUT) { + return false; + } + + // 网络错误通常可以重试 + if (error instanceof AppError) { + const retryableTypes = [ + ErrorType.NETWORK, + ErrorType.TIMEOUT, + ErrorType.API, + ]; + return retryableTypes.includes(error.type); + } + + // 其他错误默认可以重试 + return true; +} + +// ============================================ +// 主请求函数 +// ============================================ + +/** + * 增强型 fetch 请求 - 支持超时、重试、AbortSignal + * + * @param url - 请求 URL + * @param options - Fetch 选项 + * @param config - 请求配置 (超时、重试等) + * @returns 请求结果 + */ +export async function robustFetch( + url: string, + options: RequestInit = {}, + config: RequestConfig = {} +): Promise> { + const { + timeout = API_TIMEOUT.DEFAULT, + retries = RETRY_CONFIG.MAX_ATTEMPTS, + retryDelay = RETRY_CONFIG.INITIAL_DELAY, + retryOnTimeout = true, + abortSignal, + headers = {}, + } = config; + + const startTime = Date.now(); + let lastError: AppError | undefined; + let attempts = 0; + + // 创建超时控制器 + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + // 合并 headers + const mergedHeaders = { + ...headers, + 'Content-Type': 'application/json', + }; + + // 如果传入了 abortSignal,监听它 + if (abortSignal) { + abortSignal.addEventListener('abort', () => controller.abort()); + } + + try { + // 重试循环 + while (attempts < retries) { + attempts++; + + try { + const response = await fetch(url, { + ...options, + headers: mergedHeaders, + signal: controller.signal, + }); + + // 检查 HTTP 状态 + if (!response.ok) { + throw AppError.api( + `HTTP ${response.status}: ${response.statusText}`, + `HTTP_${response.status}`, + { url, status: response.status } + ); + } + + // 解析 JSON + const data = await response.json() as T; + + // 清理超时 + clearTimeout(timeoutId); + + return { + success: true, + data, + attempts, + duration: Date.now() - startTime, + }; + } catch (error) { + // 如果是AbortError,检查是否超时 + if (error instanceof DOMException && error.name === 'AbortError') { + const isTimeout = !controller.signal.aborted; + if (isTimeout) { + lastError = AppError.timeout(url, timeout); + } else { + lastError = AppError.network('请求被中止'); + } + } else if (error instanceof AppError) { + lastError = error; + } else if (error instanceof Error) { + lastError = AppError.network(error.message, error); + } else { + lastError = AppError.api(String(error), 'UNKNOWN_ERROR'); + } + + // 判断是否应该重试 + if (!shouldRetry(lastError, retryOnTimeout, attempts, retries)) { + break; + } + + // 计算并等待延迟 + const delay = calculateRetryDelay( + attempts - 1, + retryDelay, + RETRY_CONFIG.BACKOFF_MULTIPLIER, + RETRY_CONFIG.MAX_DELAY, + RETRY_CONFIG.JITTER_RANGE, + 'exponential' + ); + + console.warn( + `[Request] Attempt ${attempts}/${retries} failed: ${lastError.message}. Retrying in ${delay}ms...` + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + // 所有重试都失败了 + clearTimeout(timeoutId); + + return { + success: false, + error: lastError ?? AppError.api('请求失败', 'REQUEST_FAILED'), + attempts, + duration: Date.now() - startTime, + }; + } catch (error) { + clearTimeout(timeoutId); + + return { + success: false, + error: error instanceof AppError + ? error + : AppError.api(String(error), 'UNKNOWN_ERROR'), + attempts, + duration: Date.now() - startTime, + }; + } +} + +// ============================================ +// 便捷方法 +// ============================================ + +/** + * GET 请求 + */ +export async function robustGet( + url: string, + config?: RequestConfig +): Promise> { + return robustFetch(url, { method: 'GET' }, config); +} + +/** + * POST 请求 + */ +export async function robustPost( + url: string, + body?: unknown, + config?: RequestConfig +): Promise> { + return robustFetch(url, { + method: 'POST', + body: body ? JSON.stringify(body) : null, + }, config); +} + +/** + * DELETE 请求 + */ +export async function robustDelete( + url: string, + config?: RequestConfig +): Promise> { + return robustFetch(url, { method: 'DELETE' }, config); +} + +// ============================================ +// 专用请求工具 +// ============================================ + +/** + * 带超时的单次请求 + * + * @param url - 请求 URL + * @param options - Fetch 选项 + * @param timeoutMs - 超时时间 (毫秒) + * @returns 响应数据或抛出错误 + */ +export async function fetchWithTimeout( + url: string, + options: RequestInit = {}, + timeoutMs: number = API_TIMEOUT.DEFAULT +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + }); + + if (!response.ok) { + throw AppError.api( + `HTTP ${response.status}: ${response.statusText}`, + `HTTP_${response.status}`, + { url } + ); + } + + return await response.json() as T; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw AppError.timeout(url, timeoutMs); + } + if (error instanceof AppError) { + throw error; + } + if (error instanceof Error) { + throw AppError.network(error.message, error); + } + throw AppError.api(String(error), 'FETCH_FAILED'); + } finally { + clearTimeout(timeoutId); + } +} + +/** + * 可取消的请求包装器 + * + * @param url - 请求 URL + * @param options - Fetch 选项 + * @returns { abort, promise } - abort 函数和 promise + */ +export function cancellableFetch( + url: string, + options: RequestInit = {} +): { abort: () => void; promise: Promise } { + const controller = new AbortController(); + const promise = fetchWithTimeout(url, { + ...options, + signal: controller.signal, + }); + + return { + abort: () => controller.abort(), + promise, + }; +} + +/** + * 并发请求控制器 + * + * @param requests - 请求列表 + * @param concurrency - 并发数量 + * @returns 所有请求结果 + */ +export async function concurrentFetch( + requests: Array<() => Promise>, + concurrency: number = 5 +): Promise { + const results: T[] = []; + const executing = new Set>(); + + for (const request of requests) { + const promise = Promise.resolve().then(request); + results.push(promise as T); + executing.add(promise); + + // 清理完成的 promise + promise.finally(() => executing.delete(promise)); + + // 达到并发限制时等待 + if (executing.size >= concurrency) { + await Promise.race(executing); + } + } + + // 等待所有请求完成 + await Promise.all(executing); + + return results; +} diff --git a/src/utils/schemas.ts b/src/utils/schemas.ts new file mode 100644 index 0000000..ce1f06b --- /dev/null +++ b/src/utils/schemas.ts @@ -0,0 +1,354 @@ +/** + * Zod Schema 验证系统 - 提供类型安全的API响应验证 + * 增强数据完整性和运行时安全性 + */ + +import { z } from 'zod'; + +// ============================================ +// Rclone API Schemas +// ============================================ + +/** + * Rclone 文件信息 Schema + */ +export const RcloneFileInfoSchema = z.object({ + Path: z.string(), + Name: z.string(), + Size: z.number().nonnegative(), + MimeType: z.string().optional(), + ModTime: z.string().datetime().or(z.string()), // 某些情况下可能不是标准ISO格式 + IsDir: z.boolean(), +}); + +export type RcloneFileInfo = z.infer; + +/** + * Rclone 存储空间信息 Schema + */ +export const RcloneStorageSpaceSchema = z.object({ + total: z.number().nonnegative(), + used: z.number().nonnegative(), + free: z.number().nonnegative(), + trashed: z.number().nonnegative().optional(), +}); + +export type RcloneStorageSpace = z.infer; + +/** + * Rclone 版本信息 Schema + */ +export const RcloneVersionSchema = z.object({ + arch: z.string(), + decomposed: z.array(z.number()), + goTags: z.string(), + goVersion: z.string(), + isBeta: z.boolean(), + isGit: z.boolean(), + linking: z.string(), + os: z.string(), + version: z.string(), +}); + +export type RcloneVersion = z.infer; + +/** + * Rclone 统计信息 Schema + */ +export const RcloneStatsSchema = z.object({ + bytes: z.number().nonnegative(), + checks: z.number().nonnegative(), + deletedDirs: z.number().nonnegative(), + deletes: z.number().nonnegative(), + elapsedTime: z.number().nonnegative(), + errors: z.number().nonnegative(), + eta: z.number().nullable().optional(), + fatalError: z.boolean(), + renames: z.number().nonnegative(), + retryError: z.boolean(), + serverSideCopies: z.number().nonnegative(), + serverSideCopyBytes: z.number().nonnegative(), + serverSideMoveBytes: z.number().nonnegative(), + serverSideMoves: z.number().nonnegative(), + speed: z.number().nonnegative(), + totalBytes: z.number().nonnegative(), + totalChecks: z.number().nonnegative(), + totalTransfers: z.number().nonnegative(), + transferTime: z.number().nonnegative(), + lastError: z.string().optional(), + transferring: z.array(z.record(z.unknown())).optional(), +}); + +export type RcloneStats = z.infer; + +/** + * Rclone 配置提供者选项 Schema + */ +export const RcloneProviderOptionSchema = z.object({ + Name: z.string(), + Help: z.string(), + Type: z.string(), + Default: z.union([z.string(), z.number(), z.boolean()]).optional(), + ValueStr: z.string().optional(), + DefaultStr: z.string().optional(), + Required: z.boolean().optional(), + Advanced: z.boolean().optional(), + IsPassword: z.boolean().optional(), + Provider: z.string().optional(), + ShortOpt: z.string().optional(), + Examples: z.array( + z.object({ + Value: z.string(), + Help: z.string(), + }) + ).optional(), +}); + +export type RcloneProviderOption = z.infer; + +/** + * Rclone 配置提供者 Schema + */ +export const RcloneProviderSchema = z.object({ + Name: z.string(), + Description: z.string(), + Prefix: z.string(), + Options: z.array(RcloneProviderOptionSchema), +}); + +export type RcloneProvider = z.infer; + +/** + * Rclone API 列表响应 Schema + */ +export const RcloneListResponseSchema = z.object({ + list: z.array(RcloneFileInfoSchema).optional(), +}); + +export type RcloneListResponse = z.infer; + +// ============================================ +// OpenList API Schemas +// ============================================ + +/** + * OpenList API 基础响应 Schema + */ +export const OpenListApiResponseSchema = z.object({ + code: z.number(), + message: z.string().optional(), + data: z.unknown().optional(), +}); + +export type OpenListApiResponse = z.infer; + +/** + * OpenList 存储项 Schema + */ +export const OpenListStorageItemSchema = z.object({ + id: z.number(), + mount_path: z.string(), + driver: z.string(), + status: z.enum(['work', 'error', 'disabled']).or(z.string()), + addition: z.union([z.string(), z.record(z.unknown())]).optional(), +}); + +export type OpenListStorageItem = z.infer; + +/** + * OpenList 存储列表响应 Schema + */ +export const OpenListStorageListResponseSchema = OpenListApiResponseSchema.extend({ + data: z.object({ + content: z.array(OpenListStorageItemSchema).optional(), + }).optional(), +}); + +export type OpenListStorageListResponse = z.infer; + +/** + * OpenList 设置响应 Schema + */ +export const OpenListSettingResponseSchema = OpenListApiResponseSchema.extend({ + data: z.object({ + value: z.string().optional(), + version: z.string().optional(), + }).optional(), +}); + +export type OpenListSettingResponse = z.infer; + +/** + * OpenList 存储详情响应 Schema + */ +export const OpenListStorageDetailResponseSchema = OpenListApiResponseSchema.extend({ + data: z.object({ + id: z.number().optional(), + mount_path: z.string().optional(), + driver: z.string().optional(), + addition: z.union([z.string(), z.record(z.unknown())]).optional(), + }).optional(), +}); + +export type OpenListStorageDetailResponse = z.infer; + +// ============================================ +// Application Config Schemas +// ============================================ + +/** + * 挂载配置项 Schema + */ +export const MountListItemSchema = z.object({ + storageName: z.string(), + mountPath: z.string(), + parameters: z.object({ + vfsOpt: z.record(z.unknown()), + mountOpt: z.record(z.unknown()), + }), + autoMount: z.boolean(), +}); + +export type MountListItem = z.infer; + +/** + * 任务运行时间配置 Schema + */ +export const TaskTimeConfigSchema = z.object({ + intervalDays: z.number().nonnegative(), + h: z.number().min(0).max(23), + m: z.number().min(0).max(59), + s: z.number().min(0).max(59), +}); + +export type TaskTimeConfig = z.infer; + +/** + * 任务运行配置 Schema + */ +export const TaskRunConfigSchema = z.object({ + runId: z.number().optional(), + mode: z.enum(['start', 'time', 'interval', 'disposable']), + time: TaskTimeConfigSchema, + interval: z.number().nonnegative().optional(), +}); + +export type TaskRunConfig = z.infer; + +/** + * 任务列表项 Schema + */ +export const TaskListItemSchema = z.object({ + name: z.string().min(1), + taskType: z.enum(['copy', 'move', 'delete', 'sync', 'bisync']), + source: z.object({ + storageName: z.string(), + path: z.string(), + }), + target: z.object({ + storageName: z.string(), + path: z.string(), + }), + parameters: z.record(z.unknown()).optional(), + enable: z.boolean(), + run: TaskRunConfigSchema, + runInfo: z.object({ + error: z.boolean().optional(), + msg: z.string().optional(), + }).optional(), +}); + +export type TaskListItem = z.infer; + +/** + * NMConfig Schema + */ +export const NMConfigSchema = z.object({ + mount: z.object({ + lists: z.array(MountListItemSchema), + }), + task: z.array(TaskListItemSchema), + api: z.object({ + url: z.string().url(), + }), + settings: z.object({ + themeMode: z.enum(['dark', 'light', 'auto']).or(z.string()), + startHide: z.boolean(), + language: z.string().optional(), + path: z.object({ + cacheDir: z.string().optional(), + }), + }), + framework: z.object({ + rclone: z.object({ + user: z.string(), + password: z.string(), + }), + openlist: z.object({ + user: z.string(), + password: z.string(), + }), + }), +}); + +export type NMConfigValidated = z.infer; + +// ============================================ +// Validation Helpers +// ============================================ + +/** + * 安全解析函数 - 捕获验证错误并返回默认值 + */ +export function safeParse(schema: z.ZodType, data: unknown, defaultValue: T): T { + const result = schema.safeParse(data); + if (result.success) { + return result.data; + } + console.warn('Schema validation failed:', result.error.errors); + return defaultValue; +} + +/** + * 部分解析函数 - 允许部分数据通过 + */ +export function partialParse(_schema: z.ZodType, _data: unknown): Partial | undefined { + // 由于 ZodType.partial() 不可用,我们直接返回原始数据的部分属性 + // 实际使用时可以通过 schema.shape 来获取可选字段 + // 这里提供一个安全的回退实现 + return {} as Partial; +} + +/** + * 数组解析函数 - 过滤无效项 + */ +export function parseArray(schema: z.ZodType, data: unknown[]): T[] { + const results: T[] = []; + for (const item of data) { + const result = schema.safeParse(item); + if (result.success) { + results.push(result.data); + } else { + console.warn('Array item validation failed:', result.error.errors); + } + } + return results; +} + +/** + * 验证结果类型 + */ +export type ValidationResult = + | { success: true; data: T } + | { success: false; errors: z.ZodError['errors'] }; + +/** + * 带详细错误信息的验证函数 + */ +export function validateWithDetails(schema: z.ZodType, data: unknown): ValidationResult { + const result = schema.safeParse(data); + if (result.success) { + return { success: true, data: result.data }; + } + return { success: false, errors: result.error.errors }; +} diff --git a/src/utils/tauri/osInfo.ts b/src/utils/tauri/osInfo.ts index 245bb67..ed9f333 100644 --- a/src/utils/tauri/osInfo.ts +++ b/src/utils/tauri/osInfo.ts @@ -1,5 +1,4 @@ import * as os from "@tauri-apps/plugin-os"; -import { OSInfo } from "../../type/config"; import { setOsInfo } from "../../services/config"; import { invoke } from "@tauri-apps/api/core"; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index bed5599..2ac5e72 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -2,10 +2,17 @@ import * as fs from "@tauri-apps/plugin-fs"; import * as shell from "@tauri-apps/plugin-shell"; import { runCmd } from "./tauri/cmd"; import { invoke } from "@tauri-apps/api/core"; -import { osInfo } from "../services/config"; -export function isEmptyObject(back: any): boolean { - return Object.keys(back).length === 0 && back.constructor === Object; +/** + * 检查对象是否为空 + * @param obj - 要检查的对象 + * @returns 如果对象为空则返回 true + */ +export function isEmptyObject(obj: Record): boolean { + if (!obj || typeof obj !== 'object') { + return true; + } + return Object.keys(obj).length === 0 && obj.constructor === Object; } export function getURLSearchParam(name: string): string { @@ -13,12 +20,11 @@ export function getURLSearchParam(name: string): string { return searchParams.get(name) || ''; } -export function getProperties(obj: Record) { - - let result: Array<{ key: any, value: any }> = [] +export function getProperties>(obj: T): Array<{ key: string, value: unknown }> { + const result: Array<{ key: string, value: unknown }> = [] for (const key in obj) { - if (obj.hasOwnProperty(key)) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { result.push({ key: key, value: obj[key] }) } } @@ -26,44 +32,80 @@ export function getProperties(obj: Record) { return result } -export function formatSize(v: number) { - let UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'ZB']; - let prev = 0, i = 0; - while (Math.floor(v) > 0 && i < UNITS.length) { - prev = v; - v /= 1024; - i += 1; +/** 文件大小单位 */ +const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'ZB'] as const; + +/** 大小计算进制 (1024) */ +const SIZE_BASE = 1024; + +/** 小数精度 (2位) */ +const DECIMAL_PRECISION = 100; + +/** + * 格式化文件大小 + * @param bytes - 字节数 + * @returns 格式化后的大小字符串 (如 "1.5 GB") + */ +export function formatSize(bytes: number): string { + if (bytes === 0) return `0 ${SIZE_UNITS[0]}`; + if (!bytes || bytes < 0 || !isFinite(bytes)) return `0 ${SIZE_UNITS[0]}`; + + let prev = bytes; + let index = 0; + + while (Math.floor(bytes) > 0 && index < SIZE_UNITS.length - 1) { + prev = bytes; + bytes /= SIZE_BASE; + index++; } - if (i > 0 && i < UNITS.length) { - v = prev; - i -= 1; + // 如果已经超出最大单位,使用最后一个单位 + if (index >= SIZE_UNITS.length) { + index = SIZE_UNITS.length - 1; } - return Math.round(v * 100) / 100 + ' ' + UNITS[i]; + + // 还原到最后一个有效的单位 + if (index > 0) { + bytes = prev; + index--; + } + + return `${Math.round(bytes * DECIMAL_PRECISION) / DECIMAL_PRECISION} ${SIZE_UNITS[index]}`; } -//格式化剩余时间 +/** 时间格式化常量 */ +const TIME_CONSTANTS = { + SECONDS_PER_HOUR: 3600, + SECONDS_PER_MINUTE: 60, + PAD_LENGTH: 2, + PAD_CHAR: '0' +} as const; + +/** + * 格式化剩余时间 + * @param etaInSeconds - 剩余秒数 + * @returns 格式化后的时间字符串 (如 "01h 30m 45s") + */ export function formatETA(etaInSeconds: number): string { - if (isNaN(etaInSeconds) || etaInSeconds <= 0) { + if (!isFinite(etaInSeconds) || etaInSeconds <= 0) { return '未知'; } - const hours = Math.floor(etaInSeconds / 3600); - const minutes = Math.floor((etaInSeconds % 3600) / 60); - const seconds = Math.floor(etaInSeconds % 60); + const hours = Math.floor(etaInSeconds / TIME_CONSTANTS.SECONDS_PER_HOUR); + const minutes = Math.floor((etaInSeconds % TIME_CONSTANTS.SECONDS_PER_HOUR) / TIME_CONSTANTS.SECONDS_PER_MINUTE); + const seconds = Math.floor(etaInSeconds % TIME_CONSTANTS.SECONDS_PER_MINUTE); - let formattedETA = ''; + const parts: string[] = []; if (hours > 0) { - formattedETA += `${hours.toString().padStart(2, '0')}h `; + parts.push(`${hours.toString().padStart(TIME_CONSTANTS.PAD_LENGTH, TIME_CONSTANTS.PAD_CHAR)}h`); } - if (minutes > 0) { - formattedETA += `${minutes.toString().padStart(2, '0')}m `; + if (minutes > 0 || hours > 0) { + parts.push(`${minutes.toString().padStart(TIME_CONSTANTS.PAD_LENGTH, TIME_CONSTANTS.PAD_CHAR)}m`); } + parts.push(`${seconds.toString().padStart(TIME_CONSTANTS.PAD_LENGTH, TIME_CONSTANTS.PAD_CHAR)}s`); - formattedETA += `${seconds.toString().padStart(2, '0')}s`; - - return formattedETA; + return parts.join(' '); } export function randomString(length: number): string { @@ -165,31 +207,46 @@ export async function fs_make_dir(path: string) { } } -export function formatPath(path: string, isWindows: boolean = false) { - path = path.replace(/\\/g, '/'); - path = path.replace(/\/+/g, '/'); +/** + * 格式化路径 + * @param path - 原始路径 + * @param isWindows - 是否为 Windows 路径 + * @returns 格式化后的路径 + */ +export function formatPath(path: string, isWindows: boolean = false): string { + if (!path || typeof path !== 'string') { + return ''; + } + + // 统一替换反斜杠为正斜杠,并合并多个连续的斜杠 + let formattedPath = path.replace(/\\/g, '/').replace(/\/+/g, '/'); if (isWindows) { - if (/^[A-Za-z]/.test(path)) { - if (path.substring(1, 2) != ':') { - path = path.substring(0, 1).toUpperCase() + ':' + path.substring(1); + // Windows 路径处理 + if (/^[A-Za-z]/.test(formattedPath)) { + // 以字母开头,需要添加驱动器冒号 + if (formattedPath.substring(1, 2) !== ':') { + formattedPath = formattedPath.substring(0, 1).toUpperCase() + ':' + formattedPath.substring(1); } - } else { - path = path.substring(1); - formatPath(path, isWindows) + } else if (formattedPath.startsWith('/')) { + // 以斜杠开头,移除开头的斜杠 + formattedPath = formattedPath.substring(1); + // 递归处理,确保正确处理所有情况 + return formatPath(formattedPath, isWindows); } } else { - if (!path.startsWith('/')) { - path = '/' + path; + // Unix/Linux 路径处理:确保以斜杠开头 + if (!formattedPath.startsWith('/')) { + formattedPath = '/' + formattedPath; } } - return path + return formattedPath; } export function mergeObjects(target: T, source: Partial): T { for (const key in source) { - if (source.hasOwnProperty(key)) { + if (Object.prototype.hasOwnProperty.call(source, key)) { const sourceValue = source[key]; const targetValue = target[key]; diff --git a/tsconfig.json b/tsconfig.json index 54de499..5c89311 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,12 +13,29 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, - /* Linting */ + /* Linting - 严格模式 */ "strict": true, - "noUnusedLocals": false, + "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + + /* 路径别名 */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@services/*": ["src/services/*"], + "@utils/*": ["src/utils/*"], + "@type/*": ["src/type/*"], + "@controller/*": ["src/controller/*"], + "@page/*": ["src/page/*"] + } }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }]