├── src ├── assets │ ├── icons │ │ ├── app-icon.icns │ │ ├── app-icon.ico │ │ ├── tray-icon.png │ │ └── app-icon.svg │ └── locales │ │ ├── zh-CN.json │ │ └── en-US.json ├── shared │ ├── defines.js │ ├── appUtils.js │ └── i18n.js ├── renderer │ ├── css │ │ ├── themes.css │ │ └── styles.css │ ├── index.html │ ├── js │ │ ├── ui-manager.js │ │ ├── context-menu.js │ │ ├── edit-item.js │ │ ├── settings.js │ │ └── renderer.js │ ├── edit-item.html │ └── settings.html ├── main │ ├── tray-manager.js │ ├── main.js │ ├── item-handler.js │ ├── data-store.js │ ├── ipc-handler.js │ └── window-manager.js └── preload │ └── preload.js ├── doc └── imgs │ └── launcher_app_main_window.png ├── .vscode └── launch.json ├── LICENSE ├── README_zh.md ├── package.json ├── .gitignore ├── README.md └── .github └── copilot-instructions.md /src/assets/icons/app-icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SolarianZ/launcher-app-electron/main/src/assets/icons/app-icon.icns -------------------------------------------------------------------------------- /src/assets/icons/app-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SolarianZ/launcher-app-electron/main/src/assets/icons/app-icon.ico -------------------------------------------------------------------------------- /src/assets/icons/tray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SolarianZ/launcher-app-electron/main/src/assets/icons/tray-icon.png -------------------------------------------------------------------------------- /doc/imgs/launcher_app_main_window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SolarianZ/launcher-app-electron/main/doc/imgs/launcher_app_main_window.png -------------------------------------------------------------------------------- /src/shared/defines.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 共享常量和类定义 3 | * 此文件包含在主进程和渲染进程之间共享的常量、枚举和类定义 4 | * 用于确保代码一致性和类型安全 5 | */ 6 | 7 | /** 8 | * 路径类型枚举 9 | * 用于标识不同类型的项目 10 | * @readonly 11 | * @enum {string} 12 | */ 13 | const PathType = { 14 | FILE: "file", // 文件类型 15 | FOLDER: "folder", // 文件夹类型 16 | URL: "url", // 网址类型 17 | COMMAND: "command", // 命令类型 18 | }; 19 | 20 | /** 21 | * 列表项类 22 | * 表示启动器中的一个项目 23 | */ 24 | class ListItem { 25 | /** 26 | * 创建一个新的列表项 27 | * @param {string} path - 项目的路径、URL或命令 28 | * @param {string} type - 项目类型,使用PathType枚举值 29 | */ 30 | constructor(path, type) { 31 | this.path = path; 32 | this.type = type; 33 | // 注意:name属性可以后续添加,默认为空 34 | } 35 | } 36 | 37 | // 导出模块定义 38 | module.exports = { 39 | PathType, 40 | ListItem, 41 | }; 42 | -------------------------------------------------------------------------------- /src/shared/appUtils.js: -------------------------------------------------------------------------------- 1 | const { app } = require('electron'); 2 | 3 | /** 4 | * 更新自启动设置 5 | * 根据配置更新应用是否开机启动 6 | * @param {boolean} enabled 是否启用自启动 7 | */ 8 | function updateAutoLaunchSettings(enabled) { 9 | // 开发环境禁止设置自启动(会导致node_modules中的electron自启) 10 | if (enabled && !app.isPackaged) { 11 | console.error('Error enabling auto launch: Auto launch is disabled in development mode.'); 12 | return false; 13 | } 14 | 15 | try { 16 | app.setLoginItemSettings({ 17 | openAtLogin: enabled, 18 | openAsHidden: true, 19 | args: ['--autostart'] 20 | }); 21 | console.log(`Auto launch ${enabled ? 'enabled' : 'disabled'}`); 22 | return true; 23 | } catch (error) { 24 | console.error('Error setting auto launch:', error); 25 | return false; 26 | } 27 | } 28 | 29 | module.exports = { 30 | updateAutoLaunchSettings 31 | }; -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "compounds": [ 4 | { 5 | "name": "Main + Renderer", 6 | "configurations": ["Main", "Renderer"], 7 | "stopAll": true 8 | } 9 | ], 10 | "configurations": [ 11 | { 12 | "name": "Renderer", 13 | "port": 9222, 14 | "request": "attach", 15 | "type": "chrome", 16 | "webRoot": "${workspaceFolder}" 17 | }, 18 | { 19 | "name": "Main", 20 | "type": "node", 21 | "request": "launch", 22 | "cwd": "${workspaceFolder}", 23 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", 24 | "windows": { 25 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" 26 | }, 27 | "args": [".", "--remote-debugging-port=9222"], 28 | "outputCapture": "std", 29 | "console": "integratedTerminal" 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 ZQY 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/renderer/css/themes.css: -------------------------------------------------------------------------------- 1 | /* 深色主题 */ 2 | .dark-theme { 3 | --primary-color: #5c9ce6; 4 | --secondary-color: #7ab85f; 5 | --text-color: #e0e0e0; 6 | --bg-color: #1e1e1e; 7 | --card-bg: #2d2d2d; 8 | --border-color: #444; 9 | --hover-bg: #3a3a3a; 10 | --active-bg: #444; 11 | --disabled-bg: #2a2a2a; 12 | --disabled-text: #777; 13 | --shadow: 0 2px 5px rgba(0, 0, 0, 0.3); 14 | --title-bar-bg: #333; 15 | --title-bar-text: #e0e0e0; 16 | --search-bg: #3d3d3d; 17 | --modal-overlay: rgba(0, 0, 0, 0.7); 18 | --tooltip-bg: #e0e0e0; 19 | --tooltip-text: #333; 20 | --drag-indicator: #5c9ce6; 21 | --icon-color: #aaa; 22 | --menu-bg: #2d2d2d; 23 | --menu-hover: #3a3a3a; 24 | --menu-border: #444; 25 | --button-text: #fff; 26 | --button-bg: #5c9ce6; 27 | --button-hover: #4c8cd6; 28 | --button-active: #3c7cc6; 29 | --button-disabled: #3a5980; 30 | --cancel-button-bg: #3d3d3d; 31 | --cancel-button-text: #e0e0e0; 32 | --cancel-button-hover: #4a4a4a; 33 | --cancel-button-active: #555; 34 | --error-color: #e05c5c; 35 | } -------------------------------------------------------------------------------- /src/assets/icons/app-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README_zh.md: -------------------------------------------------------------------------------- 1 | # Launcher App 2 | 3 | [English](./README.md) 4 | 5 | > 🚀 快速访问常用文件、文件夹、URL 和命令的桌面启动器应用 6 | 7 | ![使用Electron构建](https://img.shields.io/badge/Built%20with-Electron-47848F) 8 | ![MIT许可证](https://img.shields.io/badge/License-MIT-green) 9 | 10 | ![Main Window](./doc/imgs/launcher_app_main_window.png) 11 | 12 | ## 📋 项目简介 13 | 14 | Launcher App 是一款基于 Electron 开发的桌面快速启动器工具,帮助用户快速访问常用的文件、文件夹、网站和命令。 15 | 16 | > ⚠️ **声明:** 17 | > 1. 本项目的代码主要由人工智能辅助生成。项目结构、功能实现及界面设计均使用了 AI 技术。 18 | > 2. 此文档内容亦主要由 AI 生成。 19 | > 3. 软件未经严格测试。 20 | 21 | ## ✨ 主要功能 22 | 23 | - 🗂️ 添加和管理多种类型的项目 24 | - 文件 25 | - 文件夹 26 | - 网址和 Deep Link 27 | - 命令行指令 28 | - 🔍 快速搜索项目 29 | - 🖱️ 支持拖放文件/文件夹直接添加 30 | - 📋 右键菜单提供丰富操作选项 31 | - 🌓 支持深色和浅色主题 32 | - 🌍 支持多语言 33 | - 内置中文、英文支持 34 | - 可以通过添加翻译文件来支持更多语言 35 | - ⌨️ 自定义全局快捷键呼出应用(默认是Alt+Shift+Q) 36 | - 🧩 系统托盘集成,显示最近使用的项目 37 | - 🔄 支持拖拽重新排序列表 38 | - ⚡ 通过双击或回车快速打开项目 39 | - 💬 跨平台支持 (Windows, macOS, Linux,未严格测试) 40 | 41 | ## 📥 构建 42 | 43 | 1. 确保已安装 [Node.js](https://nodejs.org/) (推荐 22 LTS 或更高版本) 44 | 45 | 2. 克隆仓库 46 | 47 | ```bash 48 | git clone https://github.com/SolarianZ/launcher-app-electron.git 49 | cd launcher-app-electron 50 | ``` 51 | 52 | 3. 安装依赖 53 | 54 | ```bash 55 | npm install 56 | ``` 57 | 58 | 4. 启动应用 59 | 60 | ```bash 61 | npm start 62 | ``` 63 | 64 | ### 📦 打包应用 65 | 66 | 使用 electron-builder 打包为可分发的应用程序: 67 | 68 | ```bash 69 | npm run build 70 | ``` 71 | 72 | 生成的安装包将保存在 `dist` 目录中。 73 | 74 | ## 🧩 技术实现 75 | 76 | - **Electron**:跨平台桌面应用框架 77 | - **模块化架构**:主进程和渲染进程分离 78 | - **IPC通信**:进程间安全通信 79 | - **本地存储**:JSON文件持久化数据 80 | - **国际化**:多语言支持系统 81 | - **响应式UI**:适配不同尺寸和主题 82 | 83 | 更多项目细节参考 [copilot-instructions](./.github/copilot-instructions.md) 。 84 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "launcher-app", 3 | "version": "1.0.0", 4 | "description": "快速访问常用文件、文件夹、URL和指令的工具", 5 | "main": "src/main/main.js", 6 | "scripts": { 7 | "start": "electron .", 8 | "build": "electron-builder" 9 | }, 10 | "author": "ZQY", 11 | "homepage": "https://github.com/SolarianZ/launcher-app-electron", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "electron": "^35.1.4", 15 | "electron-builder": "^26.0.12" 16 | }, 17 | "build": { 18 | "appId": "com.zqy.launcher-app", 19 | "productName": "Launcher", 20 | "executableName": "Launcher", 21 | "files": [ 22 | "src/**/*", 23 | "package.json", 24 | "!src/assets/locales/**/*" 25 | ], 26 | "extraResources": [ 27 | { 28 | "from": "src/assets/locales", 29 | "to": "app/src/assets/locales", 30 | "filter": [ 31 | "*.json" 32 | ] 33 | } 34 | ], 35 | "directories": { 36 | "output": "dist" 37 | }, 38 | "win": { 39 | "target": "nsis", 40 | "icon": "src/assets/icons/app-icon.ico" 41 | }, 42 | "nsis": { 43 | "oneClick": false, 44 | "allowToChangeInstallationDirectory": true, 45 | "createDesktopShortcut": true, 46 | "createStartMenuShortcut": true, 47 | "shortcutName": "Launcher", 48 | "perMachine": false, 49 | "allowElevation": true 50 | }, 51 | "mac": { 52 | "target": "dmg", 53 | "icon": "src/assets/icons/app-icon.icns" 54 | }, 55 | "linux": { 56 | "target": "AppImage", 57 | "icon": "src/assets/icons/app-icon.ico", 58 | "category": "Utility" 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | Launcher 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 |
22 |
23 |
24 |
Launcher
25 | 26 |
27 |
28 |
29 | 30 | 31 |
32 | 33 | 34 |
35 | 36 | 37 |
38 | 39 |
40 |
41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/assets/locales/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": "设置", 3 | "appearance": "外观", 4 | "theme": "主题", 5 | "system-theme": "跟随系统", 6 | "light-theme": "浅色", 7 | "dark-theme": "深色", 8 | "language": "语言", 9 | "system-language": "跟随系统", 10 | "zh-CN": "中文", 11 | "en-US": "English", 12 | "add-language": "添加语言", 13 | "shortcuts": "快捷键", 14 | "enable-global-shortcut": "启用全局快捷键", 15 | "enable-shortcut-desc": "使用快捷键快速打开应用", 16 | "launcher-shortcut": "打开 Launcher 快捷键", 17 | "record-shortcut": "记录", 18 | "reset": "重置", 19 | "press-new-shortcut": "按下新的快捷键", 20 | "shortcut-input-desc": "点击\"记录\"按钮后按下组合键", 21 | "shortcut-need-modifier": "快捷键需要至少包含 Ctrl 或 Alt 之一", 22 | "shortcut-need-modifier-macos": "快捷键需要至少包含 Control 、 Option 或 Command 之一", 23 | "shortcut-taken": "该快捷键已被其他应用占用", 24 | "shortcut-invalid": "无效的快捷键", 25 | "shortcut-saved": "快捷键已保存", 26 | "shortcut-reset": "快捷键已重置为默认值", 27 | "enable-auto-launch": "开机自启动", 28 | "enable-auto-launch-desc": "系统启动时自动运行 Launcher", 29 | "auto-launch-enabled": "开机自启动已启用", 30 | "auto-launch-disabled": "开机自启动已关闭", 31 | "auto-launch-update-failed": "更新开机自启动设置失败", 32 | "data": "数据", 33 | "clear-data": "清空所有记录", 34 | "clear-data-desc": "移除所有已记录的项目", 35 | "data-cleared": "所有记录已清空", 36 | "open-storage": "打开应用数据文件夹", 37 | "open-storage-desc": "在文件夹中查看应用数据文件", 38 | "about": "关于", 39 | "version": "版本", 40 | "app-desc": "快速访问常用文件、文件夹、网址和命令的工具", 41 | "report-issue": "报告问题", 42 | "dev-tools-tip": "按F12打开Chrome开发者工具", 43 | "confirm-clear-data": "确定要移除所有记录吗?此操作不可撤销。", 44 | "app-name": "Launcher", 45 | "close": "关闭", 46 | "cancel": "取消", 47 | "save": "保存", 48 | "edit": "编辑", 49 | "remove": "移除", 50 | "open": "打开", 51 | "show-in-folder": "在文件夹中显示", 52 | "copy-path": "复制路径", 53 | "entry-exists": "项目已存在", 54 | "save-failed": "保存失败", 55 | "item-not-exist": "项目不存在", 56 | "clear-failed": "清除失败", 57 | "search": "搜索...", 58 | "add-new-item": "添加新项目", 59 | "item-content": "项目内容", 60 | "enter-path": "输入路径、网址或命令...", 61 | "enter-item-name": "输入项目名称(可选)", 62 | "file": "文件", 63 | "folder": "文件夹", 64 | "url": "URL", 65 | "command": "指令", 66 | "command-tip": "要执行多行命令,请将它们写入脚本文件,然后执行脚本文件", 67 | "select-file": "选择文件", 68 | "select-folder": "选择文件夹", 69 | "enter-path-required": "请输入路径或命令", 70 | "select-type-required": "请选择项目类型", 71 | "select-file-failed": "选择文件失败,请重试", 72 | "select-folder-failed": "选择文件夹失败,请重试", 73 | "update-failed": "更新失败,请重试", 74 | "add-failed": "添加失败,请重试", 75 | "context-open": "打开", 76 | "context-edit": "编辑", 77 | "context-remove": "移除", 78 | "context-show-in-folder": "在文件夹中显示", 79 | "context-copy-path": "复制路径", 80 | "context-copy-name": "复制名称", 81 | "context-copy": "复制", 82 | "context-execute": "执行", 83 | "tray-exit": "退出", 84 | "empty-list": "点击 + 按钮添加新项目", 85 | "cannot-get-file-path": "无法获取文件路径", 86 | "item-already-exists": "项目已存在" 87 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | .DS_Store 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Launcher App 2 | 3 | [中文](./README_zh.md) 4 | 5 | > 🚀 A desktop launcher application for quick access to frequently used files, folders, URLs, and commands 6 | 7 | ![Built with Electron](https://img.shields.io/badge/Built%20with-Electron-47848F) 8 | ![MIT License](https://img.shields.io/badge/License-MIT-green) 9 | 10 | ![Main Window](./doc/imgs/launcher_app_main_window.png) 11 | 12 | ## 📋 Project Introduction 13 | 14 | Launcher App is a desktop quick launcher tool developed with Electron, helping users quickly access commonly used files, folders, websites, and commands. 15 | 16 | > ⚠️ **Disclaimer:** 17 | > 1. The code of this project is mainly generated with AI assistance. The project structure, functionality implementation, and interface design all used AI technology. 18 | > 2. This document content is also mainly AI-generated. 19 | > 3. The software has not been rigorously tested. 20 | 21 | ## ✨ Main Features 22 | 23 | - 🗂️ Add and manage multiple types of items 24 | - Files 25 | - Folders 26 | - URLs and Deep Links 27 | - Command line instructions 28 | - 🔍 Quick item search 29 | - 🖱️ Support drag and drop to add files/folders 30 | - 📋 Rich operations through right-click menu 31 | - 🌓 Support dark and light themes 32 | - 🌍 Support for multiple languages 33 | - Built-in Chinese and English support 34 | - Support more languages by adding translation files 35 | - ⌨️ Custom global shortcut key to bring up the app (default is Alt+Shift+Q) 36 | - 🧩 System tray integration, showing recently used items 37 | - 🔄 Support drag and drop to reorder the list 38 | - ⚡ Quick open items with double-click or Enter key 39 | - 💬 Cross-platform support (Windows, macOS, Linux, not rigorously tested) 40 | 41 | ## 📥 Build 42 | 43 | 1. Make sure you have [Node.js](https://nodejs.org/) installed (recommended 22 LTS or higher) 44 | 45 | 2. Clone the repository 46 | 47 | ```bash 48 | git clone https://github.com/SolarianZ/launcher-app-electron.git 49 | cd launcher-app-electron 50 | ``` 51 | 52 | 3. Install dependencies 53 | 54 | ```bash 55 | npm install 56 | ``` 57 | 58 | 4. Start the application 59 | 60 | ```bash 61 | npm start 62 | ``` 63 | 64 | ### 📦 Package the Application 65 | 66 | Use electron-builder to package for distribution: 67 | 68 | ```bash 69 | npm run build 70 | ``` 71 | 72 | The generated installation packages will be saved in the `dist` directory. 73 | 74 | ## 🧩 Technical Implementation 75 | 76 | - **Electron**: Cross-platform desktop application framework 77 | - **Modular Architecture**: Separation of main process and renderer process 78 | - **IPC Communication**: Secure inter-process communication 79 | - **Local Storage**: JSON file for persistent data 80 | - **Internationalization**: Multi-language support system 81 | - **Responsive UI**: Adapts to different sizes and themes 82 | 83 | For more project details, refer to [copilot-instructions](./.github/copilot-instructions.md). 84 | -------------------------------------------------------------------------------- /src/main/tray-manager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 托盘管理模块 3 | * 负责创建和管理系统托盘图标、托盘菜单 4 | * 处理不同平台(Windows, macOS, Linux)下的托盘特性差异 5 | */ 6 | const { Tray, Menu, nativeImage, app } = require('electron'); 7 | const path = require('path'); 8 | const i18n = require('../shared/i18n'); 9 | 10 | // 全局托盘引用 - 防止被垃圾回收 11 | let tray = null; 12 | 13 | /** 14 | * 创建系统托盘 15 | * @param {Function} onClickTray 托盘点击行为 16 | * @returns {Tray} 创建的托盘对象 17 | */ 18 | function createTray(onClickTray) { 19 | if (tray) { 20 | return tray; 21 | } 22 | 23 | /** 24 | * 托盘图标路径 25 | * 根据平台选择不同的图标处理方式 26 | * 托盘图标建议尺寸: 27 | * - Windows: 16x16 或 32x32 像素(建议32x32以适应高DPI屏幕) 28 | * - macOS: 16x16 或 18x18 像素 29 | * - Linux: 根据桌面环境有所不同,通常22x22像素 30 | */ 31 | const iconPath = path.join( 32 | __dirname, 33 | '..', 34 | 'assets', 35 | 'icons', 36 | 'tray-icon.png' 37 | ); 38 | 39 | let icon = nativeImage.createFromPath(iconPath); 40 | 41 | if (process.platform === 'darwin') { 42 | /** 43 | * macOS平台特定处理 44 | * 1. 调整图标大小至18x18像素以适合macOS菜单栏 45 | * 2. 设置为模板图像(templateImage)以适应深色和浅色模式 46 | * 模板图像应为单色透明PNG,macOS会自动处理颜色 47 | */ 48 | const macSize = { width: 18, height: 18 }; 49 | icon = icon.resize(macSize); 50 | // 设置为模板图像,让macOS自动处理明暗主题 51 | icon.setTemplateImage(true); 52 | } 53 | 54 | // 创建托盘实例 55 | tray = new Tray(icon); 56 | tray.setToolTip(i18n.t('app-name')); 57 | 58 | /** 59 | * 设置托盘点击行为 60 | * 注意: 在Linux平台上通常只响应右击显示菜单,此处点击事件在某些发行版可能无效 61 | */ 62 | tray.on('click', () => { 63 | onClickTray(); 64 | }); 65 | 66 | // 监听语言变更事件 67 | i18n.addLanguageChangeListener((language) => { 68 | console.log(`Tray language changed to: ${language}`); 69 | tray.setToolTip(i18n.t('app-name')); 70 | 71 | // 如果有最新的项目列表,刷新托盘菜单 72 | if (lastItemsRef && lastHandlerRef) { 73 | updateTrayMenu(lastItemsRef, lastHandlerRef); 74 | } 75 | }); 76 | 77 | return tray; 78 | } 79 | 80 | // 存储最近的items和handler引用,用于语言变更时刷新托盘菜单 81 | let lastItemsRef = null; 82 | let lastHandlerRef = null; 83 | 84 | /** 85 | * 更新托盘菜单 86 | * @param {Array} items 项目列表 87 | * @param {Function} handleItemAction 处理项目操作的函数 88 | */ 89 | function updateTrayMenu(items, handleItemAction) { 90 | if (!tray) return; 91 | 92 | // 保存最新的引用,用于语言更新时重建菜单 93 | lastItemsRef = items; 94 | lastHandlerRef = handleItemAction; 95 | 96 | // 只显示最近的8个项目,防止菜单过长 97 | const recentItems = items.slice(0, 8).map((item) => { 98 | return { 99 | label: item.name || item.path, 100 | click: () => handleItemAction(item), 101 | }; 102 | }); 103 | 104 | /** 105 | * 创建托盘上下文菜单 106 | * 菜单项中的快捷键在平台间可能有差异: 107 | * - macOS: Command键使用 cmd 108 | * - Windows/Linux: Control键使用 ctrl 109 | */ 110 | const contextMenu = Menu.buildFromTemplate([ 111 | ...recentItems, 112 | { type: 'separator' }, 113 | { 114 | label: i18n.t('tray-exit'), 115 | click: () => { 116 | // 设置标志,表示用户明确要求退出应用 117 | app.isQuitting = true; 118 | app.quit(); 119 | }, 120 | }, 121 | ]); 122 | 123 | // 设置托盘的上下文菜单 124 | tray.setContextMenu(contextMenu); 125 | } 126 | 127 | /** 128 | * 销毁托盘图标 129 | * 在应用退出前调用,释放系统资源 130 | */ 131 | function destroyTray() { 132 | if (tray) { 133 | i18n.removeLanguageChangeListener(); 134 | tray.destroy(); 135 | tray = null; 136 | lastItemsRef = null; 137 | lastHandlerRef = null; 138 | } 139 | } 140 | 141 | // 导出模块函数 142 | module.exports = { 143 | createTray, 144 | updateTrayMenu, 145 | destroyTray 146 | }; -------------------------------------------------------------------------------- /src/assets/locales/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": "Settings", 3 | "appearance": "Appearance", 4 | "theme": "Theme", 5 | "system-theme": "System", 6 | "light-theme": "Light", 7 | "dark-theme": "Dark", 8 | "language": "Language", 9 | "system-language": "System", 10 | "zh-CN": "中文", 11 | "en-US": "English", 12 | "add-language": "Add Language", 13 | "shortcuts": "Shortcuts", 14 | "enable-global-shortcut": "Enable Global Shortcut", 15 | "enable-shortcut-desc": "Use keyboard shortcut to quickly open the app", 16 | "launcher-shortcut": "Launcher Shortcut", 17 | "record-shortcut": "Record", 18 | "reset": "Reset", 19 | "press-new-shortcut": "Press new shortcut key", 20 | "shortcut-input-desc": "Click 'Record' button then press key combination", 21 | "shortcut-need-modifier": "Shortcut needs to include at least one of Ctrl or Alt", 22 | "shortcut-need-modifier-macos": "Shortcut needs to include at least one of Control, Option or Command", 23 | "shortcut-taken": "This shortcut is already taken by another application", 24 | "shortcut-invalid": "Invalid shortcut", 25 | "shortcut-saved": "Shortcut saved", 26 | "shortcut-reset": "Shortcut reset to default", 27 | "enable-auto-launch": "Auto start on system boot", 28 | "enable-auto-launch-desc": "Run Launcher automatically when system starts", 29 | "auto-launch-enabled": "Auto start enabled", 30 | "auto-launch-disabled": "Auto start disabled", 31 | "auto-launch-update-failed": "Failed to update auto start settings", 32 | "data": "Data", 33 | "clear-data": "Clear All Records", 34 | "clear-data-desc": "Remove all recorded entries", 35 | "data-cleared": "All records cleared", 36 | "open-storage": "Open App Data Folder", 37 | "open-storage-desc": "View application data files in folder", 38 | "about": "About", 39 | "version": "Version", 40 | "app-desc": "A tool to quickly access frequently used files, folders, URLs, and commands", 41 | "report-issue": "Report Issue", 42 | "dev-tools-tip": "Press F12 to open Chrome Developer Tools", 43 | "confirm-clear-data": "Are you sure you want to remove all records? This action cannot be undone.", 44 | "app-name": "Launcher", 45 | "close": "Close", 46 | "cancel": "Cancel", 47 | "save": "Save", 48 | "edit": "Edit", 49 | "remove": "Remove", 50 | "open": "Open", 51 | "show-in-folder": "Show in folder", 52 | "copy-path": "Copy path", 53 | "entry-exists": "Entry already exists", 54 | "save-failed": "Save failed", 55 | "item-not-exist": "Item does not exist", 56 | "clear-failed": "Clear failed", 57 | "search": "Search...", 58 | "add-new-item": "Add new item", 59 | "item-content": "Item Content", 60 | "enter-path": "Enter path, URL or command...", 61 | "enter-item-name": "Enter item name (optional)", 62 | "file": "File", 63 | "folder": "Folder", 64 | "url": "URL", 65 | "command": "Command", 66 | "command-tip": "To execute multi-line commands, write them to a script file and execute it", 67 | "select-file": "Select file", 68 | "select-folder": "Select folder", 69 | "enter-path-required": "Please enter a path or command", 70 | "select-type-required": "Please select an item type", 71 | "select-file-failed": "Failed to select file, please try again", 72 | "select-folder-failed": "Failed to select folder, please try again", 73 | "update-failed": "Update failed, please try again", 74 | "add-failed": "Add failed, please try again", 75 | "context-open": "Open", 76 | "context-edit": "Edit", 77 | "context-remove": "Remove", 78 | "context-show-in-folder": "Show in folder", 79 | "context-copy-path": "Copy path", 80 | "context-copy-name": "Copy name", 81 | "context-copy": "Copy", 82 | "context-execute": "Execute", 83 | "tray-exit": "Exit", 84 | "empty-list": "Click + button to add new items", 85 | "cannot-get-file-path": "Cannot get file path", 86 | "item-already-exists": "Item already exists" 87 | } -------------------------------------------------------------------------------- /src/renderer/js/ui-manager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 共享UI管理模块 3 | * 负责处理UI相关的共享功能,包括主题和语言的管理 4 | * 避免在每个窗口脚本中重复相似代码 5 | */ 6 | 7 | // 导入工具函数 8 | const { applyTheme, updatePageTexts, setupSystemThemeListener } = window.uiUtils; 9 | const i18n = window.electronAPI.i18n; 10 | 11 | /** 12 | * 显示提示消息 13 | * 统一的Toast提示实现,避免各窗口重复实现类似功能 14 | * @param {string} message 提示内容 15 | * @param {boolean} isError 是否是错误提示,默认为false 16 | * @param {number} duration 显示时长(毫秒),默认为2000毫秒 17 | */ 18 | function showToast(message, isError = false, duration = 2000) { 19 | // 查找已有的toast元素,如果没有则创建一个 20 | let toast = document.getElementById("toast"); 21 | 22 | // 如果toast不存在,创建一个新的toast元素 23 | if (!toast) { 24 | toast = document.createElement("div"); 25 | toast.id = "toast"; 26 | document.body.appendChild(toast); 27 | } 28 | 29 | // 设置toast内容和样式 30 | toast.textContent = message; 31 | toast.className = "toast"; 32 | 33 | if (isError) { 34 | toast.classList.add("error-toast"); 35 | } 36 | 37 | // 显示toast 38 | toast.style.display = "block"; 39 | toast.style.opacity = "1"; 40 | 41 | // 定时隐藏toast 42 | setTimeout(() => { 43 | toast.style.opacity = "0"; 44 | // 动画完成后隐藏元素 45 | setTimeout(() => { 46 | toast.style.display = "none"; 47 | }, 300); // 假设过渡动画为300ms 48 | }, duration); 49 | 50 | return toast; 51 | } 52 | 53 | /** 54 | * 初始化UI管理 55 | * @param {Object} options 配置选项 56 | * @param {string} options.containerSelector 主容器选择器,如 ".app-container" 或 ".modal" 57 | * @param {Function} options.onThemeChanged 主题变更时的回调函数 (可选) 58 | * @param {Function} options.onLanguageChanged 语言变更时的回调函数 (可选) 59 | * @param {Function} options.onUIReady UI完全准备好时的回调函数 (可选) 60 | * @param {string} options.windowType 窗口类型,用于通知主进程 (可选,默认为'main') 61 | * @returns {Object} 解绑函数对象,用于在需要时移除事件监听 62 | */ 63 | async function initUI(options) { 64 | if (!options || !options.containerSelector) { 65 | console.error("Error initialiing ui manager: missing required parameters"); 66 | return; 67 | } 68 | 69 | const container = document.querySelector(options.containerSelector); 70 | if (!container) { 71 | console.error(`Container element not found: ${options.containerSelector}`); 72 | return; 73 | } 74 | 75 | // 确定窗口类型 76 | const windowType = options.windowType || 'main'; 77 | 78 | // 1. 应用主题设置 79 | const savedTheme = await window.electronAPI.getThemeConfig(); 80 | applyTheme(savedTheme, container); 81 | 82 | // 2. 应用语言设置 83 | await updatePageTexts(i18n); 84 | 85 | // 3. 监听系统主题变化 86 | setupSystemThemeListener(container); 87 | 88 | // 4. 绑定主题变更事件 89 | const onThemeChangedHandler = (theme) => { 90 | console.log("Theme changed to:", theme); 91 | applyTheme(theme, container); 92 | 93 | // 如果提供了回调函数,则调用 94 | if (typeof options.onThemeChanged === 'function') { 95 | options.onThemeChanged(theme); 96 | } 97 | }; 98 | 99 | // 5. 绑定语言变更事件 100 | const onLanguageChangedHandler = (language) => { 101 | console.log("Language changed to:", language); 102 | updatePageTexts(i18n); 103 | 104 | // 如果提供了回调函数,则调用 105 | if (typeof options.onLanguageChanged === 'function') { 106 | options.onLanguageChanged(language); 107 | } 108 | }; 109 | 110 | // 6. 添加事件监听 111 | const themeCleanup = window.electronAPI.onThemeChanged(onThemeChangedHandler); 112 | const languageCleanup = window.electronAPI.onLanguageChanged(onLanguageChangedHandler); 113 | 114 | // 7. 调用UI准备完成回调(如果提供了) 115 | if (typeof options.onUIReady === 'function') { 116 | console.log("UI ready, calling onUIReady callback"); 117 | // 确保UI元素都已经渲染完成 118 | setTimeout(async () => { 119 | await options.onUIReady(); 120 | 121 | // 8. 通知主进程UI已准备完成,可以显示窗口了 122 | console.log(`UI initialized and ready: ${windowType}`); 123 | window.electronAPI.notifyUIReady(windowType); 124 | }, 10); 125 | } else { 126 | // 如果没有提供onUIReady回调,直接通知主进程 127 | console.log(`UI initialized: ${windowType}`); 128 | setTimeout(() => { 129 | window.electronAPI.notifyUIReady(windowType); 130 | }, 10); // 使用短暂延迟确保DOM完全渲染 131 | } 132 | 133 | // 返回解绑函数对象,用于在需要时移除事件监听 134 | return { 135 | unbindAll: () => { 136 | // 调用从事件注册时返回的清理函数 137 | if (themeCleanup && typeof themeCleanup === 'function') { 138 | themeCleanup(); 139 | } 140 | 141 | if (languageCleanup && typeof languageCleanup === 'function') { 142 | languageCleanup(); 143 | } 144 | 145 | console.log('UI manager event listeners cleaned up'); 146 | } 147 | }; 148 | } 149 | 150 | // 导出模块API 151 | window.uiManager = { 152 | initUI, 153 | showToast: showToast 154 | }; -------------------------------------------------------------------------------- /src/renderer/edit-item.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 项目内容 13 | 14 | 15 | 16 | 17 | 77 | 78 | 79 | 80 | 81 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | -------------------------------------------------------------------------------- /src/main/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 主进程入口文件 3 | * 负责应用程序的初始化、生命周期管理和核心功能协调 4 | */ 5 | const { app, globalShortcut } = require('electron'); 6 | 7 | // 导入拆分后的模块 8 | const windowManager = require('./window-manager'); 9 | const dataStore = require('./data-store'); 10 | const trayManager = require('./tray-manager'); 11 | const itemHandler = require('./item-handler'); 12 | const ipcHandler = require('./ipc-handler'); 13 | const i18n = require('../shared/i18n'); 14 | const appUtils = require('../shared/appUtils'); 15 | 16 | // 只允许一个实例运行 17 | const singleInstanceLock = app.requestSingleInstanceLock(); 18 | if (!singleInstanceLock) { 19 | app.quit(); 20 | } else { 21 | app.on('second-instance', (event, commandLine, workingDirectory) => { 22 | // 当第二个实例试图启动时,聚焦到主窗口 23 | windowManager.showMainWindow(); 24 | }); 25 | } 26 | 27 | // 添加一个标志,用于区分应用是要退出还是只是关闭主窗口 28 | app.isQuitting = false; 29 | 30 | // 存储当前注册的全局快捷键 31 | let currentRegisteredShortcut = null; 32 | 33 | /** 34 | * 注册全局快捷键 35 | * 根据用户配置设置全局键盘快捷键 36 | */ 37 | function registerGlobalShortcuts() { 38 | // 注销之前可能注册的快捷键 39 | unregisterGlobalShortcuts(); 40 | 41 | // 加载快捷键配置 42 | const shortcutConfig = dataStore.getAppConfig().shortcut; 43 | 44 | // 如果启用了全局快捷键,则注册 45 | if (shortcutConfig.enabled && shortcutConfig.shortcut) { 46 | try { 47 | globalShortcut.register(shortcutConfig.shortcut, () => { 48 | // 修改为只能打开窗口,不再支持关闭窗口 49 | windowManager.showMainWindow(); 50 | }); 51 | currentRegisteredShortcut = shortcutConfig.shortcut; 52 | console.log(`Global shortcut ${shortcutConfig.shortcut} registered successfully`); 53 | } catch (error) { 54 | console.error(`Error registering global shortcut: ${error.message}`); 55 | } 56 | } 57 | } 58 | 59 | /** 60 | * 注销所有已注册的全局快捷键 61 | */ 62 | function unregisterGlobalShortcuts() { 63 | if (currentRegisteredShortcut) { 64 | try { 65 | globalShortcut.unregister(currentRegisteredShortcut); 66 | currentRegisteredShortcut = null; 67 | console.log('Global shortcut unregistered'); 68 | } catch (error) { 69 | console.error(`Error unregistering global shortcut: ${error.message}`); 70 | } 71 | } 72 | } 73 | 74 | /** 75 | * 更新托盘菜单 76 | * 在数据变化时调用此函数更新托盘菜单 77 | * 显示最近的项目并允许用户快速访问 78 | */ 79 | function updateTrayMenuWithItems() { 80 | trayManager.updateTrayMenu(dataStore.getItems(), itemHandler.handleItemAction); 81 | } 82 | 83 | /** 84 | * 初始化应用语言设置 85 | * 获取配置文件中存储的语言设置或系统语言,并应用 86 | */ 87 | function initializeLanguage() { 88 | // 从配置文件中获取语言设置 89 | const appConfig = dataStore.getAppConfig(); 90 | let selectedLanguage; 91 | 92 | // 根据配置决定使用哪种语言 93 | if (appConfig.language && appConfig.language !== "system") { 94 | // 使用用户配置的语言 95 | selectedLanguage = appConfig.language; 96 | console.log(`Using language from config: ${selectedLanguage}`); 97 | } else { 98 | // 配置为"system"或未设置,则使用系统语言 99 | selectedLanguage = i18n.getSystemLanguage(); 100 | console.log(`Using system language: ${selectedLanguage}`); 101 | } 102 | 103 | // 设置为全局语言变量,以便在创建新窗口时使用 104 | global.appLanguage = selectedLanguage; 105 | 106 | // 初始化i18n模块 107 | i18n.setLanguage(selectedLanguage); 108 | 109 | console.log(`Application language initialized to: ${selectedLanguage}`); 110 | } 111 | 112 | /** 113 | * 初始化应用主题设置 114 | * 获取配置文件中存储的主题设置,并应用到全局变量 115 | */ 116 | function initializeTheme() { 117 | // 从配置文件中获取主题设置 118 | const appConfig = dataStore.getAppConfig(); 119 | const theme = appConfig.theme || "system"; 120 | 121 | // 设置为全局主题变量,以便在创建新窗口时使用 122 | global.appTheme = theme; 123 | 124 | console.log(`Application theme initialized to: ${theme}`); 125 | } 126 | 127 | /** 128 | * 初始化自启动设置 129 | */ 130 | function initializeAutoLaunch() { 131 | const appConfig = dataStore.getAppConfig(); 132 | const autoLaunchEnabled = appConfig.autoLaunch?.enabled || false; 133 | appUtils.updateAutoLaunchSettings(autoLaunchEnabled); 134 | } 135 | 136 | // 应用初始化 - 当Electron完成初始化并准备创建浏览器窗口时触发 137 | app.whenReady().then(() => { 138 | // 首先加载应用配置 139 | dataStore.loadItems(); 140 | dataStore.loadAppConfig(); 141 | 142 | // 初始化应用语言和主题 143 | initializeLanguage(); 144 | initializeTheme(); 145 | 146 | // 初始化自启动设置 147 | initializeAutoLaunch(); 148 | 149 | // 创建主窗口 150 | const isAutoLaunch = process.argv.includes('--autostart'); 151 | windowManager.createMainWindow(!isAutoLaunch); // 开机自启时,静默启动到托盘 152 | 153 | // 创建系统托盘图标 154 | trayManager.createTray(windowManager.showMainWindow); 155 | updateTrayMenuWithItems(); 156 | 157 | // 注册全局快捷键 158 | registerGlobalShortcuts(); 159 | 160 | // 设置IPC通信处理器 - 用于主进程和渲染进程间通信 161 | ipcHandler.setupIpcHandlers(); 162 | 163 | // 在数据存储中添加更新回调,确保数据变化时托盘菜单同步更新 164 | dataStore.addChangeListener(updateTrayMenuWithItems); 165 | 166 | // 添加快捷键配置变化监听器 167 | dataStore.addShortcutChangeListener(config => { 168 | registerGlobalShortcuts(); 169 | }); 170 | }); 171 | 172 | // 当所有窗口关闭时 173 | app.on('window-all-closed', () => { 174 | // 在macOS上,关闭所有窗口通常不会退出应用程序 175 | // 除非用户使用Cmd+Q显式退出 176 | if (process.platform !== 'darwin') { 177 | app.quit(); 178 | } 179 | }); 180 | 181 | // macOS平台特定 - 点击dock图标时 182 | app.on('activate', () => { 183 | // 在macOS上,当点击dock图标且没有其他窗口打开时, 184 | // 通常在应用程序中重新创建一个窗口 185 | if (!windowManager.getMainWindow()) { 186 | windowManager.createMainWindow(); 187 | } 188 | }); 189 | 190 | // 应用退出前清理资源 191 | app.on('will-quit', () => { 192 | // 注销所有快捷键 193 | unregisterGlobalShortcuts(); 194 | globalShortcut.unregisterAll(); 195 | // 销毁托盘图标 196 | trayManager.destroyTray(); 197 | }); 198 | -------------------------------------------------------------------------------- /src/renderer/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 设置 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 111 |
112 | 113 | 114 | 115 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # GitHub Copilot 指导文件 2 | 3 | ## 项目概述 4 | 5 | Launcher App 是一个基于 Electron 的启动器应用程序,用于管理用户常用的文件、文件夹、URL 和指令。 6 | 该应用程序包含项目列表界面(主界面)、项目编辑界面、项目右键菜单、设置界面、托盘图标、托盘右键菜单等。 7 | 支持多平台(Windows, macOS, Linux)、多语言(中文、英文)和主题切换(浅色、深色)。 8 | 9 | ## 主要功能 10 | 11 | 1. **项目管理** 12 | - 添加、编辑和移除项目(文件、文件夹、URL、命令) 13 | - 拖放添加文件/文件夹 14 | - 项目排序与搜索 15 | - 右键菜单操作(打开、复制、在文件夹中显示等) 16 | 17 | 2. **用户界面** 18 | - 主窗口(项目列表) 19 | - 项目编辑窗口 20 | - 设置窗口 21 | - 系统托盘集成 22 | - 键盘导航与快捷键 23 | - 统一的Toast提示组件 24 | 25 | 3. **系统集成** 26 | - 全局快捷键(Alt+Shift+Q 呼出应用) 27 | - 系统托盘菜单(显示最近8个项目) 28 | - 跨平台终端命令执行 29 | - 系统文件操作(在文件夹中显示、复制文件) 30 | 31 | 4. **设置与配置** 32 | - 主题设置(浅色/深色/跟随系统) 33 | - 语言设置(中文/英文/跟随系统) 34 | - 数据管理(清空数据、查看数据存储位置) 35 | 36 | ## 项目结构 37 | 38 | ``` 39 | launcher-app-electron/ 40 | ├── main.js # 应用入口 41 | ├── package.json # 项目配置 42 | ├── src/ 43 | │ ├── assets/ # 静态资源 44 | │ │ ├── icons/ # 应用图标 45 | │ │ └── locales/ # 语言文件 46 | │ ├── main/ # 主进程代码 47 | │ │ ├── data-store.js # 数据存储 48 | │ │ ├── ipc-handler.js # IPC通信 49 | │ │ ├── item-handler.js # 项目处理 50 | │ │ ├── main.js # 主进程入口 51 | │ │ ├── tray-manager.js # 托盘管理 52 | │ │ └── window-manager.js # 窗口管理 53 | │ ├── preload/ # 预加载脚本 54 | │ │ └── preload.js # 预加载入口 55 | │ ├── renderer/ # 渲染进程代码 56 | │ │ ├── css/ # 样式文件 57 | │ │ ├── js/ # 渲染进程脚本 58 | │ │ ├── edit-item.html # 编辑项目窗口 59 | │ │ ├── index.html # 主窗口 60 | │ │ └── settings.html # 设置窗口 61 | │ └── shared/ # 共享代码 62 | │ ├── defines.js # 共享定义 63 | │ └── i18n.js # 国际化 64 | └── ... 65 | ``` 66 | 67 | ## 关键模块说明 68 | 69 | ### 主进程模块 70 | 71 | 1. **window-manager.js** 72 | - 负责创建和管理所有窗口(主窗口、项目编辑窗口、设置窗口) 73 | - 处理窗口生命周期、位置和大小记忆、平台特定窗口样式 74 | 75 | 2. **tray-manager.js** 76 | - 创建和管理系统托盘图标 77 | - 根据平台差异处理托盘图标和菜单 78 | - 显示最近使用的项目列表 79 | 80 | 3. **data-store.js** 81 | - 负责数据持久化存储 82 | - 提供项目CRUD操作 83 | - 存储窗口配置 84 | 85 | 4. **item-handler.js** 86 | - 处理项目类型判断 87 | - 执行项目操作(打开文件/文件夹/URL、执行命令) 88 | - 处理平台特定的终端命令执行 89 | 90 | 5. **ipc-handler.js** 91 | - 配置主进程与渲染进程间的通信 92 | - 处理窗口控制、数据操作、项目操作等IPC消息 93 | 94 | ### 渲染进程模块 95 | 96 | 1. **renderer.js** 97 | - 主窗口逻辑 98 | - 列表渲染和交互 99 | - 拖放处理 100 | - 键盘导航 101 | - 拖拽排序功能 102 | 103 | 2. **edit-item.js** 104 | - 项目编辑表单处理 105 | - 自动类型判断 106 | - 文件/文件夹选择对话框 107 | 108 | 3. **settings.js** 109 | - 主题和语言设置 110 | - 数据管理 111 | - 应用信息显示 112 | 113 | 4. **context-menu.js** 114 | - 右键菜单生成 115 | - 针对不同项目类型生成不同菜单项 116 | 117 | 5. **ui-manager.js** 118 | - 共享 UI 管理函数 119 | - 主题应用与切换 120 | - 语言更新处理 121 | - 事件监听管理 122 | - 提供统一的Toast提示组件 123 | 124 | ### 共享模块 125 | 126 | 1. **defines.js** 127 | - 定义项目类型常量(文件、文件夹、URL、命令) 128 | - 提供 ListItem 类定义 129 | 130 | 2. **i18n.js** 131 | - 多语言支持 132 | - 语言切换 133 | - 文本翻译 134 | 135 | ## 编码规范 136 | 137 | 此项目是 Electron 和 Web 技术的示例项目,需要遵守以下规范,以便入门者阅读学习。 138 | 139 | 1. 注重代码质量和可读性,使用有意义的变量和函数名称。 140 | 2. 添加详细的注释,尤其是处理平台特定代码时。 141 | 3. 分离关注点,将不同功能模块化处理。 142 | 4. 使用异步编程处理IO操作,避免阻塞主线程。 143 | 5. 使用预加载脚本来安全地暴露API,而不是直接启用nodeIntegration。 144 | 6. 避免代码重复,将常用功能提取为模块或共享函数。 145 | 7. 保持代码的一致性,不要在项目中维护多个具有相同功能的文件。 146 | 8. 使用配置文件存储数据,不要使用localStorage。 147 | 9. 打印日志时使用英文,不需要多语言支持,避免乱码。 148 | 149 | ### 代码优化建议 150 | 151 | 1. **UI/UX 一致性** 152 | - 确保所有窗口在主题切换、语言切换时有一致的行为 153 | 154 | 2. **模块化改进** 155 | - 将重复的主题和语言处理函数抽象为共享模块 156 | - 通过预加载脚本提供共享的类型和常量定义,避免重复定义 157 | 158 | 3. **错误处理** 159 | - 添加更全面的错误捕获和用户友好的错误显示 160 | - 实现操作日志记录功能 161 | 162 | ## Electron API备注 163 | 164 | **preload** 165 | 166 | - Electron默认启用沙盒化,在 `preload.js` 只允许使用 `require` 导入Electron和Node的特定子集,不能使用 `require` 导入自定义的Javascript代码。如果需要访问自定义的JavaScript API,需要借助IPC通信。 167 | 168 | **webUtils** 169 | 170 | - 最新版Electron中,需要使用 `webUtils.getPathForFile(file)` 替代 `event.dataTransfer.files[0].path` 来获取文件路径。 171 | 172 | ## API 参考 173 | 174 | ### 主进程暴露给渲染进程的API 175 | 176 | 通过预加载脚本(`preload.js`),主进程向渲染进程暴露了以下API: 177 | 178 | 1. **窗口控制API** 179 | - `window.electronAPI.closeMainWindow()` - 关闭/隐藏窗口 180 | - `window.electronAPI.showSettingsWindow()` - 显示设置窗口 181 | 182 | 2. **项目管理API** 183 | - `window.electronAPI.getItems()` - 获取所有项目 184 | - `window.electronAPI.addItem(item)` - 添加项目 185 | - `window.electronAPI.removeItem(index)` - 移除项目 186 | - `window.electronAPI.updateItem(index, updatedItem)` - 更新项目 187 | - `window.electronAPI.updateItemsOrder(items)` - 更新项目排序 188 | 189 | 3. **项目操作API** 190 | - `window.electronAPI.openItem(item)` - 打开项目 191 | - `window.electronAPI.showItemInFolder(path)` - 在文件夹中显示 192 | - `window.electronAPI.copyText(text)` - 复制文本 193 | - `window.electronAPI.getItemType(path)` - 获取项目类型 194 | 195 | 4. **文件选择API** 196 | - `window.electronAPI.selectFile()` - 打开文件选择对话框 197 | - `window.electronAPI.selectFolder()` - 打开文件夹选择对话框 198 | - `window.electronAPI.getFileOrFolderPath(item)` - 获取拖放文件的路径 199 | 200 | 5. **设置API** 201 | - `window.electronAPI.themeChanged(theme)` - 通知主题变更 202 | - `window.electronAPI.languageChanged(language)` - 通知语言变更 203 | - `window.electronAPI.openStorageLocation()` - 打开存储位置 204 | - `window.electronAPI.clearAllItems()` - 清空所有项目 205 | 206 | 6. **事件监听API** 207 | - `window.electronAPI.onItemsUpdated(callback)` - 监听项目更新 208 | - `window.electronAPI.onThemeChanged(callback)` - 监听主题变更 209 | - `window.electronAPI.onLanguageChanged(callback)` - 监听语言变更 210 | 211 | 7. **国际化API** 212 | - `window.electronAPI.i18n.t(key, params)` - 翻译文本 213 | - `window.electronAPI.i18n.setLanguage(language)` - 设置语言 214 | - `window.electronAPI.i18n.getCurrentLanguage()` - 获取当前语言 215 | - `window.electronAPI.i18n.getSystemLanguage()` - 获取系统语言 216 | - `window.electronAPI.i18n.getAvailableLanguages()` - 获取可用语言列表 217 | - `window.electronAPI.i18n.getLanguageName()` - 获取语言名称 218 | 219 | ### 渲染进程暴露的API 220 | 221 | 除了通过预加载脚本暴露的API外,渲染进程还提供了以下内部API: 222 | 223 | 1. **UI管理器API (ui-manager.js)** 224 | - `window.uiManager.initUI(options)` - 初始化UI 225 | - `options.containerSelector` - 主容器选择器 226 | - `options.onThemeChanged` - 主题变更回调 (可选) 227 | - `options.onLanguageChanged` - 语言变更回调 (可选) 228 | - `window.uiManager.showToast(message, isError, duration)` - 显示提示消息 229 | - `message` - 提示内容 230 | - `isError` - 是否为错误提示,默认false 231 | - `duration` - 显示时长(毫秒),默认2000毫秒 232 | 233 | ## 通用操作流程 234 | 235 | 1. **添加新项目流程** 236 | - 用户点击添加按钮或拖放文件 237 | - 显示编辑窗口 238 | - 填写/确认项目信息 239 | - 保存项目到数据存储 240 | - 刷新主窗口列表 241 | - 更新托盘菜单 242 | 243 | 2. **打开项目流程** 244 | - 用户双击项目或右键菜单选择打开 245 | - 根据项目类型执行对应操作: 246 | - 文件/文件夹: 使用shell.openPath 247 | - URL: 使用shell.openExternal 248 | - 命令: 使用平台特定方法在终端执行 249 | 250 | 3. **设置主题流程** 251 | - 用户在设置窗口选择主题 252 | - 保存主题设置到本地存储 253 | - 通知所有窗口应用新主题 254 | - 根据主题应用相应CSS类 255 | 256 | 4. **设置语言流程** 257 | - 用户在设置窗口选择语言 258 | - 保存语言设置 259 | - 使用i18n模块加载对应语言资源 260 | - 更新所有窗口的文本 261 | -------------------------------------------------------------------------------- /src/main/item-handler.js: -------------------------------------------------------------------------------- 1 | const { shell, clipboard } = require("electron"); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | const { exec } = require("child_process"); 5 | const os = require("os"); 6 | const { PathType } = require("../shared/defines"); 7 | 8 | /** 9 | * 处理项目动作(打开文件、文件夹、URL或执行命令) 10 | * @param {Object} item 要处理的项目对象 11 | */ 12 | function handleItemAction(item) { 13 | switch (item.type) { 14 | case PathType.FILE: 15 | case PathType.FOLDER: 16 | shell.openPath(item.path); 17 | break; 18 | case PathType.URL: 19 | // URL处理逻辑 20 | let urlToOpen = item.path; 21 | 22 | // 匹配任何协议前缀 (http://, https://, mailto:, tel:, app: 等) 23 | const protocolRegex = /^[a-z][a-z0-9+.-]*:(?:\/\/)?/i; 24 | // 匹配标准域名格式 (example.com, www.example.com 等) 25 | const domainRegex = /^([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,})(:[0-9]{1,5})?(\/.*)?$/i; 26 | 27 | // 只有当URL没有协议前缀,但符合标准域名格式时才添加https:// 28 | if (!protocolRegex.test(urlToOpen) && domainRegex.test(urlToOpen)) { 29 | urlToOpen = `https://${urlToOpen}`; 30 | } 31 | 32 | shell.openExternal(urlToOpen); 33 | break; 34 | case PathType.COMMAND: 35 | executeCommand(item.path); 36 | break; 37 | } 38 | } 39 | 40 | /** 41 | * 安全执行命令 42 | * @param {string} command 要执行的命令 43 | */ 44 | function executeCommand(command) { 45 | // 根据平台打开终端窗口执行命令,使用引号包裹命令,防止注入 46 | const platform = process.platform; 47 | 48 | // 确保工作目录存在 49 | const tmpDir = os.tmpdir(); 50 | const workDir = path.join(tmpDir, "launcher-app-temp"); 51 | 52 | // 创建工作目录(如果不存在) 53 | if (!fs.existsSync(workDir)) { 54 | try { 55 | fs.mkdirSync(workDir, { recursive: true }); 56 | } catch (error) { 57 | console.error("Error creating working directory:", error); 58 | } 59 | } 60 | 61 | try { 62 | if (platform === "win32") { 63 | executeCommandForWindows(command, workDir); 64 | } else if (platform === "darwin") { 65 | executeCommandForMac(command, workDir); 66 | } else { 67 | executeCommandForLinux(command, workDir); 68 | } 69 | } catch (error) { 70 | console.error("Error executing command:", error); 71 | } 72 | } 73 | 74 | /* Windows平台特定代码 75 | * 使用CMD执行命令: 76 | * 1. /K - 执行命令后保持窗口打开 77 | * 2. /C - 执行命令后关闭窗口 78 | * 3. 使用detached和unref,避免CMD窗口关闭时Launcher被关闭 79 | */ 80 | function executeCommandForWindows(command, workDir) { 81 | const execOptions = { 82 | windowsHide: false, 83 | detached: true, 84 | shell: true, 85 | }; 86 | 87 | if (/^\/[KC]/i.test(command)) { 88 | // 命令自带 /K 或 /C 参数的情况 89 | 90 | // 提取开头的 /K 或 /C 及可能跟随的空格 91 | const cmdPrefix = command.match(/^\/[KC]\s*/i)[0]; 92 | // 获取实际的命令部分 93 | const actualCommand = command.substring(cmdPrefix.length); 94 | 95 | // 构建新命令: start cmd /K(或/C) "cd /d 工作目录 && 实际命令" 96 | const newCommand = `start cmd ${cmdPrefix}"cd /d "${workDir}" && ${actualCommand}"`; 97 | 98 | const child = exec(newCommand, execOptions); 99 | child.unref(); 100 | } else { 101 | // 默认使用 /K 模式保持窗口打开 102 | const child = exec(`start cmd /K "cd /d "${workDir}" && ${command}"`, execOptions); 103 | child.unref(); 104 | } 105 | } 106 | 107 | /** 108 | * macOS平台执行命令 109 | * 使用AppleScript在Terminal中执行命令 110 | * 转义引号以防止命令注入攻击 111 | */ 112 | function executeCommandForMac(command, workDir) { 113 | // 更安全的转义处理 114 | // 首先转义工作目录路径中的特殊字符 115 | const escapedWorkDir = workDir 116 | .replace(/\\/g, '\\\\') // 转义反斜杠 117 | .replace(/"/g, '\\"') // 转义双引号 118 | .replace(/'/g, "'\\''"); // 转义单引号 119 | 120 | // 然后转义命令中的特殊字符 121 | const escapedCommand = command 122 | .replace(/\\/g, '\\\\') // 转义反斜杠 123 | .replace(/"/g, '\\"') // 转义双引号 124 | .replace(/'/g, "'\\''"); // 转义单引号 125 | 126 | // 构建完整的AppleScript命令,确保命令能在Terminal中正确执行 127 | exec( 128 | `osascript -e 'tell app "Terminal" to do script "cd \\"${escapedWorkDir}\\" && ${escapedCommand}"'` 129 | ); 130 | } 131 | 132 | /** 133 | * Linux平台执行命令 134 | * Linux有多种不同的终端模拟器,需要尝试多种终端 135 | */ 136 | function executeCommandForLinux(command, workDir) { 137 | const terminals = [ 138 | 'gnome-terminal -- bash -c "cd \\"{WORKDIR}\\" && {CMD}; exec bash"', // GNOME桌面环境 139 | 'konsole --noclose -e bash -c "cd \\"{WORKDIR}\\" && {CMD}"', // KDE桌面环境 140 | 'xterm -hold -e bash -c "cd \\"{WORKDIR}\\" && {CMD}"', // 通用X终端 141 | 'x-terminal-emulator -e bash -c "cd \\"{WORKDIR}\\" && {CMD}; exec bash"', // Debian/Ubuntu默认终端 142 | ]; 143 | 144 | // 安全处理命令,转义引号以防止命令注入 145 | const escapedCommand = command 146 | .replace(/\\/g, '\\\\') // 转义反斜杠 147 | .replace(/"/g, '\\"') // 转义双引号 148 | .replace(/'/g, "'\\''"); // 转义单引号 149 | 150 | const escapedWorkDir = workDir 151 | .replace(/\\/g, '\\\\') // 转义反斜杠 152 | .replace(/"/g, '\\"') // 转义双引号 153 | .replace(/'/g, "'\\''"); // 转义单引号 154 | 155 | // 尝试所有可能的终端,直到一个成功 156 | tryNextLinuxTerminal(terminals, 0, escapedCommand, escapedWorkDir); 157 | } 158 | 159 | /** 160 | * 递归尝试不同的Linux终端 161 | * @param {Array} terminals 终端命令列表 162 | * @param {number} index 当前尝试的索引 163 | * @param {string} command 要执行的命令 164 | * @param {string} workDir 工作目录路径 165 | */ 166 | function tryNextLinuxTerminal(terminals, index, command, workDir = "") { 167 | if (index >= terminals.length) { 168 | console.error("No available terminal found"); 169 | return; 170 | } 171 | 172 | // 替换命令模板中的{CMD}和{WORKDIR}为实际值 173 | let terminalCmd = terminals[index].replace("{CMD}", command); 174 | if (workDir) { 175 | terminalCmd = terminalCmd.replace("{WORKDIR}", workDir); 176 | } 177 | 178 | exec(terminalCmd, (error) => { 179 | if (error) { 180 | console.warn(`Terminal ${index + 1}/${terminals.length} failed, trying next one...`); 181 | // 递归尝试下一个终端 182 | tryNextLinuxTerminal(terminals, index + 1, command, workDir); 183 | } 184 | }); 185 | } 186 | 187 | /** 188 | * 在文件夹中显示项目 189 | * @param {string} path 文件路径 190 | */ 191 | function showItemInFolder(path) { 192 | shell.showItemInFolder(path); 193 | } 194 | 195 | /** 196 | * 复制文本到剪贴板 197 | * @param {string} text 要复制的文本 198 | */ 199 | function copyText(text) { 200 | clipboard.writeText(text); 201 | } 202 | 203 | /** 204 | * 判断项目类型(文件、文件夹、URL或命令) 205 | * @param {string} path 路径或URL 206 | * @returns {string} 项目类型 207 | */ 208 | function getItemType(path) { 209 | if (!path || typeof path !== "string") { 210 | return undefined; 211 | } 212 | 213 | // 判断是否是文件或文件夹 214 | const fileExists = fs.existsSync(path); 215 | if (fileExists) { 216 | const stats = fs.statSync(path); 217 | if (stats.isDirectory()) { 218 | return PathType.FOLDER; 219 | } 220 | 221 | if (stats.isFile()) { 222 | return PathType.FILE; 223 | } 224 | 225 | // 其他类型的文件(如socket等)暂不处理 226 | return undefined; 227 | } 228 | 229 | // 判断是否是标准协议URL和自定义协议deep link 230 | // 匹配如http://, https://, ftp://, app://, myapp://等协议格式 231 | const protocolRegex = /^[a-z][a-z0-9+.-]*:\/\//i; 232 | // 匹配标准域名格式 (包括www开头和不带www的域名) 233 | const domainRegex = 234 | /^([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,})(:[0-9]{1,5})?(\/.*)?$/i; 235 | const isUrl = protocolRegex.test(path) || domainRegex.test(path); 236 | if (isUrl) { 237 | return PathType.URL; 238 | } 239 | 240 | // 不是URL也不是文件/文件夹,则认为是命令 241 | return PathType.COMMAND; 242 | } 243 | 244 | // 导出模块函数 245 | module.exports = { 246 | handleItemAction, 247 | showItemInFolder, 248 | copyText, 249 | getItemType, 250 | }; 251 | -------------------------------------------------------------------------------- /src/renderer/js/context-menu.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 上下文菜单脚本 3 | * 负责在主窗口中创建和管理右键菜单 4 | */ 5 | document.addEventListener("DOMContentLoaded", () => { 6 | // 引用i18n模块,用于本地化菜单项目 7 | const i18n = window.electronAPI.i18n; 8 | 9 | // 上下文菜单DOM元素 10 | let contextMenu = null; 11 | 12 | /** 13 | * 创建上下文菜单 14 | * 根据不同项目类型创建对应的菜单 15 | * @param {MouseEvent} e 鼠标事件 16 | * @param {HTMLElement} target 触发菜单的目标元素 17 | */ 18 | async function createContextMenu(e, target) { 19 | // 防止默认事件和事件冒泡 20 | e.preventDefault(); 21 | e.stopPropagation(); 22 | 23 | // 移除可能存在的旧菜单 24 | removeContextMenu(); 25 | 26 | // 确保目标是列表项 27 | if (!target.classList.contains("list-item")) { 28 | target = target.closest(".list-item"); 29 | } 30 | 31 | if (!target) return; 32 | 33 | // 获取项目数据 34 | const index = parseInt(target.dataset.index); 35 | const currentItems = await window.electronAPI.getItems(); 36 | const item = currentItems[index]; 37 | 38 | if (!item) return; 39 | 40 | // 创建上下文菜单元素 41 | contextMenu = document.createElement("div"); 42 | contextMenu.className = "context-menu"; 43 | 44 | // 设置菜单位置 45 | contextMenu.style.left = `${e.pageX}px`; 46 | contextMenu.style.top = `${e.pageY}px`; 47 | 48 | // 应用当前主题到右键菜单 49 | const appContainer = document.querySelector(".app-container"); 50 | if (appContainer && appContainer.classList.contains("dark-theme")) { 51 | contextMenu.classList.add("dark-theme"); 52 | } else if (appContainer && appContainer.classList.contains("light-theme")) { 53 | contextMenu.classList.add("light-theme"); 54 | } 55 | 56 | // 创建常用菜单项目 57 | const menuItems = await createMenuItems(item, index); 58 | 59 | // 将菜单项添加到菜单 60 | menuItems.forEach(menuItem => { 61 | contextMenu.appendChild(menuItem); 62 | }); 63 | 64 | // 添加菜单到DOM 65 | document.body.appendChild(contextMenu); 66 | 67 | // 确保菜单不超出视窗 68 | adjustMenuPosition(contextMenu); 69 | 70 | // 高亮显示选中的项目 71 | document.querySelectorAll(".list-item.active").forEach(el => { 72 | el.classList.remove("active"); 73 | }); 74 | target.classList.add("active"); 75 | 76 | // 添加点击事件监听器,点击外部关闭菜单 77 | setTimeout(() => { 78 | document.addEventListener("click", handleDocumentClick); 79 | }, 0); 80 | } 81 | 82 | /** 83 | * 根据项目类型创建对应的菜单项 84 | * @param {Object} item 项目对象 85 | * @param {number} index 项目索引 86 | * @returns {HTMLElement[]} 菜单项元素数组 87 | */ 88 | async function createMenuItems(item, index) { 89 | const menuItems = []; 90 | 91 | // 打开项目 92 | const openItem = document.createElement("div"); 93 | openItem.className = "menu-item"; 94 | openItem.textContent = await i18n.t("open"); 95 | openItem.addEventListener("click", () => { 96 | window.electronAPI.openItem(item); 97 | removeContextMenu(); // 点击后关闭菜单 98 | }); 99 | menuItems.push(openItem); 100 | 101 | // 根据项目类型添加特定菜单项 102 | if (item.type === "file" || item.type === "folder") { 103 | // 在文件夹中显示 104 | const showInFolder = document.createElement("div"); 105 | showInFolder.className = "menu-item"; 106 | showInFolder.textContent = await i18n.t("show-in-folder"); 107 | showInFolder.addEventListener("click", () => { 108 | window.electronAPI.showItemInFolder(item.path); 109 | removeContextMenu(); // 点击后关闭菜单 110 | }); 111 | menuItems.push(showInFolder); 112 | } 113 | 114 | // 复制路径 115 | const copyPath = document.createElement("div"); 116 | copyPath.className = "menu-item"; 117 | copyPath.textContent = await i18n.t("copy-path"); 118 | copyPath.addEventListener("click", () => { 119 | window.electronAPI.copyText(item.path); 120 | // 显示成功提示 121 | if (window.appFunctions && window.appFunctions.showToast) { 122 | window.appFunctions.showToast(i18n.t("path-copied")); 123 | } 124 | removeContextMenu(); // 点击后关闭菜单 125 | }); 126 | menuItems.push(copyPath); 127 | 128 | // 分隔线 129 | const divider = document.createElement("div"); 130 | divider.className = "menu-divider"; 131 | menuItems.push(divider); 132 | 133 | // 编辑项目 134 | const editItem = document.createElement("div"); 135 | editItem.className = "menu-item"; 136 | editItem.textContent = await i18n.t("edit"); 137 | editItem.addEventListener("click", () => { 138 | window.electronAPI.showEditItemDialog(item, index); 139 | removeContextMenu(); // 点击后关闭菜单 140 | }); 141 | menuItems.push(editItem); 142 | 143 | // 删除项目 144 | const removeItem = document.createElement("div"); 145 | removeItem.className = "menu-item"; 146 | removeItem.textContent = await i18n.t("remove"); 147 | removeItem.addEventListener("click", () => { 148 | if (window.appFunctions && window.appFunctions.removeItem) { 149 | window.appFunctions.removeItem(index); 150 | } 151 | removeContextMenu(); // 点击后关闭菜单 152 | }); 153 | menuItems.push(removeItem); 154 | 155 | return menuItems; 156 | } 157 | 158 | /** 159 | * 调整菜单位置,确保不超出视窗 160 | * @param {HTMLElement} menu 菜单元素 161 | */ 162 | function adjustMenuPosition(menu) { 163 | const rect = menu.getBoundingClientRect(); 164 | const windowWidth = window.innerWidth; 165 | const windowHeight = window.innerHeight; 166 | 167 | // 检查右侧是否超出视窗 168 | if (rect.right > windowWidth) { 169 | menu.style.left = `${windowWidth - rect.width - 5}px`; 170 | } 171 | 172 | // 检查底部是否超出视窗 173 | if (rect.bottom > windowHeight) { 174 | menu.style.top = `${windowHeight - rect.height - 5}px`; 175 | } 176 | } 177 | 178 | /** 179 | * 处理点击事件,关闭上下文菜单 180 | */ 181 | function handleDocumentClick(e) { 182 | if (contextMenu && !contextMenu.contains(e.target)) { 183 | removeContextMenu(); 184 | } 185 | } 186 | 187 | /** 188 | * 移除上下文菜单 189 | * 从文档中移除现有的上下文菜单 190 | */ 191 | function removeContextMenu() { 192 | if (contextMenu) { 193 | document.body.removeChild(contextMenu); 194 | contextMenu = null; 195 | document.removeEventListener("click", handleDocumentClick); 196 | } 197 | } 198 | 199 | /** 200 | * 设置列表项的右键菜单 201 | * 为列表容器添加右键菜单事件监听 202 | */ 203 | function setupContextMenu() { 204 | const listContainer = document.getElementById("list-container"); 205 | 206 | if (listContainer) { 207 | listContainer.addEventListener("contextmenu", (e) => { 208 | // 查找点击的列表项 209 | const target = e.target.closest(".list-item"); 210 | if (target) { 211 | createContextMenu(e, target); 212 | } 213 | }); 214 | } 215 | } 216 | 217 | // 设置右键菜单 218 | setupContextMenu(); 219 | }); -------------------------------------------------------------------------------- /src/renderer/js/edit-item.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 项目编辑窗口脚本 3 | * 负责处理项目的添加和编辑功能 4 | * 包括表单验证、文件选择和保存操作 5 | */ 6 | document.addEventListener("DOMContentLoaded", async () => { 7 | // DOM元素引用 8 | const itemPathInput = document.getElementById("item-path"); 9 | const itemNameInput = document.getElementById("item-name"); 10 | const itemTypeSelect = document.getElementById("item-type"); 11 | const saveBtn = document.getElementById("save-btn"); 12 | const cancelBtn = document.getElementById("cancel-btn"); 13 | const selectFileBtn = document.getElementById("select-file-btn"); 14 | const selectFolderBtn = document.getElementById("select-folder-btn"); 15 | const commandTip = document.getElementById("command-tip"); 16 | 17 | // 导入i18n模块、PathType常量 18 | const i18n = window.electronAPI.i18n; 19 | const PathType = window.defines.PathType; 20 | 21 | // 初始化UI,保存返回的解绑函数对象 22 | const uiCleanup = window.uiManager.initUI({ 23 | containerSelector: ".modal", 24 | windowType: "edit-item" // 指定窗口类型为项目编辑窗口 25 | }); 26 | 27 | // 当页面卸载时清理监听器 28 | window.addEventListener('beforeunload', () => { 29 | if (uiCleanup && typeof uiCleanup.unbindAll === 'function') { 30 | uiCleanup.unbindAll(); 31 | } 32 | }); 33 | 34 | // 跟踪是否处于编辑模式 35 | let isEditMode = false; 36 | let editingItemIndex = -1; 37 | 38 | // 初始化页面 39 | initPage(); 40 | 41 | /** 42 | * 初始化页面 43 | * 应用主题和语言设置,设置事件监听器 44 | */ 45 | async function initPage() { 46 | // 监听编辑项目数据事件,注意要在其他异步操作之前注册监听器,避免错过事件窗口 47 | window.electronAPI.onEditItemData(({ item, index }) => { 48 | console.log("Editing item data:", item, index); 49 | // 进入编辑模式 50 | isEditMode = true; 51 | editingItemIndex = index; 52 | 53 | // 填充表单数据 54 | itemPathInput.value = item.path; 55 | itemTypeSelect.value = item.type; 56 | // 如果有名称,填充名称字段 57 | if (item.name) { 58 | itemNameInput.value = item.name; 59 | } 60 | 61 | updateCommandTipVisibility(); 62 | 63 | // 启用保存按钮 64 | saveBtn.disabled = false; 65 | 66 | // 让路径输入框获得焦点 67 | setTimeout(() => itemPathInput.focus(), 100); 68 | }); 69 | 70 | // 默认情况下(新增模式),让路径输入框获得焦点 71 | setTimeout(() => itemPathInput.focus(), 100); 72 | 73 | // 根据当前选择的类型控制提示信息的显示/隐藏 74 | updateCommandTipVisibility(); 75 | } 76 | 77 | /** 78 | * 更新命令提示信息的可见性 79 | * 仅当类型为"指令"时显示提示 80 | */ 81 | function updateCommandTipVisibility() { 82 | commandTip.style.display = itemTypeSelect.value === PathType.COMMAND ? "block" : "none"; 83 | } 84 | 85 | /** 86 | * 项目类型选择变更事件 87 | * 控制命令提示信息的显示/隐藏 88 | */ 89 | itemTypeSelect.addEventListener("change", () => { 90 | updateCommandTipVisibility(); 91 | }); 92 | 93 | /** 94 | * 阻止在路径输入框中输入换行符 95 | * 当用户按下Enter键时,阻止默认行为 96 | */ 97 | itemPathInput.addEventListener("keydown", (e) => { 98 | if (e.key === "Enter" && !e.ctrlKey && !e.metaKey) { 99 | e.preventDefault(); 100 | } 101 | }); 102 | 103 | /** 104 | * 确保粘贴到路径输入框的内容不包含换行符 105 | */ 106 | itemPathInput.addEventListener("paste", (e) => { 107 | // 阻止默认粘贴行为 108 | e.preventDefault(); 109 | 110 | // 获取剪贴板数据 111 | let pasteData = (e.clipboardData || window.clipboardData).getData("text"); 112 | 113 | // 移除所有换行符 114 | if (pasteData) { 115 | 116 | // 替换所有换行符(\n, \r, \r\n)为空格 117 | pasteData = pasteData.replace(/[\r\n]+/g, " "); 118 | 119 | // 在当前光标位置插入处理后的文本 120 | const selectionStart = itemPathInput.selectionStart; 121 | const selectionEnd = itemPathInput.selectionEnd; 122 | const currentValue = itemPathInput.value; 123 | 124 | itemPathInput.value = currentValue.substring(0, selectionStart) + 125 | pasteData + 126 | currentValue.substring(selectionEnd); 127 | 128 | // 更新光标位置 129 | itemPathInput.selectionStart = itemPathInput.selectionEnd = 130 | selectionStart + pasteData.length; 131 | 132 | // 手动触发input事件以更新验证 133 | itemPathInput.dispatchEvent(new Event("input")); 134 | } 135 | }); 136 | 137 | /** 138 | * 文件选择按钮点击事件 139 | * 使用系统对话框选择文件 140 | */ 141 | selectFileBtn.addEventListener("click", async () => { 142 | try { 143 | const result = await window.electronAPI.selectFile(); 144 | if (!result.canceled) { 145 | itemPathInput.value = result.filePath; 146 | // 自动设置类型为文件 147 | itemTypeSelect.value = PathType.FILE; 148 | // 更新命令提示可见性 149 | updateCommandTipVisibility(); 150 | saveBtn.disabled = false; 151 | } 152 | } catch (error) { 153 | console.error("Error selecting file:", error); 154 | const errorMessage = await i18n.t("select-file-failed"); 155 | window.uiManager.showToast(errorMessage, true); 156 | } 157 | }); 158 | 159 | /** 160 | * 文件夹选择按钮点击事件 161 | * 使用系统对话框选择文件夹 162 | */ 163 | selectFolderBtn.addEventListener("click", async () => { 164 | try { 165 | const result = await window.electronAPI.selectFolder(); 166 | if (!result.canceled) { 167 | itemPathInput.value = result.filePath; 168 | // 自动设置类型为文件夹 169 | itemTypeSelect.value = PathType.FOLDER; 170 | // 更新命令提示可见性 171 | updateCommandTipVisibility(); 172 | saveBtn.disabled = false; 173 | } 174 | } catch (error) { 175 | console.error("Error selecting folder:", error); 176 | const errorMessage = await i18n.t("select-folder-failed"); 177 | window.uiManager.showToast(errorMessage, true); 178 | } 179 | }); 180 | 181 | /** 182 | * 路径输入变化事件处理 183 | * 自动推断项目类型,启用/禁用保存按钮 184 | */ 185 | itemPathInput.addEventListener("input", async () => { 186 | const path = itemPathInput.value.trim(); 187 | // 获取路径对应的项目类型 188 | const type = await window.electronAPI.getItemType(path); 189 | 190 | if (path && type) { 191 | // 有效路径,启用添加按钮 192 | saveBtn.disabled = false; 193 | // 自动设置类型 194 | itemTypeSelect.value = type; 195 | // 更新命令提示可见性 196 | updateCommandTipVisibility(); 197 | } else { 198 | // 无效路径,禁用添加按钮 199 | saveBtn.disabled = true; 200 | } 201 | }); 202 | 203 | /** 204 | * 保存按钮点击事件处理 205 | * 添加新项目或更新现有项目 206 | */ 207 | saveBtn.addEventListener("click", async () => { 208 | // 获取表单数据 209 | const path = itemPathInput.value.trim(); 210 | const type = itemTypeSelect.value; 211 | const name = itemNameInput.value.trim(); 212 | 213 | // 表单验证 214 | if (!path) { 215 | const errorMessage = await i18n.t("enter-path-required"); 216 | window.uiManager.showToast(errorMessage, true); 217 | return; 218 | } 219 | 220 | if (!type) { 221 | const errorMessage = await i18n.t("select-type-required"); 222 | window.uiManager.showToast(errorMessage, true); 223 | return; 224 | } 225 | 226 | // 创建项目对象 227 | const newItem = { 228 | type: type, 229 | path: path, 230 | }; 231 | 232 | // 如果用户提供了名称,则添加到项目中 233 | if (name) { 234 | newItem.name = name; 235 | } 236 | 237 | try { 238 | let result; 239 | 240 | // 根据模式决定是更新还是添加 241 | if (isEditMode) { 242 | // 编辑现有项目 243 | result = await window.electronAPI.updateItem(editingItemIndex, newItem); 244 | } else { 245 | // 添加新项目 246 | result = await window.electronAPI.addItem(newItem); 247 | } 248 | 249 | // 处理操作结果 250 | if (result.success) { 251 | // 保存成功,关闭窗口 252 | window.electronAPI.closeAddItemWindow(); 253 | } else { 254 | // 显示错误提示 255 | window.uiManager.showToast(result.message, true); 256 | } 257 | } catch (error) { 258 | console.error(`Error ${isEditMode ? "updating" : "adding"} item:`, error); 259 | const errorMessage = await i18n.t( 260 | isEditMode ? "update-failed" : "add-failed" 261 | ); 262 | window.uiManager.showToast(errorMessage, true); 263 | } 264 | }); 265 | 266 | /** 267 | * 取消按钮点击事件 268 | * 关闭编辑窗口,不保存任何更改 269 | */ 270 | cancelBtn.addEventListener("click", () => { 271 | window.electronAPI.closeAddItemWindow(); 272 | }); 273 | 274 | /** 275 | * 键盘事件处理 276 | * - Escape: 关闭窗口 277 | * - Enter+Ctrl: 提交表单 278 | * - F12: 开发者工具 279 | */ 280 | document.addEventListener("keydown", (e) => { 281 | if (e.key === "Escape") { 282 | window.electronAPI.closeAddItemWindow(); 283 | e.preventDefault(); 284 | } else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { 285 | // 使用 Ctrl+Enter / Cmd+Enter 提交表单 286 | if (!saveBtn.disabled) { 287 | saveBtn.click(); 288 | } 289 | e.preventDefault(); 290 | } else if (e.key === "F12") { 291 | window.electronAPI.openDevTools(); 292 | e.preventDefault(); 293 | } 294 | }); 295 | }); 296 | -------------------------------------------------------------------------------- /src/main/data-store.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 数据存储模块 3 | * 负责应用程序数据的持久化存储和管理 4 | * 包括项目列表和窗口配置等数据 5 | */ 6 | const { app } = require("electron"); 7 | const fs = require("fs"); 8 | const path = require("path"); 9 | 10 | /** 11 | * 数据文件路径 12 | * 使用Electron的app.getPath("userData")获取跨平台的用户数据目录 13 | * - Windows: %APPDATA%\[appname]\ 14 | * - macOS: ~/Library/Application Support/[appname]/ 15 | * - Linux: ~/.config/[appname]/ 16 | */ 17 | const userDataFolder = path.join(app.getPath("userData"), "UserData"); 18 | const dataFilePath = path.join(userDataFolder, "items.json"); 19 | const configFilePath = path.join(userDataFolder, "configs.json"); 20 | 21 | // 全局变量存储项目列表 22 | let items = []; 23 | // 全局变量存储配置 24 | let appConfig = { 25 | /** 26 | * 主窗口配置 27 | */ 28 | mainWindow: { 29 | width: 400, 30 | height: 600, 31 | x: undefined, 32 | y: undefined 33 | }, 34 | /** 35 | * 主题配置 36 | */ 37 | theme: "system", 38 | /** 39 | * 语言配置 40 | */ 41 | language: "system", 42 | /** 43 | * 快捷键配置 44 | */ 45 | shortcut: { 46 | enabled: true, 47 | shortcut: "Alt+Shift+Q" 48 | }, 49 | /** 50 | * 自启动配置 51 | */ 52 | autoLaunch: { 53 | enabled: false 54 | } 55 | }; 56 | 57 | // 存储数据变化监听器 - 观察者模式实现 58 | const changeListeners = []; 59 | // 存储快捷键配置变化监听器 60 | const shortcutChangeListeners = []; 61 | 62 | /** 63 | * 添加数据变化监听器 64 | * @param {Function} listener 监听函数 65 | */ 66 | function addChangeListener(listener) { 67 | if (typeof listener === 'function' && !changeListeners.includes(listener)) { 68 | changeListeners.push(listener); 69 | } 70 | } 71 | 72 | /** 73 | * 移除数据变化监听器 74 | * @param {Function} listener 要移除的监听函数 75 | */ 76 | function removeChangeListener(listener) { 77 | const index = changeListeners.indexOf(listener); 78 | if (index !== -1) { 79 | changeListeners.splice(index, 1); 80 | } 81 | } 82 | 83 | /** 84 | * 通知所有监听器数据已变化 85 | */ 86 | function notifyChangeListeners() { 87 | for (const listener of changeListeners) { 88 | try { 89 | listener(); 90 | } catch (error) { 91 | console.error("Error executing listener:", error); 92 | } 93 | } 94 | } 95 | 96 | /** 97 | * 加载保存的项目列表 98 | * @returns {Array} 项目列表 99 | */ 100 | function loadItems() { 101 | try { 102 | if (fs.existsSync(dataFilePath)) { 103 | const data = fs.readFileSync(dataFilePath, "utf8"); 104 | items = JSON.parse(data); 105 | return items; 106 | } 107 | return []; 108 | } catch (error) { 109 | console.error("Error loading items:", error); 110 | items = []; 111 | return []; 112 | } 113 | } 114 | 115 | /** 116 | * 保存项目列表到磁盘 117 | * @returns {boolean} 是否保存成功 118 | */ 119 | function saveItems() { 120 | try { 121 | const dirPath = path.dirname(dataFilePath); 122 | if (!fs.existsSync(dirPath)) { 123 | fs.mkdirSync(dirPath, { recursive: true }); 124 | } 125 | fs.writeFileSync(dataFilePath, JSON.stringify(items, null, 2), "utf8"); 126 | notifyChangeListeners(); 127 | return true; 128 | } catch (error) { 129 | console.error("Error saving items:", error); 130 | return false; 131 | } 132 | } 133 | 134 | /** 135 | * 添加新项目 136 | * @param {Object} item 要添加的项目 137 | * @returns {Object} 结果对象,包含成功标志和可能的错误消息 138 | */ 139 | function addItem(item) { 140 | // 检查是否已存在相同路径的项目 141 | const exists = items.some((i) => i.path === item.path); 142 | if (exists) { 143 | return { success: false, message: "Item already exists" }; 144 | } 145 | 146 | items.push(item); 147 | const saved = saveItems(); 148 | return { success: saved, message: saved ? "" : "Save failed" }; 149 | } 150 | 151 | /** 152 | * 更新指定索引的项目 153 | * @param {number} index 项目索引 154 | * @param {Object} updatedItem 更新后的项目 155 | * @returns {Object} 结果对象 156 | */ 157 | function updateItem(index, updatedItem) { 158 | if (index < 0 || index >= items.length) { 159 | return { success: false, message: "Item does not exist" }; 160 | } 161 | 162 | items[index] = updatedItem; 163 | const saved = saveItems(); 164 | return { success: saved, message: saved ? "" : "Save failed" }; 165 | } 166 | 167 | /** 168 | * 移除指定索引的项目 169 | * @param {number} index 项目索引 170 | * @returns {Object} 结果对象 171 | */ 172 | function removeItem(index) { 173 | if (index < 0 || index >= items.length) { 174 | return { success: false, message: "Item does not exist" }; 175 | } 176 | 177 | items.splice(index, 1); 178 | const saved = saveItems(); 179 | return { success: saved, message: saved ? "" : "Save failed" }; 180 | } 181 | 182 | /** 183 | * 更新项目顺序 184 | * @param {Array} newItems 新的项目列表 185 | * @returns {Object} 结果对象 186 | */ 187 | function updateItemsOrder(newItems) { 188 | items = newItems; 189 | const saved = saveItems(); 190 | return { success: saved, message: saved ? "" : "Save failed" }; 191 | } 192 | 193 | /** 194 | * 获取当前项目列表 195 | * @returns {Array} 项目列表 196 | */ 197 | function getItems() { 198 | return items; 199 | } 200 | 201 | /** 202 | * 清除所有项目 203 | * @returns {Object} 结果对象 204 | */ 205 | function clearAllItems() { 206 | items = []; 207 | const saved = saveItems(); 208 | return { success: saved, message: saved ? "" : "Clear failed" }; 209 | } 210 | 211 | /** 212 | * 获取存储文件路径 213 | * @returns {string} 存储文件的绝对路径 214 | */ 215 | function getStoragePath() { 216 | return dataFilePath; 217 | } 218 | 219 | /** 220 | * 获取应用数据文件夹路径 221 | * @returns {string} 应用数据文件夹的绝对路径 222 | */ 223 | function getUserDataPath() { 224 | return app.getPath("userData"); 225 | } 226 | 227 | /** 228 | * 获取App配置 229 | * @returns {Object} App配置对象 230 | */ 231 | function getAppConfig() { 232 | return appConfig; 233 | } 234 | 235 | /** 236 | * 加载App配置 237 | * @returns {Object} App配置 238 | */ 239 | function loadAppConfig() { 240 | try { 241 | if (fs.existsSync(configFilePath)) { 242 | const config = fs.readFileSync(configFilePath, "utf8"); 243 | appConfig = JSON.parse(config); 244 | return appConfig; 245 | } 246 | return appConfig; 247 | } catch (error) { 248 | console.error("Error loading window config:", error); 249 | return appConfig; 250 | } 251 | } 252 | 253 | /** 254 | * 保存App配置到磁盘 255 | * @param {Object} config 要保存的App配置 256 | * @returns {boolean} 是否保存成功 257 | */ 258 | function saveAppConfig(config = null) { 259 | if (config) { 260 | appConfig = config; 261 | } 262 | 263 | try { 264 | const dirPath = path.dirname(configFilePath); 265 | if (!fs.existsSync(dirPath)) { 266 | fs.mkdirSync(dirPath, { recursive: true }); 267 | } 268 | fs.writeFileSync(configFilePath, JSON.stringify(appConfig, null, 2), "utf8"); 269 | return true; 270 | } catch (error) { 271 | console.error("Error saving window config:", error); 272 | return false; 273 | } 274 | } 275 | 276 | /** 277 | * 更新主题配置 278 | */ 279 | function updateThemeConfig(theme) { 280 | appConfig.theme = theme; 281 | return saveAppConfig(); 282 | } 283 | 284 | /** 285 | * 更新语言配置 286 | */ 287 | function updateLanguageConfig(language) { 288 | appConfig.language = language; 289 | return saveAppConfig(); 290 | } 291 | 292 | /** 293 | * 更新主窗口配置 294 | * @param {Object} bounds 窗口的边界配置 {x, y, width, height} 295 | * @returns {boolean} 是否保存成功 296 | */ 297 | function updateMainWindowConfig(bounds) { 298 | appConfig.mainWindow = { ...appConfig.mainWindow, ...bounds }; 299 | return saveAppConfig(); 300 | } 301 | 302 | /** 303 | * 更新快捷键配置 304 | * @param {Object} config 新的快捷键配置 305 | * @returns {boolean} 是否保存成功 306 | */ 307 | function updateShortcutConfig(config) { 308 | appConfig.shortcut = { ...appConfig.shortcut, ...config }; 309 | const result = saveAppConfig(); 310 | if (result) { 311 | notifyShortcutChangeListeners(); 312 | } 313 | return result; 314 | } 315 | 316 | /** 317 | * 更新自启动配置 318 | * @param {Object} config 新的自启动配置 319 | * @returns {boolean} 是否保存成功 320 | */ 321 | function updateAutoLaunchConfig(config) { 322 | appConfig.autoLaunch = { ...appConfig.autoLaunch, ...config }; 323 | return saveAppConfig(); 324 | } 325 | 326 | /** 327 | * 添加快捷键配置变化监听器 328 | * @param {Function} listener 监听函数 329 | */ 330 | function addShortcutChangeListener(listener) { 331 | if (typeof listener === 'function' && !shortcutChangeListeners.includes(listener)) { 332 | shortcutChangeListeners.push(listener); 333 | } 334 | } 335 | 336 | /** 337 | * 移除快捷键配置变化监听器 338 | * @param {Function} listener 要移除的监听函数 339 | */ 340 | function removeShortcutChangeListener(listener) { 341 | const index = shortcutChangeListeners.indexOf(listener); 342 | if (index !== -1) { 343 | shortcutChangeListeners.splice(index, 1); 344 | } 345 | } 346 | 347 | /** 348 | * 通知所有快捷键配置变化监听器 349 | */ 350 | function notifyShortcutChangeListeners() { 351 | for (const listener of shortcutChangeListeners) { 352 | try { 353 | listener(appConfig.shortcut); 354 | } catch (error) { 355 | console.error("Error executing shortcut listener:", error); 356 | } 357 | } 358 | } 359 | 360 | // 导出模块函数 361 | module.exports = { 362 | // App配置 363 | loadAppConfig, 364 | saveAppConfig, 365 | getAppConfig, 366 | getStoragePath, 367 | getUserDataPath, 368 | 369 | // 语言配置 370 | updateLanguageConfig, 371 | 372 | // 主题配置 373 | updateThemeConfig, 374 | 375 | // 主窗口配置 376 | updateMainWindowConfig, 377 | 378 | // 快捷键配置 379 | updateShortcutConfig, 380 | addShortcutChangeListener, 381 | removeShortcutChangeListener, 382 | 383 | // 自启动配置 384 | updateAutoLaunchConfig, 385 | 386 | // 列表项目 387 | getItems, 388 | loadItems, 389 | saveItems, 390 | addItem, 391 | updateItem, 392 | removeItem, 393 | updateItemsOrder, 394 | clearAllItems, 395 | 396 | // 监听器 397 | addChangeListener, 398 | removeChangeListener, 399 | }; 400 | -------------------------------------------------------------------------------- /src/main/ipc-handler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * IPC通信处理模块 3 | * 负责处理主进程和渲染进程之间的所有通信 4 | * 所有渲染进程通过预加载脚本暴露的API与主进程通信 5 | */ 6 | const { ipcMain, dialog, app } = require('electron'); 7 | 8 | // 导入其他模块 9 | const windowManager = require('./window-manager'); 10 | const dataStore = require('./data-store'); 11 | const itemHandler = require('./item-handler'); 12 | const i18n = require('../shared/i18n'); 13 | const appUtils = require('../shared/appUtils'); 14 | 15 | /** 16 | * 设置所有 IPC 通信处理器 17 | * IPC通信类型分为两种: 18 | * 1. on/send - 单向通信,不等待回复 19 | * 2. handle/invoke - 请求/响应模式,返回Promise 20 | */ 21 | function setupIpcHandlers() { 22 | /** 23 | * 窗口控制相关IPC处理 24 | */ 25 | 26 | ipcMain.on('close-main-window', () => { 27 | windowManager.hideMainWindow(); 28 | }); 29 | 30 | ipcMain.on('close-add-item-window', () => { 31 | windowManager.closeAddItemWindow(); 32 | }); 33 | 34 | ipcMain.on('close-settings-window', () => { 35 | windowManager.closeSettingsWindow(); 36 | }); 37 | 38 | ipcMain.on('show-settings-window', () => { 39 | windowManager.createSettingsWindow(); 40 | }); 41 | 42 | /** 43 | * UI就绪通知处理 44 | * 当渲染进程完成UI初始化(主题和语言应用完成)后,通知主进程显示窗口 45 | */ 46 | ipcMain.on('ui-ready', (event, windowType) => { 47 | if (windowType === 'main') { 48 | windowManager.showMainWindowWhenReady(); 49 | } else if (windowType === 'settings') { 50 | windowManager.showSettingsWindowWhenReady(); 51 | } else if (windowType === 'edit-item') { 52 | windowManager.showAddItemWindowWhenReady(); 53 | } 54 | }); 55 | 56 | /** 57 | * 项目数据管理相关IPC处理 58 | * 处理项目的获取、添加、更新、移除和重排序 59 | */ 60 | ipcMain.handle('get-items', () => { 61 | return dataStore.getItems(); 62 | }); 63 | 64 | ipcMain.handle('add-item', (event, item) => { 65 | const result = dataStore.addItem(item); 66 | if (result.success) { 67 | // 获取新添加项目的索引(在数组末尾) 68 | const itemIndex = dataStore.getItems().length - 1; 69 | 70 | // 通知所有窗口数据已更新,同时传递新添加的项目索引 71 | windowManager.notifyItemsUpdated(itemIndex); 72 | 73 | // 返回结果时包含新项目的索引 74 | return { ...result, itemIndex }; 75 | } 76 | return result; 77 | }); 78 | 79 | ipcMain.handle('update-item', (event, { index, updatedItem }) => { 80 | const result = dataStore.updateItem(index, updatedItem); 81 | if (result.success) { 82 | windowManager.notifyItemsUpdated(); 83 | } 84 | return result; 85 | }); 86 | 87 | ipcMain.handle('remove-item', (event, index) => { 88 | const result = dataStore.removeItem(index); 89 | if (result.success) { 90 | windowManager.notifyItemsUpdated(); 91 | } 92 | return result; 93 | }); 94 | 95 | ipcMain.handle('update-items-order', (event, newItems) => { 96 | const result = dataStore.updateItemsOrder(newItems); 97 | return result; 98 | }); 99 | 100 | /** 101 | * 项目编辑窗口相关IPC处理 102 | * 处理添加和编辑项目的对话框 103 | */ 104 | ipcMain.on('show-add-item-dialog', () => { 105 | windowManager.createAddItemWindow(); 106 | }); 107 | 108 | ipcMain.on('show-edit-item-dialog', (event, { item, index }) => { 109 | windowManager.createEditItemWindow(item, index); 110 | }); 111 | 112 | /** 113 | * 项目类型判断 114 | * 根据路径确定项目类型(文件、文件夹、URL或命令) 115 | */ 116 | ipcMain.handle('get-item-type', (event, path) => { 117 | return itemHandler.getItemType(path); 118 | }); 119 | 120 | /** 121 | * 项目操作相关IPC处理 122 | * 处理打开项目、在文件夹中显示、复制等操作 123 | */ 124 | ipcMain.on('open-item', (event, item) => { 125 | itemHandler.handleItemAction(item); 126 | }); 127 | 128 | ipcMain.on('show-item-in-folder', (event, path) => { 129 | itemHandler.showItemInFolder(path); 130 | }); 131 | 132 | ipcMain.on('copy-text', (event, text) => { 133 | itemHandler.copyText(text); 134 | }); 135 | 136 | /** 137 | * 文件和文件夹选择对话框 138 | * 使用系统原生对话框选择文件或文件夹 139 | */ 140 | ipcMain.handle('select-file', async () => { 141 | const addItemWindow = windowManager.getAddItemWindow(); 142 | if (!addItemWindow) return { canceled: true }; 143 | 144 | const { canceled, filePaths } = await dialog.showOpenDialog(addItemWindow, { 145 | properties: ['openFile'] 146 | }); 147 | 148 | if (canceled || !filePaths || filePaths.length === 0) { 149 | return { canceled: true }; 150 | } 151 | 152 | return { 153 | canceled: false, 154 | filePath: filePaths[0], 155 | }; 156 | }); 157 | 158 | ipcMain.handle('select-folder', async () => { 159 | const addItemWindow = windowManager.getAddItemWindow(); 160 | if (!addItemWindow) return { canceled: true }; 161 | 162 | const { canceled, filePaths } = await dialog.showOpenDialog(addItemWindow, { 163 | properties: ['openDirectory'] 164 | }); 165 | 166 | if (canceled || !filePaths || filePaths.length === 0) { 167 | return { canceled: true }; 168 | } 169 | 170 | return { 171 | canceled: false, 172 | filePath: filePaths[0], 173 | }; 174 | }); 175 | 176 | /** 177 | * 主题相关IPC处理 178 | */ 179 | ipcMain.handle('get-theme-config', () => { 180 | return dataStore.getAppConfig().theme; 181 | }); 182 | 183 | ipcMain.on('theme-changed', (event, theme) => { 184 | // 保存主题配置到配置文件 185 | dataStore.updateThemeConfig(theme); 186 | 187 | // 获取主窗口并发送主题变更通知 188 | const mainWindow = windowManager.getMainWindow(); 189 | if (mainWindow && !mainWindow.isDestroyed()) { 190 | mainWindow.webContents.send('theme-changed', theme); 191 | } 192 | 193 | // 通知编辑窗口(如果存在) 194 | const editItemWindow = windowManager.getAddItemWindow(); 195 | if (editItemWindow && !editItemWindow.isDestroyed()) { 196 | editItemWindow.webContents.send('theme-changed', theme); 197 | } 198 | 199 | // 存储主题设置到全局变量,以便在创建新窗口时使用 200 | global.appTheme = theme; 201 | }); 202 | 203 | /** 204 | * 语言相关IPC处理 205 | */ 206 | ipcMain.handle('get-language-config', () => { 207 | return dataStore.getAppConfig().language; 208 | }); 209 | 210 | ipcMain.on('language-changed', (event, language) => { 211 | // 保存语言配置到配置文件 212 | dataStore.updateLanguageConfig(language); 213 | 214 | // 获取所有窗口并发送语言变更通知 215 | const mainWindow = windowManager.getMainWindow(); 216 | if (mainWindow && !mainWindow.isDestroyed()) { 217 | mainWindow.webContents.send('language-changed', language); 218 | } 219 | 220 | // 通知编辑窗口(如果存在) 221 | const editItemWindow = windowManager.getAddItemWindow(); 222 | if (editItemWindow && !editItemWindow.isDestroyed()) { 223 | editItemWindow.webContents.send('language-changed', language); 224 | } 225 | 226 | // 存储语言设置到全局变量,以便在创建新窗口时使用 227 | global.appLanguage = language; 228 | }); 229 | 230 | /** 231 | * 设置相关 IPC 处理 232 | * 处理打开存储位置、清除所有项目等操作 233 | */ 234 | ipcMain.on('open-storage-location', () => { 235 | const userDataPath = dataStore.getUserDataPath(); 236 | if (userDataPath) { 237 | require('electron').shell.openPath(userDataPath); 238 | } 239 | }); 240 | 241 | ipcMain.on('clear-all-items', () => { 242 | dataStore.clearAllItems(); 243 | windowManager.notifyItemsUpdated(); 244 | }); 245 | 246 | /** 247 | * 开发者工具相关IPC处理 248 | */ 249 | ipcMain.on('open-devtools', (event) => { 250 | // 检查事件来源是哪个窗口 251 | const webContents = event.sender; 252 | webContents.openDevTools({ mode: 'detach' }); 253 | }); 254 | 255 | /** 256 | * 外部链接相关IPC处理 257 | */ 258 | ipcMain.on('open-external-link', (event, url) => { 259 | require('electron').shell.openExternal(url); 260 | }); 261 | 262 | /** 263 | * 应用信息相关IPC处理 264 | */ 265 | ipcMain.handle('get-app-info', () => { 266 | const { app } = require('electron'); 267 | return { 268 | version: app.getVersion(), 269 | name: app.getName(), 270 | electronVersion: process.versions.electron, 271 | }; 272 | }); 273 | 274 | /** 275 | * 国际化(i18n)相关IPC处理 276 | * 在主进程中处理所有i18n功能调用 277 | */ 278 | 279 | // 翻译文本 280 | ipcMain.handle('i18n-translate', (event, { key, params }) => { 281 | return i18n.t(key, params); 282 | }); 283 | 284 | // 设置语言 285 | ipcMain.handle('i18n-set-language', (event, language) => { 286 | return i18n.setLanguage(language); 287 | }); 288 | 289 | // 获取当前语言 290 | ipcMain.handle('i18n-get-current-language', () => { 291 | return i18n.getCurrentLanguage(); 292 | }); 293 | 294 | // 获取系统语言 295 | ipcMain.handle('i18n-get-system-language', () => { 296 | return i18n.getSystemLanguage(); 297 | }); 298 | 299 | // 获取可用语言列表 300 | ipcMain.handle('i18n-get-available-languages', () => { 301 | return i18n.getAvailableLanguages(); 302 | }); 303 | 304 | // 获取语言名称 305 | ipcMain.handle('i18n-get-language-name', (event, langCode) => { 306 | return i18n.getLanguageName(langCode); 307 | }); 308 | 309 | /** 310 | * 快捷键配置相关IPC处理 311 | * 获取和更新快捷键配置 312 | */ 313 | ipcMain.handle('get-shortcut-config', () => { 314 | return dataStore.getAppConfig().shortcut; 315 | }); 316 | 317 | ipcMain.handle('update-shortcut-config', (event, config) => { 318 | return dataStore.updateShortcutConfig(config); 319 | }); 320 | 321 | ipcMain.handle('test-shortcut', (event, shortcut) => { 322 | try { 323 | // 尝试注册快捷键,如果成功就立即注销 324 | const success = require('electron').globalShortcut.register(shortcut, () => { }); 325 | if (success) { 326 | require('electron').globalShortcut.unregister(shortcut); 327 | return { success: true }; 328 | } 329 | return { success: false, message: i18n.t('shortcut-taken') }; 330 | } catch (error) { 331 | return { success: false, message: `${i18n.t('shortcut-invalid')}: ${error.message}` }; 332 | } 333 | }); 334 | 335 | /** 336 | * 获取和更新自启动配置 337 | */ 338 | ipcMain.handle('get-auto-launch-config', () => { 339 | return dataStore.getAppConfig().autoLaunch; 340 | }); 341 | 342 | ipcMain.handle('update-auto-launch-config', (event, config) => { 343 | // 更新自启动设置 344 | const result = appUtils.updateAutoLaunchSettings(config.enabled); 345 | if (result) { 346 | dataStore.updateAutoLaunchConfig(config); 347 | } 348 | 349 | return result; 350 | }); 351 | } 352 | 353 | // 导出模块函数 354 | module.exports = { 355 | setupIpcHandlers 356 | }; -------------------------------------------------------------------------------- /src/preload/preload.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 预加载脚本 3 | * 在渲染进程加载前执行,安全地暴露特定API给渲染进程 4 | * 作为渲染进程和主进程之间的桥梁,确保渲染进程不直接访问Node.js API 5 | */ 6 | const { contextBridge, ipcRenderer, webUtils } = require("electron"); 7 | 8 | /** 9 | * 保持与defines.js中的PathType一致 10 | */ 11 | const PathType = { 12 | FILE: "file", // 文件类型 13 | FOLDER: "folder", // 文件夹类型 14 | URL: "url", // 网址类型 15 | COMMAND: "command", // 命令类型 16 | }; 17 | 18 | /** 19 | * 共享常量 20 | */ 21 | contextBridge.exposeInMainWorld("defines", { 22 | PathType: PathType, 23 | }); 24 | 25 | /** 26 | * 通过contextBridge安全地暴露API给渲染进程 27 | * 所有渲染进程的JavaScript可以通过window.electronAPI访问这些函数 28 | */ 29 | contextBridge.exposeInMainWorld("electronAPI", { 30 | /** 31 | * 窗口控制相关API 32 | * 允许渲染进程控制应用窗口 33 | */ 34 | closeMainWindow: () => ipcRenderer.send("close-main-window"), 35 | closeAddItemWindow: () => ipcRenderer.send("close-add-item-window"), 36 | closeSettingsWindow: () => ipcRenderer.send("close-settings-window"), 37 | showSettingsWindow: () => ipcRenderer.send("show-settings-window"), 38 | notifyUIReady: (windowType) => ipcRenderer.send("ui-ready", windowType), 39 | 40 | /** 41 | * 主题相关API 42 | */ 43 | themeChanged: (theme) => ipcRenderer.send("theme-changed", theme), 44 | getThemeConfig: () => ipcRenderer.invoke("get-theme-config"), 45 | 46 | /** 47 | * 语言相关API 48 | */ 49 | languageChanged: (language) => ipcRenderer.send("language-changed", language), 50 | getLanguageConfig: () => ipcRenderer.invoke("get-language-config"), 51 | 52 | /** 53 | * 事件监听相关API 54 | * 允许渲染进程注册对主进程事件的监听 55 | */ 56 | // 监听列表更新事件 57 | onItemsUpdated: (callback) => { 58 | const listener = (event, newItemIndex) => callback(newItemIndex); 59 | ipcRenderer.on("items-updated", listener); 60 | 61 | // 返回清理函数,用于移除事件监听,防止内存泄漏 62 | return () => { 63 | ipcRenderer.removeListener("items-updated", listener); 64 | }; 65 | }, 66 | 67 | // 监听编辑项目数据 68 | onEditItemData: (callback) => { 69 | const listener = (event, data) => callback(data); 70 | ipcRenderer.on("edit-item-data", listener); 71 | // 返回清理函数 72 | return () => { 73 | ipcRenderer.removeListener("edit-item-data", listener); 74 | }; 75 | }, 76 | 77 | // 监听主题变更 78 | onThemeChanged: (callback) => { 79 | const listener = (event, theme) => callback(theme); 80 | ipcRenderer.on("theme-changed", listener); 81 | return () => { 82 | ipcRenderer.removeListener("theme-changed", listener); 83 | }; 84 | }, 85 | 86 | // 监听语言变更 87 | onLanguageChanged: (callback) => { 88 | const listener = (event, language) => callback(language); 89 | ipcRenderer.on("language-changed", listener); 90 | return () => { 91 | ipcRenderer.removeListener("language-changed", listener); 92 | }; 93 | }, 94 | 95 | /** 96 | * 项目管理相关API 97 | * 允许渲染进程获取、添加、更新和移除项目 98 | */ 99 | getItems: () => ipcRenderer.invoke("get-items"), 100 | addItem: (item) => ipcRenderer.invoke("add-item", item), 101 | removeItem: (index) => ipcRenderer.invoke("remove-item", index), 102 | updateItemsOrder: (items) => ipcRenderer.invoke("update-items-order", items), 103 | showAddItemDialog: () => ipcRenderer.send("show-add-item-dialog"), 104 | showEditItemDialog: (item, index) => 105 | ipcRenderer.send("show-edit-item-dialog", { item, index }), 106 | 107 | /** 108 | * 项目类型判断API 109 | * 根据路径判断项目类型(文件、文件夹、URL或命令) 110 | */ 111 | getItemType: (path) => ipcRenderer.invoke("get-item-type", path), 112 | 113 | /** 114 | * 项目操作相关API 115 | * 允许渲染进程对项目执行各种操作 116 | */ 117 | updateItem: (index, updatedItem) => 118 | ipcRenderer.invoke("update-item", { index, updatedItem }), 119 | openItem: (item) => ipcRenderer.send("open-item", item), 120 | showItemInFolder: (path) => ipcRenderer.send("show-item-in-folder", path), 121 | copyText: (text) => ipcRenderer.send("copy-text", text), 122 | 123 | /** 124 | * 文件路径获取API 125 | * 使用webUtils.getPathForFile获取文件路径 126 | * 注意:最新版Electron中,无法使用event.dataTransfer.files[0].path 127 | * 必须使用webUtils.getPathForFile来安全获取文件路径 128 | */ 129 | getFileOrFolderPath: (item) => { 130 | if (!item) return undefined; 131 | return webUtils.getPathForFile(item); 132 | }, 133 | 134 | /** 135 | * 文件和文件夹选择对话框API 136 | * 允许渲染进程调用原生文件选择对话框 137 | */ 138 | selectFile: () => ipcRenderer.invoke("select-file"), 139 | selectFolder: () => ipcRenderer.invoke("select-folder"), 140 | 141 | /** 142 | * 快捷键配置相关API 143 | * 获取和更新快捷键配置 144 | */ 145 | getShortcutConfig: () => ipcRenderer.invoke("get-shortcut-config"), 146 | updateShortcutConfig: (config) => ipcRenderer.invoke("update-shortcut-config", config), 147 | testShortcut: (shortcut) => ipcRenderer.invoke("test-shortcut", shortcut), 148 | 149 | /** 150 | * 自启动配置相关API 151 | * 获取和更新自启动配置 152 | */ 153 | getAutoLaunchConfig: () => ipcRenderer.invoke("get-auto-launch-config"), 154 | updateAutoLaunchConfig: (config) => ipcRenderer.invoke("update-auto-launch-config", config), 155 | 156 | /** 157 | * 平台信息API 158 | * 获取当前运行平台信息(win32、darwin、linux) 159 | */ 160 | getPlatform: () => process.platform, 161 | 162 | /** 163 | * 设置相关API 164 | * 提供各种设置和实用功能 165 | */ 166 | openStorageLocation: () => ipcRenderer.send("open-storage-location"), 167 | clearAllItems: () => ipcRenderer.send("clear-all-items"), 168 | openDevTools: () => ipcRenderer.send("open-devtools"), 169 | openExternalLink: (url) => ipcRenderer.send("open-external-link", url), 170 | getAppInfo: () => ipcRenderer.invoke("get-app-info"), 171 | 172 | /** 173 | * 国际化(i18n)API 174 | * 与主进程通信获取多语言支持功能 175 | */ 176 | i18n: { 177 | t: (key, params = null) => ipcRenderer.invoke("i18n-translate", { key, params }), 178 | setLanguage: (language) => ipcRenderer.invoke("i18n-set-language", language), 179 | getCurrentLanguage: () => ipcRenderer.invoke("i18n-get-current-language"), 180 | getSystemLanguage: () => ipcRenderer.invoke("i18n-get-system-language"), 181 | getAvailableLanguages: () => ipcRenderer.invoke("i18n-get-available-languages"), 182 | getLanguageName: (langCode) => ipcRenderer.invoke("i18n-get-language-name", langCode), 183 | addLanguageChangeListener: (callback) => { 184 | const listener = (event, language) => callback(language); 185 | ipcRenderer.on("language-changed", listener); 186 | return () => { 187 | ipcRenderer.removeListener("language-changed", listener); 188 | }; 189 | } 190 | }, 191 | }); 192 | 193 | /** 194 | * 暴露UI工具函数给渲染进程 195 | * 提供共享的UI功能,如主题应用和语言处理 196 | */ 197 | contextBridge.exposeInMainWorld("uiUtils", { 198 | /** 199 | * 应用主题设置 200 | * @param {string} theme 主题类型:"system", "light", "dark" 201 | * @param {HTMLElement} container 要应用主题的容器元素 202 | */ 203 | applyTheme: (theme, container) => { 204 | // 移除所有主题类 205 | container.classList.remove("dark-theme", "light-theme"); 206 | 207 | // 根据主题类型应用相应的CSS类 208 | if (theme === "system") { 209 | // 使用内部函数处理系统主题 210 | internalApplySystemTheme(container); 211 | } else if (theme === "dark") { 212 | container.classList.add("dark-theme"); 213 | } else if (theme === "light") { 214 | container.classList.add("light-theme"); 215 | } 216 | }, 217 | 218 | /** 219 | * 应用系统主题 220 | * 根据系统深色/浅色模式设置相应的主题 221 | * @param {HTMLElement} container 要应用主题的容器元素 222 | */ 223 | applySystemTheme: (container) => { 224 | internalApplySystemTheme(container); 225 | }, 226 | 227 | /** 228 | * 更新页面文本 229 | * 根据当前语言设置更新所有带有特定属性的元素文本 230 | * @param {Object} i18n 国际化模块实例 231 | */ 232 | updatePageTexts: async (i18n) => { 233 | try { 234 | // 更新普通文本元素 235 | const elements = document.querySelectorAll('[data-i18n]'); 236 | for (const el of elements) { 237 | const key = el.getAttribute('data-i18n'); 238 | el.textContent = await i18n.t(key); 239 | } 240 | 241 | // 更新带有 title 属性的元素 242 | const titleElements = document.querySelectorAll('[data-i18n-title]'); 243 | for (const el of titleElements) { 244 | const key = el.getAttribute('data-i18n-title'); 245 | el.title = await i18n.t(key); 246 | } 247 | 248 | // 更新带有 placeholder 属性的元素 249 | const placeholderElements = document.querySelectorAll('[data-i18n-placeholder]'); 250 | for (const el of placeholderElements) { 251 | const key = el.getAttribute('data-i18n-placeholder'); 252 | el.placeholder = await i18n.t(key); 253 | } 254 | 255 | // 更新select元素的选项文本 256 | const selects = document.querySelectorAll("select"); 257 | for (const select of selects) { 258 | const options = Array.from(select.options); 259 | for (const option of options) { 260 | if (option.hasAttribute("data-i18n")) { 261 | const key = option.getAttribute("data-i18n"); 262 | option.textContent = await i18n.t(key); 263 | } 264 | } 265 | } 266 | } catch (error) { 267 | console.error("Error updating page texts:", error); 268 | } 269 | }, 270 | 271 | /** 272 | * 监听系统主题变化 273 | * 当系统主题变化时,如果应用设置为跟随系统,则自动更新主题 274 | * @param {HTMLElement} container 要应用主题的容器元素 275 | */ 276 | setupSystemThemeListener: (container) => { 277 | if (window.matchMedia) { 278 | window 279 | .matchMedia("(prefers-color-scheme: dark)") 280 | .addEventListener("change", async () => { 281 | // 获取当前主题配置 282 | const themeConfig = await ipcRenderer.invoke("get-theme-config"); 283 | if (themeConfig === "system") { 284 | // 调用内部函数 285 | internalApplySystemTheme(container); 286 | } 287 | }); 288 | } 289 | }, 290 | }); 291 | 292 | /** 293 | * 内部辅助函数,应用系统主题 294 | * 抽取为内部函数以避免重复代码 295 | * @param {HTMLElement} container 要应用主题的容器元素 296 | */ 297 | function internalApplySystemTheme(container) { 298 | // 检测系统是否处于深色模式 299 | const isDarkMode = 300 | window.matchMedia && 301 | window.matchMedia("(prefers-color-scheme: dark)").matches; 302 | 303 | // 应用相应的主题类 304 | if (isDarkMode) { 305 | container.classList.add("dark-theme"); 306 | } else { 307 | container.classList.remove("dark-theme"); 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /src/main/window-manager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 窗口管理模块 3 | * 负责创建和管理应用程序中的所有窗口 4 | */ 5 | const { BrowserWindow, app, nativeTheme } = require('electron'); 6 | const path = require('path'); 7 | const dataStore = require('./data-store'); 8 | 9 | /** 10 | * titleBarOverlay: 在Windows上自定义标题栏外观 11 | * 配合titleBarStyle: 'hidden'使用 12 | */ 13 | const titleBarOverlay = { 14 | height: 30, 15 | color: 'rgba(0, 0, 0, 0)', 16 | symbolColor: 'white', 17 | }; 18 | 19 | // 全局窗口引用 - 防止垃圾回收导致窗口被关闭 20 | let mainWindow = null; 21 | let addItemWindow = null; 22 | let settingsWindow = null; // 添加设置窗口引用 23 | 24 | /** 25 | * 根据当前主题获取适当的窗口背景色 26 | * @returns {string} 适合当前主题的背景色 27 | */ 28 | function getThemeBackgroundColor() { 29 | const theme = global.appTheme || 'system'; 30 | 31 | if (theme === 'light') { 32 | return '#f5f5f5'; // 浅色主题背景色 33 | } else if (theme === 'dark') { 34 | return '#1e1e1e'; // 深色主题背景色 35 | } else { 36 | // 跟随系统 37 | return nativeTheme.shouldUseDarkColors ? '#1e1e1e' : '#f5f5f5'; 38 | } 39 | } 40 | 41 | /** 42 | * 执行窗口淡出动画 43 | * 通过逐渐降低窗口透明度实现平滑淡出效果,避免在macOS上出现闪烁 44 | * @param {BrowserWindow} window 需要淡出的窗口对象 45 | * @param {Function} onComplete 动画完成后的回调函数 46 | * @param {Object} options 动画选项 47 | * @param {number} options.fadeStep 每次淡出的透明度步长,默认 0.1 48 | * @param {number} options.fadeInterval 淡出动画的时间间隔(毫秒),默认 10ms 49 | */ 50 | function fadeWindowOut(window, onComplete, options = {}) { 51 | if (!window || window.isDestroyed()) { 52 | return; 53 | } 54 | 55 | // 如果不是macOS,则跳过淡出动画直接执行回调 56 | if (process.platform !== 'darwin') { 57 | onComplete(); 58 | return; 59 | } 60 | 61 | // 动画参数 62 | const fadeStep = options.fadeStep || 0.1; 63 | const fadeInterval = options.fadeInterval || 10; 64 | 65 | // 确保起始透明度为1 66 | window.setOpacity(1.0); 67 | 68 | // 执行淡出动画 69 | const fade = () => { 70 | if (!window || window.isDestroyed()) return; 71 | 72 | let opacity = window.getOpacity() - fadeStep; 73 | if (opacity > 0) { 74 | window.setOpacity(opacity); 75 | setTimeout(fade, fadeInterval); 76 | } else { 77 | // 完全透明后执行回调 78 | window.setOpacity(0); 79 | onComplete(); 80 | } 81 | }; 82 | 83 | // 开始淡出动画 84 | fade(); 85 | } 86 | 87 | /** 88 | * 创建主窗口 89 | * @returns {BrowserWindow} 创建的主窗口对象 90 | */ 91 | function createMainWindow(showOnReady = true) { 92 | // 如果主窗口已存在,则返回现有窗口 93 | if (mainWindow && !mainWindow.isDestroyed()) { 94 | return mainWindow; 95 | } 96 | 97 | // 加载保存的窗口配置 98 | const appConfig = dataStore.loadAppConfig(); 99 | const mainWindowConfig = appConfig.mainWindow; 100 | 101 | /** 102 | * 设置窗口选项 103 | * 注意:某些配置是平台特定的,如titleBarStyle和titleBarOverlay 104 | */ 105 | const windowOptions = { 106 | width: mainWindowConfig.width, 107 | height: mainWindowConfig.height, 108 | minWidth: 300, 109 | minHeight: 300, 110 | maximizable: false, 111 | minimizable: false, 112 | fullscreenable: false, 113 | /** 114 | * titleBarStyle: 在macOS上使用自定义标题栏 115 | * 'hidden' - 隐藏标题栏,内容延伸到整个窗口,窗口控制按钮可见 116 | * Windows平台通过设置frame: false实现 117 | */ 118 | titleBarStyle: 'hidden', 119 | titleBarOverlay: titleBarOverlay, 120 | show: false, // 先创建隐藏窗口,等UI准备好后再显示,避免闪烁 121 | frame: false, // 无框窗口 122 | backgroundColor: getThemeBackgroundColor(), // 添加暗色背景,避免白色闪烁 123 | webPreferences: { 124 | preload: path.join(__dirname, '..', 'preload', 'preload.js'), 125 | }, 126 | }; 127 | 128 | // 如果存在保存的位置坐标,则使用它们 129 | if (mainWindowConfig.x !== undefined && mainWindowConfig.y !== undefined) { 130 | windowOptions.x = mainWindowConfig.x; 131 | windowOptions.y = mainWindowConfig.y; 132 | } 133 | 134 | // 创建主窗口实例 135 | mainWindow = new BrowserWindow(windowOptions); 136 | 137 | // 加载渲染进程的HTML文件 138 | mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')); 139 | 140 | // 打开开发者工具 141 | // mainWindow.webContents.openDevTools(); 142 | 143 | // 注意:不再在这里显示窗口,而是等待渲染进程通知UI准备完成 144 | // UI准备完成后会调用 showMainWindowWhenReady 函数 145 | 146 | // 不在任务栏显示图标 147 | // mainWindow.setSkipTaskbar(true); 148 | 149 | // 保存窗口调整大小时的尺寸 150 | mainWindow.on('resize', () => { 151 | if (!mainWindow.isMaximized()) { 152 | const bounds = mainWindow.getBounds(); 153 | dataStore.updateMainWindowConfig({ 154 | width: bounds.width, 155 | height: bounds.height 156 | }); 157 | } 158 | }); 159 | 160 | // 保存窗口移动时的位置 161 | mainWindow.on('move', () => { 162 | if (!mainWindow.isMaximized()) { 163 | const bounds = mainWindow.getBounds(); 164 | dataStore.updateMainWindowConfig({ 165 | x: bounds.x, 166 | y: bounds.y 167 | }); 168 | } 169 | }); 170 | 171 | // 拦截关闭事件,在Windows上点击关闭按钮时隐藏窗口而非退出应用 172 | mainWindow.on('close', (event) => { 173 | // 如果不是真正要退出应用程序(如app.quit()或app.exit()) 174 | // 且不在macOS上(macOS有自己的窗口管理行为) 175 | if (!app.isQuitting && process.platform !== 'darwin') { 176 | event.preventDefault(); // 阻止默认关闭行为 177 | mainWindow.hide(); // 隐藏窗口 178 | } 179 | }); 180 | 181 | // 窗口关闭时清除引用,避免内存泄漏 182 | mainWindow.on('closed', () => { 183 | mainWindow = null; 184 | }); 185 | 186 | return mainWindow; 187 | } 188 | 189 | /** 190 | * 创建添加项目窗口 191 | * @returns {BrowserWindow} 创建的添加项目窗口对象 192 | */ 193 | function createAddItemWindow() { 194 | // 如果窗口已存在则聚焦并返回 195 | if (addItemWindow && !addItemWindow.isDestroyed()) { 196 | addItemWindow.focus(); 197 | return addItemWindow; 198 | } 199 | 200 | addItemWindow = new BrowserWindow({ 201 | width: 400, 202 | height: 370, 203 | frame: false, 204 | modal: true, 205 | show: false, 206 | parent: mainWindow, 207 | resizable: false, 208 | maximizable: false, 209 | minimizable: false, 210 | fullscreenable: false, 211 | titleBarStyle: 'hidden', 212 | titleBarOverlay: titleBarOverlay, 213 | backgroundColor: getThemeBackgroundColor(), // 添加背景色,避免白色闪烁 214 | webPreferences: { 215 | preload: path.join(__dirname, '..', 'preload', 'preload.js'), 216 | }, 217 | }); 218 | 219 | addItemWindow.loadFile(path.join(__dirname, '..', 'renderer', 'edit-item.html')); 220 | 221 | // 注意:不再在ready-to-show事件中显示窗口,而是等待渲染进程通知UI准备完成 222 | 223 | addItemWindow.on('closed', () => { 224 | addItemWindow = null; 225 | }); 226 | 227 | return addItemWindow; 228 | } 229 | 230 | /** 231 | * 创建编辑项目窗口 232 | * @param {Object} item 要编辑的项目 233 | * @param {number} index 项目索引 234 | * @returns {BrowserWindow} 创建的编辑窗口对象 235 | */ 236 | function createEditItemWindow(item, index) { 237 | // 使用相同的窗口处理编辑功能 238 | const window = createAddItemWindow(); 239 | 240 | // 窗口准备好后发送编辑数据 241 | window.once('ready-to-show', () => { 242 | window.webContents.send('edit-item-data', { item, index }); 243 | }); 244 | 245 | return window; 246 | } 247 | 248 | /** 249 | * 创建设置窗口 250 | * @returns {BrowserWindow} 创建的设置窗口对象 251 | */ 252 | function createSettingsWindow() { 253 | // 如果窗口已存在则聚焦并返回 254 | if (settingsWindow && !settingsWindow.isDestroyed()) { 255 | settingsWindow.focus(); 256 | return settingsWindow; 257 | } 258 | 259 | settingsWindow = new BrowserWindow({ 260 | width: 400, 261 | height: 600, 262 | frame: false, 263 | modal: true, 264 | show: false, 265 | resizable: false, 266 | maximizable: false, 267 | minimizable: false, 268 | fullscreenable: false, 269 | titleBarStyle: 'hidden', 270 | titleBarOverlay: titleBarOverlay, 271 | parent: mainWindow, 272 | backgroundColor: getThemeBackgroundColor(), // 添加背景色,避免白色闪烁 273 | webPreferences: { 274 | preload: path.join(__dirname, '..', 'preload', 'preload.js'), 275 | }, 276 | }); 277 | 278 | settingsWindow.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html')); 279 | 280 | // 注意:不再在ready-to-show事件中显示窗口,而是等待渲染进程通知UI准备完成 281 | 282 | settingsWindow.on('closed', () => { 283 | settingsWindow = null; 284 | }); 285 | 286 | return settingsWindow; 287 | } 288 | 289 | /** 290 | * 关闭设置窗口 291 | * 使用平滑动画避免闪烁 292 | */ 293 | function closeSettingsWindow() { 294 | if (settingsWindow && !settingsWindow.isDestroyed()) { 295 | // 使用公共的淡出函数处理窗口关闭 296 | fadeWindowOut(settingsWindow, () => { 297 | settingsWindow.close(); 298 | settingsWindow = null; 299 | }); 300 | } 301 | } 302 | 303 | /** 304 | * 显示主窗口(如不存在则创建) 305 | */ 306 | function showMainWindow() { 307 | // 检查窗口是否存在且未被销毁 308 | if (!mainWindow || mainWindow.isDestroyed()) { 309 | // 如果窗口不存在或已销毁,则创建新窗口 310 | createMainWindow(); 311 | return; 312 | } 313 | 314 | // 无论窗口是否可见,都确保显示并聚焦 315 | mainWindow.show(); 316 | mainWindow.focus(); 317 | } 318 | 319 | /** 320 | * 关闭添加/编辑项目窗口 321 | * 使用平滑动画避免闪烁 322 | */ 323 | function closeAddItemWindow() { 324 | if (addItemWindow && !addItemWindow.isDestroyed()) { 325 | // 使用公共的淡出函数处理窗口关闭 326 | fadeWindowOut(addItemWindow, () => { 327 | addItemWindow.close(); 328 | addItemWindow = null; 329 | }); 330 | } 331 | } 332 | 333 | /** 334 | * 隐藏主窗口 335 | * 在macOS上使用淡出动画避免闪烁 336 | */ 337 | function hideMainWindow() { 338 | if (mainWindow && !mainWindow.isDestroyed()) { 339 | // 使用公共的淡出函数处理窗口隐藏 340 | fadeWindowOut(mainWindow, () => { 341 | mainWindow.hide(); 342 | // 重置透明度,以便下次显示 343 | setTimeout(() => { 344 | if (mainWindow && !mainWindow.isDestroyed()) { 345 | mainWindow.setOpacity(1.0); 346 | } 347 | }, 100); 348 | }); 349 | } 350 | } 351 | 352 | /** 353 | * 通知主窗口项目列表已更新 354 | * @param {number} newItemIndex 新添加项目的索引(可选) 355 | */ 356 | function notifyItemsUpdated(newItemIndex) { 357 | if (mainWindow && !mainWindow.isDestroyed()) { 358 | mainWindow.webContents.send('items-updated', newItemIndex); 359 | } 360 | } 361 | 362 | /** 363 | * 获取主窗口引用 364 | * @returns {BrowserWindow} 主窗口对象 365 | */ 366 | function getMainWindow() { 367 | return mainWindow; 368 | } 369 | 370 | /** 371 | * 获取添加项目窗口引用 372 | * @returns {BrowserWindow} 添加项目窗口对象 373 | */ 374 | function getAddItemWindow() { 375 | return addItemWindow; 376 | } 377 | 378 | /** 379 | * 获取设置窗口引用 380 | * @returns {BrowserWindow} 设置窗口对象 381 | */ 382 | function getSettingsWindow() { 383 | return settingsWindow; 384 | } 385 | 386 | /** 387 | * 当UI准备好后显示主窗口 388 | * 由渲染进程通知UI(主题和语言)已完全准备就绪时调用 389 | */ 390 | function showMainWindowWhenReady() { 391 | if (mainWindow && !mainWindow.isDestroyed()) { 392 | mainWindow.show(); 393 | mainWindow.focus(); 394 | } 395 | } 396 | 397 | /** 398 | * 当UI准备好后显示设置窗口 399 | */ 400 | function showSettingsWindowWhenReady() { 401 | if (settingsWindow && !settingsWindow.isDestroyed()) { 402 | settingsWindow.show(); 403 | settingsWindow.focus(); 404 | } 405 | } 406 | 407 | /** 408 | * 当UI准备好后显示添加/编辑项目窗口 409 | */ 410 | function showAddItemWindowWhenReady() { 411 | if (addItemWindow && !addItemWindow.isDestroyed()) { 412 | addItemWindow.show(); 413 | addItemWindow.focus(); 414 | } 415 | } 416 | 417 | // 导出模块函数 418 | module.exports = { 419 | createMainWindow, 420 | createAddItemWindow, 421 | createEditItemWindow, 422 | createSettingsWindow, 423 | showMainWindow, 424 | closeAddItemWindow, 425 | closeSettingsWindow, 426 | hideMainWindow, 427 | notifyItemsUpdated, 428 | getMainWindow, 429 | getAddItemWindow, 430 | getSettingsWindow, 431 | showMainWindowWhenReady, 432 | showSettingsWindowWhenReady, 433 | showAddItemWindowWhenReady 434 | }; -------------------------------------------------------------------------------- /src/shared/i18n.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 国际化支持模块 (i18n) 3 | * 提供多语言支持功能,支持从外部 JSON 文件动态加载翻译 4 | * 允许用户自定义添加新的语言文件 5 | */ 6 | 7 | const fs = require('fs'); 8 | const path = require('path'); 9 | const { app } = require('electron'); 10 | 11 | // 获取应用的路径(区分开发环境和打包后环境) 12 | const isPackaged = app && app.isPackaged; 13 | const appPath = isPackaged ? path.dirname(process.resourcesPath) : process.cwd(); 14 | 15 | // 语言文件目录路径 - 修正资源路径 16 | const localesDir = isPackaged 17 | ? path.join(process.resourcesPath, 'app', 'src', 'assets', 'locales') 18 | : path.join(appPath, 'src', 'assets', 'locales'); 19 | 20 | // 用户自定义语言文件目录,放在用户数据目录下 21 | const userLocalesDir = path.join( 22 | app ? app.getPath('userData') : path.join(appPath, 'user-data'), 23 | 'locales' 24 | ); 25 | 26 | console.log('Locale directories:', { 27 | appPath, 28 | isPackaged, 29 | localesDir, 30 | userLocalesDir, 31 | localesDirExists: fs.existsSync(localesDir) 32 | }); 33 | 34 | // 确保用户自定义语言文件目录存在 35 | try { 36 | if (!fs.existsSync(userLocalesDir)) { 37 | fs.mkdirSync(userLocalesDir, { recursive: true }); 38 | } 39 | } catch (error) { 40 | console.error('Error creating user locale directory:', error); 41 | } 42 | 43 | // 存储已加载的翻译内容 44 | const translations = {}; 45 | 46 | // 存储可用的语言列表 47 | let availableLanguages = []; 48 | 49 | /** 50 | * 从文件加载翻译内容 51 | * @param {string} langCode 语言代码 52 | * @returns {Object} 翻译内容 53 | */ 54 | function loadTranslationFile(langCode) { 55 | let translation = {}; 56 | 57 | // 先尝试从用户自定义目录加载 58 | const userFilePath = path.join(userLocalesDir, `${langCode}.json`); 59 | let userTranslation = {}; 60 | 61 | try { 62 | if (fs.existsSync(userFilePath)) { 63 | const fileContent = fs.readFileSync(userFilePath, 'utf8'); 64 | userTranslation = JSON.parse(fileContent); 65 | console.log(`Loaded user custom language file: ${langCode}`); 66 | } 67 | } catch (error) { 68 | console.error(`Error loading user language file ${langCode}.json:`, error); 69 | } 70 | 71 | // 再从应用内置目录加载 72 | const builtinFilePath = path.join(localesDir, `${langCode}.json`); 73 | let builtinTranslation = {}; 74 | 75 | try { 76 | if (fs.existsSync(builtinFilePath)) { 77 | const fileContent = fs.readFileSync(builtinFilePath, 'utf8'); 78 | builtinTranslation = JSON.parse(fileContent); 79 | console.log(`Loaded built-in language file: ${langCode}`); 80 | } else { 81 | console.warn(`Built-in language file not found: ${builtinFilePath}`); 82 | 83 | // 尝试备用路径(开发环境中可能的路径) 84 | const altPath = path.join(appPath, 'src', 'assets', 'locales', `${langCode}.json`); 85 | if (fs.existsSync(altPath)) { 86 | const fileContent = fs.readFileSync(altPath, 'utf8'); 87 | builtinTranslation = JSON.parse(fileContent); 88 | console.log(`Loaded language file from alternative path: ${langCode}`); 89 | } 90 | } 91 | } catch (error) { 92 | console.error(`Error loading built-in language file ${langCode}.json:`, error); 93 | } 94 | 95 | // 用户翻译覆盖内置翻译 96 | translation = { ...builtinTranslation, ...userTranslation }; 97 | 98 | return translation; 99 | } 100 | 101 | /** 102 | * 加载所有可用的语言文件 103 | */ 104 | function loadAllLanguages() { 105 | const builtinLangs = new Set(); 106 | const userLangs = new Set(); 107 | 108 | // 获取内置语言文件列表 109 | try { 110 | if (fs.existsSync(localesDir)) { 111 | fs.readdirSync(localesDir) 112 | .filter(file => file.endsWith('.json')) 113 | .forEach(file => { 114 | const langCode = file.replace('.json', ''); 115 | builtinLangs.add(langCode); 116 | 117 | // 加载内置语言 118 | translations[langCode] = loadTranslationFile(langCode); 119 | }); 120 | console.log('Loaded built-in language files from standard path'); 121 | } else { 122 | // 尝试备用路径(开发环境中可能的路径) 123 | const altLocalesDir = path.join(appPath, 'src', 'assets', 'locales'); 124 | if (fs.existsSync(altLocalesDir)) { 125 | fs.readdirSync(altLocalesDir) 126 | .filter(file => file.endsWith('.json')) 127 | .forEach(file => { 128 | const langCode = file.replace('.json', ''); 129 | builtinLangs.add(langCode); 130 | 131 | // 加载内置语言 132 | translations[langCode] = loadTranslationFile(langCode); 133 | }); 134 | console.log('Loaded built-in language files from alternative path'); 135 | } else { 136 | console.error('Built-in language directory not found:', { localesDir, altLocalesDir }); 137 | } 138 | } 139 | } catch (error) { 140 | console.error('Error reading built-in language directory:', error); 141 | } 142 | 143 | // 获取用户自定义语言文件列表 144 | try { 145 | if (fs.existsSync(userLocalesDir)) { 146 | fs.readdirSync(userLocalesDir) 147 | .filter(file => file.endsWith('.json')) 148 | .forEach(file => { 149 | const langCode = file.replace('.json', ''); 150 | userLangs.add(langCode); 151 | 152 | // 如果之前没有加载过这个语言,现在加载它 153 | if (!translations[langCode]) { 154 | translations[langCode] = loadTranslationFile(langCode); 155 | } 156 | }); 157 | } 158 | } catch (error) { 159 | console.error('Error reading user language directory:', error); 160 | } 161 | 162 | // 合并语言列表 163 | availableLanguages = [...new Set([...builtinLangs, ...userLangs])]; 164 | console.log('Available languages:', availableLanguages); 165 | } 166 | 167 | // 首次加载所有语言 168 | loadAllLanguages(); 169 | 170 | // 获取系统语言 171 | function getSystemLanguage() { 172 | // 获取系统语言,如果无法获取则默认使用英文 173 | const systemLang = (typeof navigator !== 'undefined' ? navigator.language : null) || 174 | (app ? app.getLocale() : null) || 'en-US'; 175 | 176 | console.log('System language:', systemLang); 177 | 178 | // 将系统语言映射到支持的语言 179 | if (systemLang.startsWith('zh')) { 180 | return 'zh-CN'; 181 | } else { 182 | return 'en-US'; // 默认英文 183 | } 184 | } 185 | 186 | // 当前语言,默认跟随系统 187 | let currentLanguage = null; 188 | 189 | // 存储语言变化监听器 - 观察者模式实现 190 | const languageChangeListeners = []; 191 | 192 | /** 193 | * 添加语言变化监听器 194 | * @param {Function} listener 监听函数 195 | */ 196 | function addLanguageChangeListener(listener) { 197 | if (typeof listener === 'function' && !languageChangeListeners.includes(listener)) { 198 | languageChangeListeners.push(listener); 199 | } 200 | } 201 | 202 | /** 203 | * 移除语言变化监听器 204 | * @param {Function} listener 要移除的监听函数 205 | */ 206 | function removeLanguageChangeListener(listener) { 207 | const index = languageChangeListeners.indexOf(listener); 208 | if (index !== -1) { 209 | languageChangeListeners.splice(index, 1); 210 | } 211 | } 212 | 213 | /** 214 | * 通知所有监听器语言已变化 215 | */ 216 | function notifyLanguageChangeListeners(newLanguage) { 217 | for (const listener of languageChangeListeners) { 218 | try { 219 | listener(newLanguage); 220 | } catch (error) { 221 | console.error("Error executing language change listener:", error); 222 | } 223 | } 224 | } 225 | 226 | /** 227 | * 获取当前使用的语言 228 | * @returns {string} 当前语言代码 229 | */ 230 | function getCurrentLanguage() { 231 | if (!currentLanguage) { 232 | try { 233 | // 尝试从 main 进程中获取语言设置 234 | const { app } = require('electron'); 235 | if (app) { 236 | const dataStore = require('../main/data-store'); 237 | const appConfig = dataStore.getAppConfig(); 238 | if (appConfig.language === 'system' || !appConfig.language) { 239 | currentLanguage = getSystemLanguage(); 240 | } else { 241 | currentLanguage = appConfig.language; 242 | } 243 | } else { 244 | // 在渲染进程中,无法直接访问 dataStore 245 | // 此时应该已经由主进程设置了当前语言 246 | currentLanguage = getSystemLanguage(); 247 | } 248 | } catch (error) { 249 | console.error('Error getting language configuration:', error); 250 | currentLanguage = getSystemLanguage(); 251 | } 252 | } 253 | return currentLanguage; 254 | } 255 | 256 | /** 257 | * 设置当前语言 258 | * @param {string} lang 语言代码或"system" 259 | */ 260 | function setLanguage(lang) { 261 | let newLang; 262 | 263 | if (lang === 'system') { 264 | newLang = getSystemLanguage(); 265 | } else if (translations[lang]) { 266 | newLang = lang; 267 | } else { 268 | // 尝试先加载该语言文件 269 | try { 270 | translations[lang] = loadTranslationFile(lang); 271 | if (Object.keys(translations[lang]).length > 0) { 272 | // 成功加载语言文件 273 | newLang = lang; 274 | if (!availableLanguages.includes(lang)) { 275 | availableLanguages.push(lang); 276 | } 277 | } else { 278 | newLang = 'en-US'; // 默认回退到英文 279 | console.error(`Language file is empty: ${lang}`); 280 | } 281 | } catch (error) { 282 | newLang = 'en-US'; // 默认回退到英文 283 | console.error(`Unsupported language: ${lang}`, error); 284 | } 285 | } 286 | 287 | // 保存到全局变量 288 | currentLanguage = newLang; 289 | 290 | // 尝试保存到 data-store 291 | try { 292 | const { app } = require('electron'); 293 | if (app) { 294 | const dataStore = require('../main/data-store'); 295 | dataStore.updateLanguageConfig(lang); 296 | } 297 | } catch (error) { 298 | console.error('Error saving language configuration:', error); 299 | } 300 | 301 | // 通知语言变化 302 | notifyLanguageChangeListeners(newLang); 303 | 304 | return newLang; 305 | } 306 | 307 | /** 308 | * 获取所有可用的语言列表 309 | * @returns {Array} 语言代码数组 310 | */ 311 | function getAvailableLanguages() { 312 | // 重新加载语言文件,以确保获取最新列表 313 | loadAllLanguages(); 314 | return availableLanguages; 315 | } 316 | 317 | /** 318 | * 获取语言名称 319 | * @param {string} langCode 语言代码 320 | * @returns {string} 语言名称 321 | */ 322 | function getLanguageName(langCode) { 323 | // 使用当前语言显示目标语言的名称 324 | const lang = getCurrentLanguage(); 325 | const translationSet = translations[lang] || translations['en-US']; 326 | 327 | // 如果有该语言的翻译,则使用翻译 328 | if (translationSet && translationSet[langCode]) { 329 | return translationSet[langCode]; 330 | } 331 | 332 | // 如果没有翻译,则使用语言代码 333 | return langCode; 334 | } 335 | 336 | /** 337 | * 添加或更新用户自定义语言 338 | * @param {string} langCode 语言代码 339 | * @param {Object} translation 翻译内容 340 | */ 341 | function addUserLanguage(langCode, translation) { 342 | try { 343 | const filePath = path.join(userLocalesDir, `${langCode}.json`); 344 | fs.writeFileSync(filePath, JSON.stringify(translation, null, 2), 'utf8'); 345 | 346 | // 重新加载该语言 347 | translations[langCode] = loadTranslationFile(langCode); 348 | 349 | // 更新可用语言列表 350 | if (!availableLanguages.includes(langCode)) { 351 | availableLanguages.push(langCode); 352 | } 353 | 354 | return true; 355 | } catch (error) { 356 | console.error(`Error adding user custom language ${langCode}:`, error); 357 | return false; 358 | } 359 | } 360 | 361 | /** 362 | * 获取翻译文本 363 | * @param {string} key 翻译键 364 | * @param {Object} params 替换参数 (可选) 365 | * @returns {string} 翻译后的文本 366 | */ 367 | function t(key, params = null) { 368 | const lang = getCurrentLanguage(); 369 | const translationSet = translations[lang] || translations['en-US']; 370 | 371 | let text = translationSet[key]; 372 | if (text === undefined) { 373 | console.warn(`Missing translation: ${key} for language: ${lang}`); 374 | // 尝试从英文中获取 375 | text = translations['en-US'][key]; 376 | // 如果英文也没有,则返回键名 377 | if (text === undefined) { 378 | return key; 379 | } 380 | } 381 | 382 | // 处理参数替换 383 | if (params) { 384 | Object.keys(params).forEach(param => { 385 | text = text.replace(`{${param}}`, params[param]); 386 | }); 387 | } 388 | 389 | return text; 390 | } 391 | 392 | // 导出模块功能 393 | module.exports = { 394 | t, 395 | setLanguage, 396 | getCurrentLanguage, 397 | getSystemLanguage, 398 | addLanguageChangeListener, 399 | removeLanguageChangeListener, 400 | getAvailableLanguages, 401 | getLanguageName, 402 | addUserLanguage 403 | }; -------------------------------------------------------------------------------- /src/renderer/js/settings.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 设置页面的脚本 3 | * 处理设置窗口的所有功能,包括主题设置、数据管理和应用信息显示 4 | */ 5 | document.addEventListener("DOMContentLoaded", async () => { 6 | // 导入i18n模块 7 | const i18n = window.electronAPI.i18n; 8 | 9 | // DOM 元素引用 10 | const themeSelect = document.getElementById("theme-select"); 11 | const languageSelect = document.getElementById("language-select"); 12 | const clearDataBtn = document.getElementById("clear-data-btn"); 13 | const openStorageBtn = document.getElementById("open-storage-btn"); 14 | const githubLink = document.getElementById("github-link"); 15 | const reportIssueLink = document.getElementById("report-issue"); 16 | 17 | // 快捷键设置元素引用 18 | const enableShortcutCheckbox = document.getElementById("enable-shortcut"); 19 | const shortcutInput = document.getElementById("shortcut-input"); 20 | const recordShortcutBtn = document.getElementById("record-shortcut-btn"); 21 | const resetShortcutBtn = document.getElementById("reset-shortcut-btn"); 22 | 23 | // 自启动设置元素引用 24 | const enableAutoLaunchCheckbox = document.getElementById("enable-auto-launch"); 25 | 26 | const modalContainer = document.querySelector(".modal"); 27 | 28 | // 初始化UI,保存返回的解绑函数对象 29 | const uiCleanup = window.uiManager.initUI({ 30 | containerSelector: ".modal", 31 | windowType: "settings" // 指定窗口类型为设置窗口 32 | }); 33 | 34 | // 当页面卸载时清理监听器 35 | window.addEventListener('beforeunload', () => { 36 | if (uiCleanup && typeof uiCleanup.unbindAll === 'function') { 37 | uiCleanup.unbindAll(); 38 | } 39 | }); 40 | 41 | // 初始化设置页面 42 | await initSettingsPage(); 43 | 44 | // 加载已保存的主题设置 45 | await loadThemeSetting(); 46 | 47 | // 加载可用语言列表和已保存的语言设置 48 | await loadLanguages(); 49 | 50 | // 加载快捷键设置 51 | await loadShortcutSettings(); 52 | 53 | // 加载自启动设置 54 | await loadAutoLaunchSetting(); 55 | 56 | /** 57 | * 事件监听设置部分 58 | */ 59 | 60 | /** 61 | * 主题选择变化事件 62 | * 保存并应用用户选择的主题 63 | */ 64 | themeSelect.addEventListener("change", () => { 65 | const theme = themeSelect.value; 66 | // 立即应用主题到当前窗口 67 | window.uiUtils.applyTheme(theme, modalContainer); 68 | // 通知主进程和其他窗口主题已更改 69 | window.electronAPI.themeChanged(theme); 70 | }); 71 | 72 | /** 73 | * 语言选择变化事件 74 | * 保存并应用用户选择的语言 75 | */ 76 | languageSelect.addEventListener("change", async () => { 77 | const language = languageSelect.value; 78 | // 应用新语言 79 | await i18n.setLanguage(language); 80 | // 更新页面文本 81 | await window.uiUtils.updatePageTexts(i18n); 82 | // 通知主进程和其他窗口语言已更改 83 | window.electronAPI.languageChanged(language); 84 | }); 85 | 86 | /** 87 | * 启用全局快捷键复选框变化事件 88 | * 保存设置并更新界面状态 89 | */ 90 | enableShortcutCheckbox.addEventListener("change", async () => { 91 | if (!enableShortcutCheckbox.checked && recordingShortcut) { 92 | // 如果取消选中时正在录制快捷键,停止录制 93 | // 恢复上次的有效值 94 | const config = await window.electronAPI.getShortcutConfig(); 95 | setShortcutInputValueWithFormat(config.shortcut); 96 | stopRecordingShortcut(); 97 | } 98 | 99 | updateShortcutConfig({ enabled: enableShortcutCheckbox.checked }); 100 | updateShortcutInputState(); 101 | }); 102 | 103 | /** 104 | * 清空数据按钮点击事件 105 | * 显示确认对话框,确认后清空所有项目 106 | */ 107 | clearDataBtn.addEventListener("click", async () => { 108 | const confirmMessage = await i18n.t("confirm-clear-data"); 109 | if (confirm(confirmMessage)) { 110 | window.electronAPI.clearAllItems(); 111 | window.uiManager.showToast(await i18n.t("data-cleared")); 112 | } 113 | }); 114 | 115 | /** 116 | * 打开存储位置按钮点击事件 117 | * 打开应用数据存储目录 118 | */ 119 | openStorageBtn.addEventListener("click", () => { 120 | window.electronAPI.openStorageLocation(); 121 | }); 122 | 123 | /** 124 | * GitHub链接点击事件处理 125 | * 使用默认浏览器打开GitHub仓库 126 | */ 127 | githubLink.addEventListener("click", (e) => { 128 | e.preventDefault(); 129 | window.electronAPI.openExternalLink("https://github.com/SolarianZ/launcher-app-electron"); 130 | }); 131 | 132 | /** 133 | * 报告问题链接点击事件处理 134 | * 使用默认浏览器打开GitHub issues页面 135 | */ 136 | reportIssueLink.addEventListener("click", (e) => { 137 | e.preventDefault(); 138 | window.electronAPI.openExternalLink("https://github.com/SolarianZ/launcher-app-electron/issues"); 139 | }); 140 | 141 | /** 142 | * 启用自启动复选框变化事件 143 | * 保存设置并更新系统配置 144 | */ 145 | enableAutoLaunchCheckbox.addEventListener("change", async () => { 146 | try { 147 | const result = await window.electronAPI.updateAutoLaunchConfig({ 148 | enabled: enableAutoLaunchCheckbox.checked 149 | }); 150 | if (result) { 151 | window.uiManager.showToast(await i18n.t( 152 | enableAutoLaunchCheckbox.checked ? "auto-launch-enabled" : "auto-launch-disabled" 153 | )); 154 | } else { 155 | enableAutoLaunchCheckbox.checked = !enableAutoLaunchCheckbox.checked; 156 | window.uiManager.showToast(await i18n.t("auto-launch-update-failed"), true); 157 | } 158 | } catch (error) { 159 | console.error("Error updating auto launch config:", error); 160 | window.uiManager.showToast(await i18n.t("auto-launch-update-failed"), true); 161 | } 162 | }); 163 | 164 | /** 165 | * 全局键盘事件处理 166 | * - Escape: 关闭窗口 167 | * - F12: 开发者工具 168 | */ 169 | document.addEventListener("keydown", (e) => { 170 | if (e.key === "Escape") { 171 | window.electronAPI.closeSettingsWindow(); 172 | e.preventDefault(); 173 | } else if (e.key === "F12") { 174 | window.electronAPI.openDevTools(); 175 | e.preventDefault(); 176 | } 177 | }); 178 | 179 | function setShortcutInputValueWithFormat(shortcut) { 180 | // 将快捷键中的字母键转换为大写显示 181 | const parts = shortcut.split('+'); 182 | const formattedParts = parts.map(part => { 183 | // 如果是单个字符且是字母,转换为大写 184 | if (part.length === 1 && part.match(/[a-zA-Z]/)) { 185 | return part.toUpperCase(); 186 | } 187 | // 其他情况(修饰键等)保持不变 188 | return part; 189 | }); 190 | shortcutInput.value = formattedParts.join('+'); 191 | } 192 | 193 | // 处理快捷键录入的事件监听 194 | setupShortcutRecording(); 195 | 196 | /** 197 | * 初始化设置页面 198 | * 获取应用信息并显示版本号 199 | */ 200 | async function initSettingsPage() { 201 | // 获取应用信息 202 | try { 203 | const appInfo = await window.electronAPI.getAppInfo(); 204 | document.getElementById("version-number").textContent = appInfo.version; 205 | } catch (error) { 206 | console.error("Error getting app info:", error); 207 | } 208 | } 209 | 210 | /** 211 | * 加载主题设置 212 | * 从主进程获取主题设置并设置到下拉选择框 213 | */ 214 | async function loadThemeSetting() { 215 | // 通过 API 获取保存的主题设置 216 | const savedTheme = await window.electronAPI.getThemeConfig(); 217 | themeSelect.value = savedTheme; 218 | } 219 | 220 | /** 221 | * 加载可用语言列表和已保存的语言设置 222 | * 从i18n模块获取所有可用语言并填充到下拉选择框 223 | */ 224 | async function loadLanguages() { 225 | try { 226 | // 清空现有选项,只保留"系统"选项 227 | while (languageSelect.options.length > 1) { 228 | languageSelect.remove(1); 229 | } 230 | 231 | // 获取所有可用语言 232 | const languages = await i18n.getAvailableLanguages(); 233 | 234 | // 添加语言选项 235 | for (const langCode of languages) { 236 | const langName = await i18n.getLanguageName(langCode); 237 | const option = document.createElement('option'); 238 | option.value = langCode; 239 | option.textContent = langName; 240 | languageSelect.appendChild(option); 241 | } 242 | 243 | // 获取保存的语言设置 244 | const savedLanguage = await window.electronAPI.getLanguageConfig(); 245 | languageSelect.value = savedLanguage; 246 | } catch (error) { 247 | console.error("Error loading language list:", error); 248 | } 249 | } 250 | 251 | /** 252 | * 加载快捷键配置 253 | * 从主进程获取快捷键配置并设置到界面 254 | */ 255 | async function loadShortcutSettings() { 256 | try { 257 | const config = await window.electronAPI.getShortcutConfig(); 258 | enableShortcutCheckbox.checked = config.enabled; 259 | setShortcutInputValueWithFormat(config.shortcut); 260 | updateShortcutInputState(); 261 | } catch (error) { 262 | console.error("Error loading shortcut settings:", error); 263 | } 264 | } 265 | 266 | /** 267 | * 加载自启动设置 268 | * 从主进程获取自启动设置并设置到界面 269 | */ 270 | async function loadAutoLaunchSetting() { 271 | try { 272 | const config = await window.electronAPI.getAutoLaunchConfig(); 273 | enableAutoLaunchCheckbox.checked = config.enabled; 274 | } catch (error) { 275 | console.error("Error loading auto-launch setting:", error); 276 | } 277 | } 278 | 279 | /** 280 | * 更新快捷键配置 281 | * 向主进程发送更新的快捷键配置 282 | * @param {Object} config 配置对象,可以包含 enabled 和 shortcut 属性 283 | */ 284 | async function updateShortcutConfig(config) { 285 | try { 286 | await window.electronAPI.updateShortcutConfig(config); 287 | } catch (error) { 288 | console.error("Error updating shortcut config:", error); 289 | } 290 | } 291 | 292 | /** 293 | * 更新快捷键输入框状态 294 | * 根据启用状态设置输入框和按钮的可用性 295 | */ 296 | function updateShortcutInputState() { 297 | const enabled = enableShortcutCheckbox.checked; 298 | shortcutInput.disabled = !enabled; 299 | recordShortcutBtn.disabled = !enabled; 300 | resetShortcutBtn.disabled = !enabled; 301 | 302 | // 根据启用状态设置快捷键相关控件的样式 303 | if (enabled) { 304 | shortcutInput.classList.remove('disabled-input'); 305 | recordShortcutBtn.classList.remove('disabled-btn'); 306 | resetShortcutBtn.classList.remove('disabled-btn'); 307 | } else { 308 | shortcutInput.classList.add('disabled-input'); 309 | recordShortcutBtn.classList.add('disabled-btn'); 310 | resetShortcutBtn.classList.add('disabled-btn'); 311 | } 312 | } 313 | 314 | /** 315 | * 是否正在录制快捷键 316 | */ 317 | let recordingShortcut = false; 318 | 319 | /** 320 | * 进入快捷键录入模式 321 | */ 322 | async function startRecordingShortcut() { 323 | shortcutInput.classList.add("recording"); 324 | recordingShortcut = true; 325 | recordShortcutBtn.textContent = await i18n.t("cancel"); 326 | shortcutInput.value = await i18n.t("press-new-shortcut"); 327 | } 328 | 329 | /** 330 | * 结束快捷键录入模式 331 | */ 332 | async function stopRecordingShortcut() { 333 | shortcutInput.classList.remove("recording"); 334 | recordingShortcut = false; 335 | recordShortcutBtn.textContent = await i18n.t("record-shortcut"); 336 | } 337 | 338 | /** 339 | * 设置快捷键录入的事件监听 340 | */ 341 | function setupShortcutRecording() { 342 | let pressedKeys = new Set(); 343 | 344 | /** 345 | * 快捷键记录按钮点击事件 346 | * 进入快捷键记录模式 347 | */ 348 | recordShortcutBtn.addEventListener("click", async () => { 349 | if (!recordingShortcut) { 350 | startRecordingShortcut(); 351 | } else { 352 | // 恢复上次的有效值 353 | const config = await window.electronAPI.getShortcutConfig(); 354 | setShortcutInputValueWithFormat(config.shortcut); 355 | 356 | stopRecordingShortcut(); 357 | } 358 | }); 359 | 360 | /** 361 | * 快捷键重置按钮点击事件 362 | * 重置快捷键为默认值 363 | */ 364 | resetShortcutBtn.addEventListener("click", async () => { 365 | // 如果在录制模式中,先退出录制模式 366 | if (recordingShortcut) { 367 | await stopRecordingShortcut(); 368 | } 369 | 370 | pressedKeys.clear(); 371 | 372 | setShortcutInputValueWithFormat("Alt+Shift+Q"); 373 | updateShortcutConfig({ shortcut: "Alt+Shift+Q" }); 374 | }); 375 | 376 | // 录制时捕获按键 377 | document.addEventListener("keydown", async (e) => { 378 | if (!recordingShortcut) 379 | return; 380 | 381 | e.preventDefault(); 382 | 383 | const key = e.key; 384 | pressedKeys.add(key); 385 | if (key === "Control" || key === "Alt" || key === "Shift" || key === "Meta") 386 | return; 387 | 388 | // 如果按下了非修饰键,尝试提交按键组合 389 | 390 | // 检查是否包含除Shift以外的修饰键 391 | const hasRequiredModifier = pressedKeys.has("Control") || pressedKeys.has("Alt") || pressedKeys.has("Meta"); 392 | if (!hasRequiredModifier) { 393 | // 如果不含必需的修饰键,显示错误消息 394 | window.uiManager.showToast(await i18n.t("shortcut-need-modifier"), true); 395 | 396 | // 恢复上次的有效值 397 | const config = await window.electronAPI.getShortcutConfig(); 398 | setShortcutInputValueWithFormat(config.shortcut); 399 | 400 | stopRecordingShortcut(); 401 | pressedKeys.clear(); 402 | 403 | return; 404 | } 405 | 406 | // 创建快捷键字符串 407 | const shortcut = Array.from(pressedKeys).join("+"); 408 | pressedKeys.clear(); 409 | 410 | // 测试快捷键是否可用 411 | const testResult = await window.electronAPI.testShortcut(shortcut); 412 | if (testResult.success) { 413 | setShortcutInputValueWithFormat(shortcut); 414 | 415 | // 保存新快捷键 416 | updateShortcutConfig({ shortcut }); 417 | 418 | stopRecordingShortcut(); 419 | } else { 420 | // 失败,显示错误消息 421 | window.uiManager.showToast(testResult.message, true); 422 | 423 | // 恢复上次的有效值 424 | const config = await window.electronAPI.getShortcutConfig(); 425 | setShortcutInputValueWithFormat(config.shortcut); 426 | 427 | stopRecordingShortcut(); 428 | } 429 | }); 430 | 431 | // 监听按键释放,从集合中移除 432 | document.addEventListener("keyup", (e) => { 433 | if (!recordingShortcut) 434 | return; 435 | 436 | e.preventDefault(); 437 | pressedKeys.delete(e.key); 438 | }); 439 | } 440 | }); 441 | -------------------------------------------------------------------------------- /src/renderer/css/styles.css: -------------------------------------------------------------------------------- 1 | /* 全局变量 */ 2 | :root { 3 | --primary-color: #4a86e8; 4 | --secondary-color: #6aa84f; 5 | --text-color: #333; 6 | --bg-color: #f5f5f5; 7 | --card-bg: #fff; 8 | --border-color: #ddd; 9 | --hover-bg: #e9e9e9; 10 | --active-bg: #d9d9d9; 11 | --disabled-bg: #f0f0f0; 12 | --disabled-text: #999; 13 | --shadow: 0 2px 5px rgba(0, 0, 0, 0.1); 14 | --title-bar-bg: #e0e0e0; 15 | --title-bar-text: #333; 16 | --search-bg: #fff; 17 | --modal-overlay: rgba(0, 0, 0, 0.5); 18 | --tooltip-bg: #333; 19 | --tooltip-text: #fff; 20 | --drag-indicator: #4a86e8; 21 | --icon-color: #555; 22 | --menu-bg: #fff; 23 | --menu-hover: #f0f0f0; 24 | --menu-border: #ddd; 25 | --button-text: #fff; 26 | --button-bg: #4a86e8; 27 | --button-hover: #3a76d8; 28 | --button-active: #2a66c8; 29 | --button-disabled: #a0b8e0; 30 | --cancel-button-bg: #f0f0f0; 31 | --cancel-button-text: #333; 32 | --cancel-button-hover: #e0e0e0; 33 | --cancel-button-active: #d0d0d0; 34 | --error-color: #d23f31; 35 | } 36 | 37 | /* 全局样式 */ 38 | * { 39 | margin: 0; 40 | padding: 0; 41 | box-sizing: border-box; 42 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 43 | } 44 | 45 | body { 46 | background-color: transparent; 47 | overflow: hidden; 48 | } 49 | 50 | /* 禁用状态样式 */ 51 | .disabled-input { 52 | background-color: var(--disabled-bg) !important; 53 | color: var(--disabled-text) !important; 54 | cursor: not-allowed !important; 55 | border-color: var(--disabled-bg) !important; 56 | } 57 | 58 | .disabled-btn { 59 | background-color: var(--disabled-bg) !important; 60 | color: var(--disabled-text) !important; 61 | cursor: not-allowed !important; 62 | border-color: var(--disabled-bg) !important; 63 | opacity: 0.7; 64 | } 65 | 66 | /* 应用容器 */ 67 | .app-container { 68 | width: 100vw; 69 | height: 100vh; 70 | background-color: var(--bg-color); 71 | overflow: hidden; 72 | display: flex; 73 | flex-direction: column; 74 | transition: all 0.3s ease; 75 | box-shadow: var(--shadow); 76 | } 77 | 78 | /* 标题栏 */ 79 | .title-bar { 80 | height: 30px; 81 | flex-shrink: 0; 82 | background-color: var(--title-bar-bg); 83 | display: flex; 84 | align-items: center; 85 | justify-content: space-between; 86 | padding: 0 10px; 87 | user-select: none; 88 | -webkit-app-region: drag; 89 | } 90 | 91 | .title-bar-left, 92 | .title-bar-right { 93 | flex: 1; 94 | display: flex; 95 | gap: 10px; 96 | -webkit-app-region: no-drag; 97 | } 98 | 99 | /* 标题容器 */ 100 | .title-container { 101 | display: flex; 102 | align-items: center; 103 | justify-content: center; 104 | position: relative; 105 | -webkit-app-region: drag; 106 | } 107 | 108 | .window-title { 109 | color: var(--title-bar-text); 110 | font-weight: 500; 111 | text-align: center; 112 | } 113 | 114 | /* 设置按钮样式 */ 115 | .settings-button { 116 | width: 18px; 117 | height: 18px; 118 | border: none; 119 | background-color: transparent; 120 | cursor: pointer; 121 | outline: none; 122 | display: flex; 123 | align-items: center; 124 | justify-content: center; 125 | margin-left: 8px; 126 | -webkit-app-region: no-drag; 127 | } 128 | 129 | .settings-button:hover { 130 | background-color: var(--hover-bg); 131 | } 132 | 133 | .settings-button:active { 134 | background-color: var(--active-bg); 135 | } 136 | 137 | /* 设置模态窗口 */ 138 | .settings-modal { 139 | width: 100%; 140 | height: 100%; 141 | background-color: var(--modal-overlay); 142 | z-index: 1000; 143 | } 144 | 145 | /* 设置页样式 */ 146 | .settings-section { 147 | margin-bottom: 20px; 148 | border-bottom: 1px solid var(--border-color); 149 | padding-bottom: 15px; 150 | } 151 | 152 | .settings-section:last-child { 153 | border-bottom: none; 154 | margin-bottom: 0; 155 | } 156 | 157 | .settings-section h3 { 158 | font-size: 16px; 159 | margin-bottom: 10px; 160 | color: var(--text-color); 161 | } 162 | 163 | .settings-item { 164 | display: flex; 165 | flex-direction: column; 166 | margin-bottom: 15px; 167 | } 168 | 169 | .settings-item:last-child { 170 | margin-bottom: 0; 171 | } 172 | 173 | .settings-item label { 174 | margin-bottom: 5px; 175 | color: var(--text-color); 176 | } 177 | 178 | /* 提示文本样式 */ 179 | .common-tip { 180 | font-size: 12px; 181 | color: var(--disabled-text); 182 | margin-top: 2px; 183 | } 184 | 185 | .settings-btn { 186 | background-color: var(--button-bg); 187 | color: var(--button-text); 188 | border: none; 189 | padding: 8px 12px; 190 | border-radius: 4px; 191 | cursor: pointer; 192 | margin-bottom: 5px; 193 | transition: background-color 0.2s; 194 | text-align: left; 195 | } 196 | 197 | .settings-btn:hover { 198 | background-color: var(--button-hover); 199 | } 200 | 201 | /* 快捷键设置样式 */ 202 | .checkbox-container { 203 | display: flex; 204 | align-items: center; 205 | gap: 8px; 206 | margin-bottom: 5px; 207 | } 208 | 209 | .toggle-checkbox { 210 | width: 16px; 211 | height: 16px; 212 | cursor: pointer; 213 | } 214 | 215 | .shortcut-input-container { 216 | display: flex; 217 | align-items: center; 218 | gap: 8px; 219 | margin-bottom: 5px; 220 | } 221 | 222 | .shortcut-input { 223 | flex: 1; 224 | padding: 8px 12px; 225 | border-radius: 4px; 226 | border: 1px solid var(--border-color); 227 | background-color: var(--search-bg); 228 | color: var(--text-color); 229 | font-family: monospace; 230 | text-align: center; 231 | } 232 | 233 | .settings-btn.small { 234 | padding: 4px 8px; 235 | font-size: 12px; 236 | } 237 | 238 | .recording { 239 | background-color: var(--error-color); 240 | color: white; 241 | } 242 | 243 | /* 关于部分样式 */ 244 | .about-section { 245 | display: flex; 246 | flex-direction: column; 247 | align-items: center; 248 | text-align: center; 249 | } 250 | 251 | .app-logo { 252 | font-size: 48px; 253 | margin-bottom: 10px; 254 | } 255 | 256 | .app-title { 257 | font-size: 18px; 258 | font-weight: bold; 259 | margin-bottom: 5px; 260 | color: var(--text-color); 261 | } 262 | 263 | .app-version { 264 | font-size: 14px; 265 | color: var(--disabled-text); 266 | margin-bottom: 10px; 267 | } 268 | 269 | .app-desc { 270 | margin-bottom: 10px; 271 | color: var(--text-color); 272 | } 273 | 274 | .app-links { 275 | margin-top: 10px; 276 | } 277 | 278 | .app-links a { 279 | color: var(--primary-color); 280 | text-decoration: none; 281 | } 282 | 283 | .app-links a:hover { 284 | text-decoration: underline; 285 | } 286 | 287 | .window-title { 288 | flex-grow: 1; 289 | text-align: center; 290 | color: var(--title-bar-text); 291 | font-weight: 500; 292 | } 293 | 294 | .window-control { 295 | width: 16px; 296 | height: 16px; 297 | border-radius: 50%; 298 | cursor: pointer; 299 | display: flex; 300 | align-items: center; 301 | justify-content: center; 302 | font-size: 12px; 303 | color: var(--title-bar-text); 304 | } 305 | 306 | /* 搜索框 */ 307 | .search-container { 308 | padding: 10px; 309 | background-color: var(--bg-color); 310 | display: flex; 311 | align-items: center; 312 | gap: 8px; 313 | } 314 | 315 | .search-input { 316 | flex: 1; 317 | padding: 8px 12px; 318 | border-radius: 4px; 319 | border: 1px solid var(--border-color); 320 | background-color: var(--search-bg); 321 | color: var(--text-color); 322 | } 323 | 324 | /* 按钮 */ 325 | .add-button { 326 | background-color: var(--button-bg); 327 | color: var(--button-text); 328 | width: 32px; 329 | height: 32px; 330 | border-radius: 4px; 331 | border: none; 332 | display: flex; 333 | align-items: center; 334 | justify-content: center; 335 | cursor: pointer; 336 | font-size: 18px; 337 | transition: background-color 0.2s; 338 | } 339 | 340 | .add-button:hover { 341 | background-color: var(--button-hover); 342 | } 343 | 344 | .add-button:active { 345 | background-color: var(--button-active); 346 | } 347 | 348 | /* 列表 */ 349 | .list-container { 350 | flex-grow: 1; 351 | overflow-y: auto; 352 | padding: 10px; 353 | } 354 | 355 | /* 空列表消息 */ 356 | .empty-list-message { 357 | text-align: center; 358 | padding: 20px; 359 | color: var(--text-color); 360 | display: flex; 361 | flex-direction: column; 362 | align-items: center; 363 | justify-content: center; 364 | height: 100px; 365 | margin-top: 20px; 366 | } 367 | 368 | .empty-text { 369 | font-size: 16px; 370 | color: var(--disabled-text); 371 | } 372 | 373 | .list-item { 374 | display: flex; 375 | align-items: center; 376 | padding: 10px; 377 | border-radius: 4px; 378 | margin-bottom: 8px; 379 | background-color: var(--card-bg); 380 | cursor: pointer; 381 | user-select: none; 382 | transition: background-color 0.2s; 383 | border: 1px solid var(--border-color); 384 | } 385 | 386 | .list-item:hover { 387 | background-color: var(--hover-bg); 388 | } 389 | 390 | .list-item.active { 391 | background-color: var(--active-bg); 392 | border-color: var(--primary-color); 393 | } 394 | 395 | .list-item.dragging { 396 | opacity: 0.5; 397 | border: 1px dashed var(--drag-indicator); 398 | } 399 | 400 | .list-item.drop-target { 401 | border-bottom: 2px solid var(--drag-indicator); 402 | } 403 | 404 | .item-icon { 405 | width: 24px; 406 | height: 24px; 407 | margin-right: 12px; 408 | color: var(--icon-color); 409 | display: flex; 410 | align-items: center; 411 | justify-content: center; 412 | } 413 | 414 | .item-text { 415 | flex-grow: 1; 416 | white-space: nowrap; 417 | overflow: hidden; 418 | text-overflow: ellipsis; 419 | color: var(--text-color); 420 | } 421 | 422 | /* 工具提示 */ 423 | .tooltip { 424 | /* fixed 相对于视口定位 */ 425 | position: fixed; 426 | background-color: var(--tooltip-bg); 427 | color: var(--tooltip-text); 428 | padding: 5px 10px; 429 | border-radius: 4px; 430 | font-size: 14px; 431 | /* 提高z-index确保在所有元素之上 */ 432 | z-index: 10000; 433 | max-width: 300px; 434 | word-wrap: break-word; 435 | box-shadow: var(--shadow); 436 | /* 确保tooltip不会捕获鼠标事件 */ 437 | pointer-events: none; 438 | } 439 | 440 | /* 右键菜单 */ 441 | .context-menu { 442 | /* fixed 相对于视口定位,而不是相对于父容器 */ 443 | position: fixed; 444 | width: 200px; 445 | background-color: var(--menu-bg); 446 | border: 1px solid var(--menu-border); 447 | border-radius: 4px; 448 | box-shadow: var(--shadow); 449 | /* 提高 z-index 确保在所有元素之上 */ 450 | z-index: 10000; 451 | } 452 | 453 | .menu-item { 454 | padding: 8px 12px; 455 | cursor: pointer; 456 | color: var(--text-color); 457 | } 458 | 459 | .menu-item:hover { 460 | background-color: var(--menu-hover); 461 | } 462 | 463 | .menu-divider { 464 | height: 1px; 465 | background-color: var(--menu-border); 466 | margin: 5px 0; 467 | } 468 | 469 | /* 模态框 */ 470 | .modal { 471 | /* width: 400px; */ 472 | max-width: 100vw; 473 | max-height: 100vh; 474 | /* 确保模态框填满整个视口高度 */ 475 | height: 100vh; 476 | background-color: var(--card-bg); 477 | overflow: hidden; 478 | box-shadow: var(--shadow); 479 | display: flex; 480 | flex-direction: column; 481 | } 482 | 483 | .modal-title-bar { 484 | height: 30px; 485 | background-color: var(--title-bar-bg); 486 | display: flex; 487 | align-items: center; 488 | padding: 0 15px; 489 | color: var(--title-bar-text); 490 | font-weight: 500; 491 | -webkit-app-region: drag; 492 | } 493 | 494 | .modal-content { 495 | /* 让内容区域占据剩余空间 */ 496 | flex: 1; 497 | display: flex; 498 | flex-direction: column; 499 | padding: 20px; 500 | overflow-y: auto; 501 | } 502 | 503 | /* 表单 */ 504 | .form-group { 505 | margin-bottom: 15px; 506 | } 507 | 508 | .form-input { 509 | width: 100%; 510 | padding: 8px 12px; 511 | border-radius: 4px; 512 | border: 1px solid var(--border-color); 513 | background-color: var(--search-bg); 514 | color: var(--text-color); 515 | } 516 | 517 | .form-select { 518 | width: 100%; 519 | padding: 8px 12px; 520 | border-radius: 4px; 521 | border: 1px solid var(--border-color); 522 | background-color: var(--search-bg); 523 | color: var(--text-color); 524 | } 525 | 526 | /* 按钮组 */ 527 | .button-group { 528 | display: flex; 529 | justify-content: flex-end; 530 | gap: 10px; 531 | margin-top: 20px; 532 | } 533 | 534 | .btn { 535 | padding: 8px 16px; 536 | border-radius: 4px; 537 | border: none; 538 | cursor: pointer; 539 | font-weight: 500; 540 | transition: background-color 0.2s; 541 | } 542 | 543 | .btn-primary { 544 | background-color: var(--button-bg); 545 | color: var(--button-text); 546 | } 547 | 548 | .btn-primary:hover { 549 | background-color: var(--button-hover); 550 | } 551 | 552 | .btn-primary:active { 553 | background-color: var(--button-active); 554 | } 555 | 556 | .btn-primary:disabled { 557 | background-color: var(--button-disabled); 558 | cursor: not-allowed; 559 | } 560 | 561 | .btn-secondary { 562 | background-color: var(--cancel-button-bg); 563 | color: var(--cancel-button-text); 564 | } 565 | 566 | .btn-secondary:hover { 567 | background-color: var(--cancel-button-hover); 568 | } 569 | 570 | .btn-secondary:active { 571 | background-color: var(--cancel-button-active); 572 | } 573 | 574 | /* 提示框 */ 575 | .toast { 576 | position: fixed; 577 | bottom: 20px; 578 | left: 50%; 579 | transform: translateX(-50%); 580 | background-color: var(--tooltip-bg); 581 | color: var(--tooltip-text); 582 | padding: 10px 20px; 583 | border-radius: 4px; 584 | box-shadow: var(--shadow); 585 | z-index: 1000; 586 | opacity: 0; 587 | transition: opacity 0.3s; 588 | } 589 | 590 | .error-toast { 591 | background-color: var(--error-color); 592 | color: white; 593 | } 594 | 595 | /* 拖拽指示器 */ 596 | .drag-indicator { 597 | height: 2px; 598 | background-color: var(--drag-indicator); 599 | margin: 4px 0; 600 | } 601 | 602 | /* 滚动条样式 */ 603 | .list-container::-webkit-scrollbar, 604 | .modal-content::-webkit-scrollbar { 605 | width: 8px; 606 | } 607 | 608 | .list-container::-webkit-scrollbar-track, 609 | .modal-content::-webkit-scrollbar-track { 610 | background: var(--bg-color); 611 | } 612 | 613 | .list-container::-webkit-scrollbar-thumb, 614 | .modal-content::-webkit-scrollbar-thumb { 615 | background-color: var(--border-color); 616 | border-radius: 4px; 617 | } 618 | 619 | .list-container::-webkit-scrollbar-thumb:hover, 620 | .modal-content::-webkit-scrollbar-thumb:hover { 621 | background-color: var(--icon-color); 622 | } -------------------------------------------------------------------------------- /src/renderer/js/renderer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 主窗口渲染进程脚本 3 | * 负责项目列表的渲染、项目操作、搜索过滤、拖放功能等 4 | * 包含用户界面交互逻辑和事件处理 5 | */ 6 | document.addEventListener("DOMContentLoaded", () => { 7 | // DOM元素引用 8 | const addBtn = document.getElementById("add-button"); 9 | const searchInput = document.getElementById("search-input"); 10 | const listContainer = document.getElementById("list-container"); 11 | const settingsButton = document.getElementById("settings-button"); 12 | const appContainer = document.querySelector(".app-container"); 13 | 14 | // 导入i18n模块 15 | const i18n = window.electronAPI.i18n; 16 | 17 | // 当前项目列表(内存中存储) 18 | let currentItems = []; 19 | 20 | // 预先设置与主题匹配的背景色,避免背景闪烁 21 | const setInitialThemeBackground = async () => { 22 | const savedTheme = await window.electronAPI.getThemeConfig(); 23 | const isDark = savedTheme === "dark" || 24 | (savedTheme === "system" && window.matchMedia && 25 | window.matchMedia("(prefers-color-scheme: dark)").matches); 26 | 27 | // 确保背景色与主题一致,避免闪烁 28 | if (isDark) { 29 | listContainer.style.backgroundColor = "var(--bg-color)"; 30 | appContainer.style.backgroundColor = "var(--bg-color)"; 31 | } else { 32 | listContainer.style.backgroundColor = "var(--bg-color)"; 33 | appContainer.style.backgroundColor = "var(--bg-color)"; 34 | } 35 | }; 36 | 37 | // 立即应用初始背景色 38 | setInitialThemeBackground(); 39 | 40 | // 初始时将列表容器设置为不可见,避免主题应用前闪烁 41 | listContainer.style.visibility = 'hidden'; 42 | // 设置占位符,保持布局,避免内容加载后出现跳动 43 | listContainer.innerHTML = '
'; 44 | 45 | // 初始化UI,保存返回的解绑函数对象 46 | const uiCleanup = window.uiManager.initUI({ 47 | containerSelector: ".app-container", 48 | windowType: "main", // 指定窗口类型为主窗口 49 | onUIReady: async () => { 50 | // UI准备好后,加载并显示项目列表 51 | await initPage(); 52 | // 显示列表容器 53 | listContainer.style.visibility = 'visible'; 54 | } 55 | }); 56 | 57 | // 当页面卸载时清理监听器 58 | window.addEventListener('beforeunload', () => { 59 | if (uiCleanup && typeof uiCleanup.unbindAll === 'function') { 60 | uiCleanup.unbindAll(); 61 | } 62 | }); 63 | 64 | /** 65 | * 事件监听设置部分 66 | */ 67 | 68 | // 添加按钮点击事件 - 显示添加项目对话框 69 | addBtn.addEventListener("click", () => { 70 | window.electronAPI.showAddItemDialog(); 71 | }); 72 | 73 | // 搜索框输入事件 - 实时过滤列表项目 74 | searchInput.addEventListener("input", () => { 75 | filterItems(searchInput.value.toLowerCase()); 76 | }); 77 | 78 | // 设置按钮点击事件 - 打开独立设置窗口 79 | settingsButton.addEventListener("click", () => { 80 | window.electronAPI.showSettingsWindow(); 81 | }); 82 | 83 | /** 84 | * 全局键盘事件处理 85 | * - Escape: 关闭窗口 86 | * - Delete: 移除选中项目 87 | * - Enter: 打开选中项目 88 | * - 方向键: 列表导航 89 | * - F12: 打开开发者工具 90 | * - Ctrl+F: 搜索框获得焦点 91 | */ 92 | document.addEventListener("keydown", (e) => { 93 | if (e.key === "Escape") { 94 | window.electronAPI.closeMainWindow(); 95 | } else if (e.key === "Delete") { 96 | const activeItem = document.querySelector(".list-item.active"); 97 | if (activeItem) { 98 | const index = parseInt(activeItem.dataset.index); 99 | removeItem(index); 100 | } 101 | } else if (e.key === "Enter") { 102 | const activeItem = document.querySelector(".list-item.active"); 103 | if (activeItem) { 104 | const index = parseInt(activeItem.dataset.index); 105 | window.electronAPI.openItem(currentItems[index]); 106 | } 107 | } else if (e.key === "ArrowUp" || e.key === "ArrowDown") { 108 | navigateList(e.key === "ArrowUp" ? -1 : 1); 109 | } else if (e.key === "F12") { 110 | window.electronAPI.openDevTools(); 111 | e.preventDefault(); 112 | } else if (e.key === "f" && (e.ctrlKey || e.metaKey)) { 113 | // Ctrl+F (Windows/Linux) 或 Command+F (macOS) 使搜索框获得焦点 114 | searchInput.focus(); 115 | e.preventDefault(); 116 | } 117 | }); 118 | 119 | // 处理拖放文件功能 120 | let draggingItem = false; 121 | 122 | listContainer.addEventListener("dragover", (e) => { 123 | e.preventDefault(); 124 | listContainer.classList.add("drag-over"); 125 | }); 126 | 127 | listContainer.addEventListener("dragleave", () => { 128 | listContainer.classList.remove("drag-over"); 129 | }); 130 | 131 | listContainer.addEventListener("drop", async (e) => { 132 | e.preventDefault(); 133 | listContainer.classList.remove("drag-over"); 134 | 135 | // 检查是否是内部拖拽排序操作 136 | if (draggingItem) { 137 | // 是内部拖拽排序,不处理外部文件拖入逻辑 138 | return; 139 | } 140 | 141 | // 使用 webUtils 获取文件路径 142 | const filePath = await window.electronAPI.getFileOrFolderPath(e.dataTransfer.files[0]); 143 | if (!filePath) { 144 | window.uiManager.showToast(await i18n.t("cannot-get-file-path"), true); 145 | return; 146 | } 147 | 148 | // 检查是否已存在 149 | const exists = currentItems.some((item) => item.path === filePath); 150 | 151 | if (exists) { 152 | window.uiManager.showToast(await i18n.t("item-already-exists")); 153 | return; 154 | } 155 | 156 | // 判断项目类型 157 | const itemType = await window.electronAPI.getItemType(filePath); 158 | 159 | // 创建新项目 160 | const newItem = { 161 | type: itemType, 162 | path: filePath, 163 | name: filePath.split("/").pop().split("\\").pop(), // 从路径中提取文件名 164 | }; 165 | 166 | // 添加项目并刷新列表 167 | const result = await window.electronAPI.addItem(newItem); 168 | if (result.success) { 169 | await loadItems(); 170 | // 如果成功添加,且有返回的索引,则直接使用这个索引 171 | // result.itemIndex 包含了新添加的项目索引 172 | if (result.itemIndex !== undefined) { 173 | // 先刷新列表以确保项目显示 174 | // 选中并滚动到该项目 175 | selectItemByIndex(result.itemIndex); 176 | } 177 | } else { 178 | window.uiManager.showToast(result.message, true); 179 | } 180 | }); 181 | 182 | // 初始化页面 183 | async function initPage() { 184 | await loadItems(); 185 | 186 | // 添加对列表更新的监听 187 | window.electronAPI.onItemsUpdated(async (newItemIndex) => { 188 | console.log("Items updated, refreshing list...", newItemIndex ? `New item index: ${newItemIndex}` : ""); 189 | await loadItems(); 190 | 191 | // 如果有新添加的项目索引,选中并滚动到该项目 192 | if (newItemIndex !== undefined) { 193 | selectItemByIndex(newItemIndex); 194 | } 195 | }); 196 | } 197 | 198 | // 加载项目列表 199 | async function loadItems() { 200 | currentItems = await window.electronAPI.getItems(); 201 | if (currentItems.length > 0) { 202 | document.querySelector(".empty-list-message")?.remove(); 203 | renderItems(currentItems); 204 | } else { 205 | // 显示空列表消息 206 | listContainer.innerHTML = `
207 |
点击 + 按钮添加新的项目
208 |
`; 209 | 210 | // 更新空列表消息的翻译 211 | await window.uiUtils.updatePageTexts(i18n); 212 | } 213 | } 214 | 215 | // 渲染项目列表 216 | function renderItems(items) { 217 | listContainer.innerHTML = ""; 218 | 219 | items.forEach((item, index) => { 220 | const listItem = document.createElement("div"); 221 | listItem.classList.add("list-item"); 222 | listItem.dataset.index = index; 223 | listItem.dataset.type = item.type; 224 | listItem.dataset.path = item.path; 225 | 226 | // 设置拖拽属性 227 | listItem.draggable = true; 228 | 229 | // 图标 230 | const icon = getIconForType(item.type); 231 | 232 | listItem.innerHTML = ` 233 |
${icon}
234 |
${item.name || item.path}
235 | `; 236 | 237 | // 事件监听 238 | listItem.addEventListener("click", () => { 239 | // 激活选中项 240 | document 241 | .querySelectorAll(".list-item.active") 242 | .forEach((el) => el.classList.remove("active")); 243 | listItem.classList.add("active"); 244 | }); 245 | 246 | listItem.addEventListener("dblclick", () => { 247 | window.electronAPI.openItem(item); 248 | }); 249 | 250 | listContainer.appendChild(listItem); 251 | }); 252 | 253 | // 添加拖拽排序功能 254 | setupDragAndSort(); 255 | } 256 | 257 | // 根据类型获取图标 258 | function getIconForType(type) { 259 | switch (type) { 260 | case "file": 261 | return "📄"; 262 | case "folder": 263 | return "📁"; 264 | case "url": 265 | return "🌐"; 266 | case "command": 267 | return "⌨️"; 268 | default: 269 | return "❓"; 270 | } 271 | } 272 | 273 | // 过滤项目 274 | function filterItems(query) { 275 | const filteredItems = query 276 | ? currentItems.filter( 277 | (item) => 278 | (item.name && item.name.toLowerCase().includes(query)) || 279 | item.path.toLowerCase().includes(query) 280 | ) 281 | : currentItems; 282 | 283 | renderItems(filteredItems); 284 | } 285 | 286 | // 移除项目 287 | async function removeItem(index) { 288 | const result = await window.electronAPI.removeItem(index); 289 | if (result.success) { 290 | await loadItems(); 291 | } 292 | } 293 | 294 | // 设置拖拽排序 295 | function setupDragAndSort() { 296 | const items = document.querySelectorAll(".list-item"); 297 | let draggedItem = null; 298 | let indicator = document.createElement("div"); 299 | indicator.classList.add("drag-indicator"); 300 | let dropPosition = null; // 添加变量跟踪放置位置 301 | 302 | // 添加自动滚动相关变量 303 | let autoScrollInterval = null; 304 | const SCROLL_SPEED = 5; // 滚动速度 305 | const SCROLL_THRESHOLD = 50; // 触发自动滚动的阈值(距离容器边缘的像素) 306 | 307 | // 停止自动滚动 308 | const stopAutoScroll = () => { 309 | if (autoScrollInterval) { 310 | clearInterval(autoScrollInterval); 311 | autoScrollInterval = null; 312 | } 313 | }; 314 | 315 | items.forEach((item) => { 316 | item.addEventListener("dragstart", (e) => { 317 | draggedItem = item; 318 | item.classList.add("dragging"); 319 | e.dataTransfer.setData("text/plain", item.dataset.index); 320 | setTimeout(() => { item.style.opacity = "0.5"; }, 0); 321 | draggingItem = true; 322 | }); 323 | 324 | item.addEventListener("dragend", () => { 325 | draggedItem = null; 326 | item.classList.remove("dragging"); 327 | item.style.opacity = "1"; 328 | indicator.remove(); 329 | dropPosition = null; // 重置放置位置 330 | stopAutoScroll(); // 停止自动滚动 331 | draggingItem = false; 332 | }); 333 | 334 | item.addEventListener("dragover", (e) => { 335 | e.preventDefault(); 336 | if (draggedItem && draggedItem !== item) { 337 | // 计算应该插在当前项的上方还是下方 338 | const rect = item.getBoundingClientRect(); 339 | const y = e.clientY - rect.top; 340 | const isBelow = y > rect.height / 2; 341 | 342 | // 移除所有现有的drop-target类 343 | items.forEach((i) => i.classList.remove("drop-before", "drop-after")); 344 | 345 | // 根据放置位置添加相应的类 346 | if (isBelow) { 347 | item.classList.add("drop-after"); 348 | dropPosition = { target: item, position: "after" }; 349 | } else { 350 | item.classList.add("drop-before"); 351 | dropPosition = { target: item, position: "before" }; 352 | } 353 | 354 | // 添加指示器 355 | if (isBelow) { 356 | if (item.nextSibling !== indicator) { 357 | item.after(indicator); 358 | } 359 | } else { 360 | if (item.previousSibling !== indicator) { 361 | item.before(indicator); 362 | } 363 | } 364 | } 365 | }); 366 | }); 367 | 368 | // 当鼠标在列表容器内移动时,处理自动滚动 369 | listContainer.addEventListener("dragover", (e) => { 370 | e.preventDefault(); 371 | if (!draggedItem) return; 372 | 373 | const containerRect = listContainer.getBoundingClientRect(); 374 | const mouseY = e.clientY; 375 | 376 | // 判断是否需要向上滚动 377 | if (mouseY < containerRect.top + SCROLL_THRESHOLD) { 378 | stopAutoScroll(); 379 | autoScrollInterval = setInterval(() => { 380 | listContainer.scrollTop -= SCROLL_SPEED; 381 | }, 16); 382 | } 383 | // 判断是否需要向下滚动 384 | else if (mouseY > containerRect.bottom - SCROLL_THRESHOLD) { 385 | stopAutoScroll(); 386 | autoScrollInterval = setInterval(() => { 387 | listContainer.scrollTop += SCROLL_SPEED; 388 | }, 16); 389 | } 390 | }); 391 | 392 | // 当鼠标离开列表容器时停止滚动 393 | listContainer.addEventListener("dragleave", () => { 394 | stopAutoScroll(); 395 | }); 396 | 397 | // 在列表容器上监听drop事件,而不是在每个项目上 398 | listContainer.addEventListener("drop", async (e) => { 399 | e.preventDefault(); 400 | stopAutoScroll(); // 确保停止自动滚动 401 | 402 | if (draggedItem && dropPosition) { 403 | // 获取拖拽项的索引 404 | const draggedIndex = parseInt(draggedItem.dataset.index); 405 | const targetIndex = parseInt(dropPosition.target.dataset.index); 406 | let newIndex; 407 | 408 | // 根据放置位置计算新索引 409 | if (dropPosition.position === "after") { 410 | newIndex = targetIndex + 1; 411 | } else { 412 | newIndex = targetIndex; 413 | } 414 | 415 | // 调整索引,考虑拖拽项被移除后的影响 416 | if (draggedIndex < newIndex) { 417 | newIndex--; 418 | } 419 | 420 | // 重新排序 421 | const itemsCopy = [...currentItems]; 422 | const [removed] = itemsCopy.splice(draggedIndex, 1); 423 | itemsCopy.splice(newIndex, 0, removed); 424 | 425 | // 更新后端存储 426 | const result = await window.electronAPI.updateItemsOrder(itemsCopy); 427 | if (result.success) { 428 | await loadItems(); 429 | console.log("Order updated:", draggedIndex, "->", newIndex); 430 | } 431 | } 432 | 433 | indicator.style.display = "none"; 434 | dropPosition = null; // 重置放置位置 435 | }); 436 | } 437 | 438 | // 键盘导航列表 439 | function navigateList(direction) { 440 | const items = document.querySelectorAll(".list-item"); 441 | const activeItem = document.querySelector(".list-item.active"); 442 | 443 | if (!items.length) return; 444 | 445 | if (!activeItem) { 446 | // 如果没有选中项,选择第一个或最后一个 447 | items[direction > 0 ? 0 : items.length - 1].classList.add("active"); 448 | return; 449 | } 450 | 451 | // 获取当前索引 452 | const currentIndex = Array.from(items).indexOf(activeItem); 453 | let nextIndex = currentIndex + direction; 454 | 455 | // 边界检查 456 | if (nextIndex < 0) nextIndex = items.length - 1; 457 | if (nextIndex >= items.length) nextIndex = 0; 458 | 459 | // 移除当前活动项 460 | activeItem.classList.remove("active"); 461 | 462 | // 设置新的活动项 463 | items[nextIndex].classList.add("active"); 464 | 465 | // 确保项目可见 466 | const newActiveItem = items[nextIndex]; 467 | newActiveItem.scrollIntoView({ block: "nearest" }); 468 | } 469 | 470 | // 根据索引选中项目并确保其可见 471 | function selectItemByIndex(index) { 472 | // 先取消所有选中状态 473 | document.querySelectorAll(".list-item.active").forEach(el => { 474 | el.classList.remove("active"); 475 | }); 476 | 477 | // 找到对应索引的项目 478 | const targetItem = document.querySelector(`.list-item[data-index="${index}"]`); 479 | 480 | if (targetItem) { 481 | // 选中该项目 482 | targetItem.classList.add("active"); 483 | 484 | // 确保项目在视图中可见(滚动到该项目) 485 | targetItem.scrollIntoView({ block: "nearest", behavior: "smooth" }); 486 | } 487 | } 488 | 489 | // 把loadItems和removeItem函数暴露到全局,供其他脚本使用 490 | window.appFunctions = { 491 | loadItems, 492 | removeItem 493 | }; 494 | }); 495 | --------------------------------------------------------------------------------