mirror of
https://github.com/VirtualHotBar/NetMount.git
synced 2026-06-20 18:06:05 +08:00
Merge branch 'main' into main
This commit is contained in:
5
src-tauri/Cargo.lock
generated
5
src-tauri/Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: ''
|
||||
|
||||
@@ -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 }
|
||||
31
src/type/controller/storage/info.d.ts
vendored
31
src/type/controller/storage/info.d.ts
vendored
@@ -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 }
|
||||
export { StorageInfoType, StorageParamsType, StorageParamItemType, ParamItemOptionType, FilterType, RcloneProvider, RcloneProviderOption }
|
||||
93
src/type/openlist/openlistInfo.d.ts
vendored
93
src/type/openlist/openlistInfo.d.ts
vendored
@@ -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 };
|
||||
// OpenList 存储项接口
|
||||
interface OpenlistStorageItem {
|
||||
id: number;
|
||||
mount_path: string;
|
||||
driver: string;
|
||||
order: number;
|
||||
status: 'work' | string;
|
||||
addition: string | Record<string, unknown>;
|
||||
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 };
|
||||
2
src/type/rclone/rcloneInfo.d.ts
vendored
2
src/type/rclone/rcloneInfo.d.ts
vendored
@@ -81,4 +81,4 @@ interface FileInfo {
|
||||
isDir: boolean;
|
||||
}
|
||||
|
||||
export { RcloneInfo, FileInfo, StorageSpace,StorageList }
|
||||
export { RcloneInfo, FileInfo, StorageSpace,StorageList,RcloneVersion,MountList }
|
||||
14
src/type/rclone/storage/mount/parameters.d.ts
vendored
14
src/type/rclone/storage/mount/parameters.d.ts
vendored
@@ -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 }
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class Aria2 {
|
||||
|
||||
// 解析aria2的命令行输出
|
||||
private parseOutput(output: string): Aria2Attrib {
|
||||
let tempAria2Attrib: Aria2Attrib = {
|
||||
const tempAria2Attrib: Aria2Attrib = {
|
||||
state: 'request',
|
||||
speed: '',
|
||||
percentage: 0,
|
||||
|
||||
230
src/utils/constants.ts
Normal file
230
src/utils/constants.ts
Normal file
@@ -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;
|
||||
427
src/utils/error.ts
Normal file
427
src/utils/error.ts
Normal file
@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown>
|
||||
): 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<string, unknown>,
|
||||
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<string, unknown>): AppError {
|
||||
return new AppError({
|
||||
message,
|
||||
type: ErrorType.CONFIG,
|
||||
severity: ErrorSeverity.CRITICAL,
|
||||
code: 'CONFIG_ERROR',
|
||||
details,
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化错误信息
|
||||
*/
|
||||
toJSON(): Record<string, unknown> {
|
||||
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<ErrorHandlerConfig> = {}
|
||||
): 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<T>(
|
||||
fn: () => T | Promise<T>,
|
||||
fallback?: T,
|
||||
config?: Partial<ErrorHandlerConfig>
|
||||
): Promise<T | undefined> {
|
||||
return Promise.resolve()
|
||||
.then(() => fn())
|
||||
.catch((error) => {
|
||||
this.handle(error, config);
|
||||
return fallback;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 便捷函数 - 快速处理错误
|
||||
*/
|
||||
export function handleError(
|
||||
error: unknown,
|
||||
config?: Partial<ErrorHandlerConfig>
|
||||
): AppError {
|
||||
return ErrorHandler.handle(error, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 便捷函数 - 安全执行
|
||||
*/
|
||||
export function safeExecute<T>(
|
||||
fn: () => T | Promise<T>,
|
||||
fallback?: T,
|
||||
config?: Partial<ErrorHandlerConfig>
|
||||
): Promise<T | undefined> {
|
||||
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,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ApiResponse> {
|
||||
// 检查 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<T>(
|
||||
operation: () => Promise<T>,
|
||||
fullPath: string,
|
||||
method: string
|
||||
): Promise<T> {
|
||||
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}
|
||||
/**
|
||||
* OpenList API Ping 检查
|
||||
*/
|
||||
async function openlist_api_ping(): Promise<boolean> {
|
||||
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<ApiResponse> {
|
||||
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<ApiResponse> {
|
||||
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 };
|
||||
@@ -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<boolean> {
|
||||
/**
|
||||
* 构建完整 API URL
|
||||
*/
|
||||
function buildApiUrl(path: string): string {
|
||||
return `${rcloneInfo.endpoint.url}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一处理 API 响应
|
||||
*/
|
||||
async function handleApiResponse(
|
||||
res: Response,
|
||||
fullPath: string,
|
||||
method: string
|
||||
): Promise<RcloneApiResponse> {
|
||||
// 检查 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<boolean> {
|
||||
console.log(e)
|
||||
return false;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function rclone_api_post(path: string, bodyData: object = {}, ignoreError?: boolean) {
|
||||
/**
|
||||
* 打印错误信息
|
||||
*/
|
||||
async function printError(error: Error | Response): Promise<void> {
|
||||
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<boolean> {
|
||||
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<RcloneApiResponse | undefined> {
|
||||
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 }
|
||||
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<RcloneApiResponse | undefined> {
|
||||
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 };
|
||||
395
src/utils/request.ts
Normal file
395
src/utils/request.ts
Normal file
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求响应
|
||||
*/
|
||||
export interface RequestResult<T> {
|
||||
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<T = unknown>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
config: RequestConfig = {}
|
||||
): Promise<RequestResult<T>> {
|
||||
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<T = unknown>(
|
||||
url: string,
|
||||
config?: RequestConfig
|
||||
): Promise<RequestResult<T>> {
|
||||
return robustFetch<T>(url, { method: 'GET' }, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求
|
||||
*/
|
||||
export async function robustPost<T = unknown>(
|
||||
url: string,
|
||||
body?: unknown,
|
||||
config?: RequestConfig
|
||||
): Promise<RequestResult<T>> {
|
||||
return robustFetch<T>(url, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
}, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 请求
|
||||
*/
|
||||
export async function robustDelete<T = unknown>(
|
||||
url: string,
|
||||
config?: RequestConfig
|
||||
): Promise<RequestResult<T>> {
|
||||
return robustFetch<T>(url, { method: 'DELETE' }, config);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 专用请求工具
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 带超时的单次请求
|
||||
*
|
||||
* @param url - 请求 URL
|
||||
* @param options - Fetch 选项
|
||||
* @param timeoutMs - 超时时间 (毫秒)
|
||||
* @returns 响应数据或抛出错误
|
||||
*/
|
||||
export async function fetchWithTimeout<T>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
timeoutMs: number = API_TIMEOUT.DEFAULT
|
||||
): Promise<T> {
|
||||
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<T>(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
): { abort: () => void; promise: Promise<T> } {
|
||||
const controller = new AbortController();
|
||||
const promise = fetchWithTimeout<T>(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
return {
|
||||
abort: () => controller.abort(),
|
||||
promise,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 并发请求控制器
|
||||
*
|
||||
* @param requests - 请求列表
|
||||
* @param concurrency - 并发数量
|
||||
* @returns 所有请求结果
|
||||
*/
|
||||
export async function concurrentFetch<T>(
|
||||
requests: Array<() => Promise<T>>,
|
||||
concurrency: number = 5
|
||||
): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
const executing = new Set<Promise<unknown>>();
|
||||
|
||||
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;
|
||||
}
|
||||
354
src/utils/schemas.ts
Normal file
354
src/utils/schemas.ts
Normal file
@@ -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<typeof RcloneFileInfoSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof RcloneStorageSpaceSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof RcloneVersionSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof RcloneStatsSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof RcloneProviderOptionSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof RcloneProviderSchema>;
|
||||
|
||||
/**
|
||||
* Rclone API 列表响应 Schema
|
||||
*/
|
||||
export const RcloneListResponseSchema = z.object({
|
||||
list: z.array(RcloneFileInfoSchema).optional(),
|
||||
});
|
||||
|
||||
export type RcloneListResponse = z.infer<typeof RcloneListResponseSchema>;
|
||||
|
||||
// ============================================
|
||||
// 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<typeof OpenListApiResponseSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof OpenListStorageItemSchema>;
|
||||
|
||||
/**
|
||||
* OpenList 存储列表响应 Schema
|
||||
*/
|
||||
export const OpenListStorageListResponseSchema = OpenListApiResponseSchema.extend({
|
||||
data: z.object({
|
||||
content: z.array(OpenListStorageItemSchema).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type OpenListStorageListResponse = z.infer<typeof OpenListStorageListResponseSchema>;
|
||||
|
||||
/**
|
||||
* OpenList 设置响应 Schema
|
||||
*/
|
||||
export const OpenListSettingResponseSchema = OpenListApiResponseSchema.extend({
|
||||
data: z.object({
|
||||
value: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type OpenListSettingResponse = z.infer<typeof OpenListSettingResponseSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof OpenListStorageDetailResponseSchema>;
|
||||
|
||||
// ============================================
|
||||
// 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<typeof MountListItemSchema>;
|
||||
|
||||
/**
|
||||
* 任务运行时间配置 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<typeof TaskTimeConfigSchema>;
|
||||
|
||||
/**
|
||||
* 任务运行配置 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<typeof TaskRunConfigSchema>;
|
||||
|
||||
/**
|
||||
* 任务列表项 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<typeof TaskListItemSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof NMConfigSchema>;
|
||||
|
||||
// ============================================
|
||||
// Validation Helpers
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 安全解析函数 - 捕获验证错误并返回默认值
|
||||
*/
|
||||
export function safeParse<T>(schema: z.ZodType<T>, 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<T>(_schema: z.ZodType<T>, _data: unknown): Partial<T> | undefined {
|
||||
// 由于 ZodType.partial() 不可用,我们直接返回原始数据的部分属性
|
||||
// 实际使用时可以通过 schema.shape 来获取可选字段
|
||||
// 这里提供一个安全的回退实现
|
||||
return {} as Partial<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数组解析函数 - 过滤无效项
|
||||
*/
|
||||
export function parseArray<T>(schema: z.ZodType<T>, 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<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; errors: z.ZodError['errors'] };
|
||||
|
||||
/**
|
||||
* 带详细错误信息的验证函数
|
||||
*/
|
||||
export function validateWithDetails<T>(schema: z.ZodType<T>, data: unknown): ValidationResult<T> {
|
||||
const result = schema.safeParse(data);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
return { success: false, errors: result.error.errors };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<string, unknown>): 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<string, any>) {
|
||||
|
||||
let result: Array<{ key: any, value: any }> = []
|
||||
export function getProperties<T extends Record<string, unknown>>(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<string, any>) {
|
||||
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<T>(target: T, source: Partial<T>): 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];
|
||||
|
||||
|
||||
@@ -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" }]
|
||||
|
||||
Reference in New Issue
Block a user