Merge branch 'main' into main

This commit is contained in:
caiqingzhi2020
2026-02-17 18:18:45 +08:00
committed by GitHub
20 changed files with 2005 additions and 168 deletions

5
src-tauri/Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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 {

View File

@@ -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: ''

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 };

View File

@@ -81,4 +81,4 @@ interface FileInfo {
isDir: boolean;
}
export { RcloneInfo, FileInfo, StorageSpace,StorageList }
export { RcloneInfo, FileInfo, StorageSpace,StorageList,RcloneVersion,MountList }

View File

@@ -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 }

View File

@@ -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
View 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
View 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,
}
);
});
}

View File

@@ -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,
});
}

View File

@@ -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)

View File

@@ -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 };

View File

@@ -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
View 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
View 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 };
}

View File

@@ -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";

View File

@@ -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];

View File

@@ -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" }]