| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348 |
- package main
- import (
- "embed"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "sync"
- "time"
- "github.com/getlantern/systray"
- )
- // ---------- 嵌入资源 ----------
- //go:embed icon.ico
- var iconData []byte
- // 确保 embed 包被"使用"(Go 1.26+ 要求)
- var _ = embed.FS{}
- // ---------- 配置(优先级:环境变量 > .env 文件 > 默认值) ----------
- var (
- apiKey string
- balanceURL string
- 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() {
- // 加载 .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")
- if apiKey == "" {
- systray.SetTooltip("DeepSeek - 未配置 API Key\n请在 .env 文件中设置 DEEPSEEK_API_KEY")
- } else {
- systray.SetTooltip("DeepSeek 余额查询 - 正在获取...")
- }
- // 菜单项
- mBalance := systray.AddMenuItem("余额: 查询中...", "")
- mBalance.Disable()
- mManualRefresh := systray.AddMenuItem("刷新余额", "手动刷新")
- mAutoRefresh := systray.AddMenuItem("自动刷新: 开启", "切换自动刷新")
- mThreshold := systray.AddMenuItem("低余额告警: 关闭", "切换低余额告警")
- systray.AddSeparator()
- mConfigPath := systray.AddMenuItem("打开 .env 配置文件", "用记事本编辑 .env")
- mAbout := systray.AddMenuItem("关于", "关于本程序")
- mQuit := systray.AddMenuItem("退出", "关闭程序")
- // 首次查询
- go func() {
- if apiKey == "" {
- mBalance.SetTitle("余额: 未配置 Key")
- mBalance.SetTooltip("点击「打开 .env 配置文件」设置 API Key")
- } else {
- 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:
- if apiKey != "" {
- 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:
- if apiKey == "" {
- showMessageBox("DeepSeek Tray", "未配置 API Key。\n请点击右键菜单「打开 .env 配置文件」设置 DEEPSEEK_API_KEY。")
- } else {
- 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))
- if apiKey != "" {
- checkLowBalance()
- }
- } else {
- mThreshold.SetTitle("低余额告警: 关闭")
- mThreshold.SetTooltip("切换低余额告警")
- }
- case <-mConfigPath.ClickedCh:
- openEnvFile()
- case <-mAbout.ClickedCh:
- 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()
- 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 检查余额是否低于阈值,通过弹窗告警
- func checkLowBalance() {
- _, _, totalVal := fetchBalance()
- if totalVal > 0 && totalVal < lowThreshold {
- msg := fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", totalVal, lowThreshold)
- showMessageBox("⚠️ DeepSeek 余额不足", msg)
- }
- }
- // ---------- .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
- }
- return defaultVal
- }
- func boolToStr(b bool) string {
- if b {
- return "是"
- }
- return "否"
- }
|