caesar 2 месяцев назад
Родитель
Сommit
36a1fa5c3e
6 измененных файлов с 394 добавлено и 1 удалено
  1. 65 1
      README.md
  2. 17 0
      go.mod
  3. 32 0
      go.sum
  4. BIN
      icon.ico
  5. 261 0
      main.go
  6. 19 0
      msgbox_windows.go

+ 65 - 1
README.md

@@ -1,3 +1,67 @@
 # deepseek-tray
 
-deepseek余额查询系统图标
+DeepSeek 余额查询系统托盘工具,基于 Go + `getlantern/systray` 实现。
+
+## 特性
+
+- **单文件分发**:图标使用 `//go:embed` 编译进 EXE,无外部依赖
+- **托盘常驻**:系统托盘显示实时余额,鼠标悬停查看详情
+- **自动刷新**:可配置定时刷新间隔,支持开关切换
+- **低余额告警**:余额低于阈值时弹出 Windows 提示框
+- **API Key 安全**:通过环境变量 `DEEPSEEK_API_KEY` 传入,不写入代码
+- **资源占用极低**:内存 ~5 MB,CPU 几乎为零
+
+## 快速开始
+
+### 1. 配置环境变量
+
+```cmd
+set DEEPSEEK_API_KEY=sk-你的DeepSeek密钥
+```
+
+可选配置:
+
+| 环境变量 | 默认值 | 说明 |
+|----------|--------|------|
+| `DEEPSEEK_API_KEY` | (必填) | DeepSeek API 密钥 |
+| `DEEPSEEK_BALANCE_URL` | `https://api.deepseek.com/user/balance` | 余额查询接口 |
+
+### 2. 运行
+
+```cmd
+.\deepseek-tray.exe
+```
+
+图标将出现在系统托盘中,右键菜单包含:
+
+- **余额显示**:实时显示当前余额
+- **刷新余额**:手动触发一次查询
+- **自动刷新: 开启/关闭**:切换定时刷新
+- **低余额告警: 开启/关闭**:切换余额不足提醒(默认阈值 ¥1)
+- **关于**:查看当前运行状态
+- **退出**:关闭程序
+
+### 3. 开机自启(可选)
+
+`Win + R` → 输入 `shell:startup` → 将 `deepseek-tray.exe` 快捷方式拖入。
+
+## 开发编译
+
+```bash
+go build -ldflags="-H=windowsgui" -o deepseek-tray.exe
+```
+
+## 项目结构
+
+```
+deepseek-tray/
+├── main.go              # 主逻辑:托盘、菜单、事件循环、API 调用
+├── msgbox_windows.go    # Windows MessageBox 封装(低余额告警弹窗)
+├── icon.ico             # 托盘图标(编译时嵌入)
+├── go.mod / go.sum      # Go 模块依赖
+└── README.md
+```
+
+## 许可
+
+CC0 1.0 Universal

+ 17 - 0
go.mod

@@ -0,0 +1,17 @@
+module deepseek-tray
+
+go 1.26.3
+
+require github.com/getlantern/systray v1.2.2
+
+require (
+	github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
+	github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
+	github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
+	github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
+	github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
+	github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
+	github.com/go-stack/stack v1.8.0 // indirect
+	github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
+	golang.org/x/sys v0.1.0 // indirect
+)

+ 32 - 0
go.sum

@@ -0,0 +1,32 @@
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
+github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
+github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
+github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
+github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
+github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
+github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
+github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
+github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
+github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
+github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
+github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
+github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
+github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
+github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
+github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
+github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
+github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=


+ 261 - 0
main.go

@@ -0,0 +1,261 @@
+package main
+
+import (
+	"embed"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"sync"
+	"time"
+
+	"github.com/getlantern/systray"
+)
+
+// ---------- 嵌入资源 ----------
+//go:embed icon.ico
+var iconData []byte
+
+// 确保 embed 包被"使用"(Go 1.26+ 要求)
+var _ = embed.FS{}
+
+// ---------- 默认配置(可被环境变量覆盖) ----------
+var (
+	apiKey         = getEnv("DEEPSEEK_API_KEY", "")
+	balanceURL     = getEnv("DEEPSEEK_BALANCE_URL", "https://api.deepseek.com/user/balance")
+	requestTimeout = 15 // 秒
+	refreshMinutes = 10 // 自动刷新间隔,0 表示关闭
+	lowThreshold   = 1.0 // 余额低于此值(元)时告警
+)
+
+// ---------- 运行时状态 ----------
+var (
+	lastBalance string
+	lastUpdate  time.Time
+	mu          sync.RWMutex
+)
+
+// ---------- API 响应结构 ----------
+type balanceResponse struct {
+	IsAvailable  bool `json:"is_available"`
+	BalanceInfos []struct {
+		Currency        string `json:"currency"`
+		TotalBalance    string `json:"total_balance"`
+		GrantedBalance  string `json:"granted_balance"`
+		ToppedUpBalance string `json:"topped_up_balance"`
+	} `json:"balance_infos"`
+}
+
+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)
+	}
+	systray.Run(onReady, onExit)
+}
+
+func onReady() {
+	systray.SetIcon(iconData)
+	systray.SetTitle("DeepSeek")
+	systray.SetTooltip("DeepSeek 余额查询 - 正在获取...")
+
+	// 菜单项
+	mBalance := systray.AddMenuItem("余额: 查询中...", "")
+	mBalance.Disable()
+
+	mManualRefresh := systray.AddMenuItem("刷新余额", "手动刷新")
+	mAutoRefresh := systray.AddMenuItem("自动刷新: 开启", "切换自动刷新")
+	mThreshold := systray.AddMenuItem("低余额告警: 关闭", "切换低余额告警")
+	systray.AddSeparator()
+	mAbout := systray.AddMenuItem("关于", "关于本程序")
+	mQuit := systray.AddMenuItem("退出", "关闭程序")
+
+	// 首次查询
+	go refreshBalance(mBalance)
+
+	// 定时器控制
+	var (
+		ticker      *time.Ticker
+		tickerStop  chan struct{}
+		autoRefresh = true
+		alertOn     = false
+	)
+
+	startTicker := func() {
+		if refreshMinutes <= 0 {
+			return
+		}
+		ticker = time.NewTicker(time.Duration(refreshMinutes) * time.Minute)
+		tickerStop = make(chan struct{})
+		go func() {
+			for {
+				select {
+				case <-ticker.C:
+					refreshBalance(mBalance)
+					if alertOn {
+						checkLowBalance()
+					}
+				case <-tickerStop:
+					ticker.Stop()
+					return
+				}
+			}
+		}()
+	}
+
+	stopTicker := func() {
+		if tickerStop != nil {
+			close(tickerStop)
+			tickerStop = nil
+		}
+	}
+
+	startTicker()
+
+	// 事件循环
+	for {
+		select {
+		case <-mManualRefresh.ClickedCh:
+			refreshBalance(mBalance)
+
+		case <-mAutoRefresh.ClickedCh:
+			if autoRefresh {
+				stopTicker()
+				mAutoRefresh.SetTitle("自动刷新: 关闭")
+				autoRefresh = false
+			} else {
+				startTicker()
+				mAutoRefresh.SetTitle("自动刷新: 开启")
+				autoRefresh = true
+			}
+
+		case <-mThreshold.ClickedCh:
+			alertOn = !alertOn
+			if alertOn {
+				mThreshold.SetTitle("低余额告警: 开启")
+				mThreshold.SetTooltip(fmt.Sprintf("余额低于 ¥%.0f 时提醒", lowThreshold))
+				checkLowBalance()
+			} else {
+				mThreshold.SetTitle("低余额告警: 关闭")
+				mThreshold.SetTooltip("切换低余额告警")
+			}
+
+		case <-mAbout.ClickedCh:
+			systray.SetTooltip(fmt.Sprintf(
+				"自动刷新:%s | 间隔:%dmin | 告警:%s",
+				boolToStr(autoRefresh), refreshMinutes, boolToStr(alertOn),
+			))
+
+		case <-mQuit.ClickedCh:
+			stopTicker()
+			systray.Quit()
+			return
+		}
+	}
+}
+
+func onExit() {
+	// 清理
+}
+
+// refreshBalance 请求 API 并更新托盘状态
+func refreshBalance(menuItem *systray.MenuItem) {
+	text, currency, totalVal := fetchBalance()
+	mu.Lock()
+	lastBalance = text
+	lastUpdate = time.Now()
+	mu.Unlock()
+
+	tooltip := fmt.Sprintf("DeepSeek 余额: %s %s\n更新时间: %s",
+		text, currency, lastUpdate.Format("15:04:05"))
+	systray.SetTooltip(tooltip)
+	menuItem.SetTitle("余额: " + text + " " + currency)
+	menuItem.SetTooltip(tooltip)
+
+	// 更新程序标题
+	if totalVal > 0 {
+		systray.SetTitle(text)
+	} else {
+		systray.SetTitle("DeepSeek")
+	}
+}
+
+// fetchBalance 返回 (余额文本, 货币, 数值用于比较)
+func fetchBalance() (string, string, float64) {
+	client := &http.Client{Timeout: time.Duration(requestTimeout) * time.Second}
+	req, err := http.NewRequest("GET", balanceURL, nil)
+	if err != nil {
+		return "创建请求失败", "", 0
+	}
+	req.Header.Set("Authorization", "Bearer "+apiKey)
+	req.Header.Set("Accept", "application/json")
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return "请求失败: " + err.Error(), "", 0
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode == http.StatusUnauthorized {
+		return "API Key 无效", "", 0
+	}
+	if resp.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+		return fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)), "", 0
+	}
+
+	var br balanceResponse
+	if err := json.NewDecoder(resp.Body).Decode(&br); err != nil {
+		return "解析响应失败", "", 0
+	}
+
+	if !br.IsAvailable || len(br.BalanceInfos) == 0 {
+		return "余额不可用", "", 0
+	}
+
+	// 合并所有币种
+	var parts []string
+	var total float64
+	currency := "CNY"
+	for _, info := range br.BalanceInfos {
+		var val float64
+		fmt.Sscanf(info.TotalBalance, "%f", &val)
+		parts = append(parts, fmt.Sprintf("%s %s", info.TotalBalance, info.Currency))
+		if info.Currency == "CNY" {
+			total += val
+		}
+		currency = info.Currency
+	}
+
+	if len(parts) == 1 {
+		return parts[0], currency, total
+	}
+	return fmt.Sprintf("%v", parts), currency, total
+}
+
+// checkLowBalance 检查余额是否低于阈值,通过弹窗告警(使用 Windows API)
+func checkLowBalance() {
+	_, _, totalVal := fetchBalance()
+	if totalVal > 0 && totalVal < lowThreshold {
+		msg := fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", totalVal, lowThreshold)
+		showMessageBox("⚠️ DeepSeek 余额不足", msg)
+	}
+}
+
+// ---------- 工具函数 ----------
+
+func getEnv(key, defaultVal string) string {
+	if val, ok := os.LookupEnv(key); ok && val != "" {
+		return val
+	}
+	return defaultVal
+}
+
+func boolToStr(b bool) string {
+	if b {
+		return "是"
+	}
+	return "否"
+}

+ 19 - 0
msgbox_windows.go

@@ -0,0 +1,19 @@
+package main
+
+import (
+	"syscall"
+	"unsafe"
+)
+
+// showMessageBox 调用 Windows MessageBoxW 弹出模态对话框
+func showMessageBox(title, msg string) {
+	user32 := syscall.NewLazyDLL("user32.dll")
+	msgBox := user32.NewProc("MessageBoxW")
+
+	lpTitle, _ := syscall.UTF16PtrFromString(title)
+	lpText, _ := syscall.UTF16PtrFromString(msg)
+
+	// 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))
+}