├── .gitignore
├── README.md
├── delete-icon.svg
├── edit-icon.svg
├── images
├── error.jpg
└── tool.png
├── index.html
├── main.js
├── package.json
├── preload.js
├── renderer.js
├── scripts
└── notarize.js
└── styles.css
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependency directories
2 | node_modules/
3 | npm-debug.log
4 | yarn-debug.log
5 | yarn-error.log
6 | package-lock.json
7 | yarn.lock
8 |
9 | # Build outputs
10 | dist/
11 | build/
12 | out/
13 | release/
14 | release_new/
15 | *.exe
16 | *.dmg
17 | *.AppImage
18 | *.deb
19 | *.rpm
20 |
21 | # Environment variables and secrets
22 | .env
23 | .env.local
24 | .env.development
25 | .env.production
26 | config.json
27 | secrets.json
28 |
29 | # API Keys - never commit these!
30 | apikeys.json
31 | api_keys.js
32 | api_config.js
33 |
34 | # OS specific files
35 | .DS_Store
36 | Thumbs.db
37 | Desktop.ini
38 | $RECYCLE.BIN/
39 |
40 | # Editor folders and files
41 | .idea/
42 | .vscode/
43 | *.sublime-project
44 | *.sublime-workspace
45 | *.swp
46 | *.swo
47 | .project
48 | .classpath
49 | .settings/
50 |
51 | # Logs
52 | logs/
53 | *.log
54 | npm-debug.log*
55 | yarn-debug.log*
56 | yarn-error.log*
57 |
58 | # Temporary files
59 | .tmp/
60 | .temp/
61 | tmp/
62 | temp/
63 | *.tmp
64 |
65 | # Coverage directory used by tools like istanbul
66 | coverage/
67 |
68 | # Electron specific
69 | .electron-builder.env
70 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Copy2Translate
2 |
3 | [中文](#chinese) | [English](#english)
4 |
5 |
6 | Copy2Translate 是一个简单高效的翻译工具,旨在通过复制文本和按下快捷键来简化翻译过程。无论您是学生、专业人士,还是经常需要翻译的人,Copy2Translate 都能简化流程,为您节省时间和精力。
7 |
8 | ## 功能特点
9 |
10 | - **快捷键翻译**: 通过简单的快捷键组合即可快速翻译文本,提升工作效率。
11 | - **多种翻译提示词**: 支持多种预设翻译提示词,满足不同场景的翻译需求。
12 | - **OpenRouter API 支持**: 利用 OpenRouter API 提供的强大翻译能力。
13 | - **DeepSeek API 支持**: 集成 DeepSeek API,提供更精准的翻译结果。
14 | - **自动复制到剪贴板**: 翻译结果自动复制到剪贴板,方便后续使用。
15 |
16 |
17 | 
18 |
19 |
20 |
21 | ## 开发环境设置
22 |
23 | 如果您想在本地开发或修改此项目,请确保已安装 Node.js 和 npm。然后按照以下步骤操作:
24 |
25 | 1. 克隆仓库
26 | ```bash
27 | git clone https://github.com/LizabethLi/Copy2Translate.git
28 | cd copy2translate
29 | ```
30 |
31 | 2. 安装依赖
32 | ```bash
33 | npm install
34 | ```
35 |
36 | 3. 运行程序
37 | ```bash
38 | npm start
39 | ```
40 |
41 |
42 | 4. 构建应用(如果想要自己封装成应用软件,如 macbook 的 dmg文件,windows 的 exe 文件)
43 | ```bash
44 | npm run dist:mac # 仅构建 macOS
45 | npm run dist:win # 仅构建 Windows
46 | ```
47 |
48 | ## 软件安装说明(如果自己封装成了应用软件,或者获取了已经封装好的应用)
49 |
50 | ### Windows 用户
51 | 1. 下载最新的 `Copy2Translate-Setup.exe`
52 | 2. 双击运行安装程序
53 | 3. 按照提示完成安装
54 |
55 | ### macOS 用户
56 | 1. 下载最新的 `Copy2Translate.dmg`
57 | 2. 双击打开 DMG 文件
58 | 3. 将 `Copy2Translate.app` 拖到 `Applications` 文件夹
59 |
60 | > **注意**:由于应用未经 Apple 开发者签名认证,macOS 可能会阻止应用运行。
61 | 
62 | 您可以将应用拖入 `Applications` 文件夹后,打开"终端"应用,执行以下命令:
63 | > ```bash
64 | > xattr -rd com.apple.quarantine /Applications/Copy2Translate.app
65 | > ```
66 | > 这会移除应用的隔离属性,之后即可双击打开。
67 | >
68 | > 作为开发者,我对使用此应用可能产生的任何风险不承担责任。如果您遇到安装或运行问题,请通过提交 [GitHub Issue](https://github.com/LizabethLi/Copy2Translate/issues) 来报告。
69 |
70 |
71 | ## 使用方法
72 |
73 | 1. 首次运行时,需要设置 API Key
74 | 2. 默认快捷键为 `Command+Shift+T`(macOS)或 `Ctrl+Alt+T`(Windows/Linux),如果默认快捷键失效,您可以自己设置快捷键即可
75 | 3. 选中要翻译的文本并复制
76 | 4. 按下快捷键即可翻译
77 | 5. 翻译结果会自动复制到剪贴板,直接粘贴即可,不需要离开当前的编辑页面
78 |
79 |
80 | ## API 密钥申请
81 |
82 |
83 | ### OpenRouter API 申请步骤
84 | 1. 访问 [OpenRouter 官网](https://openrouter.ai/)
85 | 2. 注册并登录您的账户
86 | 3. 导航到 API 密钥页面
87 | 4. 创建新的 API 密钥
88 | 5. 复制 API 密钥并添加到应用的设置中
89 |
90 | ### DeepSeek API 申请步骤
91 | 1. 访问 [DeepSeek 官网](https://platform.deepseek.com/)
92 | 2. 注册并登录您的账户
93 | 3. 导航到开发者或 API 页面
94 | 4. 申请 API 访问权限并创建密钥
95 | 5. 复制 API 密钥并添加到应用的设置中
96 |
97 | ## 配置说明
98 |
99 | ### API Key 设置
100 | 1. 点击顶部的 API Key 按钮
101 | 2. 选择翻译服务提供商
102 | 3. 输入对应的 API Key,填写模型名称,参见两个网站的模型卡
103 | 4. 点击保存
104 |
105 | ### 快捷键设置
106 | 1. 在设置界面中点击"更改快捷键"
107 | 2. 按下新的快捷键组合
108 | 3. 点击确认保存
109 |
110 | ### 翻译提示词
111 | - 可以添加、编辑、删除翻译提示词
112 | - 支持多种预设提示词模板
113 | - 可以随时切换不同的翻译风格
114 |
115 | ## 商业使用与署名
116 |
117 | 本项目基于 MIT 许可证开源,您可以自由地使用、修改和分发。如果您在商业项目或公开场合(如技术分享、博客文章等)使用了本项目的代码或设计,请考虑在适当的位置(例如项目文档、关于页面、致谢部分)提及本项目名称 (Copy2Translate) 并附上 GitHub 仓库链接 [https://github.com/LizabethLi/Copy2Translate](https://github.com/LizabethLi/Copy2Translate)。这不仅是对我的工作的认可,也有助于更多人发现和使用这个工具。
118 |
119 | ## 问题反馈
120 |
121 | 如果你在使用过程中遇到任何问题或有功能建议,请通过以下方式反馈:
122 | 1. 提交 [GitHub Issue](https://github.com/LizabethLi/Copy2Translate/issues)
123 |
124 |
125 | ---
126 |
127 | ## English
128 |
129 | Copy2Translate is a simple and efficient translation tool designed to streamline the translation process by copying text and pressing a hotkey. Whether you are a student, professional, or someone who frequently needs translations, Copy2Translate simplifies the workflow, saving you time and effort.
130 |
131 | ## Features
132 |
133 | - **Hotkey Translation**: Quickly translate text with a simple hotkey combination, improving work efficiency.
134 | - **Multiple Translation Prompts**: Supports multiple preset translation prompts to meet different translation needs in various scenarios.
135 | - **OpenRouter API Support**: Utilizes the powerful translation capabilities provided by the OpenRouter API.
136 | - **DeepSeek API Support**: Integrates DeepSeek API for more accurate translation results.
137 | - **Automatic Copy to Clipboard**: Translation results are automatically copied to the clipboard for convenient subsequent use.
138 |
139 | 
140 |
141 | ## Development Environment Setup
142 |
143 | If you want to develop or modify this project locally, make sure you have Node.js and npm installed. Then follow these steps:
144 |
145 | 1. Clone the repository
146 | ```bash
147 | git clone https://github.com/LizabethLi/Copy2Translate.git
148 | cd copy2translate
149 | ```
150 |
151 | 2. Install dependencies
152 | ```bash
153 | npm install
154 | ```
155 |
156 | 3. Run the environment
157 | ```bash
158 | npm start
159 | ```
160 |
161 | 4. Build the application (if you want to package it yourself, such as dmg file for macbook or exe file for windows)
162 | ```bash
163 | npm run dist:mac # Build for macOS only
164 | npm run dist:win # Build for Windows only
165 | ```
166 |
167 | ## Software Installation Instructions (if you have packaged it yourself or obtained a pre-packaged application)
168 |
169 | ### Windows Users
170 | 1. Download the latest `Copy2Translate-Setup.exe`
171 | 2. Double-click to run the installer
172 | 3. Follow the prompts to complete the installation
173 |
174 | ### macOS Users
175 | 1. Download the latest `Copy2Translate.dmg`
176 | 2. Double-click to open the DMG file
177 | 3. Drag the application to the Applications folder
178 |
179 | > **Note**: Since the application is not signed by an Apple developer, macOS may block the application from running. You may come accross error like this:
180 |
181 | 
182 | >
183 | You can drag the application to the "Applications" folder, then open the "Terminal" application, and execute the following command:
184 | > ```bash
185 | > xattr -rd com.apple.quarantine /Applications/Copy2Translate.app
186 | > ```
187 | > This will remove the application's quarantine attribute, after which you can double-click to open it.
188 | >
189 | > As a developer, I am not responsible for any risks associated with using this application. If you encounter installation or runtime issues, please report them by submitting a [GitHub Issue](https://github.com/LizabethLi/Copy2Translate/issues).
190 |
191 | ## Usage
192 |
193 | 1. When running for the first time, you need to set the API Key
194 | 2. The default hotkey is `Command+Shift+T` (macOS) or `Ctrl+Alt+T` (Windows/Linux). If the default shortcut keys do not work, you can set your own shortcut keys.
195 | 3. Select the text you want to translate and copy it
196 | 4. Press the hotkey to translate
197 | 5. The translation result will be automatically copied to the clipboard, simply paste it without leaving the current editing page
198 |
199 | ## API Key Application
200 |
201 | ### OpenRouter API Application Steps
202 | 1. Visit the [OpenRouter official website](https://openrouter.ai/)
203 | 2. Register and log in to your account
204 | 3. Navigate to the API Key page
205 | 4. Create a new API Key
206 | 5. Copy the API Key and add it to the application settings
207 |
208 | ### DeepSeek API Application Steps
209 | 1. Visit the [DeepSeek official website](https://platform.deepseek.com/)
210 | 2. Register and log in to your account
211 | 3. Navigate to the Developer or API page
212 | 4. Apply for API access and create a key
213 | 5. Copy the API key and add it to the application settings
214 |
215 | ## Configuration Instructions
216 |
217 | ### API Key Settings
218 | 1. Click the API Key button at the top
219 | 2. Select the translation service provider
220 | 3. Enter the corresponding API Key, fill in the model name, refer to the model cards of the two websites
221 | 4. Click Save
222 |
223 | ### Shortcut Key Settings
224 | 1. Click "Change Shortcut Key" in the settings interface
225 | 2. Press the new shortcut key combination
226 | 3. Click Confirm to save
227 |
228 | ### Translation Prompts
229 | - You can add, edit, and delete translation prompts
230 | - Supports various preset prompt templates
231 | - You can switch between different translation styles at any time
232 |
233 | ## Commercial Use and Attribution
234 |
235 | This project is open-sourced under the MIT license, and you are free to use, modify, and distribute it. If you use the code or design of this project in a commercial project or public setting (such as technical sharing, blog articles, etc.), please consider mentioning the project name (Copy2Translate) and including a link to the GitHub repository [https://github.com/LizabethLi/Copy2Translate](https://github.com/LizabethLi/Copy2Translate) in an appropriate location (such as project documentation, about page, acknowledgments section). This not only recognizes my work but also helps more people discover and use this tool.
236 |
237 | ## Feedback
238 |
239 | If you encounter any problems during use or have feature suggestions, please provide feedback through the following methods:
240 | 1. Submit a [GitHub Issue](https://github.com/LizabethLi/Copy2Translate/issues)
241 |
242 |
243 |
244 |
--------------------------------------------------------------------------------
/delete-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/edit-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/images/error.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LizabethLi/Copy2Translate/8c56167bf8a1ae86272bccfbe2da3dc93357cae4/images/error.jpg
--------------------------------------------------------------------------------
/images/tool.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LizabethLi/Copy2Translate/8c56167bf8a1ae86272bccfbe2da3dc93357cae4/images/tool.png
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Copy2translate
6 |
7 |
8 |
9 |
10 |
19 |
20 |
21 |
22 |
23 |
快捷键设置
24 |
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 |
52 |
53 |
54 |
默认英文翻译
55 |
Please translate this Chinese text to English, maintaining its professional tone:
56 |
当前使用中
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | + 添加提示词
65 | ↺ 重置提示词
66 |
67 |
68 |
69 |
70 |
71 |
72 |
翻译结果
73 |
74 |
75 | 当前使用:
76 |
77 | OpenRouter (openai/gpt-4o)
78 |
79 |
80 |
81 |
模拟翻译
82 |
83 |
84 |
85 |
86 |
89 |
90 |
91 |
92 |
93 |
API Key 与模型管理
94 |
95 |
96 |
选择翻译服务提供商:
97 |
98 |
99 | 🌐
100 | OpenRouter
101 |
102 |
103 | 🔍
104 | DeepSeek
105 |
106 |
107 |
108 |
109 |
110 |
111 |
OpenRouter
112 |
113 |
114 |
请输入您的 API Key
115 |
116 |
117 | 👁️
118 |
119 |
120 |
121 |
122 | 请输入你想要的模型名称:
123 |
124 |
125 |
126 |
127 |
当前 API Key:
128 |
129 |
130 | 👁️
131 |
132 |
133 |
134 |
135 | 当前模型:
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
DeepSeek
145 |
146 |
147 |
148 |
请输入您的 API Key
149 |
150 |
151 | 👁️
152 |
153 |
154 |
155 |
156 | 请输入你想要的模型名称:
157 |
158 |
159 |
160 |
161 |
162 |
当前 API Key:
163 |
164 |
165 | 👁️
166 |
167 |
168 |
169 |
170 | 当前模型:
171 |
172 |
173 |
174 |
175 |
176 |
177 | 取消
178 | 保存
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
添加提示词
187 |
188 | 提示词名称:
189 |
190 |
191 |
192 | 提示词内容:
193 |
194 |
195 |
196 | 取消
197 | 保存
198 |
199 |
200 |
201 |
202 |
203 |
--------------------------------------------------------------------------------
/main.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config();
2 | const { app, BrowserWindow, globalShortcut, clipboard, ipcMain, Notification } = require("electron");
3 | const axios = require("axios");
4 | const path = require("path");
5 | const settings = require("electron-settings");
6 | const { HttpsProxyAgent } = require('https-proxy-agent');
7 | const https = require('https');
8 |
9 | // 使用系统代理设置
10 | app.commandLine.appendSwitch('proxy-server', process.env.HTTPS_PROXY);
11 | app.commandLine.appendSwitch('ignore-certificate-errors');
12 |
13 | let mainWindow;
14 | let userShortcut = settings.getSync("shortcut") || (process.platform === 'darwin' ? "Shift+Command+T" : "Control+Shift+T");
15 | let apiKey = settings.getSync("apiKey");
16 | let userPrompt = settings.getSync("prompt") || "Translate the following Chinese text to English:";
17 |
18 | // 修改通知函数,添加更多选项
19 | function showNotification(title, body) {
20 | if (Notification.isSupported()) {
21 | try {
22 | const notification = new Notification({
23 | title,
24 | body,
25 | silent: false,
26 | subtitle: process.platform === 'darwin' ? 'Copy2translate' : undefined, // macOS特有属性
27 | icon: process.platform === 'darwin' ? undefined : path.join(__dirname, 'icon.png'),
28 | timeoutType: 'default'
29 | });
30 |
31 | // 确保在macOS上应用已注册可发送通知
32 | if (process.platform === 'darwin') {
33 | // 设置为告警型通知,确保显示横幅
34 | notification.urgency = 'critical';
35 | }
36 |
37 | notification.show();
38 |
39 | // 为macOS添加回调以确认通知已显示
40 | if (process.platform === 'darwin') {
41 | notification.on('show', () => {
42 | console.log('通知已显示');
43 | });
44 |
45 | notification.on('click', () => {
46 | if (mainWindow) {
47 | if (mainWindow.isMinimized()) mainWindow.restore();
48 | mainWindow.focus();
49 | }
50 | });
51 | }
52 |
53 | // 如果是翻译完成的通知,让主窗口闪烁提醒用户
54 | if (title === "翻译完成" && mainWindow) {
55 | if (process.platform === 'darwin') {
56 | // macOS上使用dock弹跳作为辅助提醒
57 | try {
58 | if (app.dock && typeof app.dock.bounce === 'function') {
59 | const bounceId = app.dock.bounce('informational');
60 | // 3秒后停止弹跳
61 | setTimeout(() => {
62 | if (bounceId !== undefined) app.dock.cancelBounce(bounceId);
63 | }, 3000);
64 | }
65 | } catch (error) {
66 | console.error('Dock弹跳失败:', error);
67 | // 忽略失败,继续执行
68 | }
69 | } else if (process.platform === 'win32') {
70 | // Windows上闪烁任务栏图标
71 | mainWindow.flashFrame(true);
72 | setTimeout(() => {
73 | mainWindow.flashFrame(false);
74 | }, 2000);
75 | }
76 | }
77 | } catch (error) {
78 | console.error('显示通知失败:', error);
79 | // 如果通知失败,尝试通过窗口提醒用户
80 | if (mainWindow && mainWindow.webContents) {
81 | mainWindow.webContents.send('showTranslationComplete', { title, body });
82 | }
83 | }
84 | } else {
85 | console.warn('系统不支持通知,将使用窗口内提醒');
86 | // 通过窗口提醒用户
87 | if (mainWindow && mainWindow.webContents) {
88 | mainWindow.webContents.send('showTranslationComplete', { title, body });
89 | }
90 | }
91 | }
92 |
93 | // 注册快捷键
94 | function registerShortcut(shortcut) {
95 | try {
96 | // 验证快捷键字符串是否只包含ASCII字符
97 | if (!/^[\x00-\x7F]+$/.test(shortcut)) {
98 | console.error(`快捷键字符串只能包含ASCII字符,无效的字符串: "${shortcut}"`);
99 | showNotification("快捷键错误", "快捷键包含无效字符,已重置为默认值");
100 | shortcut = process.platform === 'darwin' ? "Shift+Command+T" : "Control+Shift+T"; // 重置为默认值
101 | settings.setSync("shortcut", shortcut);
102 | }
103 |
104 | // 注册快捷键
105 | globalShortcut.register(shortcut, async () => {
106 | const textToTranslate = clipboard.readText();
107 | if (!textToTranslate) {
108 | showNotification("翻译提示", "剪切板为空,请先复制要翻译的文本");
109 | return;
110 | }
111 |
112 | console.log("翻译中:", textToTranslate);
113 | const translatedText = await translateText(textToTranslate);
114 | clipboard.writeText(translatedText);
115 | console.log("翻译完成,已复制到剪切板:", translatedText);
116 |
117 | showNotification("翻译完成", "译文已复制到剪切板");
118 | mainWindow.webContents.send("translatedText", translatedText);
119 | });
120 |
121 | return true;
122 | } catch (error) {
123 | console.error("注册快捷键失败:", error);
124 | showNotification("快捷键错误", "注册快捷键失败,请尝试其他组合");
125 | return false;
126 | }
127 | }
128 |
129 | // 翻译文本(调用 OpenRouter API)
130 | async function translateText(text) {
131 | try {
132 | if (!apiKey) {
133 | showNotification("错误", "请先设置 API Key");
134 | mainWindow.webContents.send("showApiKeyPrompt");
135 | return "请先设置 API Key";
136 | }
137 |
138 | const instance = axios.create({
139 | proxy: false,
140 | httpsAgent: new https.Agent({
141 | rejectUnauthorized: false
142 | }),
143 | timeout: 30000
144 | });
145 |
146 | console.log('Sending request to OpenRouter API...');
147 | const response = await instance.post(
148 | "https://openrouter.ai/api/v1/chat/completions",
149 | {
150 | model: "anthropic/claude-3-opus:2024-05-23", // 可以根据需要更换模型
151 | messages: [
152 | { role: "system", content: "You are a professional translator." },
153 | { role: "user", content: `${userPrompt}\n${text}` }
154 | ],
155 | temperature: 0.7,
156 | max_tokens: 800,
157 | top_p: 0.8,
158 | frequency_penalty: 0,
159 | presence_penalty: 0
160 | },
161 | {
162 | headers: {
163 | "Content-Type": "application/json",
164 | "Authorization": `Bearer ${apiKey}`,
165 | "HTTP-Referer": "https://copy2translate.app", // 替换为你的应用域名
166 | "X-Title": "Copy2Translate"
167 | }
168 | }
169 | );
170 |
171 | console.log('Response received:', response.status);
172 | return response.data.choices[0].message.content;
173 |
174 | } catch (error) {
175 | console.error("翻译失败:", error);
176 | if (error.response) {
177 | console.error("错误详情:", error.response.data);
178 | console.error("状态码:", error.response.status);
179 | console.error("响应头:", error.response.headers);
180 | } else {
181 | console.error("错误类型:", error.name);
182 | console.error("错误消息:", error.message);
183 | if (error.code) {
184 | console.error("错误代码:", error.code);
185 | }
186 | }
187 | return "翻译失败,请检查 API Key 或网络连接";
188 | }
189 | }
190 |
191 | app.whenReady().then(() => {
192 | // 请求通知权限(仅在 macOS 上需要)
193 | if (process.platform === 'darwin') {
194 | // macOS 不需要设置 AppUserModelId,这仅在 Windows 上才需要
195 | // 设置应用名称,这在 macOS 上对于通知很重要
196 | app.name = 'Copy2translate';
197 |
198 | // 如果使用Electron v9+,可以检查通知权限
199 | if (Notification.isSupported()) {
200 | // 检查权限
201 | Notification.requestPermission().then(permission => {
202 | console.log('通知权限状态:', permission);
203 | });
204 | }
205 | } else if (process.platform === 'win32') {
206 | // 在 Windows 上设置应用 ID
207 | app.setAppUserModelId('com.copy2translate.app');
208 | }
209 |
210 | // 创建主窗口
211 | createWindow();
212 |
213 | // 注册快捷键
214 | const registeredShortcut = registerShortcut(userShortcut);
215 | console.log("已注册快捷键:", registeredShortcut ? "成功" : "失败");
216 | });
217 |
218 | // 监听应用退出,释放快捷键
219 | app.on("will-quit", () => {
220 | globalShortcut.unregisterAll();
221 | });
222 |
223 | // 在应用准备就绪时创建窗口
224 | app.on('window-all-closed', () => {
225 | if (process.platform !== 'darwin') {
226 | app.quit();
227 | }
228 | });
229 |
230 | app.on('activate', () => {
231 | if (BrowserWindow.getAllWindows().length === 0) {
232 | createWindow();
233 | }
234 | });
235 |
236 | // 定义创建窗口的函数
237 | function createWindow() {
238 | // 创建窗口
239 | mainWindow = new BrowserWindow({
240 | width: 400,
241 | height: 350,
242 | webPreferences: {
243 | nodeIntegration: false,
244 | contextIsolation: true,
245 | preload: path.join(__dirname, 'preload.js'),
246 | webSecurity: true
247 | },
248 | });
249 |
250 | // 加载HTML文件
251 | mainWindow.loadFile('index.html');
252 |
253 | // 设置 CSP 头
254 | mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => {
255 | callback({
256 | responseHeaders: {
257 | ...details.responseHeaders,
258 | 'Content-Security-Policy': ["default-src 'self'; style-src 'self' 'unsafe-inline';"]
259 | }
260 | });
261 | });
262 |
263 | // 检查是否有通知权限
264 | if (Notification.isSupported()) {
265 | console.log('通知功能支持');
266 | } else {
267 | console.log('通知功能不支持');
268 | }
269 |
270 | // 监听提示词更新
271 | setupIPCListeners();
272 |
273 | // 在开发环境中打开开发者工具
274 | if (process.env.NODE_ENV === "development") {
275 | mainWindow.webContents.openDevTools();
276 | }
277 | }
278 |
279 | // 设置IPC监听器
280 | function setupIPCListeners() {
281 | // 检查是否有 API key
282 | if (!apiKey) {
283 | mainWindow.webContents.send("showApiKeyPrompt");
284 | }
285 |
286 | // 监听快捷键更新
287 | ipcMain.on("updateShortcut", (event, newShortcut) => {
288 | // 验证快捷键字符串是否只包含ASCII字符
289 | if (!/^[\x00-\x7F]+$/.test(newShortcut)) {
290 | console.error(`快捷键字符串只能包含ASCII字符,无效的字符串: "${newShortcut}"`);
291 | showNotification("快捷键错误", "快捷键包含无效字符,请使用有效的组合");
292 | event.reply("shortcutUpdateFailed", "快捷键包含无效字符");
293 | return;
294 | }
295 |
296 | // 保存新的快捷键
297 | settings.setSync("shortcut", newShortcut);
298 | userShortcut = newShortcut;
299 |
300 | // 注销所有快捷键并注册新的快捷键
301 | globalShortcut.unregisterAll();
302 | const success = registerShortcut(newShortcut);
303 |
304 | if (success) {
305 | event.reply("shortcutUpdated", newShortcut);
306 | } else {
307 | event.reply("shortcutUpdateFailed", "注册快捷键失败");
308 | }
309 | });
310 |
311 | // 监听 API key 设置
312 | ipcMain.on("setApiKey", (event, newApiKey, provider) => {
313 | if (provider === 'openRouter') {
314 | apiKey = newApiKey;
315 | settings.setSync("apiKey", newApiKey);
316 | } else if (provider === 'deepSeek') {
317 | settings.setSync("deepSeekApiKey", newApiKey);
318 | }
319 | showNotification("设置成功", "API Key 已保存");
320 | event.reply("apiKeySaved");
321 | });
322 |
323 | // 监听模型设置
324 | ipcMain.on("setModel", (event, model, provider) => {
325 | if (provider === 'openRouter') {
326 | settings.setSync("model", model);
327 | } else if (provider === 'deepSeek') {
328 | settings.setSync("deepSeekModel", model);
329 | }
330 | });
331 |
332 | // 添加 IPC 监听器来获取快捷键
333 | ipcMain.handle('getShortcut', () => {
334 | return settings.getSync("shortcut") || (process.platform === 'darwin' ? "Shift+Command+T" : "Control+Shift+T");
335 | });
336 |
337 | // 添加 IPC 监听器来获取 prompt
338 | ipcMain.handle('getPrompt', () => {
339 | return settings.getSync("prompt") || "Translate the following Chinese text to English:";
340 | });
341 |
342 | // 添加 IPC 处理程序来获取 API Key
343 | ipcMain.handle('getApiKey', (event, provider) => {
344 | if (provider === 'openRouter') {
345 | return settings.getSync("apiKey") || "";
346 | } else if (provider === 'deepSeek') {
347 | return settings.getSync("deepSeekApiKey") || "";
348 | }
349 | return "";
350 | });
351 |
352 | // 添加 IPC 处理程序来获取模型
353 | ipcMain.handle('getModel', (event, provider) => {
354 | if (provider === 'openRouter') {
355 | return settings.getSync("model") || "openai/gpt-4o";
356 | } else if (provider === 'deepSeek') {
357 | return settings.getSync("deepSeekModel") || "deepseek-chat";
358 | }
359 | return "";
360 | });
361 |
362 | // 监听 prompt 更新
363 | ipcMain.on("updatePrompt", (event, newPrompt) => {
364 | userPrompt = newPrompt;
365 | settings.setSync("prompt", newPrompt);
366 | showNotification("设置成功", "翻译提示词已更新");
367 | });
368 |
369 | // 添加 IPC 处理程序来保存提示词列表
370 | ipcMain.on("savePrompts", (event, prompts) => {
371 | console.log("主进程保存提示词列表:", prompts);
372 | settings.setSync("prompts", prompts);
373 | });
374 |
375 | // 添加 IPC 处理程序来获取提示词列表
376 | ipcMain.handle("getPrompts", () => {
377 | const savedPrompts = settings.getSync("prompts");
378 | console.log("主进程获取提示词列表:", savedPrompts);
379 |
380 | // 如果没有保存的提示词,返回默认的三条基本提示词
381 | if (!savedPrompts || savedPrompts.length === 0) {
382 | const defaultPrompts = [
383 | {
384 | id: "default",
385 | name: "默认英文翻译",
386 | text: "Please translate this Chinese text to English, only return the translation:",
387 | isActive: true
388 | },
389 | {
390 | id: "formal",
391 | name: "正式商务",
392 | text: "Please translate this Chinese text to formal Business English. Use professional vocabulary, maintain a respectful tone, and ensure the language is appropriate for corporate communications or official documents,only return the translation:",
393 | isActive: false
394 | },
395 | {
396 | id: "casual",
397 | name: "日常口语",
398 | text: "Please translate this Chinese text to casual, conversational English. Use everyday expressions, contractions, and a friendly tone that would be appropriate for informal conversations with friends,only return the translation:",
399 | isActive: false
400 | }
401 | ];
402 |
403 | // 保存默认提示词到设置中
404 | settings.setSync("prompts", defaultPrompts);
405 | return defaultPrompts;
406 | }
407 |
408 | return savedPrompts;
409 | });
410 |
411 | // 添加 IPC 处理程序来清除提示词列表
412 | ipcMain.handle("clearPrompts", () => {
413 | console.log("清除主进程中的提示词列表");
414 | settings.unsetSync("prompts");
415 | return true;
416 | });
417 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "copy2translate",
3 | "version": "1.0.0",
4 | "main": "main.js",
5 | "scripts": {
6 | "test": "echo \"Error: no test specified\" && exit 1",
7 | "start": "cross-env NODE_ENV=production electron .",
8 | "dev": "cross-env NODE_ENV=development electron .",
9 | "pack": "electron-builder --dir",
10 | "dist": "electron-builder",
11 | "dist:mac": "electron-builder --mac",
12 | "dist:win": "electron-builder --win"
13 | },
14 | "keywords": [],
15 | "author": "Liz Li",
16 | "license": "MIT",
17 | "description": "A translation app that helps you translate text with custom shortcut keys",
18 | "dependencies": {
19 | "axios": "^1.8.2",
20 | "clipboardy": "^4.0.0",
21 | "dotenv": "^16.4.5",
22 | "electron-settings": "^4.0.2",
23 | "https-proxy-agent": "^7.0.4",
24 | "settings": "^0.1.1"
25 | },
26 | "devDependencies": {
27 | "cross-env": "^7.0.3",
28 | "electron": "^35.0.0",
29 | "electron-builder": "^25.1.8"
30 | },
31 | "build": {
32 | "appId": "com.copy2translate.app",
33 | "productName": "Copy2Translate",
34 | "copyright": "Copyright © Liz Li",
35 | "asar": true,
36 | "compression": "maximum",
37 | "mac": {
38 | "category": "public.app-category.utilities",
39 | "target": [
40 | "dmg",
41 | "zip"
42 | ],
43 | "icon": "build/icon.icns",
44 | "artifactName": "Copy2Translate.${ext}"
45 | },
46 | "win": {
47 | "target": [
48 | "nsis"
49 | ],
50 | "icon": "build/icon.ico",
51 | "artifactName": "Copy2Translate-Setup.${ext}"
52 | },
53 | "linux": {
54 | "target": [
55 | "AppImage"
56 | ],
57 | "icon": "build/icon.png"
58 | },
59 | "directories": {
60 | "output": "release_new"
61 | },
62 | "files": [
63 | "main.js",
64 | "preload.js",
65 | "renderer.js",
66 | "index.html",
67 | "styles.css",
68 | "*.svg",
69 | "build/**/*",
70 | "node_modules/**/*"
71 | ],
72 | "asarUnpack": [],
73 | "npmRebuild": false
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/preload.js:
--------------------------------------------------------------------------------
1 | const { contextBridge, ipcRenderer } = require('electron');
2 |
3 | // 暴露安全的 API 给渲染进程
4 | contextBridge.exposeInMainWorld('electron', {
5 | // 从主进程接收消息
6 | receive: (channel, func) => {
7 | const validChannels = [
8 | 'translatedText',
9 | 'showApiKeyPrompt',
10 | 'shortcutUpdated',
11 | 'shortcutUpdateFailed',
12 | 'apiKeySaved',
13 | 'apiKeyError',
14 | 'showTranslationComplete'
15 | ];
16 | if (validChannels.includes(channel)) {
17 | // 删除 IPC 事件监听器以避免内存泄漏
18 | ipcRenderer.removeAllListeners(channel);
19 | // 添加新的监听器
20 | ipcRenderer.on(channel, (event, ...args) => func(...args));
21 | }
22 | },
23 |
24 | // 一次性事件监听
25 | once: (channel, func) => {
26 | const validChannels = [
27 | 'shortcutUpdated',
28 | 'shortcutUpdateFailed',
29 | 'apiKeySaved',
30 | 'apiKeyError'
31 | ];
32 | if (validChannels.includes(channel)) {
33 | // 添加一次性监听器
34 | ipcRenderer.once(channel, (event, ...args) => func(...args));
35 | }
36 | },
37 |
38 | // 向主进程发送消息
39 | send: (channel, ...args) => {
40 | const validChannels = [
41 | 'updateShortcut',
42 | 'updatePrompt',
43 | 'setApiKey',
44 | 'setModel',
45 | 'savePrompts'
46 | ];
47 | if (validChannels.includes(channel)) {
48 | ipcRenderer.send(channel, ...args);
49 | }
50 | },
51 |
52 | // 调用主进程方法并等待结果
53 | invoke: async (channel, ...args) => {
54 | const validChannels = [
55 | 'getShortcut',
56 | 'getPrompt',
57 | 'getApiKey',
58 | 'getModel',
59 | 'getPrompts',
60 | 'clearPrompts'
61 | ];
62 | if (validChannels.includes(channel)) {
63 | return await ipcRenderer.invoke(channel, ...args);
64 | }
65 | return Promise.reject(new Error(`Invalid channel: ${channel}`));
66 | }
67 | });
--------------------------------------------------------------------------------
/renderer.js:
--------------------------------------------------------------------------------
1 | // 移除 require 语句,使用 window.electron API
2 | // const { ipcRenderer } = require("electron");
3 |
4 | // 常量定义
5 | const AUDIO_SETTINGS = {
6 | HIGH_E: 1318.51,
7 | LOW_E: 659.25,
8 | DURATION: 0.5,
9 | INITIAL_GAIN: 0.1
10 | };
11 |
12 | const PROVIDERS = {
13 | OPEN_ROUTER: 'openRouter',
14 | DEEP_SEEK: 'deepSeek'
15 | };
16 |
17 | // 默认提示词配置
18 | const DEFAULT_PROMPTS = [
19 | {
20 | id: "default",
21 | name: "默认英文翻译",
22 | text: "Please translate the following text to English, please make sure only return the translation:",
23 | isActive: true
24 | },
25 | {
26 | id: "formal",
27 | name: "正式商务",
28 | text: "Please translate the following text to formal Business English. Use professional vocabulary, maintain a respectful tone, and ensure the language is appropriate for corporate communications or official documents,please make sure only return the translation:",
29 | isActive: false
30 | },
31 | {
32 | id: "casual",
33 | name: "日常口语",
34 | text: "Please translate the following text to casual, conversational English. Use everyday expressions, contractions, and a friendly tone that would be appropriate for informal conversations with friends,please make sure only return the translation:",
35 | isActive: false
36 | }
37 | ];
38 |
39 | // 初始化提示词列表
40 | let prompts = [...DEFAULT_PROMPTS];
41 |
42 | // 标记当前是否处于编辑模式
43 | let isEditMode = false;
44 | let editingPromptId = null;
45 |
46 | // 当前选中的提供商
47 | let currentProvider = PROVIDERS.OPEN_ROUTER;
48 |
49 | document.addEventListener("DOMContentLoaded", () => {
50 | // 检查 localStorage 状态
51 | console.log("页面加载时的 localStorage 状态:");
52 | console.log("prompts:", localStorage.getItem('prompts'));
53 |
54 | // 初始化提示词列表
55 | loadPrompts();
56 |
57 | // 显示当前快捷键
58 | fetchShortcut();
59 |
60 | // 显示当前 prompt
61 | fetchPrompt();
62 |
63 | // 在页面关闭前保存提示词列表
64 | window.addEventListener('beforeunload', () => {
65 | console.log("页面关闭前保存提示词列表");
66 | savePrompts();
67 | });
68 |
69 | // 添加输入框事件监听
70 | document.getElementById("newShortcut").addEventListener("keydown", (e) => {
71 | e.preventDefault();
72 | const modifiers = [];
73 | if (e.ctrlKey) modifiers.push("Ctrl");
74 | if (e.shiftKey) modifiers.push("Shift");
75 | if (e.altKey) modifiers.push("Alt");
76 | if (e.metaKey) modifiers.push("Command");
77 |
78 | // 只接受ASCII字符
79 | const key = e.key.toUpperCase();
80 | if (key !== "CONTROL" && key !== "SHIFT" && key !== "ALT" && key !== "META") {
81 | // 检查是否为ASCII字符
82 | if (/^[\x00-\x7F]$/.test(e.key)) {
83 | const shortcut = [...modifiers, key].join("+");
84 | document.getElementById("newShortcut").value = shortcut;
85 | } else {
86 | console.warn(`忽略非ASCII字符: ${e.key}`);
87 | }
88 | }
89 | });
90 |
91 | // 监听显示 API key 输入提示
92 | window.electron.receive("showApiKeyPrompt", () => {
93 | document.getElementById("apiKeyModal").classList.remove('hidden');
94 | });
95 |
96 | // 翻译结果接收
97 | window.electron.receive("translatedText", (text) => {
98 | const translatedTextArea = document.getElementById("translatedText");
99 | translatedTextArea.value = text;
100 |
101 | // 添加动画效果以提醒用户翻译已完成
102 | translatedTextArea.classList.add("translation-complete");
103 |
104 | // 聚焦到翻译结果区域并选中所有文本
105 | translatedTextArea.focus();
106 | translatedTextArea.select();
107 |
108 | // 移除动画类,为下次翻译做准备
109 | setTimeout(() => {
110 | translatedTextArea.classList.remove("translation-complete");
111 | }, 2000);
112 | });
113 |
114 | // 添加应用内通知处理
115 | window.electron.receive("showTranslationComplete", (data) => {
116 | // 创建一个应用内通知元素
117 | showAppNotification(data.title, data.body);
118 | });
119 |
120 | // 添加按钮事件监听器
121 | document.getElementById("updateShortcutBtn").addEventListener("click", updateShortcut);
122 | document.getElementById("saveApiKeyBtn").addEventListener("click", saveApiKey);
123 | document.getElementById("addPromptBtn").addEventListener("click", showAddPromptModal);
124 | document.getElementById("resetPromptsBtn").addEventListener("click", resetPrompts);
125 | document.getElementById("savePromptBtn").addEventListener("click", handleSavePrompt);
126 | document.getElementById("cancelAddPromptBtn").addEventListener("click", hideAddPromptModal);
127 | document.getElementById("translateBtn").addEventListener("click", simulateTranslation);
128 |
129 | // 添加清除 localStorage 按钮事件监听(如果存在这个按钮)
130 | const clearLocalStorageBtn = document.getElementById("clearLocalStorageBtn");
131 | if (clearLocalStorageBtn) {
132 | clearLocalStorageBtn.addEventListener("click", clearLocalStoragePrompts);
133 | }
134 |
135 | // 顶部标题栏按钮事件监听
136 | document.getElementById("apiKeyBtn").addEventListener("click", showApiKeyModal);
137 |
138 | // API Key弹窗事件监听
139 | document.getElementById("saveApiKeyBtn").addEventListener("click", saveApiKey);
140 | document.getElementById("cancelApiKeyBtn").addEventListener("click", cancelApiKeySetting);
141 | document.getElementById("openRouterBtn").addEventListener("click", () => switchProvider('openRouter'));
142 | document.getElementById("deepSeekBtn").addEventListener("click", () => switchProvider('deepSeek'));
143 |
144 | // 密码显示/隐藏按钮
145 | document.addEventListener('click', function(e) {
146 | if (e.target.closest('.toggle-password-btn')) {
147 | const container = e.target.closest('.password-input-container');
148 | const input = container.querySelector('input');
149 | togglePasswordVisibility(input);
150 | }
151 | });
152 |
153 | // 提示词选择变更
154 | document.getElementById("promptSelect").addEventListener("change", handlePromptSelect);
155 | });
156 |
157 | // 读取并显示当前快捷键
158 | async function fetchShortcut() {
159 | try {
160 | const shortcut = await window.electron.invoke('getShortcut');
161 | console.log("Current shortcut:", shortcut);
162 | document.getElementById("currentShortcut").innerText = shortcut;
163 | } catch (error) {
164 | console.error('Error fetching shortcut:', error);
165 | }
166 | }
167 |
168 | // 读取并显示当前 prompt
169 | async function fetchPrompt() {
170 | try {
171 | const prompt = await window.electron.invoke('getPrompt');
172 | document.getElementById("promptInput").value = prompt;
173 | } catch (error) {
174 | console.error('Error fetching prompt:', error);
175 | }
176 | }
177 |
178 | // 更新快捷键
179 | function updateShortcut() {
180 | const newShortcut = document.getElementById("newShortcut").value;
181 | if (newShortcut) {
182 | // 先设置监听器
183 | window.electron.receive("shortcutUpdated", (updatedShortcut) => {
184 | document.getElementById("currentShortcut").innerText = updatedShortcut;
185 | alert(`快捷键已更新为:${updatedShortcut}`);
186 | });
187 |
188 | window.electron.receive("shortcutUpdateFailed", (reason) => {
189 | alert(`快捷键更新失败:${reason}`);
190 | // 重新获取当前快捷键
191 | fetchShortcut();
192 | });
193 |
194 | // 然后发送更新请求
195 | window.electron.send("updateShortcut", newShortcut);
196 | }
197 | }
198 |
199 | // 显示API Key设置弹窗
200 | function showApiKeyModal() {
201 | const modal = document.getElementById("apiKeyModal");
202 | modal.classList.remove('hidden');
203 |
204 | // 加载当前API Key和模型信息
205 | loadApiKeySettings();
206 | }
207 |
208 | // 加载API Key和模型设置
209 | async function loadApiKeySettings() {
210 | try {
211 | let openRouterApiKey = '';
212 | let openRouterModel = '';
213 | let deepSeekApiKey = '';
214 | let deepSeekModel = '';
215 |
216 | // 尝试从主进程获取设置
217 | try {
218 | // 尝试从主进程获取OpenRouter设置
219 | openRouterApiKey = await window.electron.invoke('getApiKey', 'openRouter') || '';
220 | openRouterModel = await window.electron.invoke('getModel', 'openRouter') || '';
221 |
222 | // 尝试从主进程获取DeepSeek设置
223 | deepSeekApiKey = await window.electron.invoke('getApiKey', 'deepSeek') || '';
224 | deepSeekModel = await window.electron.invoke('getModel', 'deepSeek') || '';
225 | } catch (ipcError) {
226 | console.warn('无法从主进程获取API设置,将使用本地存储:', ipcError);
227 |
228 | // 从本地存储获取设置作为备选方案
229 | openRouterApiKey = localStorage.getItem('openRouterApiKey') || '';
230 | openRouterModel = localStorage.getItem('openRouterModel') || '';
231 | deepSeekApiKey = localStorage.getItem('deepSeekApiKey') || '';
232 | deepSeekModel = localStorage.getItem('deepSeekModel') || '';
233 | }
234 |
235 | // 更新OpenRouter显示
236 | updateProviderDisplay('openRouter', openRouterApiKey, openRouterModel);
237 |
238 | // 更新DeepSeek显示
239 | updateProviderDisplay('deepSeek', deepSeekApiKey, deepSeekModel);
240 |
241 | // 显示当前选中的提供商设置面板
242 | switchProvider(currentProvider);
243 | } catch (error) {
244 | console.error('Error loading API Key settings:', error);
245 | }
246 | }
247 |
248 | // 更新提供商显示
249 | function updateProviderDisplay(provider, apiKey, model) {
250 | if (provider === 'openRouter') {
251 | const currentApiKeyInput = document.getElementById("currentApiKey");
252 | const currentModelInput = document.getElementById("currentModel");
253 |
254 | if (apiKey) {
255 | currentApiKeyInput.value = apiKey;
256 | currentApiKeyInput.placeholder = '';
257 | } else {
258 | currentApiKeyInput.value = '';
259 | currentApiKeyInput.placeholder = '尚未设置 API Key';
260 | }
261 |
262 | if (model) {
263 | currentModelInput.value = model;
264 | currentModelInput.placeholder = '';
265 | } else {
266 | currentModelInput.value = '';
267 | currentModelInput.placeholder = '尚未设置模型';
268 | }
269 |
270 | // 清空新输入框,只保留 placeholder
271 | document.getElementById("newApiKey").value = '';
272 | document.getElementById("newModel").value = '';
273 | } else if (provider === 'deepSeek') {
274 | const currentApiKeyInput = document.getElementById("currentDeepSeekApiKey");
275 | const currentModelInput = document.getElementById("currentDeepSeekModel");
276 |
277 | if (apiKey) {
278 | currentApiKeyInput.value = apiKey;
279 | currentApiKeyInput.placeholder = '';
280 | } else {
281 | currentApiKeyInput.value = '';
282 | currentApiKeyInput.placeholder = '尚未设置 API Key';
283 | }
284 |
285 | if (model) {
286 | currentModelInput.value = model;
287 | currentModelInput.placeholder = '';
288 | } else {
289 | currentModelInput.value = '';
290 | currentModelInput.placeholder = '尚未设置模型';
291 | }
292 |
293 | // 清空新输入框,只保留 placeholder
294 | document.getElementById("newDeepSeekApiKey").value = '';
295 | document.getElementById("newDeepSeekModel").value = '';
296 | }
297 |
298 | // 更新提供商状态
299 | updateProviderStatus(provider, apiKey);
300 | }
301 |
302 | // 更新提供商状态
303 | function updateProviderStatus(provider, apiKey) {
304 | const openRouterBtn = document.getElementById("openRouterBtn");
305 | const deepSeekBtn = document.getElementById("deepSeekBtn");
306 |
307 | const statusSpan = provider === 'openRouter'
308 | ? openRouterBtn.querySelector('.provider-status')
309 | : deepSeekBtn.querySelector('.provider-status');
310 |
311 | // 清除之前的状态类
312 | statusSpan.classList.remove('status-set', 'status-unset');
313 |
314 | if (apiKey) {
315 | statusSpan.textContent = '(已设置)';
316 | statusSpan.classList.add('status-set');
317 | } else {
318 | statusSpan.textContent = '(未设置)';
319 | statusSpan.classList.add('status-unset');
320 | }
321 | }
322 |
323 | // 保存 API Key 和模型设置
324 | function saveApiKey() {
325 | let apiKey, model;
326 |
327 | if (currentProvider === 'openRouter') {
328 | apiKey = document.getElementById("newApiKey").value;
329 | model = document.getElementById("newModel").value;
330 |
331 | // 如果没有输入新的API Key,检查是否有现有的
332 | if (!apiKey) {
333 | apiKey = document.getElementById("currentApiKey").value;
334 | }
335 | } else if (currentProvider === 'deepSeek') {
336 | apiKey = document.getElementById("newDeepSeekApiKey").value;
337 | model = document.getElementById("newDeepSeekModel").value;
338 |
339 | // 如果没有输入新的API Key,检查是否有现有的
340 | if (!apiKey) {
341 | apiKey = document.getElementById("currentDeepSeekApiKey").value;
342 | }
343 | }
344 |
345 | if (apiKey) {
346 | // 发送到主进程保存
347 | window.electron.send("setApiKey", apiKey, currentProvider);
348 | if (model) { // 只有当用户实际输入了模型时才保存
349 | window.electron.send("setModel", model, currentProvider);
350 | }
351 |
352 | // 同时保存到本地存储作为备选方案
353 | if (currentProvider === 'openRouter') {
354 | localStorage.setItem('openRouterApiKey', apiKey);
355 | if (model) {
356 | localStorage.setItem('openRouterModel', model);
357 | }
358 | } else if (currentProvider === 'deepSeek') {
359 | localStorage.setItem('deepSeekApiKey', apiKey);
360 | if (model) {
361 | localStorage.setItem('deepSeekModel', model);
362 | }
363 | }
364 |
365 | // 更新当前显示
366 | if (currentProvider === 'openRouter') {
367 | document.getElementById("currentApiKey").value = apiKey;
368 | document.getElementById("currentApiKey").placeholder = '';
369 | document.getElementById("currentModel").value = model;
370 | } else if (currentProvider === 'deepSeek') {
371 | document.getElementById("currentDeepSeekApiKey").value = apiKey;
372 | document.getElementById("currentDeepSeekApiKey").placeholder = '';
373 | document.getElementById("currentDeepSeekModel").value = model;
374 | }
375 |
376 | // 更新提供商状态
377 | updateProviderStatus(currentProvider, apiKey);
378 |
379 | // 关闭弹窗
380 | const modal = document.getElementById("apiKeyModal");
381 | modal.classList.add('hidden');
382 | } else {
383 | alert("请输入有效的 API Key");
384 | }
385 | }
386 |
387 | // 切换密码显示/隐藏
388 | function togglePasswordVisibility(input) {
389 | if (input.type === "password") {
390 | input.type = "text";
391 | } else {
392 | input.type = "password";
393 | }
394 | }
395 |
396 | // 切换提供商
397 | function switchProvider(provider) {
398 | const openRouterBtn = document.getElementById("openRouterBtn");
399 | const deepSeekBtn = document.getElementById("deepSeekBtn");
400 | const openRouterSettings = document.getElementById("openRouterSettings");
401 | const deepSeekSettings = document.getElementById("deepSeekSettings");
402 |
403 | // 保存当前选中的提供商
404 | currentProvider = provider;
405 |
406 | // 重置按钮状态
407 | openRouterBtn.classList.remove('active');
408 | deepSeekBtn.classList.remove('active');
409 |
410 | // 隐藏所有设置面板
411 | openRouterSettings.classList.add('hidden');
412 | deepSeekSettings.classList.add('hidden');
413 |
414 | // 激活选中的提供商
415 | if (provider === 'openRouter') {
416 | openRouterBtn.classList.add('active');
417 | openRouterSettings.classList.remove('hidden');
418 | } else if (provider === 'deepSeek') {
419 | deepSeekBtn.classList.add('active');
420 | deepSeekSettings.classList.remove('hidden');
421 | }
422 | }
423 |
424 | // 取消API Key设置
425 | function cancelApiKeySetting() {
426 | const modal = document.getElementById("apiKeyModal");
427 | modal.classList.add('hidden');
428 | }
429 |
430 | // 加载提示词列表
431 | function loadPrompts() {
432 | // 尝试从主进程获取提示词列表
433 | window.electron.invoke('getPrompts')
434 | .then(savedPrompts => {
435 | console.log("从主进程获取提示词:", savedPrompts);
436 |
437 | if (savedPrompts && savedPrompts.length > 0) {
438 | // 如果主进程中有提示词,使用它们
439 | prompts = savedPrompts;
440 | console.log("成功从主进程获取提示词:", prompts);
441 | } else {
442 | // 尝试从本地存储加载提示词列表
443 | const localPrompts = localStorage.getItem('prompts');
444 | console.log("从本地存储加载提示词:", localPrompts);
445 |
446 | if (localPrompts) {
447 | try {
448 | // 如果本地存储中有提示词,使用它们
449 | prompts = JSON.parse(localPrompts);
450 | console.log("成功解析本地存储中的提示词:", prompts);
451 |
452 | // 同步到主进程
453 | window.electron.send('savePrompts', prompts);
454 | } catch (error) {
455 | console.error("解析本地存储中的提示词时出错:", error);
456 | // 如果解析出错,使用默认提示词
457 | localStorage.setItem('prompts', JSON.stringify(prompts));
458 | // 同步到主进程
459 | window.electron.send('savePrompts', prompts);
460 | }
461 | } else {
462 | console.log("本地存储中没有提示词,使用默认提示词");
463 | // 如果本地存储中没有提示词,使用默认提示词并保存到本地存储
464 | localStorage.setItem('prompts', JSON.stringify(prompts));
465 | // 同步到主进程
466 | window.electron.send('savePrompts', prompts);
467 | }
468 | }
469 |
470 | // 确保至少有一个提示词是活跃的
471 | const hasActivePrompt = prompts.some(p => p.isActive);
472 | if (!hasActivePrompt && prompts.length > 0) {
473 | prompts[0].isActive = true;
474 | console.log("没有活跃的提示词,将第一个提示词设为活跃:", prompts[0]);
475 | }
476 |
477 | // 更新提示词下拉列表
478 | updatePromptSelect();
479 |
480 | // 更新提示词列表显示
481 | renderPromptsList();
482 | })
483 | .catch(error => {
484 | console.error("从主进程获取提示词时出错:", error);
485 |
486 | // 尝试从本地存储加载提示词列表
487 | const localPrompts = localStorage.getItem('prompts');
488 | console.log("从本地存储加载提示词:", localPrompts);
489 |
490 | if (localPrompts) {
491 | try {
492 | // 如果本地存储中有提示词,使用它们
493 | prompts = JSON.parse(localPrompts);
494 | console.log("成功解析本地存储中的提示词:", prompts);
495 | } catch (error) {
496 | console.error("解析本地存储中的提示词时出错:", error);
497 | // 如果解析出错,使用默认提示词
498 | localStorage.setItem('prompts', JSON.stringify(prompts));
499 | }
500 | } else {
501 | console.log("本地存储中没有提示词,使用默认提示词");
502 | // 如果本地存储中没有提示词,使用默认提示词并保存到本地存储
503 | localStorage.setItem('prompts', JSON.stringify(prompts));
504 | }
505 |
506 | // 确保至少有一个提示词是活跃的
507 | const hasActivePrompt = prompts.some(p => p.isActive);
508 | if (!hasActivePrompt && prompts.length > 0) {
509 | prompts[0].isActive = true;
510 | console.log("没有活跃的提示词,将第一个提示词设为活跃:", prompts[0]);
511 | }
512 |
513 | // 更新提示词下拉列表
514 | updatePromptSelect();
515 |
516 | // 更新提示词列表显示
517 | renderPromptsList();
518 | });
519 | }
520 |
521 | // 更新提示词下拉列表
522 | function updatePromptSelect() {
523 | const select = document.getElementById("promptSelect");
524 | select.innerHTML = '';
525 |
526 | prompts.forEach(prompt => {
527 | const option = document.createElement('option');
528 | option.value = prompt.id;
529 | option.textContent = prompt.name;
530 | if (prompt.isActive) {
531 | option.selected = true;
532 | }
533 | select.appendChild(option);
534 | });
535 | }
536 |
537 | // 渲染提示词列表
538 | function renderPromptsList() {
539 | const promptsList = document.getElementById("promptsList");
540 | promptsList.innerHTML = '';
541 |
542 | console.log("渲染提示词列表,共", prompts.length, "个提示词");
543 |
544 | prompts.forEach(prompt => {
545 | console.log("渲染提示词:", prompt.id, prompt.name, prompt.isActive ? "(活跃)" : "");
546 |
547 | const promptItem = document.createElement('div');
548 | promptItem.className = 'prompt-item';
549 |
550 | const nameDiv = document.createElement('div');
551 | nameDiv.className = 'prompt-item-name';
552 | nameDiv.textContent = prompt.name;
553 |
554 | const textDiv = document.createElement('div');
555 | textDiv.className = 'prompt-item-text';
556 | textDiv.textContent = prompt.text;
557 |
558 | const statusDiv = document.createElement('div');
559 | statusDiv.className = 'prompt-item-status';
560 | if (prompt.isActive) {
561 | statusDiv.textContent = '当前使用中';
562 | } else {
563 | statusDiv.style.display = 'none';
564 | }
565 |
566 | const actionsDiv = document.createElement('div');
567 | actionsDiv.className = 'prompt-item-actions';
568 |
569 | const editBtn = document.createElement('button');
570 | editBtn.className = 'edit-btn';
571 | editBtn.innerHTML = ' ';
572 | editBtn.onclick = () => editPrompt(prompt.id);
573 |
574 | const deleteBtn = document.createElement('button');
575 | deleteBtn.className = 'delete-btn';
576 | deleteBtn.innerHTML = ' ';
577 | deleteBtn.onclick = () => deletePrompt(prompt.id);
578 |
579 | actionsDiv.appendChild(editBtn);
580 | actionsDiv.appendChild(deleteBtn);
581 |
582 | promptItem.appendChild(nameDiv);
583 | promptItem.appendChild(textDiv);
584 | promptItem.appendChild(statusDiv);
585 | promptItem.appendChild(actionsDiv);
586 |
587 | promptsList.appendChild(promptItem);
588 | });
589 | }
590 |
591 | // 显示添加提示词弹窗
592 | function showAddPromptModal() {
593 | isEditMode = false;
594 | editingPromptId = null;
595 | document.getElementById("addPromptModal").classList.remove('hidden');
596 | document.getElementById("newPromptName").value = "";
597 | document.getElementById("newPromptText").value = "";
598 | }
599 |
600 | // 隐藏添加提示词弹窗
601 | function hideAddPromptModal() {
602 | document.getElementById("addPromptModal").classList.add('hidden');
603 | isEditMode = false;
604 | editingPromptId = null;
605 | }
606 |
607 | // 处理保存提示词(新增或更新)
608 | function handleSavePrompt() {
609 | const name = document.getElementById("newPromptName").value;
610 | const text = document.getElementById("newPromptText").value;
611 |
612 | if (!name || !text) {
613 | alert("请填写提示词名称和内容");
614 | return;
615 | }
616 |
617 | if (isEditMode && editingPromptId) {
618 | console.log("更新现有提示词:", editingPromptId);
619 | // 更新现有提示词
620 | const prompt = prompts.find(p => p.id === editingPromptId);
621 | if (prompt) {
622 | prompt.name = name;
623 | prompt.text = text;
624 |
625 | // 如果编辑的是当前活跃的提示词,同时更新当前翻译提示词的文本框
626 | if (prompt.isActive) {
627 | document.getElementById("promptInput").value = text;
628 | // 保存到主进程
629 | window.electron.send("updatePrompt", text);
630 | }
631 | }
632 | } else {
633 | console.log("添加新提示词:", name);
634 | // 添加新提示词
635 | const newPrompt = {
636 | id: Date.now().toString(),
637 | name,
638 | text,
639 | isActive: false
640 | };
641 | prompts.push(newPrompt);
642 | }
643 |
644 | savePrompts();
645 | hideAddPromptModal();
646 | }
647 |
648 | // 编辑提示词
649 | function editPrompt(id) {
650 | const prompt = prompts.find(p => p.id === id);
651 | if (!prompt) return;
652 |
653 | isEditMode = true;
654 | editingPromptId = id;
655 |
656 | // 显示弹窗并填充数据
657 | document.getElementById("addPromptModal").classList.remove('hidden');
658 | document.getElementById("newPromptName").value = prompt.name;
659 | document.getElementById("newPromptText").value = prompt.text;
660 | }
661 |
662 | // 删除提示词
663 | function deletePrompt(id) {
664 | if (prompts.length <= 1) {
665 | alert("至少保留一个提示词");
666 | return;
667 | }
668 |
669 | const prompt = prompts.find(p => p.id === id);
670 | if (!prompt) return;
671 |
672 | if (prompt.isActive) {
673 | alert("无法删除当前使用中的提示词");
674 | return;
675 | }
676 |
677 | if (confirm(`确定要删除提示词"${prompt.name}"吗?`)) {
678 | console.log("删除提示词:", id, prompt.name);
679 | prompts = prompts.filter(p => p.id !== id);
680 | console.log("删除后的提示词列表:", prompts);
681 | savePrompts();
682 | }
683 | }
684 |
685 | // 保存提示词列表到本地存储和主进程
686 | function savePrompts() {
687 | console.log("保存提示词列表:", prompts);
688 |
689 | // 保存到本地存储
690 | localStorage.setItem('prompts', JSON.stringify(prompts));
691 | console.log("保存到本地存储完成");
692 |
693 | // 保存到主进程
694 | window.electron.send('savePrompts', prompts);
695 | console.log("保存到主进程完成");
696 |
697 | // 更新界面
698 | updatePromptSelect();
699 | renderPromptsList();
700 | }
701 |
702 | // 更新提示词UI(在修改prompts后调用此函数立即更新界面)
703 | function updatePromptsUI() {
704 | console.log("更新提示词UI");
705 |
706 | // 更新提示词下拉列表
707 | updatePromptSelect();
708 |
709 | // 更新提示词列表显示
710 | renderPromptsList();
711 |
712 | // 更新当前活跃提示词的输入框
713 | const activePrompt = prompts.find(p => p.isActive);
714 | if (activePrompt) {
715 | document.getElementById("promptInput").value = activePrompt.text;
716 | }
717 |
718 | // 保存更改
719 | savePrompts();
720 | }
721 |
722 | // 处理提示词选择变更
723 | function handlePromptSelect() {
724 | const selectElement = document.getElementById("promptSelect");
725 | const selectedId = selectElement.value;
726 |
727 | // 更新活跃状态
728 | prompts.forEach(prompt => {
729 | prompt.isActive = (prompt.id === selectedId);
730 | });
731 |
732 | // 更新提示词输入框
733 | const activePrompt = prompts.find(p => p.isActive);
734 | if (activePrompt) {
735 | document.getElementById("promptInput").value = activePrompt.text;
736 | // 保存到主进程
737 | window.electron.send("updatePrompt", activePrompt.text);
738 | }
739 |
740 | // 更新列表显示
741 | renderPromptsList();
742 | savePrompts();
743 | }
744 |
745 | // 模拟翻译
746 | function simulateTranslation() {
747 | const promptText = document.getElementById("promptInput").value;
748 | const modelSelect = document.getElementById("modelSelect").value;
749 |
750 | // 这里只是模拟,实际应用中应该调用真实的翻译API
751 | const sampleText = "这是一个模拟的翻译结果。在实际应用中,这里会显示通过选定的模型(" + modelSelect + ")翻译的文本。";
752 |
753 | // 直接调用与真实翻译相同的处理方式
754 | const translatedTextArea = document.getElementById("translatedText");
755 | translatedTextArea.value = sampleText;
756 |
757 | // 添加动画效果以提醒用户翻译已完成
758 | translatedTextArea.classList.add("translation-complete");
759 |
760 | // 聚焦到翻译结果区域并选中所有文本
761 | translatedTextArea.focus();
762 | translatedTextArea.select();
763 |
764 | // 移除动画类,为下次翻译做准备
765 | setTimeout(() => {
766 | translatedTextArea.classList.remove("translation-complete");
767 | }, 2000);
768 |
769 | // 也可以通过IPC调用主进程进行实际翻译
770 | // ipcRenderer.send("translate", { text: "要翻译的文本", model: modelSelect });
771 | }
772 |
773 | // 重置提示词列表为预设值
774 | function resetPrompts() {
775 | if (!confirm("确定要重置提示词列表吗?这将删除所有自定义提示词。")) {
776 | return;
777 | }
778 |
779 | prompts = [...DEFAULT_PROMPTS];
780 |
781 | // 保存到本地存储和主进程
782 | localStorage.setItem('prompts', JSON.stringify(prompts));
783 | window.electron.send('savePrompts', prompts);
784 |
785 | // 更新界面
786 | updatePromptSelect();
787 | renderPromptsList();
788 |
789 | alert("提示词列表已重置为预设值。");
790 | }
791 |
792 | // 应用内通知函数
793 | function showAppNotification(title, message) {
794 | // 检查是否存在通知容器,如果不存在则创建
795 | let notificationContainer = document.getElementById('app-notification-container');
796 | if (!notificationContainer) {
797 | notificationContainer = document.createElement('div');
798 | notificationContainer.id = 'app-notification-container';
799 | notificationContainer.style.position = 'fixed';
800 | notificationContainer.style.top = '20px';
801 | notificationContainer.style.right = '20px';
802 | notificationContainer.style.zIndex = '9999';
803 | document.body.appendChild(notificationContainer);
804 | }
805 |
806 | // 创建通知元素
807 | const notification = document.createElement('div');
808 | notification.className = 'app-notification';
809 | notification.style.backgroundColor = 'rgba(60, 60, 60, 0.9)';
810 | notification.style.color = 'white';
811 | notification.style.padding = '12px 16px';
812 | notification.style.marginBottom = '10px';
813 | notification.style.borderRadius = '8px';
814 | notification.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)';
815 | notification.style.width = '300px';
816 | notification.style.maxWidth = '100%';
817 | notification.style.transition = 'all 0.3s ease';
818 | notification.style.opacity = '0';
819 | notification.style.transform = 'translateX(20px)';
820 |
821 | // 创建标题
822 | const titleElement = document.createElement('div');
823 | titleElement.textContent = title;
824 | titleElement.style.fontWeight = 'bold';
825 | titleElement.style.marginBottom = '5px';
826 | notification.appendChild(titleElement);
827 |
828 | // 创建消息
829 | const messageElement = document.createElement('div');
830 | messageElement.textContent = message;
831 | messageElement.style.fontSize = '14px';
832 | notification.appendChild(messageElement);
833 |
834 | // 添加到容器
835 | notificationContainer.appendChild(notification);
836 |
837 | // 触发过渡动画
838 | setTimeout(() => {
839 | notification.style.opacity = '1';
840 | notification.style.transform = 'translateX(0)';
841 | }, 10);
842 |
843 | // 自动消失
844 | setTimeout(() => {
845 | notification.style.opacity = '0';
846 | notification.style.transform = 'translateX(20px)';
847 |
848 | // 移除元素
849 | setTimeout(() => {
850 | notificationContainer.removeChild(notification);
851 | }, 300);
852 | }, 3000);
853 | }
--------------------------------------------------------------------------------
/scripts/notarize.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config();
2 | const { notarize } = require('electron-notarize');
3 |
4 | exports.default = async function notarizing(context) {
5 | const { electronPlatformName, appOutDir } = context;
6 | if (electronPlatformName !== 'darwin') {
7 | return;
8 | }
9 |
10 | const appName = context.packager.appInfo.productFilename;
11 | const appPath = `${appOutDir}/${appName}.app`;
12 |
13 | if (!process.env.APPLE_ID || !process.env.APPLE_ID_PASSWORD || !process.env.APPLE_TEAM_ID) {
14 | console.warn('Skipping notarization: Required environment variables are missing.');
15 | return;
16 | }
17 |
18 | console.log(`Notarizing ${appName}...`);
19 |
20 | try {
21 | await notarize({
22 | appBundleId: 'com.copy2translate.app',
23 | appPath,
24 | appleId: process.env.APPLE_ID,
25 | appleIdPassword: process.env.APPLE_ID_PASSWORD,
26 | teamId: process.env.APPLE_TEAM_ID,
27 | });
28 | } catch (error) {
29 | console.error('Notarization failed:', error);
30 | throw error;
31 | }
32 |
33 | console.log(`Done notarizing ${appName}`);
34 | };
--------------------------------------------------------------------------------
/styles.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
3 | margin: 0;
4 | padding: 0;
5 | background-color: #f5f5f5;
6 | color: #333;
7 | line-height: 1.5;
8 | }
9 |
10 | /* 顶部标题栏 */
11 | .header {
12 | display: flex;
13 | justify-content: space-between;
14 | align-items: center;
15 | padding: 16px 24px;
16 | background-color: #fff;
17 | border-bottom: 1px solid rgba(0, 0, 0, 0.1);
18 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
19 | max-width: 1147px;
20 | margin: 0 auto;
21 | box-sizing: border-box;
22 | }
23 |
24 | .app-title {
25 | font-size: 24px;
26 | font-weight: 600;
27 | color: #1a73e8;
28 | letter-spacing: -0.5px;
29 | }
30 |
31 | .header-actions {
32 | display: flex;
33 | gap: 12px;
34 | }
35 |
36 | .header-btn {
37 | display: flex;
38 | align-items: center;
39 | gap: 6px;
40 | padding: 8px 16px;
41 | background-color: #f8f9fa;
42 | border: 1px solid #dadce0;
43 | border-radius: 6px;
44 | font-size: 14px;
45 | font-weight: 500;
46 | color: #5f6368;
47 | cursor: pointer;
48 | transition: all 0.2s ease;
49 | }
50 |
51 | .header-btn:hover {
52 | background-color: #f1f3f4;
53 | border-color: #c6c6c6;
54 | color: #202124;
55 | }
56 |
57 | .btn-icon {
58 | font-size: 16px;
59 | }
60 |
61 | .container {
62 | max-width: 1200px;
63 | margin: 24px auto;
64 | padding: 0 24px;
65 | box-sizing: border-box;
66 | }
67 |
68 | /* 顶部设置行 - 快捷键和当前提示词并排 */
69 | .top-settings-row {
70 | display: flex;
71 | gap: 24px;
72 | margin-bottom: 24px;
73 | box-sizing: border-box;
74 | }
75 |
76 | .shortcut-section {
77 | flex: 1;
78 | }
79 |
80 | .prompt-section {
81 | flex: 2;
82 | }
83 |
84 | h2 {
85 | font-size: 18px;
86 | font-weight: 600;
87 | color: #202124;
88 | margin: 0 0 20px 0;
89 | letter-spacing: -0.3px;
90 | }
91 |
92 | /* 基础表单元素样式 */
93 | input, select, textarea {
94 | font-family: inherit;
95 | border-radius: 8px;
96 | border: 1.5px solid #dadce0;
97 | padding: 10px 14px;
98 | font-size: 14px;
99 | transition: all 0.2s ease;
100 | background-color: #fff;
101 | color: #202124;
102 | box-sizing: border-box;
103 | }
104 |
105 | /* 添加 placeholder 样式 */
106 | input::placeholder,
107 | textarea::placeholder {
108 | color: #9aa0a6;
109 | opacity: 1;
110 | }
111 |
112 | input:focus, select:focus, textarea:focus {
113 | outline: none;
114 | border-color: #1a73e8;
115 | box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.1);
116 | }
117 |
118 | button {
119 | cursor: pointer;
120 | font-weight: 500;
121 | transition: all 0.2s ease;
122 | }
123 |
124 | .update-btn, .translate-btn, .save-btn {
125 | background-color: #1a73e8;
126 | color: white;
127 | border: none;
128 | padding: 10px 20px;
129 | border-radius: 8px;
130 | font-size: 14px;
131 | font-weight: 500;
132 | transition: all 0.2s ease;
133 | }
134 |
135 | .update-btn:hover, .translate-btn:hover, .save-btn:hover {
136 | background-color: #1557b0;
137 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
138 | }
139 |
140 | .settings-section {
141 | background-color: #fff;
142 | border-radius: 12px;
143 | padding: 24px;
144 | margin-bottom: 24px;
145 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
146 | transition: box-shadow 0.2s ease;
147 | }
148 |
149 | .settings-section:hover {
150 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.08);
151 | }
152 |
153 | /* 快捷键设置 */
154 | .shortcut-container {
155 | display: flex;
156 | flex-direction: column;
157 | gap: 15px;
158 | }
159 |
160 | .current-shortcut {
161 | display: flex;
162 | align-items: center;
163 | gap: 10px;
164 | }
165 |
166 | .shortcut-input-group {
167 | display: flex;
168 | gap: 10px;
169 | }
170 |
171 | .shortcut-input-group input {
172 | flex: 1;
173 | }
174 |
175 | .update-btn:hover {
176 | background-color: #3367d6;
177 | }
178 |
179 | /* 翻译提示词 */
180 | .prompt-container {
181 | display: flex;
182 | flex-direction: column;
183 | gap: 15px;
184 | width: 100%;
185 | }
186 |
187 | .prompt-select-container {
188 | width: 100%;
189 | }
190 |
191 | .prompt-select-container select {
192 | width: 100%;
193 | }
194 |
195 | .prompt-input {
196 | width: 100%;
197 | height: 100px;
198 | resize: vertical;
199 | box-sizing: border-box;
200 | }
201 |
202 | /* 提示词管理 */
203 | .prompt-management {
204 | display: flex;
205 | flex-direction: column;
206 | gap: 15px;
207 | }
208 |
209 | .prompts-list {
210 | border: 1px solid #eee;
211 | border-radius: 8px;
212 | max-height: 500px;
213 | overflow-y: auto;
214 | }
215 |
216 | .prompt-item {
217 | padding: 16px;
218 | border-bottom: 1px solid #eee;
219 | display: grid;
220 | grid-template-columns: 1fr 2fr auto auto;
221 | align-items: center;
222 | gap: 20px;
223 | transition: background-color 0.2s ease;
224 | }
225 |
226 | .prompt-item:hover {
227 | background-color: #f8f9fa;
228 | }
229 |
230 | .prompt-item-name {
231 | font-weight: 500;
232 | color: #202124;
233 | }
234 |
235 | .prompt-item-text {
236 | color: #5f6368;
237 | font-size: 14px;
238 | white-space: nowrap;
239 | overflow: hidden;
240 | text-overflow: ellipsis;
241 | max-width: 100%;
242 | }
243 |
244 | .prompt-item-status {
245 | font-size: 12px;
246 | font-weight: 500;
247 | padding: 4px 8px;
248 | border-radius: 12px;
249 | background-color: #e8f0fe;
250 | color: #1a73e8;
251 | }
252 |
253 | .prompt-item-actions {
254 | display: flex;
255 | gap: 5px;
256 | }
257 |
258 | .edit-btn, .delete-btn {
259 | padding: 8px;
260 | border-radius: 6px;
261 | border: none;
262 | background: transparent;
263 | transition: all 0.2s ease;
264 | }
265 |
266 | .edit-btn:hover, .delete-btn:hover {
267 | background-color: #f1f3f4;
268 | }
269 |
270 | .edit-btn svg, .delete-btn svg {
271 | transition: all 0.2s ease;
272 | }
273 |
274 | .edit-btn:hover svg path, .delete-btn:hover svg path {
275 | stroke: #202124;
276 | }
277 |
278 | .add-prompt-btn {
279 | align-self: flex-end;
280 | background-color: white;
281 | border: 1px solid #ddd;
282 | padding: 8px 15px;
283 | }
284 |
285 | /* 翻译结果 */
286 | .translation-result-container {
287 | display: flex;
288 | flex-direction: column;
289 | gap: 15px;
290 | width: 100%;
291 | }
292 |
293 | .model-selection {
294 | display: flex;
295 | align-items: center;
296 | gap: 10px;
297 | width: 100%;
298 | }
299 |
300 | .model-selection select {
301 | flex: 1;
302 | }
303 |
304 | .translation-result {
305 | width: 100%;
306 | height: 150px;
307 | resize: vertical;
308 | box-sizing: border-box;
309 | }
310 |
311 | .translate-btn {
312 | align-self: flex-end;
313 | background-color: #4285f4;
314 | color: white;
315 | border: none;
316 | padding: 8px 20px;
317 | }
318 |
319 | .translate-btn:hover {
320 | background-color: #3367d6;
321 | }
322 |
323 | /* 弹窗 */
324 | .modal {
325 | position: fixed;
326 | top: 0;
327 | left: 0;
328 | width: 100%;
329 | height: 100%;
330 | background-color: rgba(0, 0, 0, 0.5);
331 | display: flex;
332 | justify-content: center;
333 | align-items: center;
334 | z-index: 1000;
335 | }
336 |
337 | .modal-content {
338 | background-color: white;
339 | padding: 20px;
340 | border-radius: 8px;
341 | max-width: 500px;
342 | width: 90%;
343 | }
344 |
345 | .modal-content h2 {
346 | margin-top: 0;
347 | }
348 |
349 | .modal-content input,
350 | .modal-content textarea {
351 | width: 100%;
352 | box-sizing: border-box;
353 | }
354 |
355 | .modal-content textarea {
356 | height: 100px;
357 | resize: vertical;
358 | }
359 |
360 | .modal-buttons {
361 | display: flex;
362 | justify-content: flex-end;
363 | gap: 10px;
364 | margin-top: 10px;
365 | }
366 |
367 | #savePromptBtn {
368 | background-color: #4285f4;
369 | color: white;
370 | border: none;
371 | }
372 |
373 | #savePromptBtn:hover {
374 | background-color: #3367d6;
375 | }
376 |
377 | /* 暗色主题 */
378 | .dark-theme {
379 | background-color: #202124;
380 | color: #e8eaed;
381 | }
382 |
383 | .dark-theme .header {
384 | background-color: #292a2d;
385 | border-bottom-color: rgba(255, 255, 255, 0.1);
386 | }
387 |
388 | .dark-theme .app-title {
389 | color: #4285f4;
390 | }
391 |
392 | .dark-theme .header-btn {
393 | background-color: #333;
394 | border-color: #444;
395 | color: #e0e0e0;
396 | }
397 |
398 | .dark-theme .header-btn:hover {
399 | background-color: #444;
400 | }
401 |
402 | .dark-theme .settings-section {
403 | background-color: #292a2d;
404 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
405 | }
406 |
407 | .dark-theme .settings-section:hover {
408 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
409 | }
410 |
411 | .dark-theme input,
412 | .dark-theme select,
413 | .dark-theme textarea {
414 | background-color: #202124;
415 | border-color: #5f6368;
416 | color: #e8eaed;
417 | }
418 |
419 | .dark-theme input:focus,
420 | .dark-theme select:focus,
421 | .dark-theme textarea:focus {
422 | border-color: #8ab4f8;
423 | box-shadow: 0 0 0 2px rgba(138, 180, 248, 0.1);
424 | }
425 |
426 | .dark-theme button {
427 | background-color: #333;
428 | color: #e0e0e0;
429 | }
430 |
431 | .dark-theme button:hover {
432 | background-color: #444;
433 | }
434 |
435 | .dark-theme .update-btn,
436 | .dark-theme .translate-btn,
437 | .dark-theme #savePromptBtn {
438 | background-color: #4285f4;
439 | color: white;
440 | }
441 |
442 | .dark-theme .update-btn:hover,
443 | .dark-theme .translate-btn:hover,
444 | .dark-theme #savePromptBtn:hover {
445 | background-color: #3367d6;
446 | }
447 |
448 | .dark-theme .prompts-list {
449 | border-color: #444;
450 | }
451 |
452 | .dark-theme .prompt-item:hover {
453 | background-color: #303134;
454 | }
455 |
456 | .dark-theme .prompt-item-name {
457 | color: #e8eaed;
458 | }
459 |
460 | .dark-theme .prompt-item-text {
461 | color: #9aa0a6;
462 | }
463 |
464 | .dark-theme .prompt-item-status {
465 | background-color: rgba(138, 180, 248, 0.2);
466 | color: #8ab4f8;
467 | }
468 |
469 | .dark-theme .edit-btn:hover,
470 | .dark-theme .delete-btn:hover {
471 | background-color: #303134;
472 | }
473 |
474 | .dark-theme .edit-btn:hover svg path,
475 | .dark-theme .delete-btn:hover svg path {
476 | stroke: #e8eaed;
477 | }
478 |
479 | .dark-theme .add-prompt-btn {
480 | background-color: #252525;
481 | border-color: #444;
482 | }
483 |
484 | .dark-theme .modal-content {
485 | background-color: #2d2d2d;
486 | color: #ffffff;
487 | }
488 |
489 | /* API Key 弹窗样式 */
490 | .api-key-modal {
491 | width: 600px;
492 | max-width: 95%;
493 | padding: 30px;
494 | }
495 |
496 | .api-key-modal h2 {
497 | font-size: 20px;
498 | margin-bottom: 20px;
499 | text-align: left;
500 | }
501 |
502 | .provider-selection p {
503 | font-size: 16px;
504 | margin-bottom: 15px;
505 | text-align: left;
506 | }
507 |
508 | .provider-buttons {
509 | display: flex;
510 | gap: 15px;
511 | margin-bottom: 25px;
512 | }
513 |
514 | .provider-btn {
515 | display: flex;
516 | align-items: center;
517 | gap: 8px;
518 | padding: 10px 15px;
519 | border: 1px solid #dadce0;
520 | border-radius: 6px;
521 | background-color: #f8f9fa;
522 | font-size: 15px;
523 | transition: all 0.2s;
524 | }
525 |
526 | .provider-btn.active {
527 | border-color: #4285f4;
528 | background-color: #e8f0fe;
529 | color: #1a73e8;
530 | }
531 |
532 | .provider-icon {
533 | font-size: 18px;
534 | }
535 |
536 | .provider-status {
537 | font-size: 13px;
538 | color: #5f6368;
539 | }
540 |
541 | .provider-status.status-set {
542 | color: #34a853;
543 | }
544 |
545 | .provider-status.status-unset {
546 | color: #ea4335;
547 | }
548 |
549 | .provider-btn.active .provider-status {
550 | color: #1a73e8;
551 | }
552 |
553 | .provider-btn.active .provider-status.status-set {
554 | color: #34a853;
555 | }
556 |
557 | .provider-btn.active .provider-status.status-unset {
558 | color: #ea4335;
559 | }
560 |
561 | .setting-item input[placeholder="尚未设置 API Key"] {
562 | color: #ea4335;
563 | font-style: italic;
564 | }
565 |
566 | /* 暗色主题下的状态样式 */
567 | .dark-theme .provider-status.status-set {
568 | color: #81c995;
569 | }
570 |
571 | .dark-theme .provider-status.status-unset {
572 | color: #f28b82;
573 | }
574 |
575 | .dark-theme .provider-btn.active .provider-status.status-set {
576 | color: #81c995;
577 | }
578 |
579 | .dark-theme .provider-btn.active .provider-status.status-unset {
580 | color: #f28b82;
581 | }
582 |
583 | .dark-theme .setting-item input[placeholder="尚未设置 API Key"] {
584 | color: #f28b82;
585 | }
586 |
587 | .provider-settings {
588 | border: 1px solid #dadce0;
589 | border-radius: 8px;
590 | padding: 20px;
591 | margin-bottom: 25px;
592 | position: relative;
593 | }
594 |
595 | .provider-title {
596 | font-size: 18px;
597 | font-weight: 500;
598 | margin-bottom: 20px;
599 | }
600 |
601 | .setup-btn {
602 | position: absolute;
603 | top: 20px;
604 | right: 20px;
605 | padding: 6px 12px;
606 | background-color: #f8f9fa;
607 | border: 1px solid #dadce0;
608 | border-radius: 4px;
609 | font-size: 14px;
610 | }
611 |
612 | .setting-item {
613 | margin-bottom: 20px;
614 | }
615 |
616 | .setting-item label {
617 | display: block;
618 | font-size: 14px;
619 | margin-bottom: 8px;
620 | color: #5f6368;
621 | }
622 |
623 | .setting-item input {
624 | width: 100%;
625 | padding: 10px 12px;
626 | border: 1px solid #dadce0;
627 | border-radius: 4px;
628 | font-size: 15px;
629 | }
630 |
631 | .setting-item input[readonly] {
632 | background-color: #f8f9fa;
633 | }
634 |
635 | .password-input-container {
636 | display: flex;
637 | align-items: center;
638 | }
639 |
640 | .password-input-container input {
641 | flex: 1;
642 | border-top-right-radius: 0;
643 | border-bottom-right-radius: 0;
644 | }
645 |
646 | .toggle-password-btn {
647 | padding: 10px 12px;
648 | border: 1px solid #dadce0;
649 | border-left: none;
650 | border-radius: 0 4px 4px 0;
651 | background-color: #f8f9fa;
652 | }
653 |
654 | .eye-icon {
655 | font-size: 16px;
656 | }
657 |
658 | .modal-buttons {
659 | display: flex;
660 | justify-content: flex-end;
661 | gap: 15px;
662 | }
663 |
664 | .cancel-btn {
665 | padding: 10px 20px;
666 | background-color: #f8f9fa;
667 | border: 1px solid #dadce0;
668 | border-radius: 4px;
669 | font-size: 15px;
670 | }
671 |
672 | .save-btn {
673 | padding: 10px 20px;
674 | background-color: #1a73e8;
675 | color: white;
676 | border: none;
677 | border-radius: 4px;
678 | font-size: 15px;
679 | }
680 |
681 | .save-btn:hover {
682 | background-color: #1765cc;
683 | }
684 |
685 | /* 暗色主题下的API Key弹窗样式 */
686 | .dark-theme .api-key-modal {
687 | background-color: #252525;
688 | }
689 |
690 | .dark-theme .provider-btn {
691 | background-color: #333;
692 | border-color: #444;
693 | color: #e0e0e0;
694 | }
695 |
696 | .dark-theme .provider-btn.active {
697 | border-color: #4285f4;
698 | background-color: rgba(66, 133, 244, 0.2);
699 | color: #4285f4;
700 | }
701 |
702 | .dark-theme .provider-status {
703 | color: #aaa;
704 | }
705 |
706 | .dark-theme .provider-btn.active .provider-status {
707 | color: #4285f4;
708 | }
709 |
710 | .dark-theme .provider-settings {
711 | border-color: #444;
712 | }
713 |
714 | .dark-theme .setting-item label {
715 | color: #aaa;
716 | }
717 |
718 | .dark-theme .setting-item input {
719 | background-color: #333;
720 | border-color: #444;
721 | color: #e0e0e0;
722 | }
723 |
724 | .dark-theme .setting-item input[readonly] {
725 | background-color: #2a2a2a;
726 | }
727 |
728 | .dark-theme .toggle-password-btn {
729 | background-color: #333;
730 | border-color: #444;
731 | color: #e0e0e0;
732 | }
733 |
734 | .dark-theme .cancel-btn {
735 | background-color: #333;
736 | border-color: #444;
737 | color: #e0e0e0;
738 | }
739 |
740 | .dark-theme .save-btn {
741 | background-color: #4285f4;
742 | }
743 |
744 | .dark-theme .save-btn:hover {
745 | background-color: #3367d6;
746 | }
747 |
748 | .hidden {
749 | display: none !important;
750 | }
751 |
752 | .prompt-buttons {
753 | display: flex;
754 | justify-content: flex-end;
755 | gap: 10px;
756 | margin-top: 10px;
757 | }
758 |
759 | .add-prompt-btn, .reset-prompts-btn {
760 | background-color: white;
761 | border: 1px solid #ddd;
762 | padding: 8px 15px;
763 | border-radius: 6px;
764 | font-size: 14px;
765 | transition: all 0.2s ease;
766 | }
767 |
768 | .add-prompt-btn:hover, .reset-prompts-btn:hover {
769 | background-color: #f1f3f4;
770 | border-color: #c6c6c6;
771 | }
772 |
773 | .reset-prompts-btn {
774 | color: #5f6368;
775 | }
776 |
777 | .dark-theme .add-prompt-btn, .dark-theme .reset-prompts-btn {
778 | background-color: #252525;
779 | border-color: #444;
780 | color: #e0e0e0;
781 | }
782 |
783 | .dark-theme .add-prompt-btn:hover, .dark-theme .reset-prompts-btn:hover {
784 | background-color: #303134;
785 | }
786 |
787 | /* 页脚样式 */
788 | .footer {
789 | text-align: center;
790 | padding: 15px 0;
791 | font-size: 14px;
792 | color: #5f6368;
793 | background-color: #f5f5f5;
794 | border-top: 1px solid #e0e0e0;
795 | margin-top: 20px;
796 | }
797 |
798 | .footer a {
799 | color: #1a73e8;
800 | text-decoration: none;
801 | transition: color 0.2s;
802 | }
803 |
804 | .footer a:hover {
805 | color: #174ea6;
806 | text-decoration: underline;
807 | }
808 |
809 | /* 暗色主题下的页脚样式 */
810 | .dark-theme .footer {
811 | background-color: #202124;
812 | color: #9aa0a6;
813 | border-top: 1px solid #3c4043;
814 | }
815 |
816 | .dark-theme .footer a {
817 | color: #8ab4f8;
818 | }
819 |
820 | .dark-theme .footer a:hover {
821 | color: #aecbfa;
822 | }
823 |
824 | /* 翻译完成动画 */
825 | @keyframes highlight-pulse {
826 | 0% { box-shadow: 0 0 5px rgba(26, 115, 232, 0.5); background-color: rgba(232, 240, 254, 0.5); }
827 | 50% { box-shadow: 0 0 15px rgba(26, 115, 232, 0.8); background-color: rgba(232, 240, 254, 0.8); }
828 | 100% { box-shadow: 0 0 5px rgba(26, 115, 232, 0.5); background-color: rgba(232, 240, 254, 0.5); }
829 | }
830 |
831 | .translation-complete {
832 | animation: highlight-pulse 1s ease-in-out 2;
833 | border-color: #1a73e8 !important;
834 | }
835 |
836 | /* 暗色主题下的翻译完成动画 */
837 | .dark-theme .translation-complete {
838 | animation: highlight-pulse-dark 1s ease-in-out 2;
839 | }
840 |
841 | @keyframes highlight-pulse-dark {
842 | 0% { box-shadow: 0 0 5px rgba(138, 180, 248, 0.5); background-color: rgba(25, 26, 28, 0.9); }
843 | 50% { box-shadow: 0 0 15px rgba(138, 180, 248, 0.8); background-color: rgba(48, 49, 52, 0.9); }
844 | 100% { box-shadow: 0 0 5px rgba(138, 180, 248, 0.5); background-color: rgba(25, 26, 28, 0.9); }
845 | }
--------------------------------------------------------------------------------