| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- package main
- import (
- "embed"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "time"
- "github.com/getlantern/systray"
- )
- //go:embed icon.ico
- var icon []byte
- var _ = embed.FS{} // 保证 Go 1.26+ embed 引用检查
- // ---------- 配置常量 ----------
- const (
- apiTimeout = 15 * time.Second // HTTP 请求超时
- refreshPeriod = 10 * time.Minute // 自动刷新间隔
- lowBalanceYuan = 1.0 // 低余额告警阈值(元)
- balanceDefaultURL = "https://api.deepseek.com/user/balance"
- )
- var (
- apiKey string
- balanceURL = balanceDefaultURL
- )
- // ---------- API 响应 ----------
- type balanceResp struct {
- IsAvailable bool `json:"is_available"`
- BalanceInfos []balanceItem `json:"balance_infos"`
- }
- type balanceItem struct {
- Currency string `json:"currency"`
- TotalBalance string `json:"total_balance"`
- GrantedBalance string `json:"granted_balance"`
- ToppedUpBalance string `json:"topped_up_balance"`
- }
- // ---------- 入口 ----------
- func main() {
- loadDotEnv()
- apiKey = envOr("DEEPSEEK_API_KEY", "")
- if v := envOr("DEEPSEEK_BALANCE_URL", ""); v != "" {
- balanceURL = v
- }
- systray.Run(onReady, func() {})
- }
- func onReady() {
- systray.SetIcon(icon)
- systray.SetTitle("DeepSeek")
- if apiKey == "" {
- systray.SetTooltip("DeepSeek - 未配置 API Key\n请在 .env 文件中设置 DEEPSEEK_API_KEY")
- } else {
- systray.SetTooltip("DeepSeek 余额查询 - 正在获取...")
- }
- // 菜单
- mBalance := systray.AddMenuItem("余额: 查询中...", "")
- mBalance.Disable()
- mRefresh := systray.AddMenuItem("刷新余额", "手动刷新")
- mAuto := systray.AddMenuItem("自动刷新: 开启", "切换自动刷新")
- mAlert := systray.AddMenuItem("低余额告警: 关闭", "切换低余额告警")
- systray.AddSeparator()
- mDotEnv := systray.AddMenuItem("打开 .env 配置文件", "编辑 API Key")
- mAbout := systray.AddMenuItem("关于", "状态信息")
- mQuit := systray.AddMenuItem("退出", "关闭程序")
- // 运行时状态(仅在事件循环 goroutine 中访问,无数据竞争)
- autoRefresh := true
- alertOn := false
- var ticker *time.Ticker
- var tickStop chan struct{}
- startTick := func() {
- ticker = time.NewTicker(refreshPeriod)
- tickStop = make(chan struct{})
- go func() {
- for {
- select {
- case <-ticker.C:
- if apiKey != "" {
- balance, _ := refreshAndUpdate(mBalance)
- if alertOn && balance < lowBalanceYuan && balance > 0 {
- showMessageBox("DeepSeek 余额不足",
- fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", balance, lowBalanceYuan))
- }
- }
- case <-tickStop:
- ticker.Stop()
- return
- }
- }
- }()
- }
- stopTick := func() {
- if tickStop != nil {
- close(tickStop)
- tickStop = nil
- }
- }
- startTick()
- // 首次查询
- go func() {
- if apiKey == "" {
- mBalance.SetTitle("余额: 未配置 Key")
- mBalance.SetTooltip("点击「打开 .env 配置文件」设置 API Key")
- } else {
- refreshAndUpdate(mBalance)
- }
- }()
- // 事件循环
- for {
- select {
- case <-mRefresh.ClickedCh:
- if apiKey == "" {
- showMessageBox("DeepSeek Tray",
- "未配置 API Key。\n请点击「打开 .env 配置文件」设置 DEEPSEEK_API_KEY。")
- } else {
- refreshAndUpdate(mBalance)
- }
- case <-mAuto.ClickedCh:
- if autoRefresh {
- stopTick()
- mAuto.SetTitle("自动刷新: 关闭")
- } else {
- startTick()
- mAuto.SetTitle("自动刷新: 开启")
- }
- autoRefresh = !autoRefresh
- case <-mAlert.ClickedCh:
- alertOn = !alertOn
- if alertOn {
- mAlert.SetTitle("低余额告警: 开启")
- mAlert.SetTooltip(fmt.Sprintf("余额低于 ¥%.0f 时提醒", lowBalanceYuan))
- if apiKey != "" {
- balance, _ := refreshAndUpdate(mBalance)
- if balance < lowBalanceYuan && balance > 0 {
- showMessageBox("DeepSeek 余额不足",
- fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", balance, lowBalanceYuan))
- }
- }
- } else {
- mAlert.SetTitle("低余额告警: 关闭")
- mAlert.SetTooltip("切换低余额告警")
- }
- case <-mDotEnv.ClickedCh:
- openDotEnv()
- case <-mAbout.ClickedCh:
- ar := "否"
- if autoRefresh {
- ar = "是"
- }
- ao := "否"
- if alertOn {
- ao = "是"
- }
- showMessageBox("DeepSeek Balance Tray",
- fmt.Sprintf("DeepSeek 余额查询系统托盘\n\n自动刷新: %s\n刷新间隔: %d 分钟\n低余额告警: %s\n\n配置文件: .env",
- ar, int(refreshPeriod.Minutes()), ao))
- case <-mQuit.ClickedCh:
- stopTick()
- systray.Quit()
- return
- }
- }
- }
- // ---------- 核心:余额获取与 UI 更新 ----------
- // refreshAndUpdate 请求 API 并更新托盘状态,返回 CNY 余额数值。
- func refreshAndUpdate(m *systray.MenuItem) (cnyBalance float64, err error) {
- text, currency, cny := fetchBalance()
- tooltip := fmt.Sprintf("DeepSeek 余额: %s %s\n更新时间: %s",
- text, currency, time.Now().Format("15:04:05"))
- systray.SetTooltip(tooltip)
- m.SetTitle("余额: " + text + " " + currency)
- m.SetTooltip(tooltip)
- if cny > 0 {
- systray.SetTitle(text)
- } else {
- systray.SetTitle("DeepSeek")
- }
- return cny, nil
- }
- // fetchBalance 返回 (余额文本, 货币, CNY 数值)。
- func fetchBalance() (string, string, float64) {
- client := &http.Client{Timeout: apiTimeout}
- 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 balanceResp
- if err := json.NewDecoder(resp.Body).Decode(&br); err != nil {
- return "解析响应失败", "", 0
- }
- if !br.IsAvailable || len(br.BalanceInfos) == 0 {
- return "余额不可用", "", 0
- }
- // 取第一个币种为主显示,叠加所有 CNY 为数值
- info := br.BalanceInfos[0]
- first := fmt.Sprintf("%s %s", info.TotalBalance, info.Currency)
- var cnyTotal float64
- for _, item := range br.BalanceInfos {
- var v float64
- fmt.Sscanf(item.TotalBalance, "%f", &v)
- if item.Currency == "CNY" {
- cnyTotal += v
- }
- }
- return first, info.Currency, cnyTotal
- }
- // ---------- .env 支持 ----------
- func loadDotEnv() {
- dir := exeDir()
- if dir == "" {
- return
- }
- data, err := os.ReadFile(filepath.Join(dir, ".env"))
- if err != nil {
- return
- }
- for _, line := range strings.Split(string(data), "\n") {
- line = strings.TrimSpace(line)
- if line == "" || line[0] == '#' {
- continue
- }
- idx := strings.IndexByte(line, '=')
- if idx < 0 {
- continue
- }
- key := strings.TrimSpace(line[:idx])
- val := strings.Trim(strings.TrimSpace(line[idx+1:]), `"`)
- // .env 不覆盖已有环境变量
- if os.Getenv(key) == "" && key != "" {
- os.Setenv(key, val)
- }
- }
- }
- func openDotEnv() {
- dir := exeDir()
- if dir == "" {
- showMessageBox("错误", "无法获取程序路径")
- return
- }
- path := filepath.Join(dir, ".env")
- if _, err := os.Stat(path); os.IsNotExist(err) {
- tmpl := "# DeepSeek API Key(必填)\nDEEPSEEK_API_KEY=sk-your-key-here\n\n# 余额查询接口(可选)\n# DEEPSEEK_BALANCE_URL=https://api.deepseek.com/user/balance\n"
- if err := os.WriteFile(path, []byte(tmpl), 0644); err != nil {
- showMessageBox("错误", "无法创建 .env 文件: "+err.Error())
- return
- }
- }
- runNotepad(path)
- }
- // exeDir 返回可执行文件所在目录路径。
- func exeDir() string {
- p, err := os.Executable()
- if err != nil {
- return ""
- }
- return filepath.Dir(p)
- }
- // ---------- 工具 ----------
- func envOr(key, def string) string {
- if v, ok := os.LookupEnv(key); ok && v != "" {
- return v
- }
- return def
- }
|