Просмотр исходного кода

update README.md, main.go and msgbox_windows.go

caesar 2 месяцев назад
Родитель
Сommit
77aae04a07
4 измененных файлов с 147 добавлено и 31 удалено
  1. 5 0
      .env
  2. 26 8
      README.md
  3. 109 22
      main.go
  4. 7 1
      msgbox_windows.go

+ 5 - 0
.env

@@ -0,0 +1,5 @@
+# DeepSeek API Key(必填)
+DEEPSEEK_API_KEY=sk-93378505bdf7492fb34556dc225db3ce
+
+# DeepSeek 余额查询接口(可选)
+# DEEPSEEK_BALANCE_URL=https://api.deepseek.com/user/balance

+ 26 - 8
README.md

@@ -5,20 +5,25 @@ DeepSeek 余额查询系统托盘工具,基于 Go + `getlantern/systray` 实
 ## 特性
 
 - **单文件分发**:图标使用 `//go:embed` 编译进 EXE,无外部依赖
+- **.env 配置**:API Key 存储在 EXE 同级目录的 `.env` 文件中,右键菜单可一键编辑
 - **托盘常驻**:系统托盘显示实时余额,鼠标悬停查看详情
 - **自动刷新**:可配置定时刷新间隔,支持开关切换
-- **低余额告警**:余额低于阈值时弹出 Windows 提示框
-- **API Key 安全**:通过环境变量 `DEEPSEEK_API_KEY` 传入,不写入代码
+- **低余额告警**:余额低于 ¥1 时弹出 Windows 提示框,亦可开关
+- **无感运行**:编译 `-ldflags -H=windowsgui` 隐藏命令行窗口,适合 NSSM 做成 Windows 服务
 - **资源占用极低**:内存 ~5 MB,CPU 几乎为零
 
 ## 快速开始
 
-### 1. 配置环境变量
+### 1. 放置 .env 文件
 
-```cmd
-set DEEPSEEK_API_KEY=sk-你的DeepSeek密钥
+在 `deepseek-tray.exe` 同目录创建 `.env` 文件:
+
+```
+DEEPSEEK_API_KEY=sk-你的DeepSeek密钥
 ```
 
+也可在托盘右键菜单中选择「打开 .env 配置文件」自动创建模板并编辑。
+
 可选配置:
 
 | 环境变量 | 默认值 | 说明 |
@@ -26,6 +31,8 @@ set DEEPSEEK_API_KEY=sk-你的DeepSeek密钥
 | `DEEPSEEK_API_KEY` | (必填) | DeepSeek API 密钥 |
 | `DEEPSEEK_BALANCE_URL` | `https://api.deepseek.com/user/balance` | 余额查询接口 |
 
+> 配置优先级:系统环境变量 > .env 文件 > 默认值。.env 不覆盖已存在的环境变量。
+
 ### 2. 运行
 
 ```cmd
@@ -38,10 +45,21 @@ set DEEPSEEK_API_KEY=sk-你的DeepSeek密钥
 - **刷新余额**:手动触发一次查询
 - **自动刷新: 开启/关闭**:切换定时刷新
 - **低余额告警: 开启/关闭**:切换余额不足提醒(默认阈值 ¥1)
+- **打开 .env 配置文件**:用记事本打开编辑 Key
 - **关于**:查看当前运行状态
 - **退出**:关闭程序
 
-### 3. 开机自启(可选)
+### 3. 使用 NSSM 设为 Windows 服务(开机自启)
+
+```cmd
+nssm install DeepSeekTray "C:\路径\deepseek-tray.exe"
+nssm set DeepSeekTray AppDirectory "C:\路径\"
+nssm start DeepSeekTray
+```
+
+EXE 同目录下的 `.env` 文件会被服务自动加载。
+
+### 4. 开机自启(手动方式,可选)
 
 `Win + R` → 输入 `shell:startup` → 将 `deepseek-tray.exe` 快捷方式拖入。
 
@@ -55,8 +73,8 @@ go build -ldflags="-H=windowsgui" -o deepseek-tray.exe
 
 ```
 deepseek-tray/
-├── main.go              # 主逻辑:托盘、菜单、事件循环、API 调用
-├── msgbox_windows.go    # Windows MessageBox 封装(低余额告警弹窗)
+├── main.go              # 主逻辑:托盘、菜单、事件循环、.env 读取、API 调用
+├── msgbox_windows.go    # Windows MessageBox 封装 + 记事本打开
 ├── icon.ico             # 托盘图标(编译时嵌入)
 ├── go.mod / go.sum      # Go 模块依赖
 └── README.md

+ 109 - 22
main.go

@@ -7,6 +7,8 @@ import (
 	"io"
 	"net/http"
 	"os"
+	"path/filepath"
+	"strings"
 	"sync"
 	"time"
 
@@ -20,10 +22,10 @@ var iconData []byte
 // 确保 embed 包被"使用"(Go 1.26+ 要求)
 var _ = embed.FS{}
 
-// ---------- 默认配置(可被环境变量覆盖) ----------
+// ---------- 配置(优先级:环境变量 > .env 文件 > 默认值) ----------
 var (
-	apiKey         = getEnv("DEEPSEEK_API_KEY", "")
-	balanceURL     = getEnv("DEEPSEEK_BALANCE_URL", "https://api.deepseek.com/user/balance")
+	apiKey         string
+	balanceURL     string
 	requestTimeout = 15 // 秒
 	refreshMinutes = 10 // 自动刷新间隔,0 表示关闭
 	lowThreshold   = 1.0 // 余额低于此值(元)时告警
@@ -48,18 +50,26 @@ type balanceResponse struct {
 }
 
 func main() {
-	if apiKey == "" {
-		fmt.Fprintln(os.Stderr, "错误: 环境变量 DEEPSEEK_API_KEY 未设置")
-		fmt.Fprintln(os.Stderr, "请设置后运行: set DEEPSEEK_API_KEY=sk-your-key")
-		os.Exit(1)
-	}
+	// 加载 .env 文件(同级目录),不覆盖已有环境变量
+	loadEnvFile()
+
+	// 读取配置
+	apiKey = getEnv("DEEPSEEK_API_KEY", "")
+	balanceURL = getEnv("DEEPSEEK_BALANCE_URL", "https://api.deepseek.com/user/balance")
+
+	// 始终启动托盘(即使无 Key,也显示图标+提示)
 	systray.Run(onReady, onExit)
 }
 
 func onReady() {
 	systray.SetIcon(iconData)
 	systray.SetTitle("DeepSeek")
-	systray.SetTooltip("DeepSeek 余额查询 - 正在获取...")
+
+	if apiKey == "" {
+		systray.SetTooltip("DeepSeek - 未配置 API Key\n请在 .env 文件中设置 DEEPSEEK_API_KEY")
+	} else {
+		systray.SetTooltip("DeepSeek 余额查询 - 正在获取...")
+	}
 
 	// 菜单项
 	mBalance := systray.AddMenuItem("余额: 查询中...", "")
@@ -69,11 +79,19 @@ func onReady() {
 	mAutoRefresh := systray.AddMenuItem("自动刷新: 开启", "切换自动刷新")
 	mThreshold := systray.AddMenuItem("低余额告警: 关闭", "切换低余额告警")
 	systray.AddSeparator()
+	mConfigPath := systray.AddMenuItem("打开 .env 配置文件", "用记事本编辑 .env")
 	mAbout := systray.AddMenuItem("关于", "关于本程序")
 	mQuit := systray.AddMenuItem("退出", "关闭程序")
 
 	// 首次查询
-	go refreshBalance(mBalance)
+	go func() {
+		if apiKey == "" {
+			mBalance.SetTitle("余额: 未配置 Key")
+	mBalance.SetTooltip("点击「打开 .env 配置文件」设置 API Key")
+		} else {
+			refreshBalance(mBalance)
+		}
+	}()
 
 	// 定时器控制
 	var (
@@ -93,9 +111,11 @@ func onReady() {
 			for {
 				select {
 				case <-ticker.C:
-					refreshBalance(mBalance)
-					if alertOn {
-						checkLowBalance()
+					if apiKey != "" {
+						refreshBalance(mBalance)
+						if alertOn {
+							checkLowBalance()
+						}
 					}
 				case <-tickerStop:
 					ticker.Stop()
@@ -118,7 +138,11 @@ func onReady() {
 	for {
 		select {
 		case <-mManualRefresh.ClickedCh:
-			refreshBalance(mBalance)
+			if apiKey == "" {
+	showMessageBox("DeepSeek Tray", "未配置 API Key。\n请点击右键菜单「打开 .env 配置文件」设置 DEEPSEEK_API_KEY。")
+			} else {
+				refreshBalance(mBalance)
+			}
 
 		case <-mAutoRefresh.ClickedCh:
 			if autoRefresh {
@@ -136,17 +160,21 @@ func onReady() {
 			if alertOn {
 				mThreshold.SetTitle("低余额告警: 开启")
 				mThreshold.SetTooltip(fmt.Sprintf("余额低于 ¥%.0f 时提醒", lowThreshold))
-				checkLowBalance()
+				if apiKey != "" {
+					checkLowBalance()
+				}
 			} else {
 				mThreshold.SetTitle("低余额告警: 关闭")
 				mThreshold.SetTooltip("切换低余额告警")
 			}
 
+		case <-mConfigPath.ClickedCh:
+			openEnvFile()
+
 		case <-mAbout.ClickedCh:
-			systray.SetTooltip(fmt.Sprintf(
-				"自动刷新:%s | 间隔:%dmin | 告警:%s",
-				boolToStr(autoRefresh), refreshMinutes, boolToStr(alertOn),
-			))
+			info := fmt.Sprintf("DeepSeek 余额查询系统托盘\n\n自动刷新: %s\n刷新间隔: %d 分钟\n低余额告警: %s\n\n配置文件: deepseek-tray.exe 同目录下的 .env",
+				boolToStr(autoRefresh), refreshMinutes, boolToStr(alertOn))
+			showMessageBox("DeepSeek Balance Tray", info)
 
 		case <-mQuit.ClickedCh:
 			stopTicker()
@@ -174,7 +202,7 @@ func refreshBalance(menuItem *systray.MenuItem) {
 	menuItem.SetTitle("余额: " + text + " " + currency)
 	menuItem.SetTooltip(tooltip)
 
-	// 更新程序标题
+	// 更新程序标题(显示在托盘旁)
 	if totalVal > 0 {
 		systray.SetTitle(text)
 	} else {
@@ -194,7 +222,7 @@ func fetchBalance() (string, string, float64) {
 
 	resp, err := client.Do(req)
 	if err != nil {
-		return "请求失败: " + err.Error(), "", 0
+		return "网络错误: " + err.Error(), "", 0
 	}
 	defer resp.Body.Close()
 
@@ -235,7 +263,7 @@ func fetchBalance() (string, string, float64) {
 	return fmt.Sprintf("%v", parts), currency, total
 }
 
-// checkLowBalance 检查余额是否低于阈值,通过弹窗告警(使用 Windows API)
+// checkLowBalance 检查余额是否低于阈值,通过弹窗告警
 func checkLowBalance() {
 	_, _, totalVal := fetchBalance()
 	if totalVal > 0 && totalVal < lowThreshold {
@@ -244,9 +272,68 @@ func checkLowBalance() {
 	}
 }
 
+// ---------- .env 文件支持 ----------
+
+// loadEnvFile 从可执行文件同级目录读取 .env 文件(不覆盖已有环境变量)
+func loadEnvFile() {
+	exePath, err := os.Executable()
+	if err != nil {
+		return
+	}
+	envPath := filepath.Join(filepath.Dir(exePath), ".env")
+	data, err := os.ReadFile(envPath)
+	if err != nil {
+		return // .env 不存在,静默跳过
+	}
+
+	lines := strings.Split(string(data), "\n")
+	for _, line := range lines {
+		line = strings.TrimSpace(line)
+		if line == "" || strings.HasPrefix(line, "#") {
+			continue
+		}
+		// 支持 KEY=VALUE 或 KEY="VALUE" 格式
+		idx := strings.IndexByte(line, '=')
+		if idx < 0 {
+			continue
+		}
+		key := strings.TrimSpace(line[:idx])
+		val := strings.TrimSpace(line[idx+1:])
+		val = strings.Trim(val, `"`) // 移除双引号
+
+		// 不覆盖已有环境变量
+		if os.Getenv(key) == "" {
+			os.Setenv(key, val)
+		}
+	}
+}
+
+// openEnvFile 用记事本打开 .env 文件,若不存在则创建模板
+func openEnvFile() {
+	exePath, err := os.Executable()
+	if err != nil {
+		showMessageBox("错误", "无法获取程序路径")
+		return
+	}
+	envPath := filepath.Join(filepath.Dir(exePath), ".env")
+
+	// 如果 .env 不存在,创建模板
+	if _, err := os.Stat(envPath); os.IsNotExist(err) {
+		tmpl := "# DeepSeek API Key(必填)\nDEEPSEEK_API_KEY=sk-your-key-here\n\n# DeepSeek 余额查询接口(可选)\n# DEEPSEEK_BALANCE_URL=https://api.deepseek.com/user/balance\n"
+		if err := os.WriteFile(envPath, []byte(tmpl), 0644); err != nil {
+			showMessageBox("错误", "无法创建 .env 文件: "+err.Error())
+			return
+		}
+	}
+
+	// 调用记事本打开
+	runNotepad(envPath)
+}
+
 // ---------- 工具函数 ----------
 
 func getEnv(key, defaultVal string) string {
+	// 先查环境变量,再查 .env(已在 loadEnvFile 中注入到 os.Environ)
 	if val, ok := os.LookupEnv(key); ok && val != "" {
 		return val
 	}

+ 7 - 1
msgbox_windows.go

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"os/exec"
 	"syscall"
 	"unsafe"
 )
@@ -16,4 +17,9 @@ func showMessageBox(title, msg string) {
 	// MB_OK | MB_ICONWARNING | MB_SETFOREGROUND | MB_TOPMOST
 	const flags = 0x00000000 | 0x00000030 | 0x00010000 | 0x00040000
 	msgBox.Call(0, uintptr(unsafe.Pointer(lpText)), uintptr(unsafe.Pointer(lpTitle)), uintptr(flags))
-}
+}
+
+// runNotepad 调用 notepad.exe 打开指定文件
+func runNotepad(path string) {
+	exec.Command("notepad.exe", path).Start()
+}