Ver Fonte

feat: v2.5.0 依据使用反馈优化(菜单宽度 + 结果回显 + 版本注入)

- 菜单宽度:长 URL 移入 tooltip,标题统一短文本(状态改为 运行中 · PID x / 启动中 · PID x)

- 操作结果回显:状态区临时显示 已是最新/更新完成/失败,解决点更新无反馈的观感问题

- 版本号:appVersion 改为构建脚本注入(-X main.appVersion),单一维护点避免忘改

- 更新流程:合并为唯一入口 UpdateAndRestart,去掉重复的 npm view 查询

- 防重入:立即更新/启动/停止/重启统一走 runOp,避免连点并发
caesar há 18 horas atrás
pai
commit
9e84f63452
6 ficheiros alterados com 131 adições e 79 exclusões
  1. 7 3
      README.md
  2. 7 2
      build.ps1
  3. 3 1
      main.go
  4. 0 18
      supervisor.go
  5. 72 29
      tray.go
  6. 42 26
      updater.go

+ 7 - 3
README.md

@@ -58,15 +58,18 @@ nssm start DeepSeekTray
 
 ## 托盘菜单
 
-- 状态:运行中 (PID) / 已停止 / 外部实例运行中 / 正在更新(提示含运行时长与重启次数)
-- 打开 Web 界面(带 token,直接进入)
+- 状态:运行中 · PID / 启动中 · PID / 已停止 / 外部实例运行中 / 正在更新
+  (操作结果会在状态区**临时回显**,如"已是最新版本 x.y.z"、"更新完成"、"失败(详见日志)")
+- 打开 Web 界面(**就绪前置灰**;就绪后点击直达,自动带 token;URL 显示在悬停提示里)
 - 启动 / 停止 / 重启服务
 - 开机自启(勾选)
-- 定期自动更新(勾选)、立即更新 DSH
+- 定期自动更新(勾选)、立即更新 DSH(已是最新则秒回,不重启服务)
 - 版本:当前安装的 dsh 版本
 - 查看日志 / 打开配置文件
 - 退出(停止服务并退出)
 
+> 菜单标题统一保持短文本(长 URL 会撑宽整个菜单),细节放在各项的鼠标悬停提示中。
+
 ## 配置
 
 位置:`%APPDATA%\deepseek-tray\config.json`(首次运行自动生成,原子写入)
@@ -153,6 +156,7 @@ nssm start DeepSeekTray
 
 ## 版本
 
+- v2.5.0 — 依据实际使用反馈优化:**菜单宽度**(长 URL 移入悬停提示)、**操作结果回显**(解决"点更新没反应"的观感问题)、**版本号改由构建脚本注入**(避免源码与版本号不一致)、更新流程合并为唯一入口(去掉重复查询)、耗时操作防重入
 - v2.4.0 — 修复实测发现的三个问题:**打开日志/配置无反应**(误用 SW_HIDE 把 explorer 窗口隐藏了 → 改用 ShellExecuteW)、**Web 界面启动期不可用**(新增就绪状态,启动中禁用点击而非打开无效地址)、**更新白等 5 分钟**(版本相同时跳过整包重装与重启);菜单耗时操作后台执行不再阻塞;日志记录 Web 启动耗时
 - v2.3.0 — 三次迭代:修复**停止/启动竞态**(不再残留进程)、配置写入线程安全、更新前先停服务、配置读写单元测试与守护生命周期测试、`build.ps1` 一键构建
 - v2.2.0 — 二次迭代:**新增单元测试**(并借其修复退避上限问题)、修复定期更新"关闭后再开启"失效、职责拆分(probe/util)、日志按天轮转、进程终止兜底、daemon 优雅退出

+ 7 - 2
build.ps1

@@ -1,7 +1,12 @@
 # deepseek-tray 构建脚本:格式检查 -> 静态检查 -> 单元测试 -> 构建
+# 版本号唯一维护点(README 的版本历史与此保持一致)
+$Version = '2.5.0'
+
 $ErrorActionPreference = 'Stop'
 Push-Location $PSScriptRoot
 try {
+    Write-Host ('deepseek-tray v' + $Version) -ForegroundColor Cyan
+
     Write-Host '[1/4] gofmt 检查'
     $unformatted = gofmt -l .
     if ($unformatted) { throw ('以下文件未格式化: ' + ($unformatted -join ', ')) }
@@ -15,11 +20,11 @@ try {
     if ($LASTEXITCODE -ne 0) { throw 'go test 失败' }
 
     Write-Host '[4/4] go build'
-    go build -trimpath -ldflags '-H=windowsgui -s -w' -o deepseek-tray.exe .
+    go build -trimpath -ldflags "-H=windowsgui -s -w -X main.appVersion=$Version" -o deepseek-tray.exe .
     if ($LASTEXITCODE -ne 0) { throw 'go build 失败' }
 
     $size = [math]::Round((Get-Item 'deepseek-tray.exe').Length / 1KB, 0)
-    Write-Host ('构建成功: deepseek-tray.exe (' + $size + ' KB)')
+    Write-Host ('构建成功: deepseek-tray.exe (' + $size + ' KB, v' + $Version + ')') -ForegroundColor Green
 } finally {
     Pop-Location
 }

+ 3 - 1
main.go

@@ -8,7 +8,9 @@ import (
 	"syscall"
 )
 
-const appVersion = "2.3.0"
+// appVersion 由构建脚本注入(go build -ldflags "-X main.appVersion=x.y.z")。
+// 之所以用变量:避免"代码改了、版本号忘改"(v2.4.0 曾出现该疏漏)。
+var appVersion = "dev"
 
 // 会话级命名互斥体(Local 前缀无需特权;Global 前缀普通用户常创建失败,会导致单实例失效)
 const mutexName = "Local\\DeepSeekTraySingleton"

+ 0 - 18
supervisor.go

@@ -105,24 +105,6 @@ func (s *Supervisor) SetAutoStart(enabled bool) error {
 	return saveConfig(&snapshot)
 }
 
-// UpdateAndRestart 先停止服务再更新,避免 Windows 上 native 模块文件被占用导致安装失败;
-// 无论更新成功与否都恢复到原有运行意图。已是最新版本时既不安装也不重启。
-func (s *Supervisor) UpdateAndRestart() (string, error) {
-	if fresh, current := s.upToDate(); fresh {
-		s.log.Printf("更新:已是最新(%s),不重启服务", current)
-		return "已是最新版本 " + current, nil
-	}
-	wasDesired := s.isDesired()
-	if wasDesired {
-		s.Stop()
-	}
-	out, err := s.Update()
-	if wasDesired {
-		s.Start()
-	}
-	return out, err
-}
-
 // Restart 重启并等待端口释放,避免新旧实例争抢端口。
 func (s *Supervisor) Restart() {
 	s.log.Printf("重启 dsh web")

+ 72 - 29
tray.go

@@ -26,8 +26,9 @@ func runTray(cfg *Config, log *Logger) {
 }
 
 func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
-	mStatus := systray.AddMenuItem("状态:初始化…", "当前服务状态")
-	mURL := systray.AddMenuItem("Web 界面:等待服务就绪…", "服务就绪后打开(自动带 token)")
+	// 菜单标题保持短文本(长 URL 会撑宽整个菜单、显得不协调),详情放 tooltip。
+	mStatus := systray.AddMenuItem("初始化…", "当前服务状态")
+	mURL := systray.AddMenuItem("打开 Web 界面", "服务就绪后可用(自动带 token)")
 	systray.AddSeparator()
 	mStart := systray.AddMenuItem("启动服务", "启动 dsh web")
 	mStop := systray.AddMenuItem("停止服务", "停止 dsh web")
@@ -35,7 +36,7 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 	systray.AddSeparator()
 	mAutoStart := systray.AddMenuItemCheckbox("开机自启", "登录 Windows 时自动启动托盘并守护服务(无窗口、不打开浏览器)", isAutoStartEnabled())
 	mAutoUpdate := systray.AddMenuItemCheckbox("定期自动更新", "按间隔检查并更新 dsh(已是最新则跳过)", cfg.AutoUpdate)
-	mUpdateNow := systray.AddMenuItem("立即更新 DSH", "npm install -g @deepseek-ai/dsh@latest(先停服务)")
+	mUpdateNow := systray.AddMenuItem("立即更新 DSH", "检查并安装最新版(先停服务;已是最新则秒回)")
 	systray.AddSeparator()
 	mVersion := systray.AddMenuItem("版本:查询中…", "已安装的 DSH 版本")
 	mLogs := systray.AddMenuItem("查看日志", "打开日志目录")
@@ -46,12 +47,33 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 	mStop.Disable()
 	mURL.Disable() // 服务就绪前不可点(避免打开无 token 的无效地址)
 
-	// runOp 在后台执行耗时操作(停止/重启可能等待数秒),避免阻塞菜单响应;同一时刻只允许一个。
+	// 临时通知:覆盖状态标题若干秒,让"点完看不到变化"的操作有回显。
+	var noticeMu sync.Mutex
+	noticeText := ""
+	var noticeUntil time.Time
+	setNotice := func(text string, d time.Duration) {
+		noticeMu.Lock()
+		noticeText, noticeUntil = text, time.Now().Add(d)
+		noticeMu.Unlock()
+	}
+	takeNotice := func() string {
+		noticeMu.Lock()
+		defer noticeMu.Unlock()
+		if noticeText == "" || time.Now().After(noticeUntil) {
+			noticeText = ""
+			return ""
+		}
+		return noticeText
+	}
+
+	// runOp 后台执行耗时操作(停止/重启/更新可能等待数秒到数分钟),
+	// 保证菜单点击立即响应;同一时刻只允许一个长操作。
 	var opMu sync.Mutex
 	runOp := func(name string, fn func()) {
 		go func() {
 			if !opMu.TryLock() {
 				log.Printf("%s:已有操作进行中,忽略本次", name)
+				setNotice("已有操作进行中…", 5*time.Second)
 				return
 			}
 			defer opMu.Unlock()
@@ -63,12 +85,16 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 	go func() {
 		ticker := time.NewTicker(3 * time.Second)
 		defer ticker.Stop()
-		var lastTitle, lastTip, lastURL string
+		var lastTitle, lastTip, lastWebTip string
 		lastRunning, lastWebEnabled := false, false
 		for range ticker.C {
 			st := sup.Status()
 
-			if title := statusTitle(st); title != lastTitle {
+			title := statusTitle(st)
+			if n := takeNotice(); n != "" {
+				title = n
+			}
+			if title != lastTitle {
 				mStatus.SetTitle(title)
 				lastTitle = title
 			}
@@ -77,29 +103,29 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 				lastTip = tip
 			}
 
-			// Web 菜单:就绪前禁用,避免"打开后无法访问"
-			webText, webEnabled := "", true
+			// Web 菜单:标题固定,仅切换可用状态与 tooltip
+			webEnable, webTip := false, ""
 			switch {
 			case st.Ready:
-				webText = "打开 Web 界面:" + shortURL(st.URL)
+				webEnable, webTip = true, "打开:"+st.URL
 			case st.External:
-				webText = "打开 Web 界面(外部实例):" + shortURL(st.URL)
+				webEnable, webTip = true, "外部实例:"+st.URL
 			case st.Running:
-				webText, webEnabled = "Web 界面:服务启动中…", false
+				webTip = "服务启动中,就绪后自动可用(首次约 10~40 秒)"
 			default:
-				webText, webEnabled = "打开 Web 界面(服务未运行)", false
-			}
-			if webText != lastURL {
-				mURL.SetTitle(webText)
-				lastURL = webText
+				webTip = "服务未运行"
 			}
-			if webEnabled != lastWebEnabled {
-				if webEnabled {
+			if webEnable != lastWebEnabled {
+				if webEnable {
 					mURL.Enable()
 				} else {
 					mURL.Disable()
 				}
-				lastWebEnabled = webEnabled
+				lastWebEnabled = webEnable
+			}
+			if webTip != lastWebTip {
+				mURL.SetTooltip(webTip)
+				lastWebTip = webTip
 			}
 
 			if st.Running != lastRunning {
@@ -144,16 +170,19 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 				st := sup.Status()
 				if !st.Ready && !st.External {
 					log.Printf("菜单:打开 Web 界面被忽略(服务未就绪)")
+					setNotice("Web 界面启动中,请稍候…", 4*time.Second)
 					continue
 				}
 				log.Printf("菜单:打开 Web 界面 %s", shortURL(st.URL))
 				if err := openExternal(st.URL); err != nil {
 					log.Printf("打开 Web 界面失败: %v", err)
+					setNotice("打开失败(详见日志)", 6*time.Second)
 				}
 			case <-mAutoStart.ClickedCh:
 				next := !mAutoStart.Checked()
 				if err := setAutoStart(next); err != nil {
 					log.Printf("设置开机自启失败: %v", err)
+					setNotice("设置开机自启失败", 6*time.Second)
 					continue
 				}
 				if next {
@@ -165,6 +194,11 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 					log.Printf("保存配置失败: %v", err)
 				}
 				log.Printf("开机自启 = %v", next)
+				if next {
+					setNotice("开机自启:已开启", 4*time.Second)
+				} else {
+					setNotice("开机自启:已关闭", 4*time.Second)
+				}
 			case <-mAutoUpdate.ClickedCh:
 				next := !mAutoUpdate.Checked()
 				if next {
@@ -176,26 +210,35 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 					log.Printf("保存配置失败: %v", err)
 				}
 				log.Printf("定期自动更新 = %v", next)
+				if next {
+					setNotice("定期自动更新:已开启", 4*time.Second)
+				} else {
+					setNotice("定期自动更新:已关闭", 4*time.Second)
+				}
 			case <-mUpdateNow.ClickedCh:
-				go func() {
-					log.Printf("菜单:立即更新(先停服务再更新)")
+				log.Printf("菜单:立即更新")
+				runOp("立即更新", func() {
 					text, err := sup.UpdateAndRestart()
 					if err != nil {
 						log.Printf("更新失败: %v", err)
+						setNotice("更新失败(详见日志)", 8*time.Second)
 					} else {
 						log.Printf("更新完成: %s", firstLine(text))
+						setNotice(firstLine(text), 8*time.Second)
 					}
 					mVersion.SetTitle("版本:" + sup.installedVersion())
-				}()
+				})
 			case <-mLogs.ClickedCh:
 				log.Printf("菜单:查看日志 %s", logDir())
 				if err := openExternal(logDir()); err != nil {
 					log.Printf("打开日志失败: %v", err)
+					setNotice("打开日志失败(详见日志)", 6*time.Second)
 				}
 			case <-mConfig.ClickedCh:
 				log.Printf("菜单:打开配置文件 %s", configPath())
 				if err := openExternal(configPath()); err != nil {
 					log.Printf("打开配置失败: %v", err)
+					setNotice("打开配置失败(详见日志)", 6*time.Second)
 				}
 			case <-mQuit.ClickedCh:
 				log.Printf("菜单:退出")
@@ -214,25 +257,25 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 func statusTitle(st Status) string {
 	switch {
 	case st.Updating:
-		return "状态:正在更新…"
+		return "正在更新…"
 	case st.Running && !st.Ready:
-		return fmt.Sprintf("状态:启动中 (PID %d)", st.PID)
+		return fmt.Sprintf("启动中 · PID %d", st.PID)
 	case st.Running:
-		return fmt.Sprintf("状态:运行中 (PID %d)", st.PID)
+		return fmt.Sprintf("运行中 · PID %d", st.PID)
 	case st.External:
-		return "状态:外部实例运行中"
+		return "外部实例运行中"
 	default:
-		return "状态:已停止"
+		return "已停止"
 	}
 }
 
 func statusTooltip(st Status) string {
-	parts := make([]string, 0, 3)
+	parts := make([]string, 0, 4)
 	if st.Running {
 		parts = append(parts, "运行 "+humanDuration(st.Uptime))
 	}
 	if st.Running && !st.Ready {
-		parts = append(parts, "Web 界面启动中(首次约需 30 秒)")
+		parts = append(parts, "Web 界面启动中")
 	}
 	if st.Restarts > 0 {
 		parts = append(parts, fmt.Sprintf("自动重启 %d 次", st.Restarts))

+ 42 - 26
updater.go

@@ -11,6 +11,23 @@ import (
 
 const allowScripts = "@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs"
 
+// beginUpdate 进入更新态(防重入,同时让 UI 显示"正在更新");已在更新中返回 false。
+func (s *Supervisor) beginUpdate() bool {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.updating {
+		return false
+	}
+	s.updating = true
+	return true
+}
+
+func (s *Supervisor) endUpdate() {
+	s.mu.Lock()
+	s.updating = false
+	s.mu.Unlock()
+}
+
 // upToDate 判断已安装版本是否已是最新(查询失败时按"非最新"处理)。
 func (s *Supervisor) upToDate() (bool, string) {
 	current := s.installedVersion()
@@ -24,27 +41,34 @@ func (s *Supervisor) upToDate() (bool, string) {
 	return latest == current, current
 }
 
-// Update 执行全局更新:npm install -g @deepseek-ai/dsh@latest --allow-scripts=...
-// 已是最新版本时直接跳过——避免版本相同时仍整包重装(实测曾耗时 5 分钟)。
-func (s *Supervisor) Update() (string, error) {
-	s.mu.Lock()
-	if s.updating {
-		s.mu.Unlock()
+// UpdateAndRestart 更新流程的唯一入口:
+// 检查版本 -> 必要时停服务 -> 安装 -> 恢复运行。
+// 全程处于"更新中"状态(UI 可见);已是最新则既不安装也不重启。
+func (s *Supervisor) UpdateAndRestart() (string, error) {
+	if !s.beginUpdate() {
 		return "", errors.New("更新已在进行中")
 	}
-	s.updating = true
-	s.mu.Unlock()
-	defer func() {
-		s.mu.Lock()
-		s.updating = false
-		s.mu.Unlock()
-	}()
+	defer s.endUpdate()
 
+	s.log.Printf("更新:检查最新版本…")
 	if fresh, current := s.upToDate(); fresh {
-		s.log.Printf("更新:已是最新(%s),跳过安装", current)
+		s.log.Printf("更新:已是最新(%s),跳过安装与重启", current)
 		return "已是最新版本 " + current, nil
 	}
 
+	wasDesired := s.isDesired()
+	if wasDesired {
+		s.Stop()
+	}
+	out, err := s.installLatest()
+	if wasDesired {
+		s.Start()
+	}
+	return out, err
+}
+
+// installLatest 执行 npm 全局安装(不做版本检查,由调用方负责)。
+func (s *Supervisor) installLatest() (string, error) {
 	exe, args := npmCommandFor("install", "-g", dshPackage+"@latest", "--allow-scripts="+allowScripts)
 	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
 	defer cancel()
@@ -115,21 +139,13 @@ func (s *Supervisor) startAutoUpdate() {
 	}()
 }
 
-// runScheduledUpdate 查询最新版本,有更新才安装并重启。
+// runScheduledUpdate 定期检查并更新(版本比对在 UpdateAndRestart 内完成一次)。
 func (s *Supervisor) runScheduledUpdate() {
-	latest, err := s.LatestVersion()
+	s.log.Printf("定期更新:检查最新版本…")
+	text, err := s.UpdateAndRestart()
 	if err != nil {
-		s.log.Printf("定期更新:查询最新版本失败: %v", err)
-		return
-	}
-	current := s.installedVersion()
-	if latest == "" || current == latest {
-		s.log.Printf("定期更新:已是最新(%s)", current)
-		return
-	}
-	s.log.Printf("定期更新:发现新版本 %s(当前 %s),开始更新", latest, current)
-	if _, err := s.UpdateAndRestart(); err != nil {
 		s.log.Printf("定期更新失败: %v", err)
 		return
 	}
+	s.log.Printf("定期更新完成: %s", firstLine(text))
 }