Bladeren bron

new type test

caesar 1 maand geleden
bovenliggende
commit
ae8e45639f
7 gewijzigde bestanden met toevoegingen van 528 en 356 verwijderingen
  1. 55 0
      app.go
  2. 294 0
      balance.go
  3. 75 0
      config.go
  4. 65 0
      feature.go
  5. 17 0
      harness.go
  6. 11 342
      main.go
  7. 11 14
      windows.go

+ 55 - 0
app.go

@@ -0,0 +1,55 @@
+package main
+
+import (
+	"context"
+	"sync"
+
+	"github.com/getlantern/systray"
+)
+
+// App owns the tray icon lifecycle and the registered features.
+type App struct {
+	features []Feature
+}
+
+// newApp assembles the feature list. New features register here — nothing else
+// in the tray lifecycle needs to change.
+func newApp(cfg Config, dotenvPath string) *App {
+	return &App{
+		features: []Feature{
+			newBalanceFeature(cfg, dotenvPath),
+			// The harness daemon feature registers here in a later step.
+		},
+	}
+}
+
+// Run blocks until the tray is closed, then shuts every feature down.
+func (a *App) Run() {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	var wg sync.WaitGroup
+
+	systray.Run(func() {
+		systray.SetIcon(appIcon)
+		systray.SetTitle("DeepSeek")
+
+		menu := &Menu{}
+		for _, f := range a.features {
+			f.Install(menu)
+		}
+		for _, f := range a.features {
+			wg.Add(1)
+			go func(f Feature) {
+				defer wg.Done()
+				f.Run(ctx)
+			}(f)
+		}
+	}, func() {
+		cancel()
+		for _, f := range a.features {
+			f.Stop()
+		}
+		wg.Wait()
+	})
+}

+ 294 - 0
balance.go

@@ -0,0 +1,294 @@
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"time"
+)
+
+const (
+	balanceHTTPTimeout  = 15 * time.Second
+	balanceRefreshMin   = 10  // auto-refresh interval in minutes
+	lowBalanceThreshold = 1.0 // alert when CNY total drops below this (元)
+)
+
+// envTemplate is written to .env when the user opens a missing config file.
+const envTemplate = "# DeepSeek API Key(必填)\nDEEPSEEK_API_KEY=sk-your-key-here\n\n# 余额查询接口(可选)\n# DEEPSEEK_BALANCE_URL=https://api.deepseek.com/user/balance\n"
+
+// BalanceAPI abstracts the balance endpoint so the tray feature is testable
+// without the network and without globals.
+type BalanceAPI interface {
+	Query(ctx context.Context, apiKey string) (Balance, error)
+}
+
+// Balance is a successful balance response.
+type Balance struct {
+	Available bool
+	Items     []BalanceItem
+}
+
+// BalanceItem is one currency row from the DeepSeek balance endpoint.
+type BalanceItem struct {
+	Currency        string `json:"currency"`
+	TotalBalance    string `json:"total_balance"`
+	GrantedBalance  string `json:"granted_balance"`
+	ToppedUpBalance string `json:"topped_up_balance"`
+}
+
+type balanceResponse struct {
+	IsAvailable  bool          `json:"is_available"`
+	BalanceInfos []BalanceItem `json:"balance_infos"`
+}
+
+// httpBalanceAPI is the production BalanceAPI implementation.
+type httpBalanceAPI struct {
+	URL    string
+	Client *http.Client
+}
+
+func (a httpBalanceAPI) Query(ctx context.Context, apiKey string) (Balance, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.URL, nil)
+	if err != nil {
+		return Balance{}, fmt.Errorf("创建请求失败: %w", err)
+	}
+	req.Header.Set("Authorization", "Bearer "+apiKey)
+	req.Header.Set("Accept", "application/json")
+
+	resp, err := a.Client.Do(req)
+	if err != nil {
+		return Balance{}, fmt.Errorf("网络错误: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		if resp.StatusCode == http.StatusUnauthorized {
+			return Balance{}, fmt.Errorf("API Key 无效")
+		}
+		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+		return Balance{}, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
+	}
+
+	var api balanceResponse
+	if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
+		return Balance{}, fmt.Errorf("解析响应失败: %w", err)
+	}
+	if !api.IsAvailable || len(api.BalanceInfos) == 0 {
+		return Balance{}, fmt.Errorf("余额不可用")
+	}
+	return Balance{Available: true, Items: api.BalanceInfos}, nil
+}
+
+// Display returns the primary display amount and its currency.
+func (b Balance) Display() (text, currency string) {
+	if len(b.Items) == 0 {
+		return "", ""
+	}
+	return b.Items[0].TotalBalance, b.Items[0].Currency
+}
+
+// CNYTotal sums every CNY row, used by the low-balance alert.
+func (b Balance) CNYTotal() float64 {
+	var sum float64
+	for _, it := range b.Items {
+		if it.Currency == "CNY" {
+			var v float64
+			fmt.Sscanf(it.TotalBalance, "%f", &v)
+			sum += v
+		}
+	}
+	return sum
+}
+
+// balanceFeature implements Feature for the DeepSeek balance tray UI.
+type balanceFeature struct {
+	cfg        Config
+	api        BalanceAPI
+	dotenvPath string
+
+	autoRefresh bool // owned by the Run goroutine only
+	alertOn     bool // owned by the Run goroutine only
+
+	menu *balanceMenu
+}
+
+type balanceMenu struct {
+	balance *Item
+	refresh *Item
+	auto    *Item
+	alert   *Item
+	dotenv  *Item
+	about   *Item
+	quit    *Item
+}
+
+func newBalanceFeature(cfg Config, dotenvPath string) *balanceFeature {
+	return &balanceFeature{
+		cfg:         cfg,
+		api:         httpBalanceAPI{URL: cfg.BalanceURL, Client: &http.Client{Timeout: balanceHTTPTimeout}},
+		dotenvPath:  dotenvPath,
+		autoRefresh: true,
+	}
+}
+
+func (b *balanceFeature) Name() string { return "balance" }
+
+func (b *balanceFeature) Install(m *Menu) {
+	b.menu = &balanceMenu{
+		balance: m.AddItem("余额: 查询中...", ""),
+		refresh: m.AddItem("刷新余额", "手动刷新"),
+		auto:    m.AddItem("自动刷新: 开启", "切换自动刷新"),
+		alert:   m.AddItem("低余额告警: 关闭", "切换低余额告警"),
+	}
+	b.menu.balance.Disable()
+	m.AddSeparator()
+	b.menu.dotenv = m.AddItem("打开 .env 配置文件", "编辑 API Key")
+	b.menu.about = m.AddItem("关于", "状态信息")
+	b.menu.quit = m.AddItem("退出", "关闭程序")
+
+	if b.cfg.APIKey != "" {
+		setTrayTooltip("DeepSeek 余额查询 - 正在获取...")
+	} else {
+		setTrayTooltip("DeepSeek - 未配置 API Key\n请在 .env 文件中设置 DEEPSEEK_API_KEY")
+	}
+}
+
+func (b *balanceFeature) Stop() {}
+
+func (b *balanceFeature) Run(ctx context.Context) {
+	results := make(chan queryResult, 1)
+
+	go func() {
+		if b.cfg.APIKey == "" {
+			b.menu.balance.SetTitle("余额: 未配置 Key")
+			b.menu.balance.SetTooltip("点击「打开 .env 配置文件」设置 API Key")
+			return
+		}
+		b.queryInto(b.menu.balance, results)
+	}()
+
+	ticker := time.NewTicker(balanceRefreshMin * time.Minute)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case r := <-results:
+			if b.alertOn && r.cny > 0 && r.cny < lowBalanceThreshold {
+				alertLowBalance(r.cny)
+			}
+
+		case <-ticker.C:
+			if b.cfg.APIKey == "" || !b.autoRefresh {
+				continue
+			}
+			b.queryInto(b.menu.balance, results)
+
+		case <-b.menu.refresh.Clicked:
+			if b.cfg.APIKey == "" {
+				showMessageBox("DeepSeek Tray", "未配置 API Key。\n请点击「打开 .env 配置文件」设置 DEEPSEEK_API_KEY。")
+				continue
+			}
+			b.queryInto(b.menu.balance, results)
+
+		case <-b.menu.auto.Clicked:
+			b.autoRefresh = !b.autoRefresh
+			if b.autoRefresh {
+				b.menu.auto.SetTitle("自动刷新: 开启")
+			} else {
+				b.menu.auto.SetTitle("自动刷新: 关闭")
+			}
+
+		case <-b.menu.alert.Clicked:
+			b.alertOn = !b.alertOn
+			if b.alertOn {
+				b.menu.alert.SetTitle("低余额告警: 开启")
+				if b.cfg.APIKey != "" {
+					b.queryInto(b.menu.balance, results)
+				}
+			} else {
+				b.menu.alert.SetTitle("低余额告警: 关闭")
+			}
+
+		case <-b.menu.dotenv.Clicked:
+			openDotEnvFile(b.dotenvPath)
+
+		case <-b.menu.about.Clicked:
+			showAbout(b.autoRefresh, b.alertOn)
+
+		case <-b.menu.quit.Clicked:
+			quitTray()
+			return
+
+		case <-ctx.Done():
+			return
+		}
+	}
+}
+
+// queryResult is a snapshot used for the low-balance alert decision.
+type queryResult struct {
+	ok  bool
+	cny float64
+}
+
+// queryInto queries the balance endpoint, updates the tray UI, and reports the
+// result for the alert path (non-blocking).
+func (b *balanceFeature) queryInto(it *Item, results chan<- queryResult) {
+	bal, err := b.api.Query(context.Background(), b.cfg.APIKey)
+	now := time.Now().Format("15:04:05")
+
+	var r queryResult
+	if err == nil {
+		text, currency := bal.Display()
+		display := text + " " + currency
+		tip := fmt.Sprintf("余额: %s | %s", display, now)
+		setTrayTooltip(tip)
+		it.SetTitle("余额: " + display)
+		it.SetTooltip(tip)
+		setTrayTitle(display)
+		r = queryResult{ok: true, cny: bal.CNYTotal()}
+	} else {
+		tip := fmt.Sprintf("%s | %s", err.Error(), now)
+		setTrayTooltip(tip)
+		it.SetTitle(err.Error())
+		it.SetTooltip(tip)
+		setTrayTitle("DeepSeek")
+	}
+
+	select {
+	case results <- r:
+	default:
+	}
+}
+
+func alertLowBalance(cny float64) {
+	showMessageBox("DeepSeek 余额不足",
+		fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", cny, lowBalanceThreshold))
+}
+
+func showAbout(autoRefresh, alertOn bool) {
+	showMessageBox("DeepSeek Balance Tray",
+		fmt.Sprintf("自动刷新: %s\n刷新间隔: %d 分钟\n低余额告警: %s (阈值 ¥%.0f)",
+			yesNo(autoRefresh), balanceRefreshMin, yesNo(alertOn), lowBalanceThreshold))
+}
+
+func yesNo(b bool) string {
+	if b {
+		return "是"
+	}
+	return "否"
+}
+
+// openDotEnvFile opens the .env file, creating a template first when absent.
+func openDotEnvFile(path string) {
+	if _, err := os.Stat(path); os.IsNotExist(err) {
+		if err := os.WriteFile(path, []byte(envTemplate), 0644); err != nil {
+			showMessageBox("错误", "无法创建 .env 文件: "+err.Error())
+			return
+		}
+	}
+	runNotepad(path)
+}

+ 75 - 0
config.go

@@ -0,0 +1,75 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+const defaultBalanceURL = "https://api.deepseek.com/user/balance"
+
+// Config is the fully-resolved runtime configuration. Values are loaded with
+// precedence: process environment > .env file > built-in default.
+type Config struct {
+	APIKey     string
+	BalanceURL string
+
+	// Harness daemon settings; consumed by the harness feature in a later step.
+	Harness harnessConfig
+}
+
+// LoadConfig resolves configuration from the .env file in dir (best-effort),
+// overlaid with process environment variables, then defaults.
+func LoadConfig(dir string) Config {
+	dotenv := readDotEnv(filepath.Join(dir, ".env"))
+
+	cfg := Config{
+		BalanceURL: defaultBalanceURL,
+		Harness: harnessConfig{
+			Enabled:    true,
+			Command:    "npx --yes @deepseek-ai/dsh web",
+			URL:        "http://127.0.0.1:3080",
+			StartDelay: 15,
+			LogFile:    "harness.log",
+		},
+	}
+
+	cfg.APIKey = firstNonEmpty(os.Getenv("DEEPSEEK_API_KEY"), dotenv["DEEPSEEK_API_KEY"])
+	if v := firstNonEmpty(os.Getenv("DEEPSEEK_BALANCE_URL"), dotenv["DEEPSEEK_BALANCE_URL"]); v != "" {
+		cfg.BalanceURL = v
+	}
+	return cfg
+}
+
+func firstNonEmpty(values ...string) string {
+	for _, v := range values {
+		if v != "" {
+			return v
+		}
+	}
+	return ""
+}
+
+// readDotEnv parses a simple KEY=VALUE file. Missing files yield an empty map.
+// Lines are trimmed; '#' starts a comment; quotes are stripped from values.
+func readDotEnv(path string) map[string]string {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil
+	}
+	m := make(map[string]string)
+	for _, raw := range strings.Split(string(data), "\n") {
+		line := strings.TrimSpace(raw)
+		if line == "" || line[0] == '#' {
+			continue
+		}
+		eq := strings.IndexByte(line, '=')
+		if eq < 1 {
+			continue
+		}
+		k := strings.TrimSpace(line[:eq])
+		v := strings.TrimSpace(line[eq+1:])
+		m[k] = strings.Trim(v, `"'`)
+	}
+	return m
+}

+ 65 - 0
feature.go

@@ -0,0 +1,65 @@
+package main
+
+import (
+	"context"
+
+	"github.com/getlantern/systray"
+)
+
+// Feature is a self-contained tray capability. It owns its menu items,
+// background loops, and mutable state. Adding a feature means implementing
+// this interface and registering it in app.go's feature list — the app's
+// event loop itself never changes.
+type Feature interface {
+	// Name identifies the feature (ordering and logging).
+	Name() string
+
+	// Install creates this feature's tray menu items. It is called exactly
+	// once, from the systray onReady goroutine, before Run.
+	Install(m *Menu)
+
+	// Run starts the feature's background work. It returns when the feature
+	// shuts down: either Stop is called, or ctx is cancelled at app exit.
+	Run(ctx context.Context)
+
+	// Stop requests shutdown and releases resources (goroutines, child
+	// processes). It must be safe to call multiple times and before Run.
+	Stop()
+}
+
+// Menu is a thin wrapper over the systray package so features never import
+// systray directly. It centralizes menu construction behind one seam.
+type Menu struct{}
+
+// Item is a handle to one tray menu entry.
+type Item struct {
+	mi      *systray.MenuItem
+	Clicked chan struct{}
+}
+
+// AddItem creates a clickable menu entry.
+func (Menu) AddItem(title, tooltip string) *Item {
+	mi := systray.AddMenuItem(title, tooltip)
+	return &Item{mi: mi, Clicked: mi.ClickedCh}
+}
+
+// AddSeparator inserts a menu divider.
+func (Menu) AddSeparator() { systray.AddSeparator() }
+
+// SetTitle updates the item's visible label.
+func (it *Item) SetTitle(t string) { it.mi.SetTitle(t) }
+
+// SetTooltip updates the item's hover text.
+func (it *Item) SetTooltip(t string) { it.mi.SetTooltip(t) }
+
+// Disable greys the item out.
+func (it *Item) Disable() { it.mi.Disable() }
+
+// Enable makes a disabled item clickable again.
+func (it *Item) Enable() { it.mi.Enable() }
+
+// setTrayTitle / setTrayTooltip / quitTray are package-level systray shims so
+// features can update the tray icon state without importing systray directly.
+func setTrayTitle(t string)   { systray.SetTitle(t) }
+func setTrayTooltip(t string) { systray.SetTooltip(t) }
+func quitTray()               { systray.Quit() }

+ 17 - 0
harness.go

@@ -0,0 +1,17 @@
+package main
+
+// The harness daemon feature will supervise the DeepSeek Harness web server:
+// start `npx @deepseek-ai/dsh web` hidden at login, health-check it,
+// reopen the browser UI on demand, and restart it on crash. It plugs into the
+// same Feature interface as balance (see balance.go) and registers in app.go.
+
+// harnessConfig carries the harness feature's configuration slice. It is
+// resolved by LoadConfig (see config.go) and consumed by the harness feature
+// in a later step.
+type harnessConfig struct {
+	Enabled    bool
+	Command    string
+	URL        string
+	StartDelay int
+	LogFile    string
+}

+ 11 - 342
main.go

@@ -1,360 +1,29 @@
 package main
 
 import (
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
 	"os"
 	"path/filepath"
-	"strings"
-	"time"
 
-	"github.com/getlantern/systray"
-
-	_ "embed" // 仅用于 //go:embed 指令
+	_ "embed"
 )
 
 //go:embed icon.ico
 var appIcon []byte
 
-const (
-	httpTimeout     = 15 * time.Second
-	refreshMinutes  = 10                         // 自动刷新间隔(分钟)
-	lowBalanceAlert = 1.0                        // 低余额告警阈值(元 CNY)
-	defaultAPIURL   = "https://api.deepseek.com/user/balance"
-)
-
-// main 中写入,之后所有 goroutine 只读。
-var (
-	apiKey     string
-	balanceURL string
-	exeDir     string
-)
-
-// ---------- API ----------
-
-type balanceAPI 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"`
-}
-
-// balanceResult 是查询结果的不可变快照。
-// ok 为 false 时 text 是错误消息;为 true 时 text 是纯数字金额(不含币种)。
-type balanceResult struct {
-	ok       bool
-	text     string
-	currency string
-	cny      float64 // CNY 总额,失败时为 0
-}
-
 func main() {
 	relaunchInUserSession()
-	exeDir = executableDir()
-	apiKey, balanceURL = loadConfig()
-	systray.Run(onReady, func() {})
-}
-
-func onReady() {
-	systray.SetIcon(appIcon)
-	systray.SetTitle("DeepSeek")
-
-	if apiKey != "" {
-		systray.SetTooltip("DeepSeek 余额查询 - 正在获取...")
-	} else {
-		systray.SetTooltip("DeepSeek - 未配置 API Key\n请在 .env 文件中设置 DEEPSEEK_API_KEY")
-	}
-
-	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("退出", "关闭程序")
-
-	autoRefresh := true
-	alertOn := false
-
-	tickDuration := refreshMinutes * time.Minute
-
-	balanceCh := make(chan balanceResult, 1)
-	var ticker *time.Ticker    // 仅 startTicker 写入
-	var tickerStop chan struct{}
 
-	startTicker := func() {
-		ticker = time.NewTicker(tickDuration)
-		tickerStop = make(chan struct{})
-		stop := tickerStop
-
-		go func() {
-			for {
-				select {
-				case <-ticker.C:
-					if apiKey == "" {
-						continue
-					}
-					r := queryAndUpdate(mBalance)
-					select {
-					case balanceCh <- r:
-					default:
-					}
-				case <-stop:
-					ticker.Stop()
-					return
-				}
-			}
-		}()
-	}
-
-	stopTicker := func() {
-		if tickerStop != nil {
-			close(tickerStop)
-			tickerStop = nil
-		}
-	}
-
-	startTicker()
-
-	// 首次查询(结果同样送入 balanceCh,确保告警逻辑一致)
-	go func() {
-		if apiKey == "" {
-			mBalance.SetTitle("余额: 未配置 Key")
-			mBalance.SetTooltip("点击「打开 .env 配置文件」设置 API Key")
-			return
-		}
-		r := queryAndUpdate(mBalance)
-		select {
-		case balanceCh <- r:
-		default:
-		}
-	}()
-
-	for {
-		select {
-		case r := <-balanceCh:
-			if alertOn && r.cny > 0 && r.cny < lowBalanceAlert {
-				alertLowBalance(r.cny)
-			}
-
-		case <-mRefresh.ClickedCh:
-			if apiKey == "" {
-				showMessageBox("DeepSeek Tray",
-					"未配置 API Key。\n请点击「打开 .env 配置文件」设置 DEEPSEEK_API_KEY。")
-				continue
-			}
-			queryAndUpdate(mBalance)
-
-		case <-mAuto.ClickedCh:
-			autoRefresh = !autoRefresh
-			if autoRefresh {
-				startTicker()
-				mAuto.SetTitle("自动刷新: 开启")
-			} else {
-				stopTicker()
-				mAuto.SetTitle("自动刷新: 关闭")
-			}
-
-		case <-mAlert.ClickedCh:
-			alertOn = !alertOn
-			if alertOn {
-				mAlert.SetTitle("低余额告警: 开启")
-				if apiKey != "" {
-					r := queryAndUpdate(mBalance)
-					if r.cny > 0 && r.cny < lowBalanceAlert {
-						alertLowBalance(r.cny)
-					}
-				}
-			} else {
-				mAlert.SetTitle("低余额告警: 关闭")
-			}
-
-		case <-mDotEnv.ClickedCh:
-			openDotEnvFile()
-
-		case <-mAbout.ClickedCh:
-			showAbout(autoRefresh, alertOn)
-
-		case <-mQuit.ClickedCh:
-			stopTicker()
-			systray.Quit()
-			return
-		}
-	}
-}
-
-// ---------- 查询 ----------
-
-func queryAndUpdate(m *systray.MenuItem) balanceResult {
-	r := queryBalance()
-	now := time.Now().Format("15:04:05")
-
-	if r.ok {
-		display := r.text + " " + r.currency
-		tip := fmt.Sprintf("余额: %s | %s", display, now)
-		systray.SetTooltip(tip)
-		m.SetTitle("余额: " + display)
-		m.SetTooltip(tip)
-		systray.SetTitle(display)
-	} else {
-		tip := fmt.Sprintf("%s | %s", r.text, now)
-		systray.SetTooltip(tip)
-		m.SetTitle(r.text)
-		m.SetTooltip(tip)
-		systray.SetTitle("DeepSeek")
-	}
-	return r
+	dir := executableDir()
+	app := newApp(LoadConfig(dir), filepath.Join(dir, ".env"))
+	app.Run()
 }
 
-func queryBalance() balanceResult {
-	client := &http.Client{Timeout: httpTimeout}
-	req, err := http.NewRequest(http.MethodGet, balanceURL, nil)
-	if err != nil {
-		return balanceResult{text: "创建请求失败"}
-	}
-	req.Header.Set("Authorization", "Bearer "+apiKey)
-	req.Header.Set("Accept", "application/json")
-
-	resp, err := client.Do(req)
-	if err != nil {
-		return balanceResult{text: "网络错误: " + err.Error()}
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		if resp.StatusCode == http.StatusUnauthorized {
-			return balanceResult{text: "API Key 无效"}
-		}
-		body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
-		return balanceResult{text: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body))}
-	}
-
-	var api balanceAPI
-	if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
-		return balanceResult{text: "解析响应失败"}
-	}
-	if !api.IsAvailable || len(api.BalanceInfos) == 0 {
-		return balanceResult{text: "余额不可用"}
-	}
-
-	var cnyTotal float64
-	for _, item := range api.BalanceInfos {
-		if item.Currency == "CNY" {
-			var v float64
-			fmt.Sscanf(item.TotalBalance, "%f", &v)
-			cnyTotal += v
-		}
-	}
-
-	first := api.BalanceInfos[0]
-	return balanceResult{
-		ok:       true,
-		text:     first.TotalBalance,
-		currency: first.Currency,
-		cny:      cnyTotal,
-	}
-}
-
-func alertLowBalance(cny float64) {
-	showMessageBox("DeepSeek 余额不足",
-		fmt.Sprintf("当前余额 ¥%.2f,低于阈值 ¥%.0f,请及时充值。", cny, lowBalanceAlert))
-}
-
-func showAbout(autoRefresh, alertOn bool) {
-	showMessageBox("DeepSeek Balance Tray",
-		fmt.Sprintf("自动刷新: %s\n刷新间隔: %d 分钟\n低余额告警: %s (阈值 ¥%.0f)",
-			yesNo(autoRefresh), refreshMinutes, yesNo(alertOn), lowBalanceAlert))
-}
-
-func yesNo(b bool) string {
-	if b {
-		return "是"
-	}
-	return "否"
-}
-
-// ---------- 配置加载 ----------
-
-// loadConfig 返回 (apiKey, balanceURL)。
-// apiKey 优先级:DEEPSEEK_API_KEY 环境变量 > .env 文件。
-// balanceURL 优先级:DEEPSEEK_BALANCE_URL 环境变量 > .env 文件 > defaultAPIURL。
-func loadConfig() (key, url string) {
-	dotenv := readDotEnv(filepath.Join(exeDir, ".env"))
-	// dotenv 可能为 nil;Go 读取 nil map 返回零值,安全。
-
-	if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {
-		key = v
-	} else if dotenv != nil {
-		key = dotenv["DEEPSEEK_API_KEY"]
-	}
-
-	if v := os.Getenv("DEEPSEEK_BALANCE_URL"); v != "" {
-		url = v
-	} else if dotenv != nil {
-		if v := dotenv["DEEPSEEK_BALANCE_URL"]; v != "" {
-			url = v
-		}
-	}
-	if url == "" {
-		url = defaultAPIURL
-	}
-	return
-}
-
-func readDotEnv(path string) map[string]string {
-	data, err := os.ReadFile(path)
-	if err != nil {
-		return nil
-	}
-	m := make(map[string]string)
-	for _, raw := range strings.Split(string(data), "\n") {
-		line := strings.TrimSpace(raw)
-		if line == "" || line[0] == '#' {
-			continue
-		}
-		eq := strings.IndexByte(line, '=')
-		if eq < 1 {
-			continue
-		}
-		k := strings.TrimSpace(line[:eq])
-		v := strings.TrimSpace(line[eq+1:])
-		v = strings.Trim(v, `"'`)
-		m[k] = v
-	}
-	return m
-}
-
-func openDotEnvFile() {
-	path := filepath.Join(exeDir, ".env")
-
-	if _, err := os.Stat(path); os.IsNotExist(err) {
-		const 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)
-}
-
-// ---------- 工具 ----------
-
+// executableDir returns the directory containing the running executable,
+// falling back to the working directory when os.Executable fails.
 func executableDir() string {
-	exe, err := os.Executable()
-	if err != nil {
-		wd, _ := os.Getwd()
-		return wd
+	if exe, err := os.Executable(); err == nil {
+		return filepath.Dir(exe)
 	}
-	return filepath.Dir(exe)
-}
+	wd, _ := os.Getwd()
+	return wd
+}

+ 11 - 14
msgbox_windows.go → windows.go

@@ -11,6 +11,7 @@ var (
 	user32   = syscall.NewLazyDLL("user32.dll")
 	kernel32 = syscall.NewLazyDLL("kernel32.dll")
 	wtsapi32 = syscall.NewLazyDLL("wtsapi32.dll")
+
 	msgBoxW          = user32.NewProc("MessageBoxW")
 	consoleSessionID = kernel32.NewProc("WTSGetActiveConsoleSessionId")
 	queryUserToken   = wtsapi32.NewProc("WTSQueryUserToken")
@@ -27,7 +28,7 @@ const (
 )
 
 func mustUTF16Ptr(s string) *uint16 {
-	p, _ := syscall.UTF16PtrFromString(s) // NUL-free strings; ignore impossible error
+	p, _ := syscall.UTF16PtrFromString(s)
 	return p
 }
 
@@ -40,23 +41,24 @@ func showMessageBox(title, msg string) {
 }
 
 func runNotepad(path string) {
-	err := exec.Command("notepad.exe", path).Start()
-	if err != nil {
+	if err := exec.Command("notepad.exe", path).Start(); err != nil {
 		showMessageBox("错误", "无法打开记事本: "+err.Error())
 	}
 }
 
-// relaunchInUserSession 若当前进程在 Session 0(如服务)中运行,
-// 则在活跃用户会话中重新启动自身,然后退出当前进程。
-// 调用方应在 main() 最开始调用此函数。
+// openBrowser launches the default browser at url without a console window.
+func openBrowser(url string) error {
+	return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
+}
+
+// relaunchInUserSession relaunches the process in the active user session when
+// the current process runs in Session 0 (e.g. started by a Windows service).
 func relaunchInUserSession() {
 	sessionID, _, _ := consoleSessionID.Call()
 	if sessionID == 0 {
-		// 当前已在用户会话或 sessionID 获取失败,不做处理
 		return
 	}
 
-	// 仅在 Session 0 时才需要重新启动
 	var ourSession uint32
 	kernel32.NewProc("ProcessIdToSessionId").Call(
 		uintptr(os.Getpid()), uintptr(unsafe.Pointer(&ourSession)),
@@ -65,7 +67,6 @@ func relaunchInUserSession() {
 		return
 	}
 
-	// 获取用户令牌
 	var token syscall.Handle
 	ret, _, _ := queryUserToken.Call(sessionID, uintptr(unsafe.Pointer(&token)))
 	if ret == 0 || token == 0 {
@@ -73,7 +74,6 @@ func relaunchInUserSession() {
 	}
 	defer syscall.CloseHandle(token)
 
-	// 创建用户环境块
 	var envBlock unsafe.Pointer
 	ret, _, _ = createEnvBlock.Call(uintptr(unsafe.Pointer(&envBlock)), uintptr(token), 0)
 	if ret == 0 {
@@ -81,14 +81,12 @@ func relaunchInUserSession() {
 	}
 	defer destroyEnvBlock.Call(uintptr(envBlock))
 
-	// 获取当前 EXE 路径
 	exePath, err := os.Executable()
 	if err != nil {
 		return
 	}
 	exePtr := mustUTF16Ptr(exePath)
 
-	// 准备 STARTUPINFO 和 PROCESS_INFORMATION
 	var si syscall.StartupInfo
 	si.Cb = uint32(unsafe.Sizeof(si))
 	var pi syscall.ProcessInformation
@@ -100,7 +98,7 @@ func relaunchInUserSession() {
 		0,
 		0,
 		0,
-		0, // no extra flags; -H windowsgui already linked
+		0,
 		uintptr(unsafe.Pointer(envBlock)),
 		0,
 		uintptr(unsafe.Pointer(&si)),
@@ -113,4 +111,3 @@ func relaunchInUserSession() {
 
 	os.Exit(0)
 }
-