├── src-tauri ├── build.rs ├── icons │ ├── icon.ico │ ├── icon.png │ ├── logo.png │ ├── 128x128.png │ ├── 32x32.png │ ├── icon.icns │ ├── StoreLogo.png │ ├── 128x128@2x.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ └── Square89x89Logo.png ├── .gitignore ├── src │ ├── main.rs │ ├── crypto_keys.rs │ ├── device_info.rs │ ├── log_analysis.rs │ └── ssh_channel_manager.rs ├── tauri.windows.conf.json ├── capabilities │ ├── default.json │ └── ssh-terminal.json ├── tauri.conf.json └── Cargo.toml ├── public ├── logo.png ├── logo-32.png ├── vite.svg └── tauri.svg ├── .vscode └── extensions.json ├── src ├── vite-env.d.ts ├── types │ └── global.d.ts ├── assets │ └── vue.svg ├── css │ ├── themes │ │ ├── index.css │ │ ├── light.css │ │ ├── dark.css │ │ └── sakura.css │ └── dropdowns.css ├── config │ └── api.config.ts ├── modules │ ├── kubernetes │ │ └── types.ts │ ├── docker │ │ ├── types.ts │ │ └── dockerManager.ts │ ├── utils │ │ └── commandHistoryManager.ts │ ├── emergency │ │ └── commandAdapter.ts │ ├── auth │ │ └── authGuard.ts │ ├── remote │ │ ├── remoteOperationsManager.ts │ │ └── sshConnectionManager.ts │ ├── ssh │ │ ├── sshTerminalManager.ts │ │ └── sshManager.ts │ ├── ui │ │ ├── iconMapping.ts │ │ ├── sshConnectionDialog.ts │ │ └── theme.ts │ ├── core │ │ └── stateManager.ts │ ├── crypto │ │ └── cryptoService.ts │ └── user │ │ └── accountSettingsModal.ts ├── styles │ ├── sftp-context-menu.css │ ├── sftp.css │ └── system-info.css └── components │ └── IconParkIcon.vue ├── tsconfig.node.json ├── tsconfig.json ├── .gitignore ├── package.json ├── create_icns.sh ├── docs └── ssh.md ├── .github └── workflows │ └── release.yml ├── exp └── link.html ├── vite.config.ts ├── README.md ├── index.html ├── container-terminal.html └── generate_icons.py /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/public/logo.png -------------------------------------------------------------------------------- /public/logo-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/public/logo-32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/logo.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tokeii0/LovelyERes/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "tauri-apps.tauri-vscode", 5 | "rust-lang.rust-analyzer" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | lovelyres_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module "*.vue" { 4 | import type { DefineComponent } from "vue"; 5 | const component: DefineComponent<{}, {}, any>; 6 | export default component; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | showNotification?: (message: string, type?: 'success' | 'error' | 'info' | 'warning') => void; 4 | dockerPageManager?: unknown; 5 | } 6 | } 7 | 8 | export {}; 9 | -------------------------------------------------------------------------------- /src-tauri/tauri.windows.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "app": { 4 | "windows": [ 5 | { 6 | "decorations": false 7 | } 8 | ] 9 | }, 10 | "bundle": { 11 | "active": false 12 | } 13 | } 14 | 15 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/css/themes/index.css: -------------------------------------------------------------------------------- 1 | /* 主题索引文件 - LovelyRes */ 2 | /* 此文件用于导入所有主题CSS文件 */ 3 | 4 | /* 如果需要同时加载所有主题(不推荐,会增加加载时间) */ 5 | /* 推荐使用动态加载方式,只加载当前使用的主题 */ 6 | 7 | /* 8 | @import url('./light.css'); 9 | @import url('./dark.css'); 10 | @import url('./sakura.css'); 11 | */ 12 | 13 | /* 主题配置信息 */ 14 | /* 15 | 可用主题: 16 | 1. light.css - 浅色主题(清新明亮) 17 | 2. dark.css - 深色主题(护眼舒适) 18 | 3. sakura.css - 樱花主题(温柔浪漫) 19 | 20 | 使用方式: 21 | - 通过 ThemeManager.setTheme(themeName) 动态加载 22 | - 主题CSS文件会自动添加到页面头部 23 | - 切换主题时会自动移除旧的主题CSS并加载新的 24 | 25 | 扩展新主题: 26 | 1. 在此目录下创建新的主题CSS文件 27 | 2. 按照现有主题的结构定义CSS变量和样式 28 | 3. 在 ThemeManager 中添加新主题的配置 29 | 4. 更新主题列表和相关逻辑 30 | */ 31 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for the main window", 5 | "windows": ["main"], 6 | "permissions": [ 7 | "core:default", 8 | "opener:default", 9 | "dialog:allow-open", 10 | "dialog:allow-save", 11 | "dialog:default", 12 | "core:window:allow-start-dragging", 13 | "core:window:allow-set-position", 14 | "core:window:allow-set-size", 15 | "core:window:allow-minimize", 16 | "core:window:allow-maximize", 17 | "core:window:allow-close", 18 | "core:webview:allow-create-webview-window", 19 | "core:webview:allow-webview-position", 20 | "core:webview:allow-webview-size" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | doc/* 26 | 27 | # Security - prevent sensitive data from being committed 28 | .env 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | *.pem 34 | *.key 35 | *.p12 36 | *.pfx 37 | config/secrets.json 38 | **/config/secrets.json 39 | secrets/ 40 | **/secrets/ 41 | 42 | # API keys and passwords (backup protection) 43 | **/*api-key* 44 | **/*apikey* 45 | **/*api_key* 46 | **/*password* 47 | **/*secret* 48 | **/*token* 49 | -------------------------------------------------------------------------------- /src-tauri/capabilities/ssh-terminal.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "ssh-terminal", 4 | "description": "Capability for the SSH terminal window", 5 | "windows": ["ssh-terminal"], 6 | "permissions": [ 7 | "core:default", 8 | "core:event:allow-listen", 9 | "core:event:allow-emit", 10 | "core:event:allow-emit-to", 11 | "core:window:allow-close", 12 | "core:window:allow-destroy", 13 | "core:window:allow-hide", 14 | "core:window:allow-show", 15 | "core:window:allow-minimize", 16 | "core:window:allow-maximize", 17 | "core:window:allow-set-title", 18 | "core:window:allow-start-dragging", 19 | "core:webview:allow-webview-close", 20 | "core:webview:allow-webview-position", 21 | "core:webview:allow-webview-size" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lovelyeres", 3 | "private": true, 4 | "version": "0.55.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vue-tsc --noEmit && vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri" 11 | }, 12 | "dependencies": { 13 | "@icon-park/svg": "^1.4.2", 14 | "@icon-park/vue-next": "^1.4.2", 15 | "@tauri-apps/api": "^2", 16 | "@tauri-apps/plugin-dialog": "^2.2.2", 17 | "@tauri-apps/plugin-fs": "^2.3.0", 18 | "@tauri-apps/plugin-opener": "^2", 19 | "marked": "^15.0.12", 20 | "vue": "^3.5.13", 21 | "xterm": "^5.3.0", 22 | "xterm-addon-fit": "^0.8.0" 23 | }, 24 | "devDependencies": { 25 | "@tauri-apps/cli": "^2", 26 | "@vitejs/plugin-vue": "^5.2.1", 27 | "terser": "^5.44.0", 28 | "typescript": "~5.6.2", 29 | "vite": "^6.0.3", 30 | "vite-plugin-bundle-obfuscator": "^1.8.0", 31 | "vue-tsc": "^2.1.10" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/config/api.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * API 配置 3 | * 4 | * 根据环境自动选择 API 地址 5 | */ 6 | 7 | // 环境类型 8 | type Environment = 'development' | 'production'; 9 | 10 | // API 配置接口 11 | interface ApiConfig { 12 | baseURL: string; 13 | timeout: number; 14 | } 15 | 16 | // 获取当前环境 17 | const getEnvironment = (): Environment => { 18 | // 在 Tauri 环境中,可以通过环境变量判断 19 | if (window.__TAURI__) { 20 | // 生产环境 21 | return 'production'; 22 | } 23 | 24 | // 开发环境(浏览器) 25 | return 'development'; 26 | }; 27 | 28 | // 环境配置 29 | const configs: Record = { 30 | development: { 31 | baseURL: 'http://localhost:3000/api/v1', 32 | timeout: 10000, 33 | }, 34 | production: { 35 | baseURL: 'http://110.42.47.180:3000/api/v1', 36 | timeout: 10000, 37 | }, 38 | }; 39 | 40 | // 导出当前环境的配置 41 | export const API_CONFIG = configs[getEnvironment()]; 42 | 43 | // 导出环境判断函数 44 | export const isDevelopment = () => getEnvironment() === 'development'; 45 | export const isProduction = () => getEnvironment() === 'production'; 46 | 47 | // 打印当前配置(仅开发环境) 48 | if (isDevelopment()) { 49 | console.log('🔧 API 配置:', API_CONFIG); 50 | } 51 | 52 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "productName": "LovelyERes", 4 | "version": "0.55.0", 5 | "identifier": "com.lovelyres.app", 6 | "build": { 7 | "beforeDevCommand": "npm run dev", 8 | "devUrl": "http://localhost:5174", 9 | "beforeBuildCommand": "npm run build", 10 | "frontendDist": "../dist" 11 | }, 12 | "app": { 13 | "withGlobalTauri": true, 14 | "windows": [ 15 | { 16 | "title": "LovelyERes - Linux Emergency Response Tool", 17 | "width": 1200, 18 | "height": 800, 19 | "decorations": true, 20 | "center": true, 21 | "resizable": true, 22 | "minimizable": true, 23 | "maximizable": true, 24 | "closable": true, 25 | "skipTaskbar": false, 26 | "alwaysOnTop": false, 27 | "fullscreen": false, 28 | "focus": true, 29 | "visible": true 30 | } 31 | ], 32 | "security": { 33 | "assetProtocol": { 34 | "enable": true, 35 | "scope": ["**"] 36 | }, 37 | "capabilities": ["default", "ssh-terminal"] 38 | } 39 | }, 40 | "bundle": { 41 | "active": true, 42 | "targets": "all", 43 | "icon": [ 44 | "icons/32x32.png", 45 | "icons/128x128.png", 46 | "icons/128x128@2x.png", 47 | "icons/icon.icns", 48 | "icons/icon.ico" 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /create_icns.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 创建 macOS ICNS 图标文件 3 | 4 | set -e 5 | 6 | SOURCE_PNG="src-tauri/icons/icon.png" 7 | ICONSET_DIR="src-tauri/icons/icon.iconset" 8 | OUTPUT_ICNS="src-tauri/icons/icon.icns" 9 | 10 | # 检查源文件 11 | if [ ! -f "$SOURCE_PNG" ]; then 12 | echo "❌ 源文件不存在: $SOURCE_PNG" 13 | exit 1 14 | fi 15 | 16 | # 创建 iconset 目录 17 | mkdir -p "$ICONSET_DIR" 18 | 19 | echo "📁 从 $SOURCE_PNG 创建 ICNS 图标..." 20 | 21 | # 生成各种尺寸的图标 22 | sips -z 16 16 "$SOURCE_PNG" --out "$ICONSET_DIR/icon_16x16.png" > /dev/null 2>&1 23 | sips -z 32 32 "$SOURCE_PNG" --out "$ICONSET_DIR/icon_16x16@2x.png" > /dev/null 2>&1 24 | sips -z 32 32 "$SOURCE_PNG" --out "$ICONSET_DIR/icon_32x32.png" > /dev/null 2>&1 25 | sips -z 64 64 "$SOURCE_PNG" --out "$ICONSET_DIR/icon_32x32@2x.png" > /dev/null 2>&1 26 | sips -z 128 128 "$SOURCE_PNG" --out "$ICONSET_DIR/icon_128x128.png" > /dev/null 2>&1 27 | sips -z 256 256 "$SOURCE_PNG" --out "$ICONSET_DIR/icon_128x128@2x.png" > /dev/null 2>&1 28 | sips -z 256 256 "$SOURCE_PNG" --out "$ICONSET_DIR/icon_256x256.png" > /dev/null 2>&1 29 | sips -z 512 512 "$SOURCE_PNG" --out "$ICONSET_DIR/icon_256x256@2x.png" > /dev/null 2>&1 30 | sips -z 512 512 "$SOURCE_PNG" --out "$ICONSET_DIR/icon_512x512.png" > /dev/null 2>&1 31 | sips -z 1024 1024 "$SOURCE_PNG" --out "$ICONSET_DIR/icon_512x512@2x.png" > /dev/null 2>&1 32 | 33 | echo "✅ 已生成所有尺寸的图标" 34 | 35 | # 使用 iconutil 创建 ICNS 文件 36 | iconutil -c icns "$ICONSET_DIR" -o "$OUTPUT_ICNS" 37 | 38 | # 清理临时文件 39 | rm -rf "$ICONSET_DIR" 40 | 41 | echo "✅ ICNS 文件已创建: $OUTPUT_ICNS" 42 | ls -lh "$OUTPUT_ICNS" 43 | 44 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lovelyres" 3 | version = "0.52.0" 4 | description = "LovelyRes - Linux Emergency Response Tool" 5 | authors = ["LovelyRes Team"] 6 | edition = "2021" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [lib] 11 | # The `_lib` suffix may seem redundant but it is necessary 12 | # to make the lib name unique and wouldn't conflict with the bin name. 13 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 14 | name = "lovelyres_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2.1", features = [] } 19 | 20 | [dependencies] 21 | tauri = { version = "2.1", features = ["protocol-asset", "unstable", "devtools"] } 22 | tauri-plugin-opener = "2.0" 23 | tauri-plugin-dialog = "2.0" 24 | tauri-plugin-fs = "2.0" 25 | serde = { version = "1", features = ["derive"] } 26 | serde_json = "1" 27 | tokio = { version = "1", features = ["full"] } 28 | dirs = "5.0" 29 | uuid = { version = "1.0", features = ["v4"] } 30 | chrono = { version = "0.4", features = ["serde"] } 31 | anyhow = "1.0" 32 | thiserror = "1.0" 33 | # SSH 和加密相关依赖 - 使用 russh 替代 ssh2 以支持 OpenSSH 10.0+ 的现代加密算法 34 | # 使用 ring 后端而不是 aws-lc-rs,避免 Windows 上需要 NASM 35 | russh = { version = "0.54.6", default-features = false, features = ["ring", "flate2", "rsa"] } 36 | russh-sftp = "2.1.1" 37 | russh-keys = "0.49.2" 38 | async-trait = "0.1.89" 39 | futures = "0.3.31" 40 | # 保留旧的 ssh2 库以便逐步迁移 41 | ssh2 = "0.9" 42 | aes-gcm = "0.10" 43 | base64 = "0.21" 44 | rand = "0.8" 45 | 46 | # Windows API 依赖 47 | [target.'cfg(windows)'.dependencies] 48 | winapi = { version = "0.3", features = ["wingdi", "winuser", "windef"] } 49 | 50 | [profile.release] 51 | panic = "unwind" 52 | lto = true 53 | opt-level = "z" 54 | strip = true 55 | codegen-units = 1 56 | -------------------------------------------------------------------------------- /src/modules/kubernetes/types.ts: -------------------------------------------------------------------------------- 1 | export interface K8sResource { 2 | id: string; 3 | name: string; 4 | namespace: string; 5 | creationTimestamp: string; 6 | labels: Record; 7 | } 8 | 9 | export interface K8sPod extends K8sResource { 10 | status: 'Running' | 'Pending' | 'Failed' | 'Succeeded' | 'Unknown'; 11 | node: string; 12 | ip: string; 13 | restarts: number; 14 | containers: K8sContainer[]; 15 | } 16 | 17 | export interface K8sContainer { 18 | name: string; 19 | image: string; 20 | ready: boolean; 21 | restarts: number; 22 | } 23 | 24 | export interface K8sDeployment extends K8sResource { 25 | replicas: number; 26 | availableReplicas: number; 27 | updatedReplicas: number; 28 | conditions: string[]; 29 | } 30 | 31 | export interface K8sService extends K8sResource { 32 | type: 'ClusterIP' | 'NodePort' | 'LoadBalancer' | 'ExternalName'; 33 | clusterIP: string; 34 | externalIPs: string[]; 35 | ports: K8sServicePort[]; 36 | } 37 | 38 | export interface K8sServicePort { 39 | name: string; 40 | port: number; 41 | targetPort: number | string; 42 | protocol: 'TCP' | 'UDP' | 'SCTP'; 43 | } 44 | 45 | export interface K8sNode { 46 | name: string; 47 | status: 'Ready' | 'NotReady' | 'Unknown'; 48 | roles: string[]; 49 | version: string; 50 | addresses: { type: string; address: string }[]; 51 | capacity: { 52 | cpu: string; 53 | memory: string; 54 | pods: string; 55 | }; 56 | allocatable: { 57 | cpu: string; 58 | memory: string; 59 | pods: string; 60 | }; 61 | } 62 | 63 | export interface K8sClusterStats { 64 | totalPods: number; 65 | runningPods: number; 66 | totalDeployments: number; 67 | totalServices: number; 68 | healthyNodes: number; 69 | totalNodes: number; 70 | cpuUsage: number; // percentage 71 | memoryUsage: number; // percentage 72 | } 73 | -------------------------------------------------------------------------------- /src/modules/docker/types.ts: -------------------------------------------------------------------------------- 1 | export interface DockerPortMapping { 2 | ip?: string; 3 | privatePort: string; 4 | publicPort?: string; 5 | protocol: string; 6 | } 7 | 8 | export interface DockerNetworkAttachment { 9 | name: string; 10 | networkId?: string; 11 | endpointId?: string; 12 | macAddress?: string; 13 | ipv4Address?: string; 14 | ipv6Address?: string; 15 | } 16 | 17 | export interface DockerMountInfo { 18 | mountType: string; 19 | source?: string; 20 | destination: string; 21 | mode?: string; 22 | rw: boolean; 23 | } 24 | 25 | export interface DockerQuickCheck { 26 | networkAttached: boolean; 27 | privileged: boolean; 28 | health?: string; 29 | } 30 | 31 | export interface DockerStatsSnapshot { 32 | cpuPercent?: number; 33 | memoryUsage?: string; 34 | memoryPercent?: number; 35 | netIo?: string; 36 | blockIo?: string; 37 | pids?: number; 38 | } 39 | 40 | export interface DockerContainerSummary { 41 | id: string; 42 | shortId: string; 43 | name: string; 44 | image: string; 45 | state: string; 46 | status: string; 47 | createdAt: string; 48 | uptime?: string; 49 | command?: string; 50 | ports: DockerPortMapping[]; 51 | cpuPercent?: number; 52 | memoryUsage?: string; 53 | memoryPercent?: number; 54 | netIo?: string; 55 | blockIo?: string; 56 | pids?: number; 57 | networkMode?: string; 58 | networks: DockerNetworkAttachment[]; 59 | mounts: DockerMountInfo[]; 60 | quickChecks: DockerQuickCheck; 61 | } 62 | 63 | export interface DockerActionResult { 64 | success: boolean; 65 | message: string; 66 | updatedState?: string | null; 67 | updatedStatus?: string | null; 68 | } 69 | 70 | export interface DockerLogsOptions { 71 | tail?: number; 72 | since?: string; 73 | timestamps?: boolean; 74 | stdout?: boolean; 75 | stderr?: boolean; 76 | } 77 | 78 | export type DockerCopyDirection = 'container-to-host' | 'host-to-container' | 'in-container'; 79 | 80 | export interface DockerCopyRequest { 81 | direction: DockerCopyDirection; 82 | source: string; 83 | target: string; 84 | } 85 | 86 | -------------------------------------------------------------------------------- /src/styles/sftp-context-menu.css: -------------------------------------------------------------------------------- 1 | /* SFTP 右键菜单样式 */ 2 | 3 | .sftp-ctx-menu { 4 | position: fixed; 5 | z-index: 9999; 6 | display: none; 7 | min-width: 180px; 8 | background: var(--bg-primary); 9 | border: 1px solid var(--border-color); 10 | box-shadow: var(--shadow-sm); 11 | border-radius: var(--border-radius); 12 | overflow: visible; 13 | } 14 | 15 | .sftp-ctx-menu .ctx-item { 16 | padding: 8px 12px; 17 | cursor: pointer; 18 | font-size: 12px; 19 | color: var(--text-primary); 20 | display: flex; 21 | align-items: center; 22 | gap: 8px; 23 | position: relative; 24 | } 25 | 26 | .sftp-ctx-menu .ctx-item:hover { 27 | background-color: var(--bg-tertiary); 28 | } 29 | 30 | .sftp-ctx-menu .ctx-parent { 31 | justify-content: space-between; 32 | } 33 | 34 | /* 子菜单默认隐藏 */ 35 | .sftp-ctx-menu .ctx-submenu { 36 | position: absolute; 37 | left: 100%; 38 | top: -1px; /* 稍微向上偏移,确保与父菜单对齐 */ 39 | min-width: 200px; 40 | background: var(--bg-primary); 41 | border: 1px solid var(--border-color); 42 | box-shadow: var(--shadow-sm); 43 | border-radius: var(--border-radius); 44 | display: none; 45 | margin-left: -1px; /* 负值让子菜单与父菜单重叠1px,避免间隙 */ 46 | overflow: visible; 47 | } 48 | 49 | /* 子菜单显示在左侧(当右侧空间不足时) */ 50 | .sftp-ctx-menu .ctx-submenu.show-left { 51 | left: auto; 52 | right: 100%; 53 | margin-left: 0; 54 | margin-right: -1px; 55 | } 56 | 57 | /* 子菜单向上调整(当底部空间不足时) */ 58 | .sftp-ctx-menu .ctx-submenu.adjust-top { 59 | top: auto; 60 | bottom: -1px; 61 | } 62 | 63 | /* 二级菜单 */ 64 | .sftp-ctx-menu .ctx-submenu-level2 { 65 | z-index: 10000; 66 | min-width: 180px; 67 | } 68 | 69 | /* 三级菜单 */ 70 | .sftp-ctx-menu .ctx-submenu-level3 { 71 | z-index: 10001; 72 | min-width: 160px; 73 | } 74 | 75 | /* 当鼠标悬停在父菜单项上时,显示子菜单 */ 76 | .sftp-ctx-menu .ctx-parent:hover > .ctx-submenu { 77 | display: block !important; 78 | } 79 | 80 | /* 当鼠标在子菜单上时,保持子菜单显示 */ 81 | .sftp-ctx-menu .ctx-submenu:hover { 82 | display: block !important; 83 | } 84 | 85 | /* 当鼠标悬停在父菜单项或其子菜单上时,保持子菜单显示 */ 86 | .sftp-ctx-menu .ctx-parent:hover > .ctx-submenu, 87 | .sftp-ctx-menu .ctx-parent:hover > .ctx-submenu:hover { 88 | display: block !important; 89 | } 90 | 91 | -------------------------------------------------------------------------------- /docs/ssh.md: -------------------------------------------------------------------------------- 1 | 在 Rust 的 ssh2 库里,非阻塞模式是通过底层 libssh2 的 non-blocking 支持实现的。核心思路是: 2 | 3 | 把底层 TcpStream 设为非阻塞。 4 | 5 | 把 ssh2::Session 配置成非阻塞模式。 6 | 7 | 调用可能返回 WouldBlock 的方法时,要用事件循环(epoll/kqueue/poll/select 等)或手动 poll 来等待 socket 可读/可写,再重试。 8 | 9 | 使用步骤 10 | 1. 设置 TcpStream 为非阻塞 11 | use std::net::TcpStream; 12 | use std::io; 13 | use ssh2::Session; 14 | 15 | fn main() -> io::Result<()> { 16 | let tcp = TcpStream::connect("example.com:22")?; 17 | tcp.set_nonblocking(true)?; // 关键:非阻塞模式 18 | 19 | let mut sess = Session::new().unwrap(); 20 | sess.set_blocking(false); // 让 libssh2 使用非阻塞 21 | 22 | sess.handshake(&tcp)?; // 可能返回 WouldBlock 23 | 24 | Ok(()) 25 | } 26 | 27 | 2. 处理 WouldBlock 28 | 29 | 大部分 ssh2 的方法(如 handshake、userauth_password、channel.read 等)在非阻塞模式下可能返回 Err(e),其中 e.code() == ErrorCode::Session(-37),会映射为 Rust 的 io::ErrorKind::WouldBlock。 30 | 31 | 用法示例: 32 | 33 | use std::io::{self, Read, Write}; 34 | use ssh2::ErrorCode; 35 | 36 | fn do_handshake(sess: &mut Session, tcp: &TcpStream) -> io::Result<()> { 37 | loop { 38 | match sess.handshake(tcp) { 39 | Ok(()) => return Ok(()), 40 | Err(ref e) if e.code() == ErrorCode::Session(-37) => { 41 | // WouldBlock,等待 socket 可读/可写 42 | std::thread::sleep(std::time::Duration::from_millis(10)); 43 | continue; 44 | } 45 | Err(e) => return Err(io::Error::new(io::ErrorKind::Other, e)), 46 | } 47 | } 48 | } 49 | 50 | 3. 用 poll 或事件循环 51 | 52 | 如果不想手动 sleep,可以配合系统调用: 53 | 54 | use nix::poll::{poll, PollFd, PollFlags}; 55 | 56 | fn wait_for_socket(fd: RawFd, for_read: bool, timeout_ms: i32) -> io::Result<()> { 57 | let mut fds = [PollFd::new(fd, if for_read { PollFlags::POLLIN } else { PollFlags::POLLOUT })]; 58 | poll(&mut fds, timeout_ms).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; 59 | Ok(()) 60 | } 61 | 62 | 63 | 这样可以等 socket 可读/可写再继续执行 SSH 操作。 64 | 65 | 4. 与 tokio 集成 66 | 67 | 如果你在 async 环境(Tokio)里,可以用 tokio::net::TcpStream,然后通过 into_std() 转成 std::net::TcpStream,设置非阻塞,再交给 ssh2::Session。 68 | 不过 ssh2 不是异步库,需要自己封装成 spawn_blocking 或者用 poll_readable/poll_writable 来模拟异步等待。 69 | 70 | 总结 71 | 72 | tcp.set_nonblocking(true) + sess.set_blocking(false) 是关键。 73 | 74 | 非阻塞模式下,大多数操作要循环重试,直到不再返回 WouldBlock。 75 | 76 | 生产环境里通常要配合 poll/epoll 或 async runtime 来高效等待。 -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | release: 10 | permissions: 11 | contents: write 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | platform: [macos-latest, ubuntu-22.04, windows-latest] 16 | 17 | runs-on: ${{ matrix.platform }} 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Install dependencies (Ubuntu only) 23 | if: matrix.platform == 'ubuntu-22.04' 24 | # Tauri v2 Linux 依赖 25 | run: | 26 | sudo apt-get update 27 | sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev 28 | 29 | - name: Rust setup 30 | uses: dtolnay/rust-toolchain@stable 31 | with: 32 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 33 | 34 | - name: Rust cache 35 | uses: swatinem/rust-cache@v2 36 | with: 37 | workspaces: './src-tauri -> target' 38 | 39 | - name: Sync node version and setup cache 40 | uses: actions/setup-node@v4 41 | with: 42 | node-version: 'lts/*' 43 | cache: 'npm' 44 | 45 | - name: Install frontend dependencies 46 | run: npm install 47 | 48 | - name: Build the app (macOS/Linux) 49 | if: matrix.platform != 'windows-latest' 50 | uses: tauri-apps/tauri-action@v0 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | with: 54 | tagName: ${{ github.ref_name }} 55 | releaseName: 'LovelyERes v__VERSION__' 56 | releaseBody: 'See the assets to download this version and install.' 57 | releaseDraft: true 58 | prerelease: false 59 | 60 | - name: Build Windows 61 | if: matrix.platform == 'windows-latest' 62 | run: npm run tauri build -- --no-bundle 63 | 64 | - name: Upload Windows EXE 65 | if: matrix.platform == 'windows-latest' 66 | uses: softprops/action-gh-release@v2 67 | with: 68 | files: src-tauri/target/release/lovelyres.exe 69 | name: LovelyERes ${{ github.ref_name }} 70 | body: 'See the assets to download this version and install.' 71 | draft: true 72 | -------------------------------------------------------------------------------- /public/tauri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/css/dropdowns.css: -------------------------------------------------------------------------------- 1 | /* Premium Dropdown Styles - LovelyRes */ 2 | 3 | /* Global Select Style - Using !important to override inline styles */ 4 | select { 5 | appearance: none !important; 6 | -webkit-appearance: none !important; 7 | -moz-appearance: none !important; 8 | background-color: var(--bg-secondary) !important; 9 | color: var(--text-primary) !important; 10 | border: 1px solid var(--border-color) !important; 11 | border-radius: var(--border-radius) !important; 12 | padding: 8px 32px 8px 12px !important; 13 | font-size: 13px !important; 14 | font-family: var(--font-family) !important; 15 | cursor: pointer !important; 16 | transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important; 17 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E") !important; 18 | background-repeat: no-repeat !important; 19 | background-position: right 8px center !important; 20 | background-size: 16px !important; 21 | outline: none !important; 22 | box-shadow: var(--shadow-sm) !important; 23 | line-height: 1.5 !important; 24 | min-height: 36px !important; 25 | /* Don't force width, let layout control it, but ensure box-sizing */ 26 | box-sizing: border-box !important; 27 | } 28 | 29 | /* Hover State */ 30 | select:hover { 31 | border-color: var(--primary-color) !important; 32 | background-color: var(--bg-hover, rgba(255, 255, 255, 0.03)) !important; 33 | transform: translateY(-1px) !important; 34 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important; 35 | } 36 | 37 | /* Focus State */ 38 | select:focus { 39 | border-color: var(--primary-color) !important; 40 | box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15) !important; 41 | background-color: var(--bg-primary) !important; 42 | transform: translateY(-1px) !important; 43 | } 44 | 45 | /* Disabled State */ 46 | select:disabled { 47 | opacity: 0.6 !important; 48 | cursor: not-allowed !important; 49 | background-color: var(--bg-tertiary) !important; 50 | transform: none !important; 51 | box-shadow: none !important; 52 | border-color: var(--border-color) !important; 53 | } 54 | 55 | /* Option Styling */ 56 | select option { 57 | background-color: var(--bg-secondary); 58 | color: var(--text-primary); 59 | padding: 8px; 60 | } 61 | 62 | /* Dark Mode Specific Adjustments */ 63 | @media (prefers-color-scheme: dark) { 64 | select { 65 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E") !important; 66 | } 67 | } -------------------------------------------------------------------------------- /src/modules/docker/dockerManager.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from '@tauri-apps/api/core'; 2 | import type { 3 | DockerActionResult, 4 | DockerContainerSummary, 5 | DockerCopyRequest, 6 | DockerLogsOptions, 7 | } from './types'; 8 | 9 | type DockerContainerAction = 'start' | 'stop' | 'restart' | 'kill' | 'pause' | 'unpause'; 10 | 11 | export class DockerManager { 12 | private containers: DockerContainerSummary[] = []; 13 | 14 | getCachedContainers(): DockerContainerSummary[] { 15 | return [...this.containers]; 16 | } 17 | 18 | async listContainers(): Promise { 19 | const containers = await invoke('docker_list_containers'); 20 | this.containers = containers; 21 | return [...containers]; 22 | } 23 | 24 | async performAction(containerRef: string, action: DockerContainerAction): Promise { 25 | return invoke('docker_container_action', { 26 | containerId: containerRef, 27 | action, 28 | }); 29 | } 30 | 31 | async getLogs(containerRef: string, options?: Partial): Promise { 32 | const payload: DockerLogsOptions | undefined = options 33 | ? { 34 | tail: options.tail, 35 | since: options.since, 36 | timestamps: options.timestamps ?? false, 37 | stdout: options.stdout ?? true, 38 | stderr: options.stderr ?? true, 39 | } 40 | : undefined; 41 | 42 | return invoke('docker_container_logs', { 43 | containerId: containerRef, 44 | options: payload, 45 | }); 46 | } 47 | 48 | async inspect(containerRef: string): Promise { 49 | return invoke('docker_inspect_container', { 50 | containerId: containerRef, 51 | }); 52 | } 53 | 54 | async readFile(containerRef: string, path: string): Promise { 55 | return invoke('docker_read_container_file', { 56 | containerId: containerRef, 57 | path, 58 | }); 59 | } 60 | 61 | async execCommand(containerRef: string, command: string): Promise { 62 | return invoke('docker_exec_command', { 63 | containerId: containerRef, 64 | command, 65 | }); 66 | } 67 | 68 | async createContainerTerminalWindow(containerName: string, containerId: string): Promise { 69 | return invoke('create_container_terminal_window', { 70 | containerName, 71 | containerId, 72 | }); 73 | } 74 | 75 | async writeFile(containerRef: string, path: string, content: string): Promise { 76 | return invoke('docker_write_container_file', { 77 | containerId: containerRef, 78 | path, 79 | content, 80 | }); 81 | } 82 | 83 | async copy(containerRef: string, request: DockerCopyRequest): Promise { 84 | return invoke('docker_copy', { 85 | containerId: containerRef, 86 | request, 87 | }); 88 | } 89 | } 90 | 91 | export const dockerManager = new DockerManager(); 92 | -------------------------------------------------------------------------------- /exp/link.html: -------------------------------------------------------------------------------- 1 | 76 | 77 | 94 | -------------------------------------------------------------------------------- /src/css/themes/light.css: -------------------------------------------------------------------------------- 1 | /* 浅色主题 - LovelyRes */ 2 | /* 清新明亮的浅色主题配色 */ 3 | 4 | [data-theme="light"] { 5 | /* 主色调 */ 6 | --primary-color: #4299e1; 7 | --secondary-color: #63b3ed; 8 | --accent-color: #81e6d9; 9 | --success-color: #48bb78; 10 | --warning-color: #ed8936; 11 | --error-color: #f56565; 12 | --info-color: #4299e1; 13 | 14 | /* 背景色 */ 15 | --bg-primary: #fafbfc; 16 | --bg-secondary: #ffffff; 17 | --bg-tertiary: #f4f6f8; 18 | --bg-dark: #2d3748; 19 | --bg-glass: rgba(255, 255, 255, 0.15); 20 | 21 | /* 文字颜色 */ 22 | --text-primary: #2d3748; 23 | --text-secondary: #718096; 24 | --text-light: #a0aec0; 25 | --text-white: #ffffff; 26 | 27 | /* 边框和阴影 */ 28 | --border-color: rgba(148, 163, 184, 0.2); 29 | --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1); 30 | --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.12); 31 | --shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.15); 32 | } 33 | 34 | /* 浅色主题特定样式 */ 35 | [data-theme="light"] .modern-title-bar { 36 | background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); 37 | border-bottom: 1px solid var(--border-color); 38 | } 39 | 40 | [data-theme="light"] .modern-sidebar { 41 | background: var(--bg-secondary); 42 | border-right: 1px solid var(--border-color); 43 | } 44 | 45 | [data-theme="light"] .main-workspace { 46 | background: var(--bg-primary); 47 | } 48 | 49 | [data-theme="light"] .workspace-header { 50 | background: var(--bg-secondary); 51 | border-bottom: 1px solid var(--border-color); 52 | } 53 | 54 | [data-theme="light"] .status-bar { 55 | background: var(--bg-secondary); 56 | border-top: 1px solid var(--border-color); 57 | } 58 | 59 | [data-theme="light"] .modern-card { 60 | background: var(--bg-secondary); 61 | border: 1px solid var(--border-color); 62 | box-shadow: var(--shadow-sm); 63 | } 64 | 65 | [data-theme="light"] .modern-card:hover { 66 | box-shadow: var(--shadow-md); 67 | border-color: var(--primary-color); 68 | } 69 | 70 | [data-theme="light"] .modern-input { 71 | background: var(--bg-secondary); 72 | border: 1px solid var(--border-color); 73 | color: var(--text-primary); 74 | } 75 | 76 | [data-theme="light"] .modern-input:focus { 77 | border-color: var(--primary-color); 78 | box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.1); 79 | } 80 | 81 | [data-theme="light"] .control-button:hover { 82 | background: var(--bg-tertiary); 83 | color: var(--text-primary); 84 | } 85 | 86 | /* 浅色主题特有的装饰效果 */ 87 | [data-theme="light"] .luxe-text { 88 | background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); 89 | -webkit-background-clip: text; 90 | -webkit-text-fill-color: transparent; 91 | background-clip: text; 92 | } 93 | 94 | /* 浅色主题的连接状态指示器 */ 95 | [data-theme="light"] .connection-status { 96 | background: var(--success-color); 97 | color: white; 98 | } 99 | 100 | /* 浅色主题的模态框样式 */ 101 | [data-theme="light"] .modal-overlay { 102 | background: rgba(0, 0, 0, 0.5); 103 | } 104 | 105 | [data-theme="light"] .modal-content { 106 | background: var(--bg-secondary); 107 | border: 1px solid var(--border-color); 108 | box-shadow: var(--shadow-lg); 109 | } 110 | -------------------------------------------------------------------------------- /src/components/IconParkIcon.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 99 | 100 | 107 | -------------------------------------------------------------------------------- /src/css/themes/dark.css: -------------------------------------------------------------------------------- 1 | /* 深色主题 - LovelyRes */ 2 | /* 护眼舒适的深色主题配色 */ 3 | 4 | [data-theme="dark"] { 5 | /* 主色调 */ 6 | --primary-color: #4299e1; 7 | --secondary-color: #63b3ed; 8 | --accent-color: #81e6d9; 9 | --success-color: #48bb78; 10 | --warning-color: #ed8936; 11 | --error-color: #f56565; 12 | --info-color: #4299e1; 13 | 14 | /* 背景色 */ 15 | --bg-primary: #0f172a; 16 | --bg-secondary: #1e293b; 17 | --bg-tertiary: #334155; 18 | --bg-dark: #475569; 19 | --bg-glass: rgba(0, 0, 0, 0.3); 20 | 21 | /* 文字颜色 */ 22 | --text-primary: #f1f5f9; 23 | --text-secondary: #cbd5e1; 24 | --text-light: #94a3b8; 25 | --text-white: #ffffff; 26 | 27 | /* 边框和阴影 */ 28 | --border-color: rgba(148, 163, 184, 0.2); 29 | --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3); 30 | --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.4); 31 | --shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.5); 32 | } 33 | 34 | /* 深色主题特定样式 */ 35 | [data-theme="dark"] .modern-title-bar { 36 | background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); 37 | border-bottom: 1px solid var(--border-color); 38 | } 39 | 40 | [data-theme="dark"] .modern-sidebar { 41 | background: var(--bg-secondary); 42 | border-right: 1px solid var(--border-color); 43 | } 44 | 45 | [data-theme="dark"] .main-workspace { 46 | background: var(--bg-primary); 47 | } 48 | 49 | [data-theme="dark"] .workspace-header { 50 | background: var(--bg-secondary); 51 | border-bottom: 1px solid var(--border-color); 52 | } 53 | 54 | [data-theme="dark"] .status-bar { 55 | background: var(--bg-secondary); 56 | border-top: 1px solid var(--border-color); 57 | } 58 | 59 | [data-theme="dark"] .modern-card { 60 | background: var(--bg-secondary); 61 | border: 1px solid var(--border-color); 62 | box-shadow: var(--shadow-sm); 63 | } 64 | 65 | [data-theme="dark"] .modern-card:hover { 66 | box-shadow: var(--shadow-md); 67 | border-color: var(--primary-color); 68 | } 69 | 70 | [data-theme="dark"] .modern-input { 71 | background: var(--bg-secondary); 72 | border: 1px solid var(--border-color); 73 | color: var(--text-primary); 74 | } 75 | 76 | [data-theme="dark"] .modern-input:focus { 77 | border-color: var(--primary-color); 78 | box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.2); 79 | } 80 | 81 | [data-theme="dark"] .control-button:hover { 82 | background: var(--bg-tertiary); 83 | color: var(--text-primary); 84 | } 85 | 86 | /* 深色主题特有的装饰效果 */ 87 | [data-theme="dark"] .luxe-text { 88 | background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); 89 | -webkit-background-clip: text; 90 | -webkit-text-fill-color: transparent; 91 | background-clip: text; 92 | } 93 | 94 | /* 深色主题的连接状态指示器 */ 95 | [data-theme="dark"] .connection-status { 96 | background: var(--success-color); 97 | color: white; 98 | } 99 | 100 | /* 深色主题的模态框样式 */ 101 | [data-theme="dark"] .modal-overlay { 102 | background: rgba(0, 0, 0, 0.7); 103 | } 104 | 105 | [data-theme="dark"] .modal-content { 106 | background: var(--bg-secondary); 107 | border: 1px solid var(--border-color); 108 | box-shadow: var(--shadow-lg); 109 | } 110 | 111 | /* 深色主题特有的发光效果 */ 112 | [data-theme="dark"] .modern-btn.primary { 113 | box-shadow: 0 0 10px rgba(66, 153, 225, 0.3); 114 | } 115 | 116 | [data-theme="dark"] .modern-btn.primary:hover { 117 | box-shadow: 0 0 15px rgba(66, 153, 225, 0.4); 118 | } 119 | 120 | /* 深色主题的滚动条样式 */ 121 | [data-theme="dark"] ::-webkit-scrollbar { 122 | width: 8px; 123 | height: 8px; 124 | } 125 | 126 | [data-theme="dark"] ::-webkit-scrollbar-track { 127 | background: var(--bg-primary); 128 | } 129 | 130 | [data-theme="dark"] ::-webkit-scrollbar-thumb { 131 | background: var(--bg-tertiary); 132 | border-radius: 4px; 133 | } 134 | 135 | [data-theme="dark"] ::-webkit-scrollbar-thumb:hover { 136 | background: var(--bg-dark); 137 | } 138 | -------------------------------------------------------------------------------- /src/modules/utils/commandHistoryManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 命令历史记录管理器 3 | * 负责持久化存储和管理命令执行历史 4 | */ 5 | 6 | export interface CommandHistoryItem { 7 | id: string; 8 | command: string; 9 | title: string; 10 | output: string; 11 | timestamp: number; 12 | exitCode?: number; 13 | } 14 | 15 | export class CommandHistoryManager { 16 | private static readonly STORAGE_KEY = 'lovelyres_command_history'; 17 | private static readonly MAX_HISTORY_SIZE = 100; // 最多保存100条历史记录 18 | 19 | /** 20 | * 保存命令到历史记录 21 | */ 22 | static saveCommand(command: string, title: string, output: string, exitCode?: number): void { 23 | try { 24 | const history = this.getHistory(); 25 | 26 | const newItem: CommandHistoryItem = { 27 | id: this.generateId(), 28 | command, 29 | title, 30 | output, 31 | timestamp: Date.now(), 32 | exitCode 33 | }; 34 | 35 | // 添加到历史记录开头 36 | history.unshift(newItem); 37 | 38 | // 限制历史记录大小 39 | if (history.length > this.MAX_HISTORY_SIZE) { 40 | history.splice(this.MAX_HISTORY_SIZE); 41 | } 42 | 43 | // 保存到 localStorage 44 | localStorage.setItem(this.STORAGE_KEY, JSON.stringify(history)); 45 | 46 | console.log('✅ 命令已保存到历史记录:', command.substring(0, 50)); 47 | } catch (error) { 48 | console.error('❌ 保存命令历史失败:', error); 49 | } 50 | } 51 | 52 | /** 53 | * 获取所有历史记录 54 | */ 55 | static getHistory(): CommandHistoryItem[] { 56 | try { 57 | const data = localStorage.getItem(this.STORAGE_KEY); 58 | if (!data) return []; 59 | 60 | const history = JSON.parse(data) as CommandHistoryItem[]; 61 | return Array.isArray(history) ? history : []; 62 | } catch (error) { 63 | console.error('❌ 读取命令历史失败:', error); 64 | return []; 65 | } 66 | } 67 | 68 | /** 69 | * 根据ID获取历史记录 70 | */ 71 | static getById(id: string): CommandHistoryItem | null { 72 | const history = this.getHistory(); 73 | return history.find(item => item.id === id) || null; 74 | } 75 | 76 | /** 77 | * 清空历史记录 78 | */ 79 | static clearHistory(): void { 80 | try { 81 | localStorage.removeItem(this.STORAGE_KEY); 82 | console.log('✅ 命令历史已清空'); 83 | } catch (error) { 84 | console.error('❌ 清空命令历史失败:', error); 85 | } 86 | } 87 | 88 | /** 89 | * 删除指定的历史记录 90 | */ 91 | static deleteById(id: string): void { 92 | try { 93 | const history = this.getHistory(); 94 | const filtered = history.filter(item => item.id !== id); 95 | localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered)); 96 | console.log('✅ 已删除历史记录:', id); 97 | } catch (error) { 98 | console.error('❌ 删除历史记录失败:', error); 99 | } 100 | } 101 | 102 | /** 103 | * 搜索历史记录 104 | */ 105 | static search(keyword: string): CommandHistoryItem[] { 106 | const history = this.getHistory(); 107 | const lowerKeyword = keyword.toLowerCase(); 108 | 109 | return history.filter(item => 110 | item.command.toLowerCase().includes(lowerKeyword) || 111 | item.title.toLowerCase().includes(lowerKeyword) || 112 | item.output.toLowerCase().includes(lowerKeyword) 113 | ); 114 | } 115 | 116 | /** 117 | * 生成唯一ID 118 | */ 119 | private static generateId(): string { 120 | return `cmd_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; 121 | } 122 | 123 | /** 124 | * 获取最近的N条历史记录 125 | */ 126 | static getRecent(count: number = 10): CommandHistoryItem[] { 127 | const history = this.getHistory(); 128 | return history.slice(0, count); 129 | } 130 | 131 | /** 132 | * 获取历史记录统计信息 133 | */ 134 | static getStats(): { total: number; oldestTimestamp: number | null; newestTimestamp: number | null } { 135 | const history = this.getHistory(); 136 | 137 | if (history.length === 0) { 138 | return { total: 0, oldestTimestamp: null, newestTimestamp: null }; 139 | } 140 | 141 | return { 142 | total: history.length, 143 | oldestTimestamp: history[history.length - 1].timestamp, 144 | newestTimestamp: history[0].timestamp 145 | }; 146 | } 147 | } 148 | 149 | -------------------------------------------------------------------------------- /src/modules/emergency/commandAdapter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 命令适配器 3 | * 根据系统类型选择合适的命令 4 | */ 5 | 6 | import type { SystemType, SystemInfo } from '../utils/systemDetector'; 7 | import type { EmergencyCommand } from './commands'; 8 | 9 | export class CommandAdapter { 10 | /** 11 | * 根据系统类型获取适配后的命令 12 | */ 13 | static getAdaptedCommand(command: EmergencyCommand, systemInfo: SystemInfo): string { 14 | // 如果命令有多系统定义 15 | if (command.commands) { 16 | // 优先使用系统特定命令 17 | const systemSpecificCmd = command.commands[systemInfo.type as keyof typeof command.commands]; 18 | if (systemSpecificCmd) { 19 | console.log(`✅ 使用 ${systemInfo.type} 特定命令:`, systemSpecificCmd.substring(0, 50)); 20 | return systemSpecificCmd; 21 | } 22 | 23 | // 尝试使用相似系统的命令(回退机制) 24 | const fallbackCmd = this.getFallbackCommand(command.commands, systemInfo.type); 25 | if (fallbackCmd) { 26 | console.log(`⚠️ 使用回退命令 (${systemInfo.type}):`, fallbackCmd.substring(0, 50)); 27 | return fallbackCmd; 28 | } 29 | 30 | // 使用默认命令 31 | if (command.commands.default) { 32 | console.log(`📌 使用默认命令:`, command.commands.default.substring(0, 50)); 33 | return command.commands.default; 34 | } 35 | } 36 | 37 | // 向后兼容:如果只有 cmd 字段 38 | if (command.cmd) { 39 | return command.cmd; 40 | } 41 | 42 | throw new Error(`命令 ${command.id} 没有可用的命令定义`); 43 | } 44 | 45 | /** 46 | * 获取回退命令 47 | * 根据系统类型的相似性选择合适的回退命令 48 | */ 49 | private static getFallbackCommand( 50 | commands: NonNullable, 51 | systemType: SystemType 52 | ): string | null { 53 | // 定义系统族群(相似的系统可以共享命令) 54 | const systemFamilies: Record = { 55 | debian: ['ubuntu', 'debian', 'kylin', 'uos', 'deepin'], 56 | redhat: ['centos', 'rhel', 'fedora', 'openeuler', 'anolis'], 57 | arch: ['arch'], 58 | suse: ['opensuse'], 59 | alpine: ['alpine'] 60 | }; 61 | 62 | // 找到当前系统所属的族群 63 | let currentFamily: SystemType[] = []; 64 | for (const systems of Object.values(systemFamilies)) { 65 | if (systems.includes(systemType)) { 66 | currentFamily = systems; 67 | break; 68 | } 69 | } 70 | 71 | // 在同族群中查找可用命令 72 | for (const similarSystem of currentFamily) { 73 | if (similarSystem !== systemType) { 74 | const cmd = commands[similarSystem as keyof typeof commands]; 75 | if (cmd) { 76 | return cmd; 77 | } 78 | } 79 | } 80 | 81 | return null; 82 | } 83 | 84 | /** 85 | * 批量适配命令 86 | */ 87 | static adaptCommands(commands: EmergencyCommand[], systemInfo: SystemInfo): Map { 88 | const adaptedCommands = new Map(); 89 | 90 | for (const command of commands) { 91 | try { 92 | const adaptedCmd = this.getAdaptedCommand(command, systemInfo); 93 | adaptedCommands.set(command.id, adaptedCmd); 94 | } catch (error) { 95 | console.error(`命令适配失败: ${command.id}`, error); 96 | } 97 | } 98 | 99 | return adaptedCommands; 100 | } 101 | 102 | /** 103 | * 检查命令是否支持当前系统 104 | */ 105 | static isCommandSupported(command: EmergencyCommand, systemInfo: SystemInfo): boolean { 106 | try { 107 | this.getAdaptedCommand(command, systemInfo); 108 | return true; 109 | } catch { 110 | return false; 111 | } 112 | } 113 | 114 | /** 115 | * 获取命令的系统支持信息 116 | */ 117 | static getCommandSupportInfo(command: EmergencyCommand): { 118 | supportedSystems: SystemType[]; 119 | hasDefault: boolean; 120 | } { 121 | const supportedSystems: SystemType[] = []; 122 | let hasDefault = false; 123 | 124 | if (command.commands) { 125 | if (command.commands.default) { 126 | hasDefault = true; 127 | } 128 | 129 | // 检查所有系统特定命令 130 | const systemTypes: SystemType[] = [ 131 | 'ubuntu', 'debian', 'centos', 'rhel', 'fedora', 132 | 'kylin', 'uos', 'deepin', 'openeuler', 'anolis', 133 | 'arch', 'opensuse', 'alpine' 134 | ]; 135 | 136 | for (const systemType of systemTypes) { 137 | if (command.commands[systemType as keyof typeof command.commands]) { 138 | supportedSystems.push(systemType); 139 | } 140 | } 141 | } 142 | 143 | return { supportedSystems, hasDefault }; 144 | } 145 | } 146 | 147 | -------------------------------------------------------------------------------- /src/css/themes/sakura.css: -------------------------------------------------------------------------------- 1 | /* 樱花主题 - LovelyRes */ 2 | /* 温柔浪漫的樱花主题配色 */ 3 | 4 | [data-theme="sakura"] { 5 | /* 主色调 - 樱花粉色系 */ 6 | --primary-color: #ff9bb3; 7 | --secondary-color: #ffb3c1; 8 | --accent-color: #ffc0cb; 9 | --success-color: #f8bbd9; 10 | --warning-color: #ffc1cc; 11 | --error-color: #ffb3ba; 12 | --info-color: #ff9eb5; 13 | 14 | /* 背景色 - 温柔的粉色调 */ 15 | --bg-primary: #fef9f9; 16 | --bg-secondary: #fffefe; 17 | --bg-tertiary: #fef5f7; 18 | --bg-dark: #c53030; 19 | --bg-glass: rgba(255, 192, 203, 0.12); 20 | 21 | /* 文字颜色 - 温暖的棕色调 */ 22 | --text-primary: #744c4c; 23 | --text-secondary: #a0616d; 24 | --text-light: #d69e9e; 25 | --text-white: #ffffff; 26 | 27 | /* 边框和阴影 - 柔和的粉色调 */ 28 | --border-color: rgba(255, 155, 179, 0.2); 29 | --shadow-sm: 0 2px 4px rgba(255, 155, 179, 0.1); 30 | --shadow-md: 0 4px 8px rgba(255, 155, 179, 0.15); 31 | --shadow-lg: 0 8px 16px rgba(255, 155, 179, 0.2); 32 | } 33 | 34 | /* 樱花主题特定样式 */ 35 | [data-theme="sakura"] .modern-title-bar { 36 | background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); 37 | border-bottom: 1px solid var(--border-color); 38 | } 39 | 40 | [data-theme="sakura"] .modern-sidebar { 41 | background: var(--bg-secondary); 42 | border-right: 1px solid var(--border-color); 43 | } 44 | 45 | [data-theme="sakura"] .main-workspace { 46 | background: var(--bg-primary); 47 | } 48 | 49 | [data-theme="sakura"] .workspace-header { 50 | background: var(--bg-secondary); 51 | border-bottom: 1px solid var(--border-color); 52 | } 53 | 54 | [data-theme="sakura"] .status-bar { 55 | background: var(--bg-secondary); 56 | border-top: 1px solid var(--border-color); 57 | } 58 | 59 | [data-theme="sakura"] .modern-card { 60 | background: var(--bg-secondary); 61 | border: 1px solid var(--border-color); 62 | box-shadow: var(--shadow-sm); 63 | } 64 | 65 | [data-theme="sakura"] .modern-card:hover { 66 | box-shadow: var(--shadow-md); 67 | border-color: var(--primary-color); 68 | } 69 | 70 | [data-theme="sakura"] .modern-input { 71 | background: var(--bg-secondary); 72 | border: 1px solid var(--border-color); 73 | color: var(--text-primary); 74 | } 75 | 76 | [data-theme="sakura"] .modern-input:focus { 77 | border-color: var(--primary-color); 78 | box-shadow: 0 0 0 3px rgba(255, 155, 179, 0.2); 79 | } 80 | 81 | [data-theme="sakura"] .control-button:hover { 82 | background: var(--bg-tertiary); 83 | color: var(--text-primary); 84 | } 85 | 86 | /* 樱花主题特有的装饰效果 */ 87 | [data-theme="sakura"] .luxe-text { 88 | background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); 89 | -webkit-background-clip: text; 90 | -webkit-text-fill-color: transparent; 91 | background-clip: text; 92 | } 93 | 94 | /* 樱花主题的连接状态指示器 */ 95 | [data-theme="sakura"] .connection-status { 96 | background: var(--success-color); 97 | color: var(--text-primary); 98 | } 99 | 100 | /* 樱花主题的模态框样式 */ 101 | [data-theme="sakura"] .modal-overlay { 102 | background: rgba(255, 192, 203, 0.3); 103 | } 104 | 105 | [data-theme="sakura"] .modal-content { 106 | background: var(--bg-secondary); 107 | border: 1px solid var(--border-color); 108 | box-shadow: var(--shadow-lg); 109 | } 110 | 111 | /* 樱花主题特有的渐变按钮效果 */ 112 | [data-theme="sakura"] .modern-btn.primary { 113 | background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); 114 | box-shadow: var(--shadow-sm); 115 | } 116 | 117 | [data-theme="sakura"] .modern-btn.primary:hover { 118 | transform: translateY(-1px); 119 | box-shadow: var(--shadow-md); 120 | } 121 | 122 | /* 樱花主题的樱花花瓣动画效果 */ 123 | [data-theme="sakura"] .app-logo::before { 124 | content: ' 🌸 '; 125 | position: absolute; 126 | top: -5px; 127 | right: -5px; 128 | font-size: 12px; 129 | opacity: 0.7; 130 | animation: sakuraPetal 3s ease-in-out infinite; 131 | } 132 | 133 | @keyframes sakuraPetal { 134 | 0%, 100% { 135 | transform: rotate(0deg) scale(1); 136 | opacity: 0.7; 137 | } 138 | 50% { 139 | transform: rotate(10deg) scale(1.1); 140 | opacity: 1; 141 | } 142 | } 143 | 144 | /* 樱花主题的特殊滚动条 */ 145 | [data-theme="sakura"] ::-webkit-scrollbar { 146 | width: 8px; 147 | height: 8px; 148 | } 149 | 150 | [data-theme="sakura"] ::-webkit-scrollbar-track { 151 | background: var(--bg-primary); 152 | } 153 | 154 | [data-theme="sakura"] ::-webkit-scrollbar-thumb { 155 | background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); 156 | border-radius: 4px; 157 | } 158 | 159 | [data-theme="sakura"] ::-webkit-scrollbar-thumb:hover { 160 | background: linear-gradient(135deg, var(--secondary-color), var(--accent-color)); 161 | } 162 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import vitePluginBundleObfuscator from 'vite-plugin-bundle-obfuscator'; 4 | // @ts-expect-error Node.js modules 5 | import { copyFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'fs'; 6 | // @ts-expect-error Node.js modules 7 | import { join } from 'path'; 8 | 9 | // @ts-expect-error process is a nodejs global 10 | const host = process.env.TAURI_DEV_HOST; 11 | 12 | // 自定义插件:复制src目录到dist 13 | function copySrcPlugin() { 14 | return { 15 | name: 'copy-src', 16 | writeBundle() { 17 | const srcDir = 'src'; 18 | const distSrcDir = 'dist/src'; 19 | 20 | function copyDir(src: string, dest: string) { 21 | if (!existsSync(dest)) { 22 | mkdirSync(dest, { recursive: true }); 23 | } 24 | 25 | const entries = readdirSync(src); 26 | for (const entry of entries) { 27 | const srcPath = join(src, entry); 28 | const destPath = join(dest, entry); 29 | 30 | if (statSync(srcPath).isDirectory()) { 31 | copyDir(srcPath, destPath); 32 | } else { 33 | copyFileSync(srcPath, destPath); 34 | } 35 | } 36 | } 37 | 38 | if (existsSync(srcDir)) { 39 | copyDir(srcDir, distSrcDir); 40 | console.log('✅ src目录已复制到dist/src'); 41 | } 42 | } 43 | }; 44 | } 45 | 46 | // https://vite.dev/config/ 47 | export default defineConfig(async () => ({ 48 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 49 | // 50 | // 1. prevent vite from obscuring rust errors 51 | clearScreen: false, 52 | // 2. tauri expects a fixed port, fail if that port is not available 53 | server: { 54 | port: 5174, 55 | strictPort: true, 56 | host: host || false, 57 | hmr: host 58 | ? { 59 | protocol: "ws", 60 | host, 61 | port: 5175, 62 | } 63 | : undefined, 64 | watch: { 65 | // 3. tell vite to ignore watching `src-tauri` 66 | ignored: ["**/src-tauri/**"], 67 | }, 68 | }, 69 | 70 | // 配置多页面应用 71 | build: { 72 | sourcemap: true, // 启用source map便于调试 73 | minify: 'esbuild' as const, // 使用 esbuild 进行基本压缩(更快,不混淆) 74 | rollupOptions: { 75 | input: { 76 | main: 'index.html', 77 | 'ssh-terminal': 'ssh-terminal.html', 78 | 'container-terminal': 'container-terminal.html', 79 | 80 | // 可以在这里添加更多页面 81 | }, 82 | onwarn(warning: any, warn: any) { 83 | // 忽略source map相关警告 84 | if (warning.code === 'SOURCEMAP_ERROR') return; 85 | if (warning.message && warning.message.includes('source map')) return; 86 | warn(warning); 87 | }, 88 | output: { 89 | // 标准文件名格式 90 | entryFileNames: 'assets/[name]-[hash].js', 91 | chunkFileNames: 'assets/[name]-[hash].js', 92 | assetFileNames: 'assets/[name]-[hash].[ext]', 93 | // 代码分块优化 94 | manualChunks(id: string) { 95 | if (id.indexOf('node_modules') !== -1) { 96 | return 'vendor'; 97 | } 98 | }, 99 | }, 100 | }, 101 | }, 102 | 103 | // 使用自定义插件 104 | plugins: [ 105 | vue(), 106 | copySrcPlugin(), 107 | // JavaScript 混淆插件(仅在生产构建时启用) 108 | vitePluginBundleObfuscator({ 109 | enable: true, // 启用混淆 110 | log: true, // 显示混淆日志 111 | autoExcludeNodeModules: true, // 自动排除 node_modules 112 | // 混淆选项 - 使用保守配置避免破坏代码 113 | options: { 114 | compact: true, // 压缩代码 115 | controlFlowFlattening: false, // 不使用控制流扁平化(可能破坏代码) 116 | deadCodeInjection: false, // 不注入死代码(可能破坏代码) 117 | debugProtection: false, // 不启用调试保护(可能影响开发) 118 | debugProtectionInterval: 0, 119 | disableConsoleOutput: false, // 允许 console 输出 120 | identifierNamesGenerator: 'hexadecimal', // 使用十六进制标识符 121 | log: false, 122 | numbersToExpressions: false, // 不转换数字为表达式(可能破坏代码) 123 | renameGlobals: false, // 不重命名全局变量(避免破坏外部引用) 124 | selfDefending: false, // 不启用自我防御(可能破坏代码) 125 | simplify: true, // 简化代码 126 | splitStrings: false, // 不分割字符串(可能破坏代码) 127 | stringArray: true, // 使用字符串数组 128 | stringArrayCallsTransform: false, // 不转换字符串数组调用(避免性能问题) 129 | stringArrayEncoding: ['base64'], // 使用 base64 编码字符串 130 | stringArrayIndexShift: true, 131 | stringArrayRotate: true, 132 | stringArrayShuffle: true, 133 | stringArrayWrappersCount: 1, 134 | stringArrayWrappersChainedCalls: true, 135 | stringArrayWrappersParametersMaxCount: 2, 136 | stringArrayWrappersType: 'variable', 137 | stringArrayThreshold: 0.75, 138 | unicodeEscapeSequence: false, // 不使用 Unicode 转义(保持可读性) 139 | } 140 | }) 141 | ], 142 | })); 143 | -------------------------------------------------------------------------------- /src-tauri/src/crypto_keys.rs: -------------------------------------------------------------------------------- 1 | /// 加密密钥模块 2 | /// 3 | /// 此模块包含硬编码的 RSA 公钥,用于客户端加密 4 | /// 公钥经过混淆处理以增加逆向工程的难度 5 | 6 | /// 混淆的公钥数据 7 | /// 8 | /// 使用 XOR 混淆,密钥为 0x5A 9 | /// 原始数据为 Base64 编码的公钥(去除 PEM 头尾) 10 | const OBFUSCATED_PUBLIC_KEY: &[u8] = &[ 11 | 0x17, 0x13, 0x13, 0x18, 0x13, 0x30, 0x1b, 0x14, 0x18, 0x3d, 0x31, 0x2b, 0x32, 0x31, 0x33, 0x1d, // MIIBIjANBgkqhkiG 12 | 0x63, 0x2d, 0x6a, 0x18, 0x1b, 0x0b, 0x1f, 0x1c, 0x1b, 0x1b, 0x15, 0x19, 0x1b, 0x0b, 0x62, 0x1b, // 9w0BAQEFAAOCAQ8A 13 | 0x17, 0x13, 0x13, 0x18, 0x19, 0x3d, 0x11, 0x19, 0x1b, 0x0b, 0x1f, 0x1b, 0x68, 0x1d, 0x39, 0x3f, // MIIBCgKCAQEA2Gce 14 | 0x2d, 0x12, 0x0c, 0x38, 0x2c, 0x18, 0x3b, 0x03, 0x09, 0x6c, 0x31, 0x28, 0x0c, 0x29, 0x0b, 0x0b, // wHVbvBaYS6krVsQQ 15 | 0x1d, 0x1f, 0x2e, 0x75, 0x10, 0x29, 0x28, 0x36, 0x09, 0x6d, 0x00, 0x03, 0x09, 0x0f, 0x32, 0x1f, // GEt/JsrlS7ZYSUhE 16 | 0x18, 0x0f, 0x30, 0x28, 0x1d, 0x3f, 0x14, 0x18, 0x34, 0x18, 0x1e, 0x3d, 0x09, 0x00, 0x1c, 0x2b, // BUjrGeNBnBDgSZFq 17 | 0x22, 0x6d, 0x6b, 0x2f, 0x2d, 0x08, 0x6a, 0x22, 0x10, 0x2d, 0x20, 0x2a, 0x1e, 0x29, 0x12, 0x2e, // x71uwR0xJwzpDsHt 18 | 0x69, 0x2c, 0x22, 0x32, 0x1b, 0x6b, 0x0f, 0x1c, 0x71, 0x32, 0x3f, 0x3f, 0x75, 0x6c, 0x1d, 0x29, // 3vxhA1UF+hee/6Gs 19 | 0x37, 0x1c, 0x0b, 0x0f, 0x2b, 0x14, 0x23, 0x22, 0x1f, 0x38, 0x28, 0x02, 0x0d, 0x30, 0x10, 0x0f, // mFQUqNyxEbrXWjJU 20 | 0x09, 0x17, 0x16, 0x34, 0x6f, 0x6e, 0x6a, 0x28, 0x2d, 0x32, 0x18, 0x2a, 0x22, 0x31, 0x08, 0x35, // SMLn540rwhBpxkRo 21 | 0x3c, 0x2c, 0x12, 0x20, 0x2b, 0x28, 0x68, 0x39, 0x12, 0x71, 0x0c, 0x6a, 0x3f, 0x6f, 0x18, 0x20, // fvHzqr2cH+V0e5Bz 22 | 0x20, 0x29, 0x68, 0x68, 0x37, 0x3b, 0x3f, 0x63, 0x02, 0x3e, 0x2a, 0x13, 0x2c, 0x0b, 0x0e, 0x62, // zs22mae9XdpIvQT8 23 | 0x28, 0x0d, 0x32, 0x3b, 0x13, 0x16, 0x38, 0x20, 0x28, 0x6d, 0x37, 0x1e, 0x1f, 0x1d, 0x1b, 0x6a, // rWhaILbzr7mDEGA0 24 | 0x19, 0x0e, 0x12, 0x29, 0x1f, 0x2b, 0x39, 0x39, 0x37, 0x2e, 0x68, 0x14, 0x2c, 0x6e, 0x20, 0x69, // CTHsEqccmt2Nv4z3 25 | 0x37, 0x6b, 0x2a, 0x71, 0x62, 0x69, 0x3d, 0x22, 0x1f, 0x02, 0x36, 0x3c, 0x29, 0x32, 0x12, 0x2d, // m1p+83gxEXlfshHw 26 | 0x2e, 0x17, 0x29, 0x20, 0x31, 0x6f, 0x6e, 0x2b, 0x00, 0x1b, 0x15, 0x2a, 0x3f, 0x1e, 0x75, 0x1b, // tMszk54qZAOpeD/A 27 | 0x6a, 0x69, 0x2f, 0x71, 0x3d, 0x0c, 0x69, 0x11, 0x3f, 0x0b, 0x30, 0x37, 0x35, 0x6a, 0x68, 0x13, // 03u+gV3KeQjmo02I 28 | 0x28, 0x0b, 0x14, 0x37, 0x0d, 0x3e, 0x18, 0x0e, 0x68, 0x00, 0x30, 0x62, 0x32, 0x10, 0x2f, 0x1d, // rQNmWdBT2Zj8hJuG 29 | 0x1e, 0x35, 0x0c, 0x31, 0x0e, 0x0d, 0x3d, 0x29, 0x3b, 0x6a, 0x6b, 0x1d, 0x33, 0x35, 0x3e, 0x1f, // DoVkTWgsa01GiodE 30 | 0x3e, 0x75, 0x0b, 0x29, 0x15, 0x2a, 0x2d, 0x16, 0x36, 0x3c, 0x6b, 0x63, 0x6b, 0x0b, 0x14, 0x12, // d/QsOpwLlf191QNH 31 | 0x08, 0x3d, 0x0b, 0x14, 0x0a, 0x6d, 0x38, 0x23, 0x0f, 0x0f, 0x2c, 0x1f, 0x71, 0x2b, 0x14, 0x2d, // RgQNP7byUUvE+qNw 32 | 0x23, 0x19, 0x3d, 0x23, 0x3d, 0x29, 0x09, 0x3f, 0x11, 0x36, 0x3e, 0x32, 0x0f, 0x2a, 0x1d, 0x75, // yCgygsSeKldhUpG/ 33 | 0x6a, 0x15, 0x63, 0x1b, 0x0a, 0x69, 0x08, 0x0e, 0x6b, 0x6a, 0x3b, 0x29, 0x30, 0x6c, 0x31, 0x2f, // 0O9AP3RT10asj6ku 34 | 0x37, 0x29, 0x63, 0x3b, 0x33, 0x75, 0x10, 0x02, 0x2c, 0x2f, 0x2e, 0x13, 0x08, 0x6c, 0x0b, 0x36, // ms9ai/JXvutIR6Ql 35 | 0x30, 0x0b, 0x13, 0x1e, 0x1b, 0x0b, 0x1b, 0x18, // jQIDAQAB 36 | ]; 37 | 38 | /// XOR 混淆密钥 39 | const XOR_KEY: u8 = 0x5A; 40 | 41 | /// 解混淆并获取 RSA 公钥 42 | /// 43 | /// # Returns 44 | /// 45 | /// 返回 PEM 格式的 RSA 公钥字符串 46 | pub fn get_rsa_public_key() -> String { 47 | // 解混淆 48 | let deobfuscated: Vec = OBFUSCATED_PUBLIC_KEY 49 | .iter() 50 | .map(|&b| b ^ XOR_KEY) 51 | .collect(); 52 | 53 | // 转换为字符串 54 | let base64_key = String::from_utf8(deobfuscated) 55 | .expect("Failed to decode obfuscated key"); 56 | 57 | // 按照 PEM 标准格式化(每行 64 字符) 58 | let mut formatted_lines = Vec::new(); 59 | let chars: Vec = base64_key.chars().collect(); 60 | 61 | for chunk in chars.chunks(64) { 62 | formatted_lines.push(chunk.iter().collect::()); 63 | } 64 | 65 | let formatted_key = formatted_lines.join("\n"); 66 | 67 | // 构建 PEM 格式 68 | format!( 69 | "-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----", 70 | formatted_key 71 | ) 72 | } 73 | 74 | #[cfg(test)] 75 | mod tests { 76 | use super::*; 77 | 78 | #[test] 79 | fn test_deobfuscate_key() { 80 | let key = get_rsa_public_key(); 81 | println!("解混淆后的公钥:\n{}", key); 82 | assert!(key.starts_with("-----BEGIN PUBLIC KEY-----")); 83 | assert!(key.ends_with("-----END PUBLIC KEY-----")); 84 | 85 | // 移除所有空白字符后检查 86 | let key_no_whitespace: String = key.chars().filter(|c| !c.is_whitespace()).collect(); 87 | println!("无空白字符的公钥: {}", key_no_whitespace); 88 | 89 | // 检查关键部分 90 | assert!(key_no_whitespace.contains("-----BEGINPUBLICKEY-----")); 91 | assert!(key_no_whitespace.contains("-----ENDPUBLICKEY-----")); 92 | assert!(key_no_whitespace.contains("MIIBIjANBgkqhkiG9w0BAQE")); 93 | assert!(key_no_whitespace.contains("2GcewHVbvBaYS6krVsQQG")); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/styles/sftp.css: -------------------------------------------------------------------------------- 1 | /** 2 | * SFTP Page Styles 3 | * Modern, aesthetic, and theme-compatible 4 | */ 5 | 6 | /* Container Layout */ 7 | .sftp-page-container { 8 | height: 100%; 9 | display: flex; 10 | flex-direction: column; 11 | background: var(--bg-secondary); 12 | border-radius: var(--border-radius-lg); 13 | border: 1px solid var(--border-color); 14 | overflow: hidden; 15 | box-shadow: var(--shadow-sm); 16 | transition: all 0.3s ease; 17 | } 18 | 19 | /* Header */ 20 | .sftp-header { 21 | padding: var(--spacing-md) var(--spacing-lg); 22 | border-bottom: 1px solid var(--border-color); 23 | background: var(--bg-tertiary); 24 | display: flex; 25 | align-items: center; 26 | justify-content: space-between; 27 | height: 60px; 28 | } 29 | 30 | .sftp-title { 31 | display: flex; 32 | align-items: center; 33 | gap: var(--spacing-sm); 34 | font-weight: 600; 35 | font-size: 16px; 36 | color: var(--text-primary); 37 | } 38 | 39 | .sftp-actions { 40 | display: flex; 41 | gap: var(--spacing-sm); 42 | } 43 | 44 | /* Toolbar & Navigation */ 45 | .sftp-toolbar { 46 | padding: var(--spacing-sm) var(--spacing-lg); 47 | border-bottom: 1px solid var(--border-color); 48 | background: var(--bg-primary); 49 | display: flex; 50 | align-items: center; 51 | gap: var(--spacing-md); 52 | height: 50px; 53 | } 54 | 55 | .sftp-nav-controls { 56 | display: flex; 57 | gap: var(--spacing-xs); 58 | } 59 | 60 | .sftp-breadcrumb-bar { 61 | flex: 1; 62 | display: flex; 63 | align-items: center; 64 | background: var(--bg-secondary); 65 | border: 1px solid var(--border-color); 66 | border-radius: var(--border-radius); 67 | padding: 4px 12px; 68 | height: 32px; 69 | transition: border-color 0.2s ease, box-shadow 0.2s ease; 70 | } 71 | 72 | .sftp-breadcrumb-bar:focus-within { 73 | border-color: var(--primary-color); 74 | box-shadow: 0 0 0 2px var(--primary-color-alpha-20); 75 | } 76 | 77 | .sftp-path-input { 78 | width: 100%; 79 | background: transparent; 80 | border: none; 81 | outline: none; 82 | color: var(--text-primary); 83 | font-size: 13px; 84 | font-family: 'JetBrains Mono', monospace; 85 | } 86 | 87 | /* File List */ 88 | .sftp-file-list-container { 89 | flex: 1; 90 | overflow-y: auto; 91 | background: var(--bg-primary); 92 | position: relative; 93 | } 94 | 95 | .sftp-table { 96 | width: 100%; 97 | border-collapse: collapse; 98 | table-layout: fixed; 99 | } 100 | 101 | .sftp-table th { 102 | position: sticky; 103 | top: 0; 104 | background: var(--bg-tertiary); 105 | padding: var(--spacing-sm) var(--spacing-md); 106 | text-align: left; 107 | font-size: 12px; 108 | font-weight: 600; 109 | color: var(--text-secondary); 110 | border-bottom: 1px solid var(--border-color); 111 | z-index: 10; 112 | backdrop-filter: blur(8px); 113 | } 114 | 115 | .sftp-table td { 116 | padding: var(--spacing-sm) var(--spacing-md); 117 | border-bottom: 1px solid var(--border-color-light); 118 | color: var(--text-primary); 119 | font-size: 13px; 120 | white-space: nowrap; 121 | overflow: hidden; 122 | text-overflow: ellipsis; 123 | transition: color 0.2s ease; 124 | } 125 | 126 | .sftp-file-row { 127 | cursor: pointer; 128 | transition: background-color 0.15s ease; 129 | } 130 | 131 | .sftp-file-row:hover { 132 | background-color: var(--bg-secondary); 133 | } 134 | 135 | .sftp-file-row.selected { 136 | background-color: var(--primary-color-alpha-10); 137 | } 138 | 139 | .file-icon-cell { 140 | display: flex; 141 | align-items: center; 142 | gap: 12px; 143 | } 144 | 145 | .file-icon { 146 | font-size: 18px; 147 | width: 24px; 148 | display: flex; 149 | align-items: center; 150 | justify-content: center; 151 | } 152 | 153 | .file-name { 154 | font-weight: 500; 155 | } 156 | 157 | /* Status Bar */ 158 | .sftp-status-bar { 159 | height: 32px; 160 | background: var(--bg-tertiary); 161 | border-top: 1px solid var(--border-color); 162 | padding: 0 var(--spacing-lg); 163 | display: flex; 164 | align-items: center; 165 | justify-content: space-between; 166 | font-size: 11px; 167 | color: var(--text-secondary); 168 | } 169 | 170 | .status-item { 171 | display: flex; 172 | align-items: center; 173 | gap: 6px; 174 | } 175 | 176 | /* Scrollbar Customization */ 177 | .sftp-file-list-container::-webkit-scrollbar { 178 | width: 8px; 179 | height: 8px; 180 | } 181 | 182 | .sftp-file-list-container::-webkit-scrollbar-track { 183 | background: transparent; 184 | } 185 | 186 | .sftp-file-list-container::-webkit-scrollbar-thumb { 187 | background: var(--scrollbar-thumb); 188 | border-radius: 4px; 189 | } 190 | 191 | .sftp-file-list-container::-webkit-scrollbar-thumb:hover { 192 | background: var(--scrollbar-thumb-hover); 193 | } 194 | 195 | /* Animations */ 196 | @keyframes fadeIn { 197 | from { opacity: 0; transform: translateY(5px); } 198 | to { opacity: 1; transform: translateY(0); } 199 | } 200 | 201 | .sftp-file-row { 202 | animation: fadeIn 0.2s ease forwards; 203 | animation-delay: calc(var(--row-index, 0) * 0.02s); 204 | } 205 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | LovelyERes Logo 3 | 4 | # LovelyERes 5 | 6 | **Linux 应急响应工具** 7 | 8 | 一款专为快速服务器管理和应急响应设计的现代化、高性能 SSH 终端及诊断工具箱。 9 | 10 | [![Tauri](https://img.shields.io/badge/Tauri-v2.0-24C8DB?style=flat-square&logo=tauri&logoColor=white)](https://tauri.app) 11 | [![Vue](https://img.shields.io/badge/Vue.js-v3.5-4FC08D?style=flat-square&logo=vue.js&logoColor=white)](https://vuejs.org) 12 | [![Rust](https://img.shields.io/badge/Rust-Backend-000000?style=flat-square&logo=rust&logoColor=white)](https://www.rust-lang.org) 13 | [![TypeScript](https://img.shields.io/badge/TypeScript-5.0-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org) 14 | [![License](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](LICENSE) 15 | 16 | [功能特性](#-功能特性) • [技术栈](#-技术栈) • [快速开始](#-快速开始) • [开发计划](#-开发计划) 17 |
18 | 19 | --- 20 | 21 | ## 📖 简介 22 | 23 | **LovelyERes** (Lovely Emergency Response) 是一款专为应急响应、CTF 比赛和日常运维设计的多功能桌面应用。与标准的 SSH 客户端不同,LovelyERes 专为 **应急响应场景和攻防演练** 优化,提供了一个稳健、安全且高效的环境,用于快速诊断、修复 Linux 服务器问题,同时也能胜任日常运维管理工作。 24 | 25 | 基于 **Tauri v2** 框架构建,它结合了轻量级的原生占用和 **Vue 3** 带来的现代化 UI 体验。 26 | 27 | ## 支持 28 | 29 | **如果觉得好用请支持我一下** 30 | 31 | image 32 | 33 | 34 | ## ✨ 功能截图 35 | 36 | ### 仪表盘界面 37 | image 38 | 39 | ### 系统信息 40 | image 41 | image 42 | 43 | ### SFTP管理 44 | image 45 | 46 | ### Docker容器管理 47 | image 48 | 49 | ### 常用命令快速执行 50 | image 51 | image 52 | 53 | ### 快速检测 54 | image 55 | 56 | ### AI分析功能 57 | image 58 | 59 | ### SSH终端 60 | image 61 | 62 | image 63 | 64 | image 65 | 66 | 67 | 68 | 69 | 70 | ## 🛠 技术栈 71 | 72 | | 组件 | 技术 | 说明 | 73 | |-----------|------------|-------------| 74 | | **核心框架** | [Tauri v2](https://tauri.app) | 构建轻量级、快速的二进制应用框架 | 75 | | **前端框架** | [Vue 3](https://vuejs.org) | 响应式 UI 框架 | 76 | | **构建工具** | [Vite](https://vitejs.dev) | 下一代前端构建工具 | 77 | | **开发语言** | [TypeScript](https://www.typescriptlang.org) | 类型安全的 JavaScript | 78 | | **后端逻辑** | [Rust](https://www.rust-lang.org) | 用于核心逻辑的系统级编程语言 | 79 | | **终端组件** | [xterm.js](https://xtermjs.org) | 全功能终端组件 | 80 | | **图标库** | [IconPark](https://iconpark.bytedance.com) | 丰富的图标资源库 | 81 | 82 | ## 📂 项目结构 83 | 84 | ```bash 85 | LovelyRes/ 86 | ├── src/ # 前端源码 (Vue 3) 87 | │ ├── components/ # UI 组件 (SSHTerminal, etc.) 88 | │ ├── config/ # 应用配置 89 | │ ├── css/ # 全局样式 & 主题 90 | │ └── App.vue # 主入口组件 91 | ├── src-tauri/ # 后端源码 (Rust) 92 | │ ├── src/ 93 | │ │ ├── ssh/ # SSH 实现 94 | │ │ ├── crypto_keys.rs# 加密逻辑 95 | │ │ └── detection_manager.rs 96 | │ ├── capabilities/ # Tauri 权限配置 97 | │ └── tauri.conf.json # Tauri 配置 98 | ├── public/ # 静态资源 (Logos, Icons) 99 | └── doc/ # 文档 100 | ``` 101 | 102 | ## 🚀 快速开始 103 | 104 | ### 环境要求 105 | 106 | - **Node.js** (v18+) 107 | - **Rust** (最新稳定版) 108 | - **Visual Studio Code** (推荐) 配合 Rust Analyzer & Volar 插件 109 | 110 | ### 安装步骤 111 | 112 | 1. **克隆仓库** 113 | ```bash 114 | git clone https://github.com/Tokeii0/LovelyERes.git 115 | cd lovelyres 116 | ``` 117 | 118 | 2. **安装依赖** 119 | ```bash 120 | npm install 121 | ``` 122 | 123 | 3. **运行开发模式** 124 | 该命令将启动前端开发服务器和 Tauri Rust 后端。 125 | ```bash 126 | npm run tauri dev 127 | ``` 128 | 129 | 4. **构建生产版本** 130 | ```bash 131 | npm run tauri build 132 | ``` 133 | 134 | ## 🤝 贡献指南 135 | 136 | 欢迎提交 Pull Request 来参与贡献! 137 | 138 | 1. Fork 本项目 139 | 2. 创建您的特性分支 (`git checkout -b feature/AmazingFeature`) 140 | 3. 提交您的更改 (`git commit -m 'Add some AmazingFeature'`) 141 | 4. 推送到分支 (`git push origin feature/AmazingFeature`) 142 | 5. 开启一个 Pull Request 143 | 144 | ## 📜 开源协议 145 | 146 | 本项目基于 AGPLv3 协议开源。详情请参阅 `LICENSE` 文件。 147 | 148 | --- 149 | 150 |
151 | Built with ❤️ by the Tokeii 152 |
153 | -------------------------------------------------------------------------------- /src-tauri/src/device_info.rs: -------------------------------------------------------------------------------- 1 | // 设备信息模块 2 | // 用于获取设备的唯一标识符(UUID) 3 | 4 | use serde::{Deserialize, Serialize}; 5 | use std::process::Command; 6 | 7 | // Windows 平台需要导入 CommandExt 来隐藏控制台窗口 8 | #[cfg(target_os = "windows")] 9 | use std::os::windows::process::CommandExt; 10 | 11 | #[derive(Debug, Clone, Serialize, Deserialize)] 12 | pub struct DeviceInfo { 13 | pub device_uuid: String, 14 | pub device_type: String, 15 | pub device_name: String, 16 | } 17 | 18 | /// 获取 Windows 设备 UUID 19 | #[cfg(target_os = "windows")] 20 | fn get_windows_uuid() -> Result { 21 | // 使用 PowerShell 获取主板 UUID(推荐方式,兼容最新 Windows 版本) 22 | // CREATE_NO_WINDOW = 0x08000000 用于隐藏控制台窗口 23 | const CREATE_NO_WINDOW: u32 = 0x08000000; 24 | 25 | let output = Command::new("powershell") 26 | .args(&[ 27 | "-NoProfile", 28 | "-Command", 29 | "(Get-CimInstance -ClassName Win32_ComputerSystemProduct).UUID" 30 | ]) 31 | .creation_flags(CREATE_NO_WINDOW) // 隐藏控制台窗口 32 | .output() 33 | .map_err(|e| format!("执行 PowerShell 命令失败: {}", e))?; 34 | 35 | if !output.status.success() { 36 | return Err("PowerShell 命令执行失败".to_string()); 37 | } 38 | 39 | let uuid = String::from_utf8_lossy(&output.stdout).trim().to_string(); 40 | 41 | if !uuid.is_empty() { 42 | return Ok(uuid); 43 | } 44 | 45 | Err("无法获取 Windows UUID".to_string()) 46 | } 47 | 48 | /// 获取 macOS 设备 UUID 49 | #[cfg(target_os = "macos")] 50 | fn get_macos_uuid() -> Result { 51 | // 使用 ioreg 获取硬件 UUID 52 | let output = Command::new("ioreg") 53 | .args(&["-rd1", "-c", "IOPlatformExpertDevice"]) 54 | .output() 55 | .map_err(|e| format!("执行 ioreg 命令失败: {}", e))?; 56 | 57 | if !output.status.success() { 58 | return Err("ioreg 命令执行失败".to_string()); 59 | } 60 | 61 | let output_str = String::from_utf8_lossy(&output.stdout); 62 | 63 | // 查找 IOPlatformUUID 64 | for line in output_str.lines() { 65 | if line.contains("IOPlatformUUID") { 66 | // 提取 UUID 值 67 | if let Some(uuid_part) = line.split('"').nth(3) { 68 | return Ok(uuid_part.to_string()); 69 | } 70 | } 71 | } 72 | 73 | Err("无法获取 macOS UUID".to_string()) 74 | } 75 | 76 | /// 获取 Linux 设备 UUID 77 | #[cfg(target_os = "linux")] 78 | fn get_linux_uuid() -> Result { 79 | // 尝试从 /etc/machine-id 读取 80 | if let Ok(machine_id) = std::fs::read_to_string("/etc/machine-id") { 81 | return Ok(machine_id.trim().to_string()); 82 | } 83 | 84 | // 尝试从 /var/lib/dbus/machine-id 读取 85 | if let Ok(machine_id) = std::fs::read_to_string("/var/lib/dbus/machine-id") { 86 | return Ok(machine_id.trim().to_string()); 87 | } 88 | 89 | // 尝试使用 dmidecode 获取主板 UUID(需要 root 权限) 90 | let output = Command::new("dmidecode") 91 | .args(&["-s", "system-uuid"]) 92 | .output() 93 | .map_err(|e| format!("执行 dmidecode 命令失败: {}", e))?; 94 | 95 | if output.status.success() { 96 | let uuid = String::from_utf8_lossy(&output.stdout).trim().to_string(); 97 | if !uuid.is_empty() { 98 | return Ok(uuid); 99 | } 100 | } 101 | 102 | Err("无法获取 Linux UUID".to_string()) 103 | } 104 | 105 | /// 获取设备类型 106 | fn get_device_type() -> String { 107 | #[cfg(target_os = "windows")] 108 | return "windows".to_string(); 109 | 110 | #[cfg(target_os = "macos")] 111 | return "macos".to_string(); 112 | 113 | #[cfg(target_os = "linux")] 114 | return "linux".to_string(); 115 | 116 | #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] 117 | return "unknown".to_string(); 118 | } 119 | 120 | /// 获取设备名称 121 | fn get_device_name() -> String { 122 | #[cfg(target_os = "windows")] 123 | { 124 | const CREATE_NO_WINDOW: u32 = 0x08000000; 125 | 126 | if let Ok(output) = Command::new("hostname") 127 | .creation_flags(CREATE_NO_WINDOW) // 隐藏控制台窗口 128 | .output() 129 | { 130 | if output.status.success() { 131 | return String::from_utf8_lossy(&output.stdout).trim().to_string(); 132 | } 133 | } 134 | } 135 | 136 | #[cfg(target_os = "macos")] 137 | { 138 | if let Ok(output) = Command::new("scutil") 139 | .args(&["--get", "ComputerName"]) 140 | .output() 141 | { 142 | if output.status.success() { 143 | return String::from_utf8_lossy(&output.stdout).trim().to_string(); 144 | } 145 | } 146 | } 147 | 148 | #[cfg(target_os = "linux")] 149 | { 150 | if let Ok(hostname) = std::fs::read_to_string("/etc/hostname") { 151 | return hostname.trim().to_string(); 152 | } 153 | if let Ok(output) = Command::new("hostname").output() { 154 | if output.status.success() { 155 | return String::from_utf8_lossy(&output.stdout).trim().to_string(); 156 | } 157 | } 158 | } 159 | 160 | "Unknown Device".to_string() 161 | } 162 | 163 | /// 获取设备信息 164 | pub fn get_device_info() -> Result { 165 | let device_uuid = { 166 | #[cfg(target_os = "windows")] 167 | { 168 | get_windows_uuid()? 169 | } 170 | 171 | #[cfg(target_os = "macos")] 172 | { 173 | get_macos_uuid()? 174 | } 175 | 176 | #[cfg(target_os = "linux")] 177 | { 178 | get_linux_uuid()? 179 | } 180 | 181 | #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] 182 | { 183 | return Err("不支持的操作系统".to_string()); 184 | } 185 | }; 186 | 187 | Ok(DeviceInfo { 188 | device_uuid, 189 | device_type: get_device_type(), 190 | device_name: get_device_name(), 191 | }) 192 | } 193 | 194 | /// Tauri 命令:获取设备信息 195 | #[tauri::command] 196 | pub async fn get_device_uuid() -> Result { 197 | get_device_info() 198 | } 199 | 200 | -------------------------------------------------------------------------------- /src/modules/auth/authGuard.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 认证守卫 3 | * 用于检查用户是否已登录,未登录则显示登录弹窗 4 | */ 5 | 6 | 7 | export class AuthGuard { 8 | private static instance: AuthGuard; 9 | 10 | private constructor() {} 11 | 12 | /** 13 | * 获取单例实例 14 | */ 15 | public static getInstance(): AuthGuard { 16 | if (!AuthGuard.instance) { 17 | AuthGuard.instance = new AuthGuard(); 18 | } 19 | return AuthGuard.instance; 20 | } 21 | 22 | /** 23 | * 检查用户是否已登录 24 | * @returns 是否已登录 25 | */ 26 | public isAuthenticated(): boolean { 27 | return true; 28 | } 29 | 30 | /** 31 | * 要求用户登录 32 | * 如果未登录,显示登录弹窗并返回 false 33 | * 如果已登录,返回 true 34 | * @param message 提示消息 35 | * @returns 是否已登录 36 | */ 37 | public requireAuth(_message?: string): boolean { 38 | return true; 39 | } 40 | 41 | /** 42 | * 显示需要登录的提示消息 43 | * @param message 提示消息 44 | */ 45 | /* 46 | private showAuthRequiredMessage(message: string): void { 47 | // 创建提示元素 48 | const toast = document.createElement('div'); 49 | toast.className = 'auth-required-toast'; 50 | toast.style.cssText = ` 51 | position: fixed; 52 | top: 80px; 53 | right: 20px; 54 | background: var(--bg-primary); 55 | border: 1px solid var(--border-color); 56 | border-left: 4px solid var(--warning-color); 57 | border-radius: var(--border-radius); 58 | padding: 12px 16px; 59 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 60 | z-index: 9999; 61 | display: flex; 62 | align-items: center; 63 | gap: 10px; 64 | animation: slideInRight 0.3s ease-out; 65 | max-width: 300px; 66 | `; 67 | 68 | toast.innerHTML = ` 69 |
79 | 80 | 81 | 82 |
83 |
84 |
85 | 需要登录 86 |
87 |
88 | ${message} 89 |
90 |
91 | 107 | `; 108 | 109 | // 添加动画样式 110 | if (!document.getElementById('auth-guard-styles')) { 111 | const style = document.createElement('style'); 112 | style.id = 'auth-guard-styles'; 113 | style.textContent = ` 114 | @keyframes slideInRight { 115 | from { 116 | transform: translateX(100%); 117 | opacity: 0; 118 | } 119 | to { 120 | transform: translateX(0); 121 | opacity: 1; 122 | } 123 | } 124 | 125 | @keyframes slideOutRight { 126 | from { 127 | transform: translateX(0); 128 | opacity: 1; 129 | } 130 | to { 131 | transform: translateX(100%); 132 | opacity: 0; 133 | } 134 | } 135 | `; 136 | document.head.appendChild(style); 137 | } 138 | 139 | // 添加到页面 140 | document.body.appendChild(toast); 141 | 142 | // 3秒后自动移除 143 | setTimeout(() => { 144 | toast.style.animation = 'slideOutRight 0.3s ease-out'; 145 | setTimeout(() => { 146 | toast.remove(); 147 | }, 300); 148 | }, 3000); 149 | } 150 | */ 151 | 152 | /** 153 | * 包装需要认证的函数 154 | * @param fn 需要认证的函数 155 | * @param message 提示消息 156 | * @returns 包装后的函数 157 | */ 158 | public withAuth any>( 159 | fn: T, 160 | message?: string 161 | ): T { 162 | return ((...args: any[]) => { 163 | if (this.requireAuth(message)) { 164 | return fn(...args); 165 | } 166 | return undefined; 167 | }) as T; 168 | } 169 | 170 | /** 171 | * 为元素添加认证检查 172 | * @param element 元素 173 | * @param message 提示消息 174 | */ 175 | public protectElement(element: HTMLElement, message?: string): void { 176 | const originalOnClick = element.onclick; 177 | 178 | element.onclick = (event) => { 179 | if (!this.requireAuth(message)) { 180 | event.preventDefault(); 181 | event.stopPropagation(); 182 | return false; 183 | } 184 | 185 | if (originalOnClick) { 186 | return originalOnClick.call(element, event); 187 | } 188 | 189 | return true; 190 | }; 191 | } 192 | 193 | /** 194 | * 批量保护元素 195 | * @param selector CSS 选择器 196 | * @param message 提示消息 197 | */ 198 | public protectElements(selector: string, message?: string): void { 199 | const elements = document.querySelectorAll(selector); 200 | elements.forEach(element => { 201 | this.protectElement(element, message); 202 | }); 203 | } 204 | } 205 | 206 | // 导出单例实例 207 | export const authGuard = AuthGuard.getInstance(); 208 | 209 | // 全局函数,供 HTML 中使用 210 | (window as any).requireAuth = (message?: string) => { 211 | return authGuard.requireAuth(message); 212 | }; 213 | 214 | -------------------------------------------------------------------------------- /src/styles/system-info.css: -------------------------------------------------------------------------------- 1 | /* System Info Container Styles */ 2 | .system-info-container { 3 | display: flex; 4 | flex-direction: column; 5 | height: 100%; 6 | padding: var(--spacing-md); 7 | gap: var(--spacing-md); 8 | animation: fadeIn 0.3s ease-out; 9 | } 10 | 11 | /* Tabs */ 12 | .system-info-tabs { 13 | display: flex; 14 | gap: var(--spacing-sm); 15 | margin-bottom: var(--spacing-md); 16 | border-bottom: 1px solid var(--border-color); 17 | padding-bottom: 2px; 18 | } 19 | 20 | .tab-btn { 21 | padding: 8px 16px; 22 | border: none; 23 | background: transparent; 24 | color: var(--text-secondary); 25 | border-bottom: 2px solid transparent; 26 | cursor: pointer; 27 | font-weight: 500; 28 | font-size: 13px; 29 | transition: all 0.2s ease; 30 | border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0; 31 | } 32 | 33 | .tab-btn:hover { 34 | color: var(--text-primary); 35 | background: var(--bg-secondary); 36 | } 37 | 38 | .tab-btn.active { 39 | color: var(--primary-color); 40 | border-bottom-color: var(--primary-color); 41 | background: linear-gradient(to top, var(--bg-secondary) 0%, transparent 100%); 42 | } 43 | 44 | /* Content Area */ 45 | .system-info-content { 46 | flex: 1; 47 | overflow: hidden; 48 | display: flex; 49 | flex-direction: column; 50 | } 51 | 52 | /* Table Container Card */ 53 | .info-table-container { 54 | background: var(--bg-secondary); 55 | border: 1px solid var(--border-color); 56 | border-radius: var(--border-radius-lg); 57 | display: flex; 58 | flex-direction: column; 59 | height: 100%; 60 | overflow: hidden; 61 | box-shadow: var(--shadow-sm); 62 | } 63 | 64 | /* Table Header Toolbar */ 65 | .table-header-toolbar { 66 | padding: var(--spacing-md); 67 | border-bottom: 1px solid var(--border-color); 68 | display: flex; 69 | justify-content: space-between; 70 | align-items: center; 71 | background: var(--bg-tertiary); 72 | } 73 | 74 | .table-title { 75 | display: flex; 76 | align-items: center; 77 | gap: 8px; 78 | font-weight: 600; 79 | color: var(--text-primary); 80 | font-size: 14px; 81 | } 82 | 83 | .search-container { 84 | display: flex; 85 | align-items: center; 86 | gap: var(--spacing-sm); 87 | } 88 | 89 | /* Form Elements */ 90 | .system-select, 91 | .system-input { 92 | padding: 6px 10px; 93 | border: 1px solid var(--border-color); 94 | border-radius: var(--border-radius); 95 | background: var(--bg-primary); 96 | color: var(--text-primary); 97 | font-size: 12px; 98 | transition: all 0.2s ease; 99 | outline: none; 100 | } 101 | 102 | .system-select:focus, 103 | .system-input:focus { 104 | border-color: var(--primary-color); 105 | box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1); 106 | } 107 | 108 | .system-btn { 109 | padding: 6px 12px; 110 | border: 1px solid var(--border-color); 111 | border-radius: var(--border-radius); 112 | background: var(--bg-secondary); 113 | color: var(--text-secondary); 114 | font-size: 12px; 115 | cursor: pointer; 116 | transition: all 0.2s ease; 117 | } 118 | 119 | .system-btn:hover { 120 | background: var(--bg-tertiary); 121 | color: var(--text-primary); 122 | border-color: var(--text-secondary); 123 | } 124 | 125 | /* Table Styles */ 126 | .table-content { 127 | flex: 1; 128 | overflow: auto; 129 | position: relative; 130 | } 131 | 132 | .system-table { 133 | width: 100%; 134 | border-collapse: separate; 135 | border-spacing: 0; 136 | font-size: 13px; 137 | } 138 | 139 | .system-table th { 140 | position: sticky; 141 | top: 0; 142 | background: var(--bg-tertiary); 143 | padding: 10px 16px; 144 | text-align: left; 145 | font-weight: 600; 146 | color: var(--text-secondary); 147 | border-bottom: 1px solid var(--border-color); 148 | white-space: nowrap; 149 | z-index: 10; 150 | backdrop-filter: blur(8px); 151 | } 152 | 153 | .system-table td { 154 | padding: 10px 16px; 155 | border-bottom: 1px solid var(--border-color); 156 | color: var(--text-primary); 157 | transition: background 0.15s ease; 158 | } 159 | 160 | .system-table tr:last-child td { 161 | border-bottom: none; 162 | } 163 | 164 | /* 斑马纹背景 */ 165 | .system-table tbody tr:nth-child(odd) td { 166 | background: var(--bg-primary); 167 | } 168 | 169 | .system-table tbody tr:nth-child(even) td { 170 | background: var(--bg-secondary); 171 | } 172 | 173 | /* Hover 效果 */ 174 | .system-table tbody tr:hover td { 175 | background: var(--bg-tertiary) !important; 176 | box-shadow: 0 2px 8px rgba(0,0,0,0.1); 177 | } 178 | 179 | /* 选中效果 */ 180 | .system-table tbody tr.selected td { 181 | background: var(--bg-tertiary) !important; 182 | box-shadow: inset 3px 0 0 var(--primary-color), 0 2px 8px rgba(0,0,0,0.1); 183 | } 184 | 185 | /* Status Badges */ 186 | .status-badge { 187 | display: inline-flex; 188 | align-items: center; 189 | padding: 2px 8px; 190 | border-radius: 12px; 191 | font-size: 11px; 192 | font-weight: 500; 193 | line-height: 1.4; 194 | } 195 | 196 | .status-badge.running, .status-badge.active { 197 | background: rgba(34, 197, 94, 0.1); 198 | color: #22c55e; 199 | border: 1px solid rgba(34, 197, 94, 0.2); 200 | } 201 | 202 | .status-badge.stopped, .status-badge.inactive { 203 | background: rgba(148, 163, 184, 0.1); 204 | color: #94a3b8; 205 | border: 1px solid rgba(148, 163, 184, 0.2); 206 | } 207 | 208 | .status-badge.failed { 209 | background: rgba(239, 68, 68, 0.1); 210 | color: #ef4444; 211 | border: 1px solid rgba(239, 68, 68, 0.2); 212 | } 213 | 214 | .status-badge.sleeping { 215 | background: rgba(59, 130, 246, 0.1); 216 | color: #3b82f6; 217 | border: 1px solid rgba(59, 130, 246, 0.2); 218 | } 219 | 220 | /* Scrollbar */ 221 | .table-content::-webkit-scrollbar { 222 | width: 8px; 223 | height: 8px; 224 | } 225 | 226 | .table-content::-webkit-scrollbar-track { 227 | background: transparent; 228 | } 229 | 230 | .table-content::-webkit-scrollbar-thumb { 231 | background: var(--scrollbar-thumb); 232 | border-radius: 4px; 233 | } 234 | 235 | .table-content::-webkit-scrollbar-thumb:hover { 236 | background: var(--scrollbar-thumb-hover); 237 | } 238 | 239 | .table-content::-webkit-scrollbar-corner { 240 | background: transparent; 241 | } 242 | -------------------------------------------------------------------------------- /src/modules/remote/remoteOperationsManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 远程操作管理器 3 | * 统一协调SSH连接、SFTP文件管理和终端操作 4 | */ 5 | 6 | import { sshConnectionManager, SSHConnectionInfo } from './sshConnectionManager'; 7 | import { sftpManager } from './sftpManager'; 8 | import { terminalManager } from './terminalManager'; 9 | 10 | export class RemoteOperationsManager { 11 | private initialized = false; 12 | private lastConnectionStatus: SSHConnectionInfo | null = null; 13 | private lastSftpPath: string = ''; 14 | private lastSftpFileCount: number = 0; 15 | 16 | /** 17 | * 初始化远程操作管理器 18 | */ 19 | async initialize(): Promise { 20 | if (this.initialized) return; 21 | 22 | // 设置SSH连接状态监听器 23 | sshConnectionManager.addListener(this.onSSHConnectionStatusChanged.bind(this)); 24 | 25 | // 设置SFTP文件列表监听器 26 | sftpManager.addListener(this.onSftpFileListChanged.bind(this)); 27 | 28 | // 设置终端历史监听器 29 | terminalManager.addListener(this.onTerminalHistoryChanged.bind(this)); 30 | 31 | // 检查现有连接状态 32 | await this.checkExistingConnection(); 33 | 34 | this.initialized = true; 35 | console.log('✅ 远程操作管理器初始化完成'); 36 | } 37 | 38 | /** 39 | * 检查现有连接状态 40 | */ 41 | private async checkExistingConnection(): Promise { 42 | try { 43 | await sshConnectionManager.checkConnectionStatus(); 44 | const status = sshConnectionManager.getConnectionStatus(); 45 | 46 | if (status?.connected) { 47 | console.log('🔗 发现现有SSH连接:', status); 48 | // 刷新SFTP文件列表 49 | await sftpManager.refreshFileList(); 50 | // 更新终端状态 51 | terminalManager.updateTerminalDisplay(); 52 | } 53 | } catch (error) { 54 | console.error('检查现有连接状态失败:', error); 55 | } 56 | } 57 | 58 | /** 59 | * SSH连接状态变化处理 60 | */ 61 | private onSSHConnectionStatusChanged(status: SSHConnectionInfo | null): void { 62 | // 只在"连接/断开"状态真正变化时作出反应,避免因 lastActivity 变化触发刷新 63 | const prevConnected = this.lastConnectionStatus?.connected || false; 64 | const nextConnected = status?.connected || false; 65 | 66 | const stateChanged = prevConnected !== nextConnected; 67 | 68 | if (stateChanged) { 69 | console.log('🔄 SSH连接状态变化:', { 70 | from: prevConnected ? '已连接' : '未连接', 71 | to: nextConnected ? '已连接' : '未连接', 72 | host: status?.host 73 | }); 74 | this.lastConnectionStatus = status; 75 | 76 | if (nextConnected) { 77 | // 仅在从未连接 -> 已连接时刷新 78 | this.refreshRemoteOperations(); 79 | } else { 80 | // 仅在从已连接 -> 未连接时清理 81 | this.clearRemoteOperations(); 82 | } 83 | } else { 84 | // 连接状态未变化(例如仅 lastActivity 更新),不触发任何刷新,保持静默 85 | } 86 | } 87 | 88 | /** 89 | * SFTP文件列表变化处理 90 | */ 91 | private onSftpFileListChanged(files: any[], path: string): void { 92 | // 只在路径变化或文件数量显著变化时记录日志 93 | const lastPath = this.lastSftpPath; 94 | const lastFileCount = this.lastSftpFileCount; 95 | 96 | if (lastPath !== path || Math.abs(lastFileCount - files.length) > 5) { 97 | console.log('📁 SFTP文件列表更新:', { 98 | path, 99 | fileCount: files.length, 100 | changed: lastPath !== path ? '路径变化' : '文件数量变化' 101 | }); 102 | this.lastSftpPath = path; 103 | this.lastSftpFileCount = files.length; 104 | } 105 | 106 | this.updateSftpDisplay(); 107 | } 108 | 109 | /** 110 | * 终端历史变化处理 111 | */ 112 | private onTerminalHistoryChanged(history: any[]): void { 113 | console.log('💻 终端历史更新:', { commandCount: history.length }); 114 | terminalManager.updateTerminalDisplay(); 115 | } 116 | 117 | /** 118 | * 刷新远程操作(连接成功后调用) 119 | */ 120 | private async refreshRemoteOperations(): Promise { 121 | try { 122 | // 刷新SFTP文件列表 123 | await sftpManager.refreshFileList(); 124 | 125 | // 更新终端状态 126 | terminalManager.updateTerminalDisplay(); 127 | 128 | // 更新UI显示 129 | this.updateSftpDisplay(); 130 | 131 | } catch (error) { 132 | console.error('刷新远程操作失败:', error); 133 | } 134 | } 135 | 136 | /** 137 | * 清理远程操作(连接断开后调用) 138 | */ 139 | private clearRemoteOperations(): void { 140 | // 更新SFTP显示 141 | this.updateSftpDisplay(); 142 | 143 | // 更新终端显示 144 | terminalManager.updateTerminalDisplay(); 145 | } 146 | 147 | /** 148 | * 更新SFTP显示 149 | */ 150 | private updateSftpDisplay(): void { 151 | const sftpFileList = document.getElementById('sftp-file-list'); 152 | if (sftpFileList) { 153 | sftpFileList.innerHTML = sftpManager.renderFileListHTML(); 154 | } 155 | 156 | // 更新路径显示 157 | const pathInput = document.querySelector('#sftp-path-input') as HTMLInputElement; 158 | if (pathInput) { 159 | pathInput.value = sftpManager.getCurrentPath(); 160 | } 161 | } 162 | 163 | /** 164 | * 获取SSH连接状态 165 | */ 166 | getSSHConnectionStatus(): SSHConnectionInfo | null { 167 | return sshConnectionManager.getConnectionStatus(); 168 | } 169 | 170 | /** 171 | * 检查是否已连接SSH 172 | */ 173 | isSSHConnected(): boolean { 174 | return sshConnectionManager.isConnected(); 175 | } 176 | 177 | /** 178 | * 刷新SFTP文件列表 179 | */ 180 | async refreshSftpFiles(): Promise { 181 | await sftpManager.refreshFileList(); 182 | } 183 | 184 | /** 185 | * 执行终端命令 186 | */ 187 | async executeTerminalCommand(command: string): Promise { 188 | await terminalManager.executeCommand(command); 189 | } 190 | 191 | /** 192 | * 清空终端历史 193 | */ 194 | clearTerminalHistory(): void { 195 | terminalManager.clearHistory(); 196 | } 197 | 198 | /** 199 | * 导航到SFTP路径 200 | */ 201 | async navigateToSftpPath(path: string): Promise { 202 | await sftpManager.navigateToPath(path); 203 | } 204 | 205 | /** 206 | * 销毁管理器 207 | */ 208 | destroy(): void { 209 | // 移除监听器 210 | sshConnectionManager.removeListener(this.onSSHConnectionStatusChanged.bind(this)); 211 | sftpManager.removeListener(this.onSftpFileListChanged.bind(this)); 212 | terminalManager.removeListener(this.onTerminalHistoryChanged.bind(this)); 213 | 214 | this.initialized = false; 215 | console.log('🗑️ 远程操作管理器已销毁'); 216 | } 217 | } 218 | 219 | // 全局远程操作管理器实例 220 | export const remoteOperationsManager = new RemoteOperationsManager(); 221 | -------------------------------------------------------------------------------- /src/modules/ssh/sshTerminalManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * SSH 终端管理器 3 | * 负责管理 SSH 终端组件的生命周期和状态 4 | */ 5 | 6 | import { createApp, App } from 'vue' 7 | import SSHTerminal from '../../components/SSHTerminal.vue' 8 | 9 | export class SSHTerminalManager { 10 | private vueApp: App | null = null 11 | private isInitialized = false 12 | private containerElement: HTMLElement | null = null 13 | private isVisible: boolean = false 14 | 15 | /** 16 | * 初始化 SSH 终端管理器 17 | */ 18 | async initialize(): Promise { 19 | if (this.isInitialized) { 20 | console.log('SSH 终端管理器已初始化') 21 | return 22 | } 23 | 24 | console.log('🔧 初始化 SSH 终端管理器...') 25 | this.isInitialized = true 26 | console.log('✅ SSH 终端管理器初始化完成') 27 | } 28 | 29 | /** 30 | * 挂载 SSH 终端组件(带重试机制,确保容器可用时再挂载) 31 | */ 32 | mountTerminal(retry = 0): void { 33 | try { 34 | // 如果已经挂载且容器仍然存在,不重复挂载(保持会话持久性) 35 | if (this.isMounted()) { 36 | console.log('✅ SSH 终端组件已存在,保持现有会话') 37 | return 38 | } 39 | 40 | // 查找容器元素 41 | this.containerElement = document.getElementById('ssh-terminal-container') 42 | if (!this.containerElement) { 43 | if (retry < 20) { 44 | const delay = 50; 45 | console.warn(`⚠️ 未找到 SSH 终端容器元素,${delay}ms 后重试(第 ${retry + 1}/20 次)`) 46 | setTimeout(() => this.mountTerminal(retry + 1), delay) 47 | return 48 | } else { 49 | console.error('❌ 未找到 SSH 终端容器元素,多次重试仍失败') 50 | return 51 | } 52 | } 53 | 54 | // 创建 Vue 应用实例 55 | this.vueApp = createApp(SSHTerminal) 56 | 57 | // 挂载到容器 58 | this.vueApp.mount(this.containerElement) 59 | this.isVisible = true 60 | 61 | const childCount = this.containerElement.childElementCount 62 | console.log(`✅ SSH 终端组件已挂载,容器子元素数量: ${childCount}`) 63 | } catch (error) { 64 | console.error('❌ 挂载 SSH 终端组件失败:', error) 65 | } 66 | } 67 | 68 | /** 69 | * 卸载 SSH 终端组件 70 | */ 71 | unmountTerminal(): void { 72 | try { 73 | if (this.vueApp && this.containerElement) { 74 | this.vueApp.unmount() 75 | this.vueApp = null 76 | 77 | // 清空容器内容 78 | this.containerElement.innerHTML = '' 79 | 80 | console.log('✅ SSH 终端组件已卸载') 81 | } 82 | } catch (error) { 83 | console.error('❌ 卸载 SSH 终端组件失败:', error) 84 | } 85 | } 86 | 87 | /** 88 | * 显示 SSH 终端组件(内嵌方案,手动控制显示以保持会话持久性) 89 | */ 90 | showTerminal(): void { 91 | const container = this.containerElement || document.getElementById('ssh-terminal-container'); 92 | if (container) { 93 | (container as HTMLElement).style.display = 'flex'; 94 | (container as HTMLElement).style.flexDirection = 'column'; 95 | console.log('🔧 设置容器显示样式:', (container as HTMLElement).style.cssText); 96 | } else { 97 | console.error('❌ 未找到SSH容器,无法显示'); 98 | } 99 | 100 | this.isVisible = true; 101 | console.log('✅ SSH 终端组件已显示(内嵌方案,会话持久)'); 102 | 103 | // 触发一次 resize,帮助 xterm 自适应 104 | setTimeout(() => { 105 | try { window.dispatchEvent(new Event('resize')); } catch {} 106 | }, 100); // 稍微延长延迟,确保CSS已生效 107 | } 108 | 109 | /** 110 | * 隐藏 SSH 终端组件(内嵌方案,手动控制隐藏以保持会话持久性) 111 | */ 112 | hideTerminal(): void { 113 | const container = this.containerElement || document.getElementById('ssh-terminal-container'); 114 | if (container) { 115 | (container as HTMLElement).style.display = 'none'; 116 | } 117 | 118 | this.isVisible = false; 119 | console.log('✅ SSH 终端组件已隐藏(会话保持)'); 120 | } 121 | 122 | /** 123 | * 检查终端是否已挂载(同时校验 DOM 容器是否存在且包含内容) 124 | */ 125 | isMounted(): boolean { 126 | const container = document.getElementById('ssh-terminal-container'); 127 | const hasContent = !!container && container.childElementCount > 0; 128 | return this.vueApp !== null && !!container && hasContent; 129 | } 130 | 131 | /** 132 | * 检查终端是否可见 133 | */ 134 | isTerminalVisible(): boolean { 135 | const container = this.containerElement || document.getElementById('ssh-terminal-container'); 136 | if (!container) return false; 137 | const style = getComputedStyle(container as HTMLElement); 138 | return style.display !== 'none' && this.isVisible; 139 | } 140 | 141 | /** 142 | * 刷新终端组件 143 | */ 144 | refresh(): void { 145 | this.unmountTerminal(); 146 | // 延迟一点时间再重新挂载,确保 DOM 清理完成 147 | setTimeout(() => { 148 | this.mountTerminal(); 149 | }, 50); 150 | } 151 | 152 | /** 153 | * 确保终端可用:若容器被重新渲染或内容丢失则自动重挂载 154 | */ 155 | ensureTerminalReady(retry = 0): void { 156 | let container = document.getElementById('ssh-terminal-container'); 157 | if (!container) { 158 | if (retry < 20) { 159 | setTimeout(() => this.ensureTerminalReady(retry + 1), 50); 160 | } else { 161 | console.error('❌ 未找到 SSH 终端容器(ensureTerminalReady 超过重试次数)'); 162 | } 163 | return; 164 | } 165 | 166 | // 检查容器是否为空或Vue应用是否丢失 167 | const emptyContainer = container.childElementCount === 0 || !container.querySelector('.xterm'); 168 | const noVueApp = this.vueApp === null; 169 | const containerChanged = this.containerElement !== container; 170 | 171 | console.log(`🔍 ensure: 检查终端状态 - Vue应用: ${noVueApp ? '无' : '有'}, 容器为空: ${emptyContainer}, 容器变化: ${containerChanged}, 子元素数: ${container.childElementCount}`); 172 | 173 | if (noVueApp || emptyContainer) { 174 | console.log('🔧 ensure: 终端需要重新挂载(Vue应用丢失或容器为空)'); 175 | // 如果Vue应用存在但容器为空,先卸载再重新挂载 176 | if (this.vueApp && emptyContainer) { 177 | console.log('🔧 ensure: 卸载现有Vue应用'); 178 | this.unmountTerminal(); 179 | } 180 | this.mountTerminal(); 181 | return; 182 | } 183 | 184 | // 更新容器引用 185 | this.containerElement = container; 186 | console.log('✅ ensure: 终端状态正常,会话保持'); 187 | 188 | if (containerChanged || emptyContainer) { 189 | console.log(`🔁 ensure: 检测到需要重挂载(containerChanged=${containerChanged}, empty=${emptyContainer})`); 190 | this.unmountTerminal(); 191 | setTimeout(() => this.mountTerminal(), 0); 192 | return; 193 | } 194 | 195 | if (!this.isTerminalVisible()) { 196 | this.showTerminal(); 197 | } 198 | } 199 | 200 | 201 | 202 | /** 203 | * 销毁管理器 204 | */ 205 | destroy(): void { 206 | this.unmountTerminal() 207 | this.isInitialized = false 208 | this.containerElement = null 209 | console.log('✅ SSH 终端管理器已销毁') 210 | } 211 | } 212 | 213 | // 创建全局实例 214 | export const sshTerminalManager = new SSHTerminalManager() 215 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | LovelyRes - Linux Emergency Response Tool 9 | 10 | 11 | 12 | 13 | 14 | 216 | 217 | 218 | 219 |
220 | 221 | 222 |
223 |
224 |
225 |
226 |
227 |
228 |
LovelyRes
229 |
230 |
231 |
232 |
正在初始化系统...
233 |
234 |
235 | 236 | 237 | 238 | -------------------------------------------------------------------------------- /src/modules/remote/sshConnectionManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * SSH连接管理器 3 | * 处理实际的SSH连接操作和状态管理 4 | * 与ssh/connectionManager.ts协同工作 5 | */ 6 | 7 | import { SSHConnectionManager as ConfigManager } from '../ssh/connectionManager'; 8 | 9 | export interface SSHConnectionInfo { 10 | id?: string; // 连接配置ID 11 | host: string; 12 | port: number; 13 | username: string; 14 | connected: boolean; 15 | lastActivity?: Date; 16 | } 17 | 18 | export class SSHConnectionManager { 19 | private connectionStatus: SSHConnectionInfo | null = null; 20 | private listeners: Array<(status: SSHConnectionInfo | null) => void> = []; 21 | private configManager: ConfigManager; 22 | 23 | constructor() { 24 | this.configManager = new ConfigManager(); 25 | } 26 | 27 | /** 28 | * 获取当前连接状态 29 | */ 30 | getConnectionStatus(): SSHConnectionInfo | null { 31 | return this.connectionStatus; 32 | } 33 | 34 | /** 35 | * 检查是否已连接 36 | */ 37 | isConnected(): boolean { 38 | return this.connectionStatus?.connected || false; 39 | } 40 | 41 | /** 42 | * 获取当前连接的ID 43 | */ 44 | getCurrentConnectionId(): string | undefined { 45 | return this.connectionStatus?.id; 46 | } 47 | 48 | /** 49 | * 手动设置连接状态(用于同步主界面连接状态) 50 | */ 51 | setConnectionStatus(status: SSHConnectionInfo | null): void { 52 | this.connectionStatus = status; 53 | this.notifyListeners(); 54 | } 55 | 56 | /** 57 | * 建立SSH连接 58 | */ 59 | async connect(host: string, port: number, username: string, password: string): Promise { 60 | try { 61 | console.log('📞 [sshConnectionManager] connect 方法被调用'); 62 | console.log(' 参数详情:', { 63 | host, 64 | port, 65 | portType: typeof port, 66 | portValue: port, 67 | username, 68 | passwordLength: password?.length || 0 69 | }); 70 | 71 | // 确保端口是数字类型 72 | const portNumber = typeof port === 'string' ? parseInt(port, 10) : port; 73 | if (isNaN(portNumber) || portNumber <= 0 || portNumber > 65535) { 74 | throw new Error(`无效的端口号: ${port} (类型: ${typeof port})`); 75 | } 76 | 77 | console.log(' 转换后的端口:', portNumber, typeof portNumber); 78 | 79 | // 调用Tauri命令连接SSH 80 | console.log('⚡ 调用 Tauri invoke: ssh_connect_direct'); 81 | await (window as any).__TAURI__.core.invoke('ssh_connect_direct', { 82 | host, 83 | port: portNumber, 84 | username, 85 | password 86 | }); 87 | 88 | console.log('✅ [sshConnectionManager] Tauri invoke 返回成功'); 89 | 90 | // 更新连接状态 91 | this.connectionStatus = { 92 | host, 93 | port, 94 | username, 95 | connected: true, 96 | lastActivity: new Date() 97 | }; 98 | 99 | // 保存连接配置(如果不存在的话) 100 | await this.saveConnectionConfig(host, port, username); 101 | 102 | // 通知监听器 103 | this.notifyListeners(); 104 | 105 | // 初始化终端工作目录 106 | if ((window as any).terminalManager && (window as any).terminalManager.initializeWorkingDirectory) { 107 | setTimeout(() => { 108 | (window as any).terminalManager.initializeWorkingDirectory(); 109 | }, 500); 110 | } 111 | 112 | } catch (error) { 113 | console.error('SSH连接失败:', error); 114 | throw error; 115 | } 116 | } 117 | 118 | /** 119 | * 断开SSH连接 120 | */ 121 | async disconnect(): Promise { 122 | try { 123 | if (this.connectionStatus?.connected) { 124 | await (window as any).__TAURI__.core.invoke('ssh_disconnect_direct'); 125 | 126 | this.connectionStatus = null; 127 | this.notifyListeners(); 128 | } 129 | } catch (error) { 130 | console.error('断开SSH连接失败:', error); 131 | } 132 | } 133 | 134 | /** 135 | * 更新最后活动时间(仅本地更新,不触发全局监听,以避免循环刷新) 136 | */ 137 | updateLastActivity(): void { 138 | if (this.connectionStatus) { 139 | this.connectionStatus.lastActivity = new Date(); 140 | // 不再调用 notifyListeners(),防止触发 UI 刷新循环 141 | } 142 | } 143 | 144 | /** 145 | * 添加状态监听器 146 | */ 147 | addListener(listener: (status: SSHConnectionInfo | null) => void): void { 148 | this.listeners.push(listener); 149 | } 150 | 151 | /** 152 | * 移除状态监听器 153 | */ 154 | removeListener(listener: (status: SSHConnectionInfo | null) => void): void { 155 | const index = this.listeners.indexOf(listener); 156 | if (index > -1) { 157 | this.listeners.splice(index, 1); 158 | } 159 | } 160 | 161 | /** 162 | * 保存连接配置 163 | */ 164 | private async saveConnectionConfig(host: string, port: number, username: string): Promise { 165 | try { 166 | // 检查是否已存在相同的连接配置 167 | const existingConnections = this.configManager.getConnections(); 168 | const exists = existingConnections.some(conn => 169 | conn.host === host && conn.port === port && conn.username === username 170 | ); 171 | 172 | if (!exists) { 173 | // 创建新的连接配置 174 | const connectionName = `${username}@${host}:${port}`; 175 | await this.configManager.addConnection({ 176 | name: connectionName, 177 | host, 178 | port, 179 | username, 180 | authType: 'password' as const, 181 | tags: ['auto-saved'], 182 | accounts: [{ 183 | username, 184 | authType: 'password' as const, 185 | isDefault: true 186 | }] 187 | }); 188 | console.log('✅ 连接配置已自动保存:', connectionName); 189 | } 190 | } catch (error) { 191 | console.error('保存连接配置失败:', error); 192 | // 不抛出错误,因为这不应该影响连接本身 193 | } 194 | } 195 | 196 | /** 197 | * 通知所有监听器 198 | */ 199 | private notifyListeners(): void { 200 | this.listeners.forEach(listener => { 201 | try { 202 | listener(this.connectionStatus); 203 | } catch (error) { 204 | console.error('SSH连接状态监听器执行失败:', error); 205 | } 206 | }); 207 | } 208 | 209 | /** 210 | * 检查连接状态(从后端获取最新状态) 211 | */ 212 | async checkConnectionStatus(): Promise { 213 | try { 214 | const status = await (window as any).__TAURI__.core.invoke('ssh_get_connection_status'); 215 | if (status) { 216 | this.connectionStatus = { 217 | host: status.host, 218 | port: status.port, 219 | username: status.username, 220 | connected: status.connected, 221 | lastActivity: new Date(status.last_activity) 222 | }; 223 | this.notifyListeners(); 224 | } else { 225 | this.connectionStatus = null; 226 | this.notifyListeners(); 227 | } 228 | return this.connectionStatus; 229 | } catch (error) { 230 | console.error('检查SSH连接状态失败:', error); 231 | return null; 232 | } 233 | } 234 | } 235 | 236 | // 全局SSH连接管理器实例 237 | export const sshConnectionManager = new SSHConnectionManager(); 238 | -------------------------------------------------------------------------------- /src/modules/ui/iconMapping.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * IconPark Icon Mapping System 3 | * Maps emoji characters to IconPark icon names for consistent UI replacement 4 | */ 5 | 6 | // IconPark icon name mappings for emojis 7 | export const EMOJI_TO_ICONPARK_MAP: Record = { 8 | // Navigation & Menu Icons 9 | '📊': 'ChartHistogramOne', // Dashboard/Charts 10 | '🖥️': 'Computer', // System information 11 | '🔧': 'Tool', // Remote operations/tools 12 | '🐳': 'Whale', // Docker (using whale icon) 13 | '🚨': 'Alarm', // Emergency commands 14 | '⚙️': 'SettingTwo', // Settings 15 | 16 | // Connection & Status Icons 17 | '🔗': 'LinkOne', // Connection/linking 18 | '🟢': 'CheckOne', // Connected status (green) 19 | '🔴': 'CloseOne', // Disconnected status (red) 20 | '⚪': 'RadioOne', // Neutral status (white) 21 | '⚫': 'RadioOne', // Offline status (black) 22 | 23 | // File & Data Management 24 | '📁': 'Folder', // File folders 25 | '📂': 'FolderOpen', // Open folders/extract 26 | '📦': 'Box', // Package/compress 27 | '💾': 'Save', // Download/save 28 | '📤': 'Upload', // Upload operations 29 | '📋': 'Clipboard', // Copy operations/lists 30 | 31 | // System & Process Icons 32 | '💻': 'LaptopComputer', // Terminal/computer 33 | '🐚': 'Terminal', // Terminal/shell 34 | '👥': 'Peoples', // Users/user management 35 | '🌐': 'Global', // Network/global connections 36 | '📈': 'TrendTwo', // Resource usage/performance 37 | '🚀': 'Rocket', // Autostart services 38 | 39 | // Action & Control Icons 40 | '🔄': 'Refresh', // Refresh/reload 41 | '➕': 'Plus', // Add/create 42 | '🗑️': 'Delete', // Delete operations 43 | '✏️': 'Edit', // Edit operations 44 | '📝': 'EditName', // Text/memo operations 45 | '🔎': 'Search', // Search/magnify 46 | '🔐': 'Lock', // Permissions/security 47 | '🏠': 'Home', // Home/root directory 48 | 49 | // Authentication & Security 50 | '🔑': 'KeyTwo', // Password authentication 51 | '🗝️': 'KeyOne', // SSH key authentication 52 | 53 | // Status & Information Icons 54 | 'ℹ️': 'Info', // Information 55 | '🚧': 'Construction', // Under development 56 | }; 57 | 58 | // Color mappings for status icons 59 | export const ICON_COLOR_MAP: Record = { 60 | // Status colors 61 | '🟢': '#22c55e', // Green for connected 62 | '🔴': '#ef4444', // Red for disconnected 63 | '⚪': '#9ca3af', // Gray for neutral 64 | '⚫': '#374151', // Dark gray for offline 65 | 66 | // Default colors for other icons 67 | 'default': 'currentColor', 68 | 'primary': 'var(--primary-color)', 69 | 'secondary': 'var(--text-secondary)', 70 | 'success': 'var(--success-color)', 71 | 'warning': 'var(--warning-color)', 72 | 'error': 'var(--error-color)', 73 | }; 74 | 75 | // Default icon sizes 76 | export const ICON_SIZES = { 77 | small: 14, 78 | medium: 16, 79 | large: 20, 80 | xlarge: 24, 81 | xxlarge: 32, 82 | huge: 48, 83 | } as const; 84 | 85 | export type IconSize = keyof typeof ICON_SIZES; 86 | 87 | /** 88 | * Get IconPark icon name from emoji 89 | */ 90 | export function getIconFromEmoji(emoji: string): string { 91 | return EMOJI_TO_ICONPARK_MAP[emoji] || 'help'; 92 | } 93 | 94 | /** 95 | * Get appropriate color for an emoji/icon 96 | */ 97 | export function getIconColor(emoji: string, customColor?: string): string { 98 | if (customColor) return customColor; 99 | return ICON_COLOR_MAP[emoji] || ICON_COLOR_MAP.default; 100 | } 101 | 102 | /** 103 | * Get icon size in pixels 104 | */ 105 | export function getIconSize(size: IconSize | number): number { 106 | if (typeof size === 'number') return size; 107 | return ICON_SIZES[size]; 108 | } 109 | 110 | /** 111 | * Generate IconPark component props from emoji 112 | */ 113 | export function getIconProps( 114 | emoji: string, 115 | options: { 116 | size?: IconSize | number; 117 | color?: string; 118 | theme?: 'outline' | 'filled' | 'two-tone' | 'multi-color'; 119 | strokeWidth?: number; 120 | } = {} 121 | ) { 122 | const { 123 | size = 'medium', 124 | color, 125 | theme = 'outline', 126 | strokeWidth = 2 127 | } = options; 128 | 129 | return { 130 | name: getIconFromEmoji(emoji), 131 | size: getIconSize(size), 132 | fill: getIconColor(emoji, color), 133 | theme, 134 | strokeWidth, 135 | }; 136 | } 137 | 138 | /** 139 | * Create HTML string for IconPark icon (for use in template strings) 140 | */ 141 | export function createIconHTML( 142 | emoji: string, 143 | options: { 144 | size?: IconSize | number; 145 | color?: string; 146 | theme?: 'outline' | 'filled' | 'two-tone' | 'multi-color'; 147 | strokeWidth?: number; 148 | className?: string; 149 | } = {} 150 | ): string { 151 | const iconName = getIconFromEmoji(emoji); 152 | const size = getIconSize(options.size || 'medium'); 153 | const color = getIconColor(emoji, options.color); 154 | const theme = options.theme || 'outline'; 155 | const strokeWidth = options.strokeWidth || 2; 156 | const className = options.className ? ` class="${options.className}"` : ''; 157 | 158 | // Create a simple SVG icon placeholder that can be easily replaced 159 | return ` 160 | 161 | 162 | ${emoji} 163 | 164 | `; 165 | } 166 | 167 | /** 168 | * Batch replace emojis in a template string with IconPark icons 169 | */ 170 | export function replaceEmojisInTemplate( 171 | template: string, 172 | defaultOptions: { 173 | size?: IconSize | number; 174 | theme?: 'outline' | 'filled' | 'two-tone' | 'multi-color'; 175 | strokeWidth?: number; 176 | } = {} 177 | ): string { 178 | let result = template; 179 | 180 | // Replace each emoji with its IconPark equivalent 181 | Object.keys(EMOJI_TO_ICONPARK_MAP).forEach(emoji => { 182 | const regex = new RegExp(emoji.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'); 183 | result = result.replace(regex, createIconHTML(emoji, defaultOptions)); 184 | }); 185 | 186 | return result; 187 | } 188 | -------------------------------------------------------------------------------- /src/modules/ui/sshConnectionDialog.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * SSH连接对话框 3 | * 提供用户友好的SSH连接界面 4 | */ 5 | 6 | import { sshConnectionManager } from '../remote/sshConnectionManager'; 7 | 8 | export class SSHConnectionDialog { 9 | private dialog: HTMLElement | null = null; 10 | 11 | /** 12 | * 显示SSH连接对话框 13 | */ 14 | show(): void { 15 | if (this.dialog) { 16 | this.hide(); // 如果已存在,先关闭 17 | } 18 | 19 | this.dialog = document.createElement('div'); 20 | this.dialog.style.cssText = ` 21 | position: fixed; 22 | top: 0; 23 | left: 0; 24 | right: 0; 25 | bottom: 0; 26 | background: rgba(0, 0, 0, 0.5); 27 | display: flex; 28 | align-items: center; 29 | justify-content: center; 30 | z-index: 10000; 31 | `; 32 | 33 | this.dialog.innerHTML = ` 34 |
42 |

43 | 🔗 SSH连接设置 44 |

45 | 46 |
47 | 50 | 64 |
65 | 66 |
67 | 70 | 86 |
87 | 88 |
89 | 92 | 106 |
107 | 108 |
109 | 112 | 126 |
127 | 128 |
129 | 135 | 141 |
142 |
143 | `; 144 | 145 | // 添加键盘事件监听 146 | this.dialog.addEventListener('keydown', (e) => { 147 | if (e.key === 'Escape') { 148 | this.hide(); 149 | } else if (e.key === 'Enter') { 150 | this.connect(); 151 | } 152 | }); 153 | 154 | // 点击背景关闭对话框 155 | this.dialog.addEventListener('click', (e) => { 156 | if (e.target === this.dialog) { 157 | this.hide(); 158 | } 159 | }); 160 | 161 | document.body.appendChild(this.dialog); 162 | 163 | // 聚焦到第一个输入框 164 | setTimeout(() => { 165 | const hostInput = document.getElementById('ssh-host') as HTMLInputElement; 166 | if (hostInput) { 167 | hostInput.focus(); 168 | } 169 | }, 100); 170 | } 171 | 172 | /** 173 | * 隐藏对话框 174 | */ 175 | hide(): void { 176 | if (this.dialog) { 177 | this.dialog.remove(); 178 | this.dialog = null; 179 | } 180 | } 181 | 182 | /** 183 | * 执行连接 184 | */ 185 | async connect(): Promise { 186 | const host = (document.getElementById('ssh-host') as HTMLInputElement)?.value; 187 | const port = parseInt((document.getElementById('ssh-port') as HTMLInputElement)?.value); 188 | const username = (document.getElementById('ssh-username') as HTMLInputElement)?.value; 189 | const password = (document.getElementById('ssh-password') as HTMLInputElement)?.value; 190 | 191 | if (!host || !username || !password) { 192 | (window as any).showConnectionStatus('请填写完整的连接信息', 'error'); 193 | return; 194 | } 195 | 196 | try { 197 | // 使用SSH连接管理器进行连接 198 | await sshConnectionManager.connect(host, port, username, password); 199 | 200 | // 连接成功,关闭对话框 201 | this.hide(); 202 | 203 | // 刷新远程操作页面(如果当前在该页面) 204 | const app = (window as any).app; 205 | const currentPage = app?.getStateManager().getState().currentPage; 206 | if (currentPage === 'remote-operations') { 207 | await (window as any).initRemoteOperationsPage(); 208 | } 209 | 210 | } catch (error) { 211 | console.error('SSH连接失败:', error); 212 | // 错误信息已在sshConnectionManager中显示 213 | } 214 | } 215 | } 216 | 217 | // 全局SSH连接对话框实例 218 | export const sshConnectionDialog = new SSHConnectionDialog(); 219 | -------------------------------------------------------------------------------- /container-terminal.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 容器终端 - LovelyRes 8 | 9 | 208 | 209 | 210 | 211 |
212 |
213 |
初始化容器终端...
214 |
215 | 216 | 239 | 240 | 241 | 242 | 243 | -------------------------------------------------------------------------------- /src/modules/core/stateManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 状态管理器 3 | * 负责应用状态的管理和同步 4 | */ 5 | 6 | import type { AppState } from './app'; 7 | import type { ModernUIRenderer } from '../ui/modernUIRenderer'; 8 | 9 | export class StateManager { 10 | private state: AppState; 11 | private listeners: Array<(state: AppState) => void> = []; 12 | private uiRenderer?: ModernUIRenderer; 13 | 14 | constructor() { 15 | this.state = { 16 | theme: 'light', 17 | isConnected: false, 18 | currentServer: undefined, 19 | loading: false, 20 | currentPage: 'dashboard', 21 | }; 22 | } 23 | 24 | /** 25 | * 初始化状态管理器 26 | */ 27 | async initialize(): Promise { 28 | try { 29 | // 从本地存储加载状态 30 | await this.loadStateFromStorage(); 31 | 32 | // 从后端加载状态 33 | await this.loadStateFromBackend(); 34 | 35 | console.log('✅ 状态管理器初始化完成'); 36 | } catch (error) { 37 | console.error('❌ 状态管理器初始化失败:', error); 38 | } 39 | } 40 | 41 | /** 42 | * 从本地存储加载状态 43 | */ 44 | private async loadStateFromStorage(): Promise { 45 | try { 46 | const savedTheme = localStorage.getItem('lovelyres-theme'); 47 | if (savedTheme && ['light', 'dark', 'sakura'].includes(savedTheme)) { 48 | this.state.theme = savedTheme as 'light' | 'dark' | 'sakura'; 49 | } 50 | } catch (error) { 51 | console.error('从本地存储加载状态失败:', error); 52 | } 53 | } 54 | 55 | /** 56 | * 从后端加载状态 57 | */ 58 | private async loadStateFromBackend(): Promise { 59 | try { 60 | // 这里可以调用后端API加载状态 61 | // const backendState = await invoke('get_app_state'); 62 | // if (backendState) { 63 | // this.setState(backendState); 64 | // } 65 | } catch (error) { 66 | console.error('从后端加载状态失败:', error); 67 | } 68 | } 69 | 70 | /** 71 | * 获取当前状态 72 | */ 73 | getState(): AppState { 74 | return { ...this.state }; 75 | } 76 | 77 | /** 78 | * 设置状态 79 | */ 80 | setState(newState: Partial): void { 81 | const oldState = { ...this.state }; 82 | this.state = { ...this.state, ...newState }; 83 | 84 | // 保存到本地存储 85 | this.saveStateToStorage(); 86 | 87 | // 通知监听器 88 | this.notifyListeners(); 89 | 90 | console.log('状态已更新:', { oldState, newState: this.state }); 91 | } 92 | 93 | /** 94 | * 设置主题 95 | */ 96 | setTheme(theme: 'light' | 'dark' | 'sakura'): void { 97 | this.setState({ theme }); 98 | 99 | // 应用主题到DOM 100 | this.applyTheme(theme); 101 | } 102 | 103 | /** 104 | * 设置当前页面 105 | */ 106 | setCurrentPage(page: 'dashboard' | 'system-info' | 'ssh-terminal' | 'remote-operations' | 'docker' | 'emergency-commands' | 'log-analysis' | 'settings'): void { 107 | this.setState({ currentPage: page }); 108 | } 109 | 110 | /** 111 | * 切换主题 112 | */ 113 | toggleTheme(): 'light' | 'dark' | 'sakura' { 114 | const themes: ('light' | 'dark' | 'sakura')[] = ['light', 'dark', 'sakura']; 115 | const currentIndex = themes.indexOf(this.state.theme); 116 | const nextIndex = (currentIndex + 1) % themes.length; 117 | const newTheme = themes[nextIndex]; 118 | 119 | this.setTheme(newTheme); 120 | return newTheme; 121 | } 122 | 123 | /** 124 | * 应用主题到DOM 125 | */ 126 | private applyTheme(theme: 'light' | 'dark' | 'sakura'): void { 127 | if (typeof document !== 'undefined') { 128 | document.documentElement.setAttribute('data-theme', theme); 129 | document.body.classList.remove('light-theme', 'dark-theme', 'sakura-theme'); 130 | document.body.classList.add(`${theme}-theme`); 131 | } 132 | } 133 | 134 | /** 135 | * 设置连接状态 136 | */ 137 | setConnected(isConnected: boolean, server?: string, serverInfo?: any): void { 138 | this.setState({ 139 | isConnected, 140 | currentServer: isConnected ? server : undefined, 141 | serverInfo: isConnected ? serverInfo : undefined 142 | }); 143 | } 144 | 145 | /** 146 | * 设置加载状态 147 | */ 148 | setLoading(loading: boolean): void { 149 | this.setState({ loading }); 150 | } 151 | 152 | /** 153 | * 保存状态到本地存储 154 | */ 155 | private saveStateToStorage(): void { 156 | try { 157 | localStorage.setItem('lovelyres-theme', this.state.theme); 158 | localStorage.setItem('lovelyres-state', JSON.stringify({ 159 | theme: this.state.theme, 160 | // 不保存敏感信息如连接状态 161 | })); 162 | } catch (error) { 163 | console.error('保存状态到本地存储失败:', error); 164 | } 165 | } 166 | 167 | /** 168 | * 添加状态监听器 169 | */ 170 | addListener(listener: (state: AppState) => void): void { 171 | this.listeners.push(listener); 172 | } 173 | 174 | /** 175 | * 移除状态监听器 176 | */ 177 | removeListener(listener: (state: AppState) => void): void { 178 | const index = this.listeners.indexOf(listener); 179 | if (index > -1) { 180 | this.listeners.splice(index, 1); 181 | } 182 | } 183 | 184 | /** 185 | * 通知所有监听器 186 | */ 187 | private notifyListeners(): void { 188 | this.listeners.forEach(listener => { 189 | try { 190 | listener(this.getState()); 191 | } catch (error) { 192 | console.error('状态监听器执行失败:', error); 193 | } 194 | }); 195 | } 196 | 197 | /** 198 | * 重置状态 199 | */ 200 | reset(): void { 201 | this.state = { 202 | theme: 'light', 203 | isConnected: false, 204 | currentServer: undefined, 205 | loading: false, 206 | currentPage: 'dashboard', 207 | }; 208 | 209 | // 清除本地存储 210 | try { 211 | localStorage.removeItem('lovelyres-theme'); 212 | localStorage.removeItem('lovelyres-state'); 213 | } catch (error) { 214 | console.error('清除本地存储失败:', error); 215 | } 216 | 217 | // 通知监听器 218 | this.notifyListeners(); 219 | } 220 | 221 | /** 222 | * 获取主题配置 223 | */ 224 | getThemeConfig() { 225 | const themeConfigs = { 226 | light: { 227 | name: '浅色', 228 | icon: '☀️', 229 | description: '清新明亮的浅色主题' 230 | }, 231 | dark: { 232 | name: '深色', 233 | icon: '🌙', 234 | description: '护眼舒适的深色主题' 235 | }, 236 | sakura: { 237 | name: '樱花粉', 238 | icon: '🌸', 239 | description: '温柔浪漫的樱花主题' 240 | } 241 | }; 242 | 243 | return themeConfigs[this.state.theme]; 244 | } 245 | 246 | /** 247 | * 获取下一个主题配置 248 | */ 249 | getNextThemeConfig() { 250 | const themes: ('light' | 'dark' | 'sakura')[] = ['light', 'dark', 'sakura']; 251 | const currentIndex = themes.indexOf(this.state.theme); 252 | const nextIndex = (currentIndex + 1) % themes.length; 253 | const nextTheme = themes[nextIndex]; 254 | 255 | const themeConfigs = { 256 | light: { name: '浅色', icon: '☀️' }, 257 | dark: { name: '深色', icon: '🌙' }, 258 | sakura: { name: '樱花粉', icon: '🌸' } 259 | }; 260 | 261 | return themeConfigs[nextTheme]; 262 | } 263 | 264 | /** 265 | * 检查是否为深色主题 266 | */ 267 | isDarkTheme(): boolean { 268 | return this.state.theme === 'dark'; 269 | } 270 | 271 | /** 272 | * 检查是否为浅色主题 273 | */ 274 | isLightTheme(): boolean { 275 | return this.state.theme === 'light'; 276 | } 277 | 278 | /** 279 | * 检查是否为樱花主题 280 | */ 281 | isSakuraTheme(): boolean { 282 | return this.state.theme === 'sakura'; 283 | } 284 | 285 | /** 286 | * 设置UI渲染器 287 | */ 288 | setUIRenderer(renderer: ModernUIRenderer): void { 289 | this.uiRenderer = renderer; 290 | } 291 | 292 | /** 293 | * 获取UI渲染器 294 | */ 295 | getUIRenderer(): ModernUIRenderer { 296 | if (!this.uiRenderer) { 297 | throw new Error('UI渲染器未初始化'); 298 | } 299 | return this.uiRenderer; 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /generate_icons.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | 图标生成脚本 - 从logo.png生成Tauri应用所需的所有图标素材 4 | """ 5 | 6 | import os 7 | import sys 8 | from PIL import Image 9 | import argparse 10 | 11 | def create_icon_with_background(source_image, size, output_path, background_color=(255, 255, 255, 0), max_upscale=0, skip_large=False): 12 | """ 13 | 创建指定尺寸的图标,智能处理小尺寸源图像 14 | """ 15 | source_size = max(source_image.width, source_image.height) 16 | 17 | # 检查是否跳过大尺寸图标 18 | if skip_large and size > source_size: 19 | print(f"⏭️ 跳过: {output_path} ({size}x{size}) [大于源图像]") 20 | return False 21 | 22 | # 检查最大放大倍数限制 23 | if max_upscale > 0 and size > source_size * max_upscale: 24 | print(f"⏭️ 跳过: {output_path} ({size}x{size}) [超过最大放大倍数 {max_upscale}x]") 25 | return False 26 | 27 | # 如果目标尺寸小于等于源图像尺寸,直接缩放 28 | if size <= source_size: 29 | resized = source_image.resize((size, size), Image.Resampling.LANCZOS) 30 | else: 31 | # 如果目标尺寸大于源图像,使用更适合的放大算法 32 | # 对于小图像放大,使用NEAREST可以保持清晰度 33 | if source_size <= 128: 34 | resized = source_image.resize((size, size), Image.Resampling.NEAREST) 35 | else: 36 | resized = source_image.resize((size, size), Image.Resampling.LANCZOS) 37 | 38 | # 如果需要背景色,创建背景图像 39 | if background_color != (255, 255, 255, 0): 40 | icon = Image.new('RGBA', (size, size), background_color) 41 | if resized.mode == 'RGBA': 42 | icon.paste(resized, (0, 0), resized) 43 | else: 44 | icon.paste(resized, (0, 0)) 45 | else: 46 | icon = resized 47 | 48 | # 保存图标 49 | icon.save(output_path, 'PNG') 50 | 51 | # 显示缩放信息 52 | scale_info = "放大" if size > source_size else "缩小" if size < source_size else "原尺寸" 53 | print(f"✓ 生成: {output_path} ({size}x{size}) [{scale_info}]") 54 | return True 55 | 56 | def create_ico_file(source_image, output_path): 57 | """ 58 | 创建Windows ICO文件,包含多个尺寸 59 | """ 60 | sizes = [128] 61 | icons = [] 62 | source_size = max(source_image.width, source_image.height) 63 | 64 | for size in sizes: 65 | # 智能选择缩放算法 66 | if size <= source_size: 67 | resized = source_image.resize((size, size), Image.Resampling.LANCZOS) 68 | else: 69 | # 对于小图像放大,使用NEAREST保持清晰度 70 | if source_size <= 128: 71 | resized = source_image.resize((size, size), Image.Resampling.NEAREST) 72 | else: 73 | resized = source_image.resize((size, size), Image.Resampling.LANCZOS) 74 | icons.append(resized) 75 | 76 | # 保存ICO文件 77 | icons[0].save(output_path, format='ICO', sizes=[(icon.width, icon.height) for icon in icons]) 78 | print(f"✓ 生成: {output_path} (多尺寸ICO)") 79 | 80 | def create_icns_file(source_image, output_path): 81 | """ 82 | 创建macOS ICNS文件 83 | 注意: 需要安装pillow-heif或使用其他工具 84 | """ 85 | try: 86 | # 创建临时PNG文件用于转换 87 | temp_png = output_path.replace('.icns', '_temp.png') 88 | create_icon_with_background(source_image, 1024, temp_png) 89 | 90 | # 使用系统工具转换(如果在macOS上) 91 | if sys.platform == 'darwin': 92 | os.system(f'sips -s format icns "{temp_png}" --out "{output_path}"') 93 | os.remove(temp_png) 94 | print(f"✓ 生成: {output_path} (macOS ICNS)") 95 | else: 96 | # 在非macOS系统上,创建一个1024x1024的PNG作为替代 97 | create_icon_with_background(source_image, 1024, output_path.replace('.icns', '.png')) 98 | print(f"⚠ 在非macOS系统上生成PNG替代: {output_path.replace('.icns', '.png')}") 99 | except Exception as e: 100 | print(f"⚠ ICNS生成失败: {e}") 101 | 102 | def main(): 103 | parser = argparse.ArgumentParser(description='从logo.png生成Tauri应用所需的图标素材') 104 | parser.add_argument('--source', '-s', default='src-tauri/icons/logo.png', 105 | help='源图标文件路径 (默认: src-tauri/icons/logo.png)') 106 | parser.add_argument('--output-dir', '-o', default='src-tauri/icons', 107 | help='输出目录 (默认: src-tauri/icons)') 108 | parser.add_argument('--max-upscale', '-m', type=int, default=0, 109 | help='最大放大倍数,0表示无限制 (默认: 0)') 110 | parser.add_argument('--skip-large', action='store_true', 111 | help='跳过生成比源图像大的图标') 112 | 113 | args = parser.parse_args() 114 | 115 | # 检查源文件 116 | if not os.path.exists(args.source): 117 | print(f"❌ 源文件不存在: {args.source}") 118 | return 1 119 | 120 | # 创建输出目录 121 | os.makedirs(args.output_dir, exist_ok=True) 122 | 123 | try: 124 | # 加载源图像 125 | source_image = Image.open(args.source) 126 | source_size = max(source_image.width, source_image.height) 127 | print(f"📁 源图像: {args.source} ({source_image.width}x{source_image.height})") 128 | 129 | # 检查源图像尺寸并给出建议 130 | if source_size < 256: 131 | print(f"⚠️ 警告: 源图像尺寸较小 ({source_size}px),建议使用至少256x256的图像以获得更好的大尺寸图标质量") 132 | elif source_size < 512: 133 | print(f"💡 提示: 源图像尺寸适中 ({source_size}px),如需更高质量的大尺寸图标,建议使用512x512或更大的图像") 134 | else: 135 | print(f"✅ 源图像尺寸良好 ({source_size}px),适合生成高质量图标") 136 | 137 | # 确保是RGBA模式 138 | if source_image.mode != 'RGBA': 139 | source_image = source_image.convert('RGBA') 140 | 141 | # 生成各种尺寸的PNG图标 142 | png_sizes = [ 143 | (32, '32x32.png'), 144 | (128, '128x128.png'), 145 | (256, '128x128@2x.png'), # 2x版本 146 | (1024, 'icon.png'), # 主图标 147 | ] 148 | 149 | generated_count = 0 150 | for size, filename in png_sizes: 151 | output_path = os.path.join(args.output_dir, filename) 152 | if create_icon_with_background(source_image, size, output_path, max_upscale=args.max_upscale, skip_large=args.skip_large): 153 | generated_count += 1 154 | 155 | # 生成Windows Store Logo尺寸 156 | store_sizes = [ 157 | (30, 'Square30x30Logo.png'), 158 | (44, 'Square44x44Logo.png'), 159 | (71, 'Square71x71Logo.png'), 160 | (89, 'Square89x89Logo.png'), 161 | (107, 'Square107x107Logo.png'), 162 | (142, 'Square142x142Logo.png'), 163 | (150, 'Square150x150Logo.png'), 164 | (284, 'Square284x284Logo.png'), 165 | (310, 'Square310x310Logo.png'), 166 | (50, 'StoreLogo.png'), 167 | ] 168 | 169 | for size, filename in store_sizes: 170 | output_path = os.path.join(args.output_dir, filename) 171 | if create_icon_with_background(source_image, size, output_path, max_upscale=args.max_upscale, skip_large=args.skip_large): 172 | generated_count += 1 173 | 174 | # 生成ICO文件 175 | ico_path = os.path.join(args.output_dir, 'icon.ico') 176 | create_ico_file(source_image, ico_path) 177 | 178 | # 生成ICNS文件 179 | icns_path = os.path.join(args.output_dir, 'icon.icns') 180 | create_icns_file(source_image, icns_path) 181 | generated_count += 2 # ICO和ICNS文件 182 | 183 | print(f"\n🎉 图标生成完成! 共生成 {generated_count} 个文件") 184 | print(f"📂 输出目录: {args.output_dir}") 185 | 186 | if args.skip_large or args.max_upscale > 0: 187 | total_possible = len(png_sizes) + len(store_sizes) + 2 188 | skipped = total_possible - generated_count 189 | if skipped > 0: 190 | print(f"⏭️ 跳过 {skipped} 个大尺寸文件") 191 | 192 | except Exception as e: 193 | print(f"❌ 生成失败: {e}") 194 | return 1 195 | 196 | return 0 197 | 198 | if __name__ == '__main__': 199 | sys.exit(main()) 200 | -------------------------------------------------------------------------------- /src/modules/ssh/sshManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * SSH管理器 - 协调器 3 | * 负责协调SSH连接管理器和系统信息管理器 4 | */ 5 | 6 | import { invoke } from '@tauri-apps/api/core'; 7 | import { SSHConnectionManager, type SSHConnection } from './connectionManager'; 8 | import { SystemInfoManager, type SystemInfo } from '../system/systemInfoManager'; 9 | 10 | export interface SSHCommand { 11 | id: string; 12 | name: string; 13 | command: string; 14 | description: string; 15 | category: string; 16 | favorite: boolean; 17 | } 18 | 19 | export class SSHManager { 20 | private connectionManager: SSHConnectionManager; 21 | private systemInfoManager: SystemInfoManager; 22 | private commands: SSHCommand[] = []; 23 | 24 | constructor() { 25 | this.connectionManager = new SSHConnectionManager(); 26 | this.systemInfoManager = new SystemInfoManager(); 27 | this.initializeDefaultCommands(); 28 | } 29 | 30 | // ===== 连接管理代理方法 ===== 31 | 32 | /** 33 | * 获取所有SSH连接 34 | */ 35 | getConnections(): SSHConnection[] { 36 | return this.connectionManager.getConnections(); 37 | } 38 | 39 | /** 40 | * 获取单个SSH连接 41 | */ 42 | getConnection(id: string): SSHConnection | undefined { 43 | return this.connectionManager.getConnection(id); 44 | } 45 | 46 | /** 47 | * 添加SSH连接 48 | */ 49 | async addConnection(connection: Omit): Promise { 50 | return this.connectionManager.addConnection(connection); 51 | } 52 | 53 | /** 54 | * 更新SSH连接 55 | */ 56 | async updateConnection(id: string, updates: Partial): Promise { 57 | return this.connectionManager.updateConnection(id, updates); 58 | } 59 | 60 | /** 61 | * 删除SSH连接 62 | */ 63 | async deleteConnection(id: string): Promise { 64 | return this.connectionManager.deleteConnection(id); 65 | } 66 | 67 | /** 68 | * 连接到服务器 69 | */ 70 | async connectToServer(id: string): Promise { 71 | const connection = this.connectionManager.getConnection(id); 72 | if (!connection) { 73 | throw new Error('连接配置不存在'); 74 | } 75 | 76 | try { 77 | console.log(`🔗 正在连接到 ${connection.name} (${connection.host}:${connection.port})`); 78 | 79 | // 调用后端建立真正的SSH连接 80 | await invoke('ssh_connect_with_auth', { 81 | host: connection.host, 82 | port: connection.port, 83 | username: connection.username, 84 | authType: connection.authType, 85 | password: connection.encryptedPassword ? await invoke('decrypt_password', { encryptedPassword: connection.encryptedPassword }) : undefined, 86 | keyPath: connection.keyPath, 87 | keyPassphrase: connection.keyPassphrase 88 | }); 89 | 90 | console.log(`✅ SSH连接已建立到 ${connection.name}`); 91 | 92 | // 更新连接状态 93 | await this.connectionManager.updateConnection(id, { 94 | isConnected: true, 95 | lastConnected: new Date() 96 | }); 97 | 98 | // 连接成功后立即获取系统信息 99 | console.log('📊 正在获取系统信息...'); 100 | await this.systemInfoManager.fetchSystemInfo(); 101 | 102 | console.log(`✅ 成功连接到 ${connection.name}`); 103 | } catch (error) { 104 | console.error(`❌ 连接失败: ${error}`); 105 | throw error; 106 | } 107 | } 108 | 109 | /** 110 | * 断开服务器连接 111 | */ 112 | async disconnectFromServer(id: string): Promise { 113 | const connection = this.connectionManager.getConnection(id); 114 | if (!connection) { 115 | throw new Error('连接配置不存在'); 116 | } 117 | 118 | try { 119 | console.log(`🔌 正在断开 ${connection.name} 的连接`); 120 | 121 | // 调用后端断开SSH连接 122 | await invoke('ssh_disconnect'); 123 | 124 | // 更新连接状态 125 | await this.connectionManager.updateConnection(id, { 126 | isConnected: false 127 | }); 128 | 129 | console.log(`✅ 已断开 ${connection.name} 的连接`); 130 | } catch (error) { 131 | console.error(`❌ 断开连接失败: ${error}`); 132 | throw error; 133 | } 134 | } 135 | 136 | /** 137 | * 连接到SSH服务器 138 | */ 139 | async connect(id: string): Promise { 140 | await this.connectionManager.connect(id); 141 | 142 | // 连接成功后,开始自动更新系统信息 143 | try { 144 | await this.systemInfoManager.fetchSystemInfo(); 145 | this.systemInfoManager.startAutoUpdate(30000); // 30秒更新一次 146 | } catch (error) { 147 | console.warn('⚠️ 获取系统信息失败,但SSH连接成功:', error); 148 | } 149 | } 150 | 151 | /** 152 | * 断开SSH连接 153 | */ 154 | async disconnect(): Promise { 155 | await this.connectionManager.disconnect(); 156 | this.systemInfoManager.stopAutoUpdate(); 157 | } 158 | 159 | /** 160 | * 获取当前活动连接 161 | */ 162 | getActiveConnection(): SSHConnection | undefined { 163 | return this.connectionManager.getActiveConnection(); 164 | } 165 | 166 | /** 167 | * 检查是否已连接 168 | */ 169 | isConnected(): boolean { 170 | return this.connectionManager.isConnected(); 171 | } 172 | 173 | /** 174 | * 测试连接 175 | */ 176 | async testConnection(connection: Omit): Promise { 177 | return this.connectionManager.testConnection(connection); 178 | } 179 | 180 | // ===== 系统信息代理方法 ===== 181 | 182 | /** 183 | * 获取系统信息 184 | */ 185 | async fetchSystemInfo(): Promise { 186 | return this.systemInfoManager.fetchSystemInfo(); 187 | } 188 | 189 | /** 190 | * 获取当前系统信息 191 | */ 192 | getSystemInfo(): SystemInfo | undefined { 193 | return this.systemInfoManager.getSystemInfo(); 194 | } 195 | 196 | /** 197 | * 开始自动更新系统信息 198 | */ 199 | startSystemInfoAutoUpdate(intervalMs: number = 30000): void { 200 | this.systemInfoManager.startAutoUpdate(intervalMs); 201 | } 202 | 203 | /** 204 | * 停止自动更新系统信息 205 | */ 206 | stopSystemInfoAutoUpdate(): void { 207 | this.systemInfoManager.stopAutoUpdate(); 208 | } 209 | 210 | // ===== 命令管理 ===== 211 | 212 | /** 213 | * 执行SSH命令 214 | */ 215 | async executeCommand(command: string): Promise { 216 | if (!this.isConnected()) { 217 | throw new Error('没有活动的SSH连接'); 218 | } 219 | 220 | try { 221 | const result = await invoke('ssh_execute_command', { command }); 222 | console.log(`✅ 命令执行成功: ${command}`); 223 | return result as string; 224 | } catch (error) { 225 | console.error(`❌ 命令执行失败: ${command}`, error); 226 | throw new Error(`命令执行失败: ${error}`); 227 | } 228 | } 229 | 230 | /** 231 | * 获取所有SSH命令 232 | */ 233 | getCommands(): SSHCommand[] { 234 | return [...this.commands]; 235 | } 236 | 237 | /** 238 | * 初始化默认命令 239 | */ 240 | private initializeDefaultCommands(): void { 241 | const defaultCommands: Omit[] = [ 242 | { 243 | name: '查看系统信息', 244 | command: 'uname -a', 245 | description: '显示系统内核信息', 246 | category: '系统信息', 247 | favorite: true 248 | }, 249 | { 250 | name: '查看内存使用', 251 | command: 'free -h', 252 | description: '显示内存使用情况', 253 | category: '系统监控', 254 | favorite: true 255 | }, 256 | { 257 | name: '查看磁盘使用', 258 | command: 'df -h', 259 | description: '显示磁盘使用情况', 260 | category: '系统监控', 261 | favorite: true 262 | } 263 | ]; 264 | 265 | this.commands = defaultCommands.map(cmd => ({ 266 | ...cmd, 267 | id: this.generateId() 268 | })); 269 | 270 | console.log('✅ 默认SSH命令已初始化'); 271 | } 272 | 273 | /** 274 | * 生成唯一ID 275 | */ 276 | private generateId(): string { 277 | return Date.now().toString(36) + Math.random().toString(36).substring(2); 278 | } 279 | 280 | /** 281 | * 清理资源 282 | */ 283 | destroy(): void { 284 | this.systemInfoManager.destroy(); 285 | console.log('✅ SSH管理器资源已清理'); 286 | } 287 | } 288 | 289 | // 导出类型 290 | export type { SSHConnection, SystemInfo }; -------------------------------------------------------------------------------- /src/modules/crypto/cryptoService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 加密服务 3 | * 负责客户端的加密/解密操作 4 | */ 5 | 6 | // 扩展 Window 接口 7 | declare global { 8 | interface Window { 9 | __TAURI__?: { 10 | core: { 11 | invoke: (cmd: string, args?: any) => Promise; 12 | }; 13 | }; 14 | } 15 | } 16 | 17 | class CryptoService { 18 | private static instance: CryptoService; 19 | private aesKey: CryptoKey | null = null; 20 | private sessionId: string | null = null; 21 | private serverPublicKey: CryptoKey | null = null; 22 | private initialized: boolean = false; 23 | 24 | private constructor() {} 25 | 26 | public static getInstance(): CryptoService { 27 | if (!CryptoService.instance) { 28 | CryptoService.instance = new CryptoService(); 29 | } 30 | return CryptoService.instance; 31 | } 32 | 33 | /** 34 | * 初始化加密(获取公钥并交换 AES 密钥) 35 | */ 36 | public async initialize(baseURL: string): Promise { 37 | if (this.initialized) { 38 | console.log('🔐 加密服务已初始化'); 39 | return; 40 | } 41 | 42 | try { 43 | console.log('🔐 开始初始化加密服务...'); 44 | 45 | // 1. 获取服务端公钥 46 | await this.fetchServerPublicKey(baseURL); 47 | 48 | // 2. 生成 AES 密钥 49 | await this.generateAESKey(); 50 | 51 | // 3. 交换密钥 52 | await this.exchangeKey(baseURL); 53 | 54 | this.initialized = true; 55 | console.log('✅ 加密服务初始化成功'); 56 | } catch (error) { 57 | console.error('❌ 加密服务初始化失败:', error); 58 | throw error; 59 | } 60 | } 61 | 62 | /** 63 | * 获取服务端公钥 64 | * 从 Rust 后端获取硬编码的公钥(已混淆) 65 | */ 66 | private async fetchServerPublicKey(baseURL: string): Promise { 67 | let publicKeyPem: string; 68 | 69 | // 检测是否在 Tauri 环境中 70 | if (window.__TAURI__) { 71 | try { 72 | // 从 Rust 后端获取公钥(硬编码,已混淆) 73 | publicKeyPem = await window.__TAURI__.core.invoke('get_rsa_public_key') as string; 74 | console.log('📥 获取服务端公钥成功(来自 Rust 后端,已解混淆)'); 75 | } catch (error) { 76 | console.error('❌ 从 Rust 后端获取公钥失败:', error); 77 | // 降级到服务器 78 | const response = await fetch(`${baseURL}/crypto/public-key`); 79 | const data = await response.json(); 80 | 81 | if (data.code !== 200) { 82 | throw new Error(data.message); 83 | } 84 | 85 | publicKeyPem = data.data.publicKey; 86 | console.log('📥 获取服务端公钥成功(来自服务器,降级)'); 87 | } 88 | } else { 89 | // 浏览器环境,从服务器获取公钥 90 | const response = await fetch(`${baseURL}/crypto/public-key`); 91 | const data = await response.json(); 92 | 93 | if (data.code !== 200) { 94 | throw new Error(data.message); 95 | } 96 | 97 | publicKeyPem = data.data.publicKey; 98 | console.log('📥 获取服务端公钥成功(来自服务器)'); 99 | } 100 | 101 | // 导入公钥 102 | const publicKeyDer = this.pemToDer(publicKeyPem); 103 | 104 | this.serverPublicKey = await window.crypto.subtle.importKey( 105 | 'spki', 106 | publicKeyDer, 107 | { 108 | name: 'RSA-OAEP', 109 | hash: 'SHA-256' 110 | }, 111 | false, 112 | ['encrypt'] 113 | ); 114 | } 115 | 116 | /** 117 | * 生成 AES-256 密钥 118 | */ 119 | private async generateAESKey(): Promise { 120 | this.aesKey = await window.crypto.subtle.generateKey( 121 | { 122 | name: 'AES-CBC', 123 | length: 256 124 | }, 125 | true, 126 | ['encrypt', 'decrypt'] 127 | ); 128 | 129 | console.log('🔑 生成 AES-256 密钥成功'); 130 | } 131 | 132 | /** 133 | * 交换 AES 密钥 134 | */ 135 | private async exchangeKey(baseURL: string): Promise { 136 | if (!this.aesKey || !this.serverPublicKey) { 137 | throw new Error('密钥未初始化'); 138 | } 139 | 140 | // 导出 AES 密钥为原始格式 141 | const aesKeyRaw = await window.crypto.subtle.exportKey('raw', this.aesKey); 142 | 143 | // 使用服务端公钥加密 AES 密钥 144 | const encryptedKey = await window.crypto.subtle.encrypt( 145 | { 146 | name: 'RSA-OAEP' 147 | }, 148 | this.serverPublicKey, 149 | aesKeyRaw 150 | ); 151 | 152 | // Base64 编码 153 | const encryptedKeyBase64 = this.arrayBufferToBase64(encryptedKey); 154 | 155 | // 发送到服务端 156 | const response = await fetch(`${baseURL}/crypto/exchange-key`, { 157 | method: 'POST', 158 | headers: { 159 | 'Content-Type': 'application/json' 160 | }, 161 | body: JSON.stringify({ 162 | encryptedKey: encryptedKeyBase64 163 | }) 164 | }); 165 | 166 | const data = await response.json(); 167 | 168 | if (data.code !== 200) { 169 | throw new Error(data.message); 170 | } 171 | 172 | // 保存会话 ID 173 | this.sessionId = data.data.sessionId; 174 | 175 | console.log(`🔄 密钥交换成功,会话 ID: ${this.sessionId}`); 176 | 177 | // TODO: 验证签名 178 | // const verified = await this.verifySignature('OK', data.data.signature); 179 | // if (!verified) { 180 | // throw new Error('签名验证失败'); 181 | // } 182 | } 183 | 184 | /** 185 | * 加密数据 186 | */ 187 | public async encryptData(data: any): Promise { 188 | if (!this.aesKey) { 189 | throw new Error('AES 密钥未初始化'); 190 | } 191 | 192 | // 序列化数据 193 | const json = JSON.stringify(data); 194 | const jsonBuffer = new TextEncoder().encode(json); 195 | 196 | // 生成随机 IV 197 | const iv = window.crypto.getRandomValues(new Uint8Array(16)); 198 | 199 | // 加密 200 | const encrypted = await window.crypto.subtle.encrypt( 201 | { 202 | name: 'AES-CBC', 203 | iv 204 | }, 205 | this.aesKey, 206 | jsonBuffer 207 | ); 208 | 209 | // 组合 IV 和密文 210 | const combined = new Uint8Array(iv.length + encrypted.byteLength); 211 | combined.set(iv, 0); 212 | combined.set(new Uint8Array(encrypted), iv.length); 213 | 214 | // Base64 编码 215 | return this.arrayBufferToBase64(combined.buffer); 216 | } 217 | 218 | /** 219 | * 解密数据 220 | */ 221 | public async decryptData(encryptedData: string): Promise { 222 | if (!this.aesKey) { 223 | throw new Error('AES 密钥未初始化'); 224 | } 225 | 226 | // Base64 解码 227 | const combined = this.base64ToArrayBuffer(encryptedData); 228 | 229 | // 提取 IV 和密文 230 | const iv = combined.slice(0, 16); 231 | const encrypted = combined.slice(16); 232 | 233 | // 解密 234 | const decrypted = await window.crypto.subtle.decrypt( 235 | { 236 | name: 'AES-CBC', 237 | iv 238 | }, 239 | this.aesKey, 240 | encrypted 241 | ); 242 | 243 | // 解析 JSON 244 | const json = new TextDecoder().decode(decrypted); 245 | return JSON.parse(json); 246 | } 247 | 248 | /** 249 | * 获取会话 ID 250 | */ 251 | public getSessionId(): string | null { 252 | return this.sessionId; 253 | } 254 | 255 | /** 256 | * 检查是否已初始化 257 | */ 258 | public isInitialized(): boolean { 259 | return this.initialized; 260 | } 261 | 262 | /** 263 | * 重置加密服务 264 | */ 265 | public reset(): void { 266 | this.aesKey = null; 267 | this.sessionId = null; 268 | this.serverPublicKey = null; 269 | this.initialized = false; 270 | console.log('🔄 加密服务已重置'); 271 | } 272 | 273 | /** 274 | * PEM 转 DER 275 | */ 276 | private pemToDer(pem: string): ArrayBuffer { 277 | const b64 = pem 278 | .replace(/-----BEGIN PUBLIC KEY-----/, '') 279 | .replace(/-----END PUBLIC KEY-----/, '') 280 | .replace(/\s/g, ''); 281 | 282 | return this.base64ToArrayBuffer(b64); 283 | } 284 | 285 | /** 286 | * ArrayBuffer 转 Base64 287 | */ 288 | private arrayBufferToBase64(buffer: ArrayBuffer): string { 289 | const bytes = new Uint8Array(buffer); 290 | let binary = ''; 291 | for (let i = 0; i < bytes.byteLength; i++) { 292 | binary += String.fromCharCode(bytes[i]); 293 | } 294 | return btoa(binary); 295 | } 296 | 297 | /** 298 | * Base64 转 ArrayBuffer 299 | */ 300 | private base64ToArrayBuffer(base64: string): ArrayBuffer { 301 | const binary = atob(base64); 302 | const bytes = new Uint8Array(binary.length); 303 | for (let i = 0; i < binary.length; i++) { 304 | bytes[i] = binary.charCodeAt(i); 305 | } 306 | return bytes.buffer; 307 | } 308 | } 309 | 310 | export const cryptoService = CryptoService.getInstance(); 311 | 312 | -------------------------------------------------------------------------------- /src-tauri/src/log_analysis.rs: -------------------------------------------------------------------------------- 1 | // 日志分析模块 2 | // 用于读取和格式化Linux系统日志 3 | 4 | use serde::{Deserialize, Serialize}; 5 | 6 | /// 日志条目 7 | #[derive(Debug, Clone, Serialize, Deserialize)] 8 | pub struct LogEntry { 9 | /// 时间戳 10 | pub timestamp: String, 11 | /// 日志级别 12 | pub level: String, 13 | /// 服务/进程名 14 | pub service: String, 15 | /// 日志内容 16 | pub message: String, 17 | /// 原始日志行 18 | pub raw: String, 19 | /// 是否高亮显示(匹配关键词) 20 | pub highlighted: bool, 21 | } 22 | 23 | /// 日志文件信息 24 | #[derive(Debug, Clone, Serialize, Deserialize)] 25 | pub struct LogFileInfo { 26 | /// 文件路径 27 | pub path: String, 28 | /// 文件名 29 | pub name: String, 30 | /// 文件大小(字节) 31 | pub size: u64, 32 | /// 最后修改时间 33 | pub modified: String, 34 | /// 是否可读 35 | pub readable: bool, 36 | } 37 | 38 | /// 日志分析结果 39 | #[derive(Debug, Clone, Serialize, Deserialize)] 40 | pub struct LogAnalysisResult { 41 | /// 日志条目列表 42 | pub entries: Vec, 43 | /// 总条目数 44 | pub total_count: usize, 45 | /// 高亮条目数 46 | pub highlighted_count: usize, 47 | /// 日志文件信息 48 | pub file_info: Option, 49 | } 50 | 51 | /// 常见的系统日志文件路径 52 | pub const COMMON_LOG_FILES: &[(&str, &str)] = &[ 53 | ("/var/log/auth.log", "认证日志"), 54 | ("/var/log/secure", "安全日志"), 55 | ("/var/log/syslog", "系统日志"), 56 | ("/var/log/messages", "系统消息"), 57 | ("/var/log/kern.log", "内核日志"), 58 | ("/var/log/cron", "计划任务日志"), 59 | ("/var/log/maillog", "邮件日志"), 60 | ("/var/log/boot.log", "启动日志"), 61 | ("/var/log/dmesg", "设备消息"), 62 | ("/var/log/audit/audit.log", "审计日志"), 63 | ]; 64 | 65 | /// 高亮关键词(用于检测可疑活动) 66 | pub const HIGHLIGHT_KEYWORDS: &[&str] = &[ 67 | "Failed password", 68 | "failed", 69 | "Failed", 70 | "FAILED", 71 | "Accepted", 72 | "accepted", 73 | "sudo", 74 | "SUDO", 75 | "authentication failure", 76 | "Invalid user", 77 | "invalid", 78 | "error", 79 | "Error", 80 | "ERROR", 81 | "warning", 82 | "Warning", 83 | "WARNING", 84 | "denied", 85 | "Denied", 86 | "DENIED", 87 | "unauthorized", 88 | "Unauthorized", 89 | "root", 90 | "ROOT", 91 | "attack", 92 | "Attack", 93 | "ATTACK", 94 | "intrusion", 95 | "Intrusion", 96 | "breach", 97 | "Breach", 98 | ]; 99 | 100 | /// 解析日志行 101 | pub fn parse_log_line(line: &str, keywords: &[&str]) -> LogEntry { 102 | let highlighted = keywords.iter().any(|kw| line.contains(kw)); 103 | 104 | // 尝试解析不同格式的日志 105 | // 格式1: syslog格式 - "Nov 22 19:43:01 hostname service[pid]: message" 106 | // 格式2: systemd格式 - "Nov 22 19:43:01 hostname systemd[1]: message" 107 | // 格式3: 简单格式 - "timestamp level message" 108 | 109 | let parts: Vec<&str> = line.splitn(2, |c: char| c == '[' || c == ':').collect(); 110 | 111 | let (timestamp, service, message) = if parts.len() >= 2 { 112 | // 尝试提取时间戳(前3个字段通常是月 日 时间) 113 | let fields: Vec<&str> = line.split_whitespace().collect(); 114 | let timestamp = if fields.len() >= 3 { 115 | format!("{} {} {}", fields.get(0).unwrap_or(&""), 116 | fields.get(1).unwrap_or(&""), 117 | fields.get(2).unwrap_or(&"")) 118 | } else { 119 | String::new() 120 | }; 121 | 122 | // 提取服务名(通常在时间戳和消息之间) 123 | let service = if fields.len() >= 5 { 124 | fields.get(4).unwrap_or(&"").trim_end_matches(':').to_string() 125 | } else { 126 | "unknown".to_string() 127 | }; 128 | 129 | // 消息是剩余的部分 130 | let message = if let Some(pos) = line.find(':') { 131 | line[pos + 1..].trim().to_string() 132 | } else { 133 | line.to_string() 134 | }; 135 | 136 | (timestamp, service, message) 137 | } else { 138 | (String::new(), "unknown".to_string(), line.to_string()) 139 | }; 140 | 141 | // 确定日志级别 142 | let level = if line.to_lowercase().contains("error") { 143 | "ERROR".to_string() 144 | } else if line.to_lowercase().contains("warn") { 145 | "WARN".to_string() 146 | } else if line.to_lowercase().contains("info") { 147 | "INFO".to_string() 148 | } else if line.to_lowercase().contains("debug") { 149 | "DEBUG".to_string() 150 | } else if line.to_lowercase().contains("fail") { 151 | "ERROR".to_string() 152 | } else { 153 | "INFO".to_string() 154 | }; 155 | 156 | LogEntry { 157 | timestamp, 158 | level, 159 | service, 160 | message, 161 | raw: line.to_string(), 162 | highlighted, 163 | } 164 | } 165 | 166 | /// 生成获取日志的命令 167 | pub fn generate_log_read_command(log_path: &str, page: usize, page_size: usize, filter: Option<&str>, date_filter: Option<&str>) -> String { 168 | let total_lines = page * page_size; 169 | 170 | let mut grep_part = String::new(); 171 | if let Some(filter_text) = filter { 172 | if !filter_text.trim().is_empty() { 173 | grep_part.push_str(&format!(" | grep -i '{}'", filter_text)); 174 | } 175 | } 176 | 177 | if let Some(date) = date_filter { 178 | if !date.trim().is_empty() { 179 | // 简单的日期匹配,假设日志行包含该日期字符串 180 | grep_part.push_str(&format!(" | grep '{}'", date)); 181 | } 182 | } 183 | 184 | // 逻辑:先过滤(如果需要),然后取最后的 N 行,再取前 page_size 行 185 | // 注意:如果使用了 grep,由于不知道匹配的总行数,分页会变得复杂。 186 | // 这里采用简化的策略:如果不使用 grep,直接用 tail 分页。 187 | // 如果使用了 grep,则对 grep 的结果进行 tail 分页。 188 | 189 | if grep_part.is_empty() { 190 | // 无过滤:tail -n (page * size) | head -n size (注意:这里 head 取的是 tail 输出的前面,即最旧的,我们需要反转) 191 | // 正确的倒序分页(最新的在最前): 192 | // page 1: tail -n 100 193 | // page 2: tail -n 200 | head -n 100 194 | // 但是 tail 输出是旧->新。 195 | // tail -n 200 输出:[Line N-199 ... Line N] 196 | // 我们需要的是 [Line N-199 ... Line N-100] 197 | // 所以是 head -n 100。 198 | 199 | if page == 1 { 200 | format!("tail -n {} {} 2>/dev/null || echo 'Log file not found'", page_size, log_path) 201 | } else { 202 | format!("tail -n {} {} 2>/dev/null | head -n {} || echo 'Log file not found'", total_lines, log_path, page_size) 203 | } 204 | } else { 205 | // 有过滤:cat file | grep ... | tail ... 206 | // 这里效率较低,但功能优先 207 | if page == 1 { 208 | format!("cat {} 2>/dev/null {} | tail -n {} || echo 'No matching entries'", log_path, grep_part, page_size) 209 | } else { 210 | format!("cat {} 2>/dev/null {} | tail -n {} | head -n {} || echo 'No matching entries'", log_path, grep_part, total_lines, page_size) 211 | } 212 | } 213 | } 214 | 215 | /// 生成获取journalctl日志的命令 216 | pub fn generate_journalctl_command(page: usize, page_size: usize, unit: Option<&str>, filter: Option<&str>, since: Option<&str>, until: Option<&str>) -> String { 217 | // journalctl 默认是旧->新。使用 -r 可以反向(新->旧)。 218 | // 使用 -r 配合分页更方便。 219 | // journalctl -r -n (page * size) | tail -n size 220 | // 注意:journalctl -n 输出的是最后的 N 行。 221 | 222 | let mut cmd = String::from("journalctl --no-pager"); 223 | 224 | if let Some(unit_name) = unit { 225 | if !unit_name.trim().is_empty() { 226 | cmd.push_str(&format!(" -u {}", unit_name)); 227 | } 228 | } 229 | 230 | if let Some(s) = since { 231 | if !s.trim().is_empty() { 232 | cmd.push_str(&format!(" --since \"{}\"", s)); 233 | } 234 | } 235 | 236 | if let Some(u) = until { 237 | if !u.trim().is_empty() { 238 | cmd.push_str(&format!(" --until \"{}\"", u)); 239 | } 240 | } 241 | 242 | if let Some(filter_text) = filter { 243 | if !filter_text.trim().is_empty() { 244 | cmd.push_str(&format!(" | grep -i '{}'", filter_text)); 245 | } 246 | } 247 | 248 | // 分页逻辑 249 | let total_lines = page * page_size; 250 | 251 | // journalctl 本身没有方便的"跳过N行"的参数(除了cursor)。 252 | // 我们可以利用 tail/head 管道。 253 | // 假设我们要看最新的日志(倒序)。 254 | // 我们可以让 journalctl 输出所有(或足够多),然后用 tail 处理。 255 | // 或者使用 -n 参数。 256 | // page 1: journalctl -n 100 257 | // page 2: journalctl -n 200 | head -n 100 (取旧的部分) 258 | 259 | if page == 1 { 260 | cmd.push_str(&format!(" -n {}", page_size)); 261 | } else { 262 | cmd.push_str(&format!(" -n {} | head -n {}", total_lines, page_size)); 263 | } 264 | 265 | cmd.push_str(" 2>/dev/null || echo 'journalctl not available'"); 266 | cmd 267 | } 268 | 269 | /// 生成获取日志文件列表的命令 270 | pub fn generate_list_log_files_command() -> String { 271 | format!( 272 | r#"find /var/log -maxdepth 2 -type f \( -name "*.log" -o -name "messages" -o -name "secure" -o -name "syslog" -o -name "auth.log" \) -readable -exec stat -c "%s|%n|%Y" {{}} \; 2>/dev/null | head -50"# 273 | ) 274 | } 275 | 276 | /// 生成获取日志文件信息的命令 277 | pub fn generate_log_file_info_command(log_path: &str) -> String { 278 | format!( 279 | r#"stat -c "size:%s|modified:%y|readable:yes" {} 2>/dev/null || echo "readable:no""#, 280 | log_path 281 | ) 282 | } 283 | -------------------------------------------------------------------------------- /src-tauri/src/ssh_channel_manager.rs: -------------------------------------------------------------------------------- 1 | use ssh2::{Channel, Session}; 2 | use std::sync::{Arc, Mutex, RwLock}; 3 | use std::collections::HashMap; 4 | use std::time::{Duration, Instant}; 5 | use crate::types::{LovelyResResult, LovelyResError}; 6 | 7 | /// SSH Channel state tracking 8 | #[derive(Debug, Clone, PartialEq)] 9 | pub enum ChannelState { 10 | Active, 11 | Closing, 12 | Closed, 13 | Error(String), 14 | } 15 | 16 | /// Enhanced SSH Channel wrapper with state management 17 | pub struct ManagedChannel { 18 | pub channel: Channel, 19 | pub state: Arc>, 20 | pub last_activity: Arc>, 21 | pub session_id: String, 22 | pub created_at: Instant, 23 | } 24 | 25 | impl ManagedChannel { 26 | pub fn new(channel: Channel, session_id: String) -> Self { 27 | Self { 28 | channel, 29 | state: Arc::new(RwLock::new(ChannelState::Active)), 30 | last_activity: Arc::new(Mutex::new(Instant::now())), 31 | session_id, 32 | created_at: Instant::now(), 33 | } 34 | } 35 | 36 | /// Check if channel is writable (not closed or closing) 37 | pub fn is_writable(&self) -> bool { 38 | match *self.state.read().unwrap() { 39 | ChannelState::Active => true, 40 | _ => false, 41 | } 42 | } 43 | 44 | /// Check if channel is readable 45 | pub fn is_readable(&self) -> bool { 46 | match *self.state.read().unwrap() { 47 | ChannelState::Active => true, 48 | _ => false, 49 | } 50 | } 51 | 52 | /// Update last activity timestamp 53 | pub fn update_activity(&self) { 54 | *self.last_activity.lock().unwrap() = Instant::now(); 55 | } 56 | 57 | /// Set channel state 58 | pub fn set_state(&self, new_state: ChannelState) { 59 | *self.state.write().unwrap() = new_state; 60 | } 61 | 62 | /// Get current state 63 | pub fn get_state(&self) -> ChannelState { 64 | self.state.read().unwrap().clone() 65 | } 66 | 67 | /// Check if channel has been inactive for too long 68 | pub fn is_stale(&self, timeout: Duration) -> bool { 69 | self.last_activity.lock().unwrap().elapsed() > timeout 70 | } 71 | } 72 | 73 | /// SSH Channel Manager for state tracking and health monitoring 74 | pub struct SSHChannelManager { 75 | channels: Arc>>>, 76 | session: Arc>>, 77 | health_check_interval: Duration, 78 | channel_timeout: Duration, 79 | } 80 | 81 | impl SSHChannelManager { 82 | pub fn new(session: Session) -> Self { 83 | Self { 84 | channels: Arc::new(RwLock::new(HashMap::new())), 85 | session: Arc::new(Mutex::new(Some(session))), 86 | health_check_interval: Duration::from_secs(3600), // 1小时检查一次,减少干扰 87 | channel_timeout: Duration::from_secs(u64::MAX / 2), // 几乎永不超时 88 | } 89 | } 90 | 91 | /// Register a new managed channel 92 | pub fn register_channel(&self, session_id: String, channel: Channel) -> Arc { 93 | let managed_channel = Arc::new(ManagedChannel::new(channel, session_id.clone())); 94 | self.channels.write().unwrap().insert(session_id, managed_channel.clone()); 95 | managed_channel 96 | } 97 | 98 | /// Get a managed channel by session ID 99 | pub fn get_channel(&self, session_id: &str) -> Option> { 100 | self.channels.read().unwrap().get(session_id).cloned() 101 | } 102 | 103 | /// Remove a channel from management 104 | pub fn remove_channel(&self, session_id: &str) { 105 | self.channels.write().unwrap().remove(session_id); 106 | } 107 | 108 | /// Check SSH session health 109 | pub fn check_session_health(&self) -> LovelyResResult { 110 | // 完全跳过keepalive检查,避免干扰SSH会话 111 | // 直接返回true,让实际的数据传输来判断连接状态 112 | Ok(true) 113 | } 114 | 115 | /// Validate channel state before operations 116 | pub fn validate_channel_for_write(&self, session_id: &str) -> LovelyResResult<()> { 117 | if let Some(managed_channel) = self.get_channel(session_id) { 118 | // Check if channel is writable 119 | if !managed_channel.is_writable() { 120 | return Err(LovelyResError::SSHError( 121 | format!("Channel {} is not writable (state: {:?})", 122 | session_id, managed_channel.get_state()) 123 | )); 124 | } 125 | 126 | // Check if channel is stale 127 | if managed_channel.is_stale(self.channel_timeout) { 128 | managed_channel.set_state(ChannelState::Error("Channel timeout".to_string())); 129 | return Err(LovelyResError::SSHError( 130 | format!("Channel {} has timed out", session_id) 131 | )); 132 | } 133 | 134 | // Check if channel is EOF 135 | if managed_channel.channel.eof() { 136 | managed_channel.set_state(ChannelState::Closed); 137 | return Err(LovelyResError::SSHError( 138 | format!("Channel {} has reached EOF", session_id) 139 | )); 140 | } 141 | 142 | Ok(()) 143 | } else { 144 | Err(LovelyResError::SSHError( 145 | format!("Channel {} not found", session_id) 146 | )) 147 | } 148 | } 149 | 150 | /// Safe write operation with state validation 151 | pub fn safe_write(&self, session_id: &str, _data: &[u8]) -> LovelyResResult { 152 | // Validate channel state first 153 | self.validate_channel_for_write(session_id)?; 154 | 155 | if let Some(_managed_channel) = self.get_channel(session_id) { 156 | // We need to get a mutable reference to the channel 157 | // Since we can't get a mutable reference through Arc, we'll need to restructure this 158 | // For now, let's return an error indicating this needs to be handled differently 159 | Err(LovelyResError::SSHError( 160 | "Channel write operation needs to be handled at a higher level".to_string() 161 | )) 162 | } else { 163 | Err(LovelyResError::SSHError( 164 | format!("Channel {} not found", session_id) 165 | )) 166 | } 167 | } 168 | 169 | /// Cleanup stale channels 170 | pub fn cleanup_stale_channels(&self) { 171 | let mut channels = self.channels.write().unwrap(); 172 | let stale_channels: Vec = channels 173 | .iter() 174 | .filter(|(_, channel)| channel.is_stale(self.channel_timeout)) 175 | .map(|(id, _)| id.clone()) 176 | .collect(); 177 | 178 | for channel_id in stale_channels { 179 | if let Some(channel) = channels.get(&channel_id) { 180 | channel.set_state(ChannelState::Error("Stale channel cleanup".to_string())); 181 | } 182 | channels.remove(&channel_id); 183 | println!("🧹 Cleaned up stale channel: {}", channel_id); 184 | } 185 | } 186 | 187 | /// Get channel statistics 188 | pub fn get_channel_stats(&self) -> HashMap { 189 | let channels = self.channels.read().unwrap(); 190 | channels 191 | .iter() 192 | .map(|(id, channel)| { 193 | let age = channel.created_at.elapsed(); 194 | (id.clone(), (channel.get_state(), age)) 195 | }) 196 | .collect() 197 | } 198 | } 199 | 200 | /// SSH Connection Health Monitor 201 | pub struct SSHHealthMonitor { 202 | channel_manager: Arc, 203 | monitoring: Arc>, 204 | } 205 | 206 | impl SSHHealthMonitor { 207 | pub fn new(channel_manager: Arc) -> Self { 208 | Self { 209 | channel_manager, 210 | monitoring: Arc::new(Mutex::new(false)), 211 | } 212 | } 213 | 214 | /// Start health monitoring in background 215 | pub fn start_monitoring(&self) { 216 | let mut monitoring = self.monitoring.lock().unwrap(); 217 | if *monitoring { 218 | return; // Already monitoring 219 | } 220 | *monitoring = true; 221 | 222 | let channel_manager = self.channel_manager.clone(); 223 | let monitoring_flag = self.monitoring.clone(); 224 | 225 | std::thread::spawn(move || { 226 | while *monitoring_flag.lock().unwrap() { 227 | // Check session health 228 | if let Err(e) = channel_manager.check_session_health() { 229 | println!("⚠️ SSH health check failed: {}", e); 230 | } 231 | 232 | // Cleanup stale channels 233 | channel_manager.cleanup_stale_channels(); 234 | 235 | // Sleep for health check interval 236 | std::thread::sleep(Duration::from_secs(30)); 237 | } 238 | }); 239 | } 240 | 241 | /// Stop health monitoring 242 | pub fn stop_monitoring(&self) { 243 | *self.monitoring.lock().unwrap() = false; 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/modules/ui/theme.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 主题管理器 3 | * 处理主题切换和用户自定义主题 4 | */ 5 | 6 | export class ThemeManager { 7 | private currentTheme: 'light' | 'dark' | 'sakura' = 'light'; 8 | 9 | /** 10 | * 切换主题 11 | */ 12 | toggleTheme(): string { 13 | const body = document.body; 14 | const currentTheme = body.getAttribute('data-theme') || 'light'; 15 | 16 | let newTheme: string; 17 | switch (currentTheme) { 18 | case 'light': 19 | newTheme = 'dark'; 20 | break; 21 | case 'dark': 22 | newTheme = 'sakura'; 23 | break; 24 | case 'sakura': 25 | newTheme = 'light'; 26 | break; 27 | default: 28 | newTheme = 'light'; 29 | } 30 | 31 | this.setTheme(newTheme); 32 | return newTheme; 33 | } 34 | 35 | /** 36 | * 设置主题 37 | */ 38 | setTheme(theme: string): void { 39 | const body = document.body; 40 | const html = document.documentElement; 41 | 42 | // 设置data-theme属性 43 | body.setAttribute('data-theme', theme); 44 | html.setAttribute('data-theme', theme); 45 | 46 | // 更新body类名 47 | body.classList.remove('light-theme', 'dark-theme', 'sakura-theme'); 48 | body.classList.add(`${theme}-theme`); 49 | 50 | // 动态加载主题CSS文件 51 | this.loadThemeCSS(theme); 52 | 53 | // 保存到localStorage 54 | localStorage.setItem('lovelyres-theme', theme); 55 | 56 | this.currentTheme = theme as 'light' | 'dark' | 'sakura'; 57 | 58 | console.log('主题已设置为:', theme); 59 | } 60 | 61 | /** 62 | * 动态加载主题CSS文件 63 | */ 64 | private loadThemeCSS(theme: string): void { 65 | // 移除之前的主题CSS 66 | const existingThemeLinks = document.querySelectorAll('link[data-theme-css]'); 67 | existingThemeLinks.forEach(link => link.remove()); 68 | 69 | // 加载新的主题CSS 70 | const link = document.createElement('link'); 71 | link.rel = 'stylesheet'; 72 | link.href = `/src/css/themes/${theme}.css`; 73 | link.setAttribute('data-theme-css', theme); 74 | 75 | // 添加加载完成事件监听 76 | link.onload = () => { 77 | console.log(`✅ 主题CSS已加载: ${theme}`); 78 | }; 79 | 80 | link.onerror = () => { 81 | console.error(`❌ 主题CSS加载失败: ${theme}`); 82 | }; 83 | 84 | document.head.appendChild(link); 85 | } 86 | 87 | /** 88 | * 获取当前主题 89 | */ 90 | getCurrentTheme(): string { 91 | return document.body.getAttribute('data-theme') || 'light'; 92 | } 93 | 94 | /** 95 | * 初始化主题 96 | */ 97 | initializeTheme(): void { 98 | // 从localStorage加载保存的主题 99 | const savedTheme = localStorage.getItem('lovelyres-theme'); 100 | 101 | if (savedTheme && ['light', 'dark', 'sakura'].includes(savedTheme)) { 102 | this.setTheme(savedTheme); 103 | } else { 104 | // 检查系统偏好 105 | const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; 106 | this.setTheme(prefersDark ? 'dark' : 'light'); 107 | } 108 | 109 | // 监听系统主题变化 110 | if (window.matchMedia) { 111 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { 112 | // 只有在没有手动设置主题时才跟随系统 113 | const savedTheme = localStorage.getItem('lovelyres-theme'); 114 | if (!savedTheme) { 115 | this.setTheme(e.matches ? 'dark' : 'light'); 116 | } 117 | }); 118 | } 119 | 120 | console.log('✅ 主题管理器初始化完成'); 121 | } 122 | 123 | /** 124 | * 获取主题配置 125 | */ 126 | getThemeConfig(theme?: string) { 127 | const targetTheme = theme || this.currentTheme; 128 | 129 | const configs = { 130 | light: { 131 | name: '浅色', 132 | icon: '☀️', 133 | description: '清新明亮的浅色主题', 134 | colors: { 135 | primary: '#4299e1', 136 | secondary: '#63b3ed', 137 | accent: '#81e6d9', 138 | background: '#f8fafc', 139 | surface: '#ffffff', 140 | text: '#1e293b' 141 | } 142 | }, 143 | dark: { 144 | name: '深色', 145 | icon: '🌙', 146 | description: '护眼舒适的深色主题', 147 | colors: { 148 | primary: '#4299e1', 149 | secondary: '#63b3ed', 150 | accent: '#81e6d9', 151 | background: '#0f172a', 152 | surface: '#1e293b', 153 | text: '#f1f5f9' 154 | } 155 | }, 156 | sakura: { 157 | name: '樱花粉', 158 | icon: '🌸', 159 | description: '温柔浪漫的樱花主题', 160 | colors: { 161 | primary: '#ff9bb3', 162 | secondary: '#ffb3c1', 163 | accent: '#ffc0cb', 164 | background: '#fef9f9', 165 | surface: '#fffefe', 166 | text: '#744c4c' 167 | } 168 | } 169 | }; 170 | 171 | return configs[targetTheme as keyof typeof configs] || configs.light; 172 | } 173 | 174 | /** 175 | * 获取所有可用主题 176 | */ 177 | getAvailableThemes() { 178 | return [ 179 | this.getThemeConfig('light'), 180 | this.getThemeConfig('dark'), 181 | this.getThemeConfig('sakura') 182 | ]; 183 | } 184 | 185 | /** 186 | * 应用自定义主题 187 | */ 188 | applyCustomTheme(customColors: Record): void { 189 | const root = document.documentElement; 190 | 191 | Object.entries(customColors).forEach(([property, value]) => { 192 | if (property.startsWith('--')) { 193 | root.style.setProperty(property, value); 194 | } else { 195 | root.style.setProperty(`--${property}`, value); 196 | } 197 | }); 198 | } 199 | 200 | /** 201 | * 重置主题到默认值 202 | */ 203 | resetTheme(): void { 204 | const root = document.documentElement; 205 | 206 | // 移除所有自定义CSS变量 207 | const computedStyle = getComputedStyle(root); 208 | const customProperties = Array.from(computedStyle).filter(prop => prop.startsWith('--')); 209 | 210 | customProperties.forEach(prop => { 211 | root.style.removeProperty(prop); 212 | }); 213 | 214 | // 重新设置当前主题 215 | this.setTheme(this.currentTheme); 216 | } 217 | 218 | /** 219 | * 导出当前主题配置 220 | */ 221 | exportThemeConfig(): string { 222 | const root = document.documentElement; 223 | const computedStyle = getComputedStyle(root); 224 | const themeConfig: Record = {}; 225 | 226 | // 获取所有CSS变量 227 | Array.from(computedStyle).forEach(prop => { 228 | if (prop.startsWith('--')) { 229 | themeConfig[prop] = computedStyle.getPropertyValue(prop).trim(); 230 | } 231 | }); 232 | 233 | return JSON.stringify({ 234 | theme: this.currentTheme, 235 | config: this.getThemeConfig(), 236 | customProperties: themeConfig 237 | }, null, 2); 238 | } 239 | 240 | /** 241 | * 导入主题配置 242 | */ 243 | importThemeConfig(configJson: string): boolean { 244 | try { 245 | const config = JSON.parse(configJson); 246 | 247 | if (config.theme) { 248 | this.setTheme(config.theme); 249 | } 250 | 251 | if (config.customProperties) { 252 | this.applyCustomTheme(config.customProperties); 253 | } 254 | 255 | return true; 256 | } catch (error) { 257 | console.error('导入主题配置失败:', error); 258 | return false; 259 | } 260 | } 261 | 262 | /** 263 | * 检查是否为深色主题 264 | */ 265 | isDarkTheme(): boolean { 266 | return this.currentTheme === 'dark'; 267 | } 268 | 269 | /** 270 | * 检查是否为浅色主题 271 | */ 272 | isLightTheme(): boolean { 273 | return this.currentTheme === 'light'; 274 | } 275 | 276 | /** 277 | * 检查是否为樱花主题 278 | */ 279 | isSakuraTheme(): boolean { 280 | return this.currentTheme === 'sakura'; 281 | } 282 | 283 | /** 284 | * 获取主题对比色 285 | */ 286 | getContrastColor(backgroundColor: string): string { 287 | // 简单的对比色计算 288 | const hex = backgroundColor.replace('#', ''); 289 | const r = parseInt(hex.substr(0, 2), 16); 290 | const g = parseInt(hex.substr(2, 2), 16); 291 | const b = parseInt(hex.substr(4, 2), 16); 292 | 293 | // 计算亮度 294 | const brightness = (r * 299 + g * 587 + b * 114) / 1000; 295 | 296 | return brightness > 128 ? '#000000' : '#ffffff'; 297 | } 298 | 299 | /** 300 | * 生成主题预览 301 | */ 302 | generateThemePreview(theme: string): string { 303 | const config = this.getThemeConfig(theme); 304 | 305 | return ` 306 |
317 |
318 | ${config.icon} 319 | ${config.name} 320 |
321 |
322 | ${config.description} 323 |
324 |
332 | 示例按钮 333 |
334 |
335 | `; 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /src/modules/user/accountSettingsModal.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 账户设置模态框 3 | */ 4 | 5 | import { apiService } from '../api/apiService'; 6 | import { userManager } from './userManager'; 7 | 8 | export class AccountSettingsModal { 9 | private static instance: AccountSettingsModal; 10 | private modal: HTMLElement | null = null; 11 | 12 | private constructor() {} 13 | 14 | public static getInstance(): AccountSettingsModal { 15 | if (!AccountSettingsModal.instance) { 16 | AccountSettingsModal.instance = new AccountSettingsModal(); 17 | } 18 | return AccountSettingsModal.instance; 19 | } 20 | 21 | /** 22 | * 显示账户设置模态框 23 | */ 24 | public show(): void { 25 | if (!this.modal) { 26 | this.createModal(); 27 | } 28 | 29 | const user = userManager.getCurrentUser(); 30 | if (!user) { 31 | console.error('❌ 未登录,无法打开账户设置'); 32 | return; 33 | } 34 | 35 | // 填充表单数据 36 | const nicknameInput = document.getElementById('account-nickname') as HTMLInputElement; 37 | const emailInput = document.getElementById('account-email') as HTMLInputElement; 38 | const qqIdInput = document.getElementById('account-qq-id') as HTMLInputElement; 39 | 40 | if (nicknameInput) nicknameInput.value = user.nickname || ''; 41 | if (emailInput) emailInput.value = user.email || ''; 42 | if (qqIdInput) qqIdInput.value = user.qq_id || ''; 43 | 44 | this.modal!.style.display = 'flex'; 45 | } 46 | 47 | /** 48 | * 隐藏账户设置模态框 49 | */ 50 | public hide(): void { 51 | if (this.modal) { 52 | this.modal.style.display = 'none'; 53 | } 54 | } 55 | 56 | /** 57 | * 创建模态框 58 | */ 59 | private createModal(): void { 60 | const modal = document.createElement('div'); 61 | modal.id = 'account-settings-modal'; 62 | modal.className = 'modal-overlay'; 63 | modal.style.cssText = ` 64 | position: fixed; 65 | top: 0; 66 | left: 0; 67 | right: 0; 68 | bottom: 0; 69 | background: rgba(0, 0, 0, 0.5); 70 | display: none; 71 | justify-content: center; 72 | align-items: center; 73 | z-index: 10000; 74 | `; 75 | 76 | modal.innerHTML = ` 77 | 214 | `; 215 | 216 | // 点击背景关闭 217 | modal.addEventListener('click', (e) => { 218 | if (e.target === modal) { 219 | this.hide(); 220 | } 221 | }); 222 | 223 | // 表单提交 224 | const form = modal.querySelector('#account-settings-form') as HTMLFormElement; 225 | form.addEventListener('submit', async (e) => { 226 | e.preventDefault(); 227 | await this.handleSubmit(); 228 | }); 229 | 230 | document.body.appendChild(modal); 231 | this.modal = modal; 232 | } 233 | 234 | /** 235 | * 处理表单提交 236 | */ 237 | private async handleSubmit(): Promise { 238 | const nicknameInput = document.getElementById('account-nickname') as HTMLInputElement; 239 | const qqIdInput = document.getElementById('account-qq-id') as HTMLInputElement; 240 | 241 | const nickname = nicknameInput.value.trim(); 242 | const qq_id = qqIdInput.value.trim(); 243 | 244 | try { 245 | // 调用 API 更新用户信息(不包括邮箱,邮箱不可修改) 246 | const updatedUser = await apiService.updateUserInfo({ 247 | nickname: nickname || undefined, 248 | qq_id: qq_id || undefined, 249 | }); 250 | 251 | // 更新本地用户信息 252 | userManager.updateUserInfo(updatedUser); 253 | 254 | // 显示成功提示 255 | (window as any).showNotification && (window as any).showNotification('账户信息更新成功', 'success'); 256 | 257 | // 关闭模态框 258 | this.hide(); 259 | } catch (error: any) { 260 | console.error('❌ 更新账户信息失败:', error); 261 | (window as any).showNotification && (window as any).showNotification( 262 | error.message || '更新账户信息失败', 263 | 'error' 264 | ); 265 | } 266 | } 267 | } 268 | 269 | // 导出单例实例 270 | export const accountSettingsModal = AccountSettingsModal.getInstance(); 271 | 272 | // 全局函数 273 | (window as any).closeAccountSettings = function() { 274 | accountSettingsModal.hide(); 275 | }; 276 | 277 | --------------------------------------------------------------------------------