From 7c4109f687ad3dd5adc609812933399db0f1efd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Wed, 19 Aug 2026 15:46:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=90=E4=BA=A4=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E8=83=BD=E7=9C=8B=20diff=EF=BC=8C=E4=B9=9F=E8=83=BD=E4=B8=A2?= =?UTF-8?q?=E7=BB=99=20AI=20=E5=AE=A1=E8=BF=99=E4=B8=80=E6=AC=A1=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E3=80=82=E6=96=87=E4=BB=B6=E6=A3=80=E6=9F=A5=E5=8F=AF?= =?UTF-8?q?=E5=8D=95=E7=8B=AC=E6=8E=92=E9=99=A4=E8=B7=AF=E5=BE=84=EF=BC=8C?= =?UTF-8?q?TODO=20=E5=8F=AA=E8=AE=A4=E5=A4=A7=E5=86=99=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E6=8A=8A=20todo=20=E8=A1=A8=E5=92=8C=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=BD=93=E6=88=90=E5=BE=85=E5=8A=9E=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 热力图按容器宽度铺满一年,不再横向滚动。托盘右键换成可换皮肤的弹层;更新下载显示进度,退出后再由脚本拉起安装器,避免还占着 exe。 --- .../checksum/build-frontend--DEV--RUNNER-npm- | 2 +- .../checksum/windows-common-generate-bindings | 2 +- admin.go | 54 +- ai.go | 62 +- app.go | 33 +- database.go | 59 +- database_test.go | 50 ++ frontend/public/tray.html | 763 ++++++++++++++++++ frontend/src/App.vue | 46 +- frontend/src/components/CommitDrawer.vue | 82 +- frontend/src/components/GitHeatmap.vue | 120 ++- frontend/src/components/ProjectAIDrawer.vue | 10 +- frontend/src/git.css | 2 +- frontend/src/main.js | 120 ++- frontend/src/polish.css | 175 +++- frontend/src/store.js | 2 +- frontend/src/style.css | 2 +- frontend/src/views/ProjectDetail.vue | 67 +- frontend/src/views/Settings.vue | 17 +- frontend/src/views/TrayPopup.vue | 141 ++++ git_test.go | 3 + main.go | 2 + model/models.go | 15 +- platform/process_other.go | 5 + platform/process_windows.go | 7 + platform/process_windows_test.go | 12 + scanner_test.go | 9 +- service/git.go | 173 ++++ service/git_patch_test.go | 68 ++ service/insights.go | 69 +- service/insights_test.go | 66 ++ service/scanner.go | 15 +- shell.go | 35 +- tray_menu.go | 238 ++++++ tray_other.go | 7 + tray_windows.go | 25 + update.go | 123 ++- update_test.go | 66 ++ 38 files changed, 2618 insertions(+), 129 deletions(-) create mode 100644 frontend/public/tray.html create mode 100644 frontend/src/views/TrayPopup.vue create mode 100644 service/git_patch_test.go create mode 100644 service/insights_test.go create mode 100644 tray_menu.go create mode 100644 tray_other.go create mode 100644 tray_windows.go create mode 100644 update_test.go diff --git a/.task/checksum/build-frontend--DEV--RUNNER-npm- b/.task/checksum/build-frontend--DEV--RUNNER-npm- index 1a829c9..9051e0b 100644 --- a/.task/checksum/build-frontend--DEV--RUNNER-npm- +++ b/.task/checksum/build-frontend--DEV--RUNNER-npm- @@ -1 +1 @@ -bcd40faf16720fe7180c537d7deb93f9 +cf71ba1d3af30612c61ba8aa4e42baf4 diff --git a/.task/checksum/windows-common-generate-bindings b/.task/checksum/windows-common-generate-bindings index 5bd0fc9..dfa38c3 100644 --- a/.task/checksum/windows-common-generate-bindings +++ b/.task/checksum/windows-common-generate-bindings @@ -1 +1 @@ -caf892d1dc4259ca637eae8b231784f9 +e2b9b9c981c8af0ab85227959989ae5f diff --git a/admin.go b/admin.go index 31114ec..8b7b9bf 100644 --- a/admin.go +++ b/admin.go @@ -3,7 +3,6 @@ package main // admin.go 云端管理员(id=1)运营后台:TOTP/stepup、统计、用户/团队、发版。 import ( - "bytes" "encoding/json" "errors" "io" @@ -253,38 +252,59 @@ func (a *App) AdminUploadRelease(version, channel, changelog, filePath string) ( } defer f.Close() - var buf bytes.Buffer - w := multipart.NewWriter(&buf) - _ = w.WriteField("version", strings.TrimSpace(version)) - _ = w.WriteField("channel", strings.TrimSpace(channel)) - _ = w.WriteField("changelog", changelog) - part, e := w.CreateFormFile("file", filepath.Base(filePath)) - if e != nil { - return nil, e - } - if _, e = io.Copy(part, f); e != nil { - return nil, errors.New("SAVE_FAILED") - } - _ = w.Close() - base := a.apiBaseURL() if base == "" { return nil, errors.New("SYNC_NOT_CONFIGURED") } - req, e := http.NewRequest(http.MethodPost, base+"/api/v1/admin/releases", &buf) + pr, pw := io.Pipe() + w := multipart.NewWriter(pw) + go func() { + var copyErr error + defer func() { + _ = w.Close() + if copyErr != nil { + _ = pw.CloseWithError(copyErr) + } else { + _ = pw.Close() + } + }() + if e := w.WriteField("version", strings.TrimSpace(version)); e != nil { + copyErr = e + return + } + if e := w.WriteField("channel", strings.TrimSpace(channel)); e != nil { + copyErr = e + return + } + if e := w.WriteField("changelog", changelog); e != nil { + copyErr = e + return + } + part, e := w.CreateFormFile("file", filepath.Base(filePath)) + if e != nil { + copyErr = e + return + } + if _, e = io.Copy(part, f); e != nil { + copyErr = errors.New("SAVE_FAILED") + } + }() + req, e := http.NewRequest(http.MethodPost, base+"/api/v1/admin/releases", pr) if e != nil { + _ = pw.Close() return nil, errors.New("SYNC_OFFLINE") } req.Header.Set("Content-Type", w.FormDataContentType()) tok := strings.TrimSpace(a.store.Meta("sync_access_token")) if tok == "" { + _ = pw.Close() return nil, errors.New("SYNC_NOT_LOGGED_IN") } req.Header.Set("Authorization", "Bearer "+tok) for k, v := range headers { req.Header.Set(k, v) } - client := &http.Client{Timeout: 10 * time.Minute} + client := &http.Client{Timeout: 20 * time.Minute} resp, e := client.Do(req) if e != nil { return nil, errors.New("SYNC_OFFLINE") diff --git a/ai.go b/ai.go index 1e98cf8..9eefff7 100644 --- a/ai.go +++ b/ai.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "view/service" "view/service/ai" ) @@ -145,6 +146,49 @@ type aiStreamEvent struct { // SendAIMessage 发送一条用户消息并启动流式回复。 // conversationID 为 0 时自动创建会话;scenario: chat | project | git | todo | ticket。 func (a *App) SendAIMessage(conversationID, projectID int64, scenario, content string) (AIConversation, error) { + return a.startAIChat(conversationID, projectID, scenario, content, "", "") +} + +// SendAIDiffMessage 针对某次提交的 diff 发起 AI 分析;后续同会话追问仍带上该 diff。 +func (a *App) SendAIDiffMessage(conversationID, projectID int64, hash, content string) (AIConversation, error) { + hash = strings.TrimSpace(hash) + if hash == "" { + return AIConversation{}, errors.New("GIT_REF_NOT_FOUND") + } + if e := a.ready(); e != nil { + return AIConversation{}, e + } + if projectID <= 0 && conversationID > 0 { + convs, _ := a.store.ListAIConversations(0) + for _, c := range convs { + if c.ID == conversationID { + projectID = c.ProjectID + break + } + } + } + p, e := a.store.GetProject(projectID) + if e != nil { + return AIConversation{}, e + } + d, e := (GitAnalyzer{}).CommitDetail(a.ctx, p.Path, hash) + if e != nil { + return AIConversation{}, e + } + title := strings.TrimSpace(d.Message) + if title == "" { + title = content + } + if r := []rune(title); len(r) > 36 { + title = string(r[:36]) + "…" + } + if !strings.HasPrefix(strings.ToLower(title), "diff") { + title = "Diff: " + title + } + return a.startAIChat(conversationID, projectID, "diff", content, service.FormatCommitDiffForAI(d, 0), title) +} + +func (a *App) startAIChat(conversationID, projectID int64, scenario, content, extraSystem, title string) (AIConversation, error) { if e := a.ready(); e != nil { return AIConversation{}, e } @@ -166,7 +210,9 @@ func (a *App) SendAIMessage(conversationID, projectID int64, scenario, content s var conv AIConversation if conversationID == 0 { - title := content + if strings.TrimSpace(title) == "" { + title = content + } if r := []rune(title); len(r) > 40 { title = string(r[:40]) + "…" } @@ -197,9 +243,19 @@ func (a *App) SendAIMessage(conversationID, projectID int64, scenario, content s a.aiStreams[conv.ID] = cancel a.mu.Unlock() - // 组装消息:场景系统提示词 + 最近历史 + 本条用户消息。 + sys := a.buildAIContext(projectID, scenario, st.Locale) + if extraSystem != "" { + sys = strings.TrimRight(sys, "\n") + "\n\n" + extraSystem + if scenario == "diff" { + if st.Locale == "en" { + sys += "\nTask: Review this commit's diff. Cover intent, risks, missing tests, and brief improvements. Use only the diff above; do not invent changes.\n" + } else { + sys += "\n任务:审查这次提交的 diff,说明改动意图、潜在风险、遗漏测试,并给出简要改进建议。只依据上面的 diff,不要编造未出现的改动。\n" + } + } + } history, _ := a.store.ListAIMessages(conv.ID) - msgs := []ai.Message{{Role: "system", Content: a.buildAIContext(projectID, scenario, st.Locale)}} + msgs := []ai.Message{{Role: "system", Content: sys}} if len(history) > 20 { history = history[len(history)-20:] } diff --git a/app.go b/app.go index 1bd324a..59b31a0 100644 --- a/app.go +++ b/app.go @@ -335,7 +335,8 @@ func (a *App) RefreshProjectInsights(id int64) (ProjectInsights, error) { return ProjectInsights{}, e } git, _ := a.store.GitStats(id) - x, e := (service.InsightService{}).Analyze(a.ctx, p, structure, git) + excludes, _ := a.store.InsightExcludes(id) + x, e := (service.InsightService{}).Analyze(a.ctx, p, structure, git, excludes) if e != nil { a.store.Log("error", "项目检查", "深度检查失败", p.Name+" | "+e.Error()) return x, e @@ -438,12 +439,35 @@ func (a *App) DeleteRule(id int64) error { return a.store.DeleteRule(id) } -// GetProjectRules 项目专属排除规则(不含全局)。 +// GetProjectRules 项目专属排除规则(不含全局、不含检查专用排除)。 func (a *App) GetProjectRules(projectID int64) ([]ExclusionRule, error) { if e := a.ready(); e != nil { return nil, e } - return a.store.ProjectRules(projectID) + all, e := a.store.ProjectRules(projectID) + if e != nil { + return nil, e + } + return withoutRuleCategory(all, "insight"), nil +} + +// GetInsightExcludes 文件检查专用排除(不影响行数统计)。 +func (a *App) GetInsightExcludes(projectID int64) ([]ExclusionRule, error) { + if e := a.ready(); e != nil { + return nil, e + } + return a.store.InsightExcludes(projectID) +} + +// AddInsightExclude 添加仅对文件检查生效的排除路径。 +func (a *App) AddInsightExclude(projectID int64, pattern string) (ExclusionRule, error) { + if e := a.ready(); e != nil { + return ExclusionRule{}, e + } + if projectID <= 0 { + return ExclusionRule{}, errors.New("PROJECT_REQUIRED") + } + return a.store.AddProjectRule(projectID, pattern, "insight") } // AddProjectRule 给某项目添加专属排除规则。 @@ -454,6 +478,9 @@ func (a *App) AddProjectRule(projectID int64, pattern, category string) (Exclusi if projectID <= 0 { return ExclusionRule{}, errors.New("PROJECT_REQUIRED") } + if category == "insight" { + category = "custom" + } return a.store.AddProjectRule(projectID, pattern, category) } func (a *App) GetLogs(level string) ([]LogEntry, error) { diff --git a/database.go b/database.go index af94358..fb3d06a 100644 --- a/database.go +++ b/database.go @@ -620,9 +620,28 @@ func (s *Store) ProjectRules(projectID int64) ([]ExclusionRule, error) { return s.queryRules(`SELECT id,pattern,category,builtin,project_id FROM exclusion_rules WHERE project_id=? ORDER BY category,pattern`, projectID) } -// RulesForProject 扫描用:全局规则 + 项目专属规则叠加。 +// RulesForProject 扫描用:全局规则 + 项目专属规则叠加(不含检查专用排除)。 func (s *Store) RulesForProject(projectID int64) ([]ExclusionRule, error) { - return s.queryRules(`SELECT id,pattern,category,builtin,project_id FROM exclusion_rules WHERE project_id IN (0,?) ORDER BY project_id,category,builtin DESC,pattern`, projectID) + all, e := s.queryRules(`SELECT id,pattern,category,builtin,project_id FROM exclusion_rules WHERE project_id IN (0,?) ORDER BY project_id,category,builtin DESC,pattern`, projectID) + if e != nil { + return nil, e + } + return withoutRuleCategory(all, "insight"), nil +} + +// InsightExcludes 仅用于文件检查的项目排除规则,不影响行数统计。 +func (s *Store) InsightExcludes(projectID int64) ([]ExclusionRule, error) { + return s.queryRules(`SELECT id,pattern,category,builtin,project_id FROM exclusion_rules WHERE project_id=? AND category='insight' ORDER BY pattern`, projectID) +} + +func withoutRuleCategory(rules []ExclusionRule, category string) []ExclusionRule { + out := []ExclusionRule{} + for _, r := range rules { + if r.Category != category { + out = append(out, r) + } + } + return out } func (s *Store) queryRules(q string, args ...any) ([]ExclusionRule, error) { @@ -659,6 +678,9 @@ func (s *Store) AddProjectRule(projectID int64, pattern, category string) (Exclu } r, e := s.db.Exec(`INSERT INTO exclusion_rules(pattern,category,builtin,project_id) VALUES(?,?,0,?)`, pattern, category, projectID) if e != nil { + if strings.Contains(strings.ToLower(e.Error()), "unique") { + return ExclusionRule{}, errors.New("PATTERN_EXISTS") + } return ExclusionRule{}, e } id, _ := r.LastInsertId() @@ -788,7 +810,7 @@ func (s *Store) LogCategories() ([]string, error) { } func (s *Store) Settings() (AppSettings, error) { x := AppSettings{DatabasePath: s.path, Theme: "dark", Locale: "zh-CN", GitScope: "current", AutoRefresh: true, GlassOpacity: 55, LoadingStyle: "fullscreen-orbit", - MinimizeToTray: true, AutoUpdateMode: "daily", AutoUpdateInterval: 1, AutoUpdateTime: "09:00", AIProvider: "spark", ImageMode: "base64", ProjectSyncMode: "auto"} + MinimizeToTray: true, TrayMenuStyle: "native", AutoUpdateMode: "daily", AutoUpdateInterval: 1, AutoUpdateTime: "09:00", AIProvider: "spark", ImageMode: "base64", ProjectSyncMode: "auto"} r, e := s.db.Query(`SELECT key,value FROM settings`) if e != nil { return x, e @@ -816,6 +838,10 @@ func (s *Store) Settings() (AppSettings, error) { } case "minimizeToTray": x.MinimizeToTray = v == "true" + case "trayMenuStyle": + if validTrayMenuStyle(v) { + x.TrayMenuStyle = normalizeTrayMenuStyle(v) + } case "autoUpdateEnabled": x.AutoUpdateEnabled = v == "true" case "autoUpdateMode": @@ -865,6 +891,11 @@ func (s *Store) SaveSettings(x AppSettings) error { if !validLoadingStyle(x.LoadingStyle) { x.LoadingStyle = "fullscreen-orbit" } + if !validTrayMenuStyle(x.TrayMenuStyle) { + x.TrayMenuStyle = "native" + } else { + x.TrayMenuStyle = normalizeTrayMenuStyle(x.TrayMenuStyle) + } if x.AutoUpdateInterval < 1 || x.AutoUpdateInterval > 720 { x.AutoUpdateInterval = 1 } @@ -884,7 +915,7 @@ func (s *Store) SaveSettings(x AppSettings) error { x.ProjectSyncMode = "auto" } vals := map[string]string{"theme": x.Theme, "locale": x.Locale, "gitScope": x.GitScope, "autoRefresh": fmt.Sprint(x.AutoRefresh), "glassOpacity": strconv.Itoa(x.GlassOpacity), "loadingStyle": x.LoadingStyle, "loadingStyleSet": "true", - "minimizeToTray": fmt.Sprint(x.MinimizeToTray), "autoUpdateEnabled": fmt.Sprint(x.AutoUpdateEnabled), "autoUpdateMode": x.AutoUpdateMode, "autoUpdateInterval": strconv.Itoa(x.AutoUpdateInterval), "autoUpdateTime": x.AutoUpdateTime, + "minimizeToTray": fmt.Sprint(x.MinimizeToTray), "trayMenuStyle": x.TrayMenuStyle, "autoUpdateEnabled": fmt.Sprint(x.AutoUpdateEnabled), "autoUpdateMode": x.AutoUpdateMode, "autoUpdateInterval": strconv.Itoa(x.AutoUpdateInterval), "autoUpdateTime": x.AutoUpdateTime, "aiProvider": x.AIProvider, "sparkKey": x.SparkKey, "deepSeekKey": x.DeepSeekKey, "syncApiKeys": fmt.Sprint(x.SyncAPIKeys), "avatarMode": x.AvatarMode, "avatarValue": x.AvatarValue, "imageMode": x.ImageMode, "projectSyncMode": x.ProjectSyncMode} if x.AvatarMode != "base64" && x.AvatarMode != "url" && x.AvatarMode != "path" { @@ -933,6 +964,26 @@ func validLoadingStyle(v string) bool { return false } } + +func validTrayMenuStyle(v string) bool { + switch v { + case "native", "liquid", "glass", "compact", "minimal", "status", "neon": + return true + default: + return false + } +} + +func normalizeTrayMenuStyle(v string) string { + if v == "minimal" { + return "compact" + } + if !validTrayMenuStyle(v) { + return "native" + } + return v +} + func (s *Store) ClearData(mode string, projectID int64) error { tx, e := s.db.Begin() if e != nil { diff --git a/database_test.go b/database_test.go index 71eaeb9..31a0337 100644 --- a/database_test.go +++ b/database_test.go @@ -255,6 +255,56 @@ func TestLoadingStylePersistsAndValidates(t *testing.T) { } } +func TestTrayMenuStylePersistsAndValidates(t *testing.T) { + s, e := OpenStore(filepath.Join(t.TempDir(), "settings.db")) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + if e = s.SaveSettings(AppSettings{Theme: "dark", Locale: "zh-CN", GitScope: "current", AutoRefresh: true, GlassOpacity: 55, TrayMenuStyle: "glass"}); e != nil { + t.Fatal(e) + } + got, e := s.Settings() + if e != nil { + t.Fatal(e) + } + if got.TrayMenuStyle != "glass" { + t.Fatalf("trayMenuStyle=%q", got.TrayMenuStyle) + } + if e = s.SaveSettings(AppSettings{GlassOpacity: 55, TrayMenuStyle: "fancy"}); e != nil { + t.Fatal(e) + } + got, e = s.Settings() + if e != nil { + t.Fatal(e) + } + if got.TrayMenuStyle != "native" { + t.Fatalf("invalid trayMenuStyle did not reset: %q", got.TrayMenuStyle) + } + if e = s.SaveSettings(AppSettings{GlassOpacity: 55, TrayMenuStyle: "minimal"}); e != nil { + t.Fatal(e) + } + got, e = s.Settings() + if e != nil { + t.Fatal(e) + } + if got.TrayMenuStyle != "compact" { + t.Fatalf("minimal alias=%q", got.TrayMenuStyle) + } + for _, style := range []string{"native", "liquid", "glass", "compact", "status", "neon"} { + if e = s.SaveSettings(AppSettings{GlassOpacity: 55, TrayMenuStyle: style}); e != nil { + t.Fatal(e) + } + got, e = s.Settings() + if e != nil { + t.Fatal(e) + } + if got.TrayMenuStyle != style { + t.Fatalf("trayMenuStyle=%q want %q", got.TrayMenuStyle, style) + } + } +} + func TestMigrateSkippedWhenVersionCurrent(t *testing.T) { db := filepath.Join(t.TempDir(), "versioned.db") s, e := OpenStore(db) diff --git a/frontend/public/tray.html b/frontend/public/tray.html new file mode 100644 index 0000000..14d4588 --- /dev/null +++ b/frontend/public/tray.html @@ -0,0 +1,763 @@ + + + + + + Tray + + + +
+ + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4a7f6eb..187608c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -19,6 +19,7 @@ import TeamSwitcher from './components/TeamSwitcher.vue' import TitleBar from './components/TitleBar.vue' import CloudClaimModal from './components/CloudClaimModal.vue' import { call, isNative, on } from './api' +import TrayPopup from './views/TrayPopup.vue' const route = useRoute() const router = useRouter() @@ -30,7 +31,25 @@ const aboutOpen = ref(false) const exitOpen = ref(false) const updateInfo = ref(null) const updateBusy = ref(false) +const updateProgress = ref({ received: 0, total: 0 }) const appVersion = ref('1.0.0') +const updatePercent = computed(() => { + const total = Number(updateProgress.value.total) || 0 + const received = Number(updateProgress.value.received) || 0 + if (total <= 0) return 0 + return Math.min(100, Math.round(received / total * 100)) +}) +function fmtSize(n) { + n = Number(n) || 0 + if (n < 1024) return `${n} B` + if (n < 1048576) return `${(n / 1024).toFixed(1)} KB` + return `${(n / 1048576).toFixed(1)} MB` +} +function errText(e) { + const code = String(e).split(':')[0].trim() + const key = 'errors.' + code + return t(key) !== key ? t(key) : String(e) +} const displayedTask = ref(null) let off let offMenus = [] @@ -41,6 +60,7 @@ const visibleTask = computed(() => activeTask.value || displayedTask.value) const activeTaskProject = computed(() => visibleTask.value?.params?.project || store.projects.find(p => p.id === visibleTask.value?.projectId)?.name || '') const loadingStyle = computed(() => store.settings.loadingStyle === 'fullscreen' ? 'fullscreen-orbit' : (store.settings.loadingStyle || 'fullscreen-orbit')) const useFullscreenLoading = computed(() => loadingStyle.value !== 'bar') +const isTrayWindow = document.documentElement.classList.contains('tray-window') async function updateSetting(key, value) { const next = await store.saveSettings({ [key]: value }) @@ -220,6 +240,14 @@ function onGlobalKey(e) { } } onMounted(async () => { + if (isTrayWindow) { + document.documentElement.classList.add('tray-window') + try { + store.applyAppearance(await call('GetSettings')) + locale.value = store.settings.locale || 'zh-CN' + } catch { /* 托盘弹层只需要外观,失败时沿用本地缓存 */ } + return + } addEventListener('keydown', onGlobalKey) addEventListener('click', onFlyoutAway) if (!native) return @@ -241,6 +269,9 @@ onMounted(async () => { on('menu:set', p => p?.key && updateSetting(p.key, p.value)), on('app:update-available', p => { if (p && !p.upToDate && !p.skipped) updateInfo.value = p + }), + on('app:update-progress', p => { + if (p) updateProgress.value = { received: Number(p.received) || 0, total: Number(p.total) || 0 } }) ] await store.boot() @@ -248,15 +279,18 @@ onMounted(async () => { try { appVersion.value = await call('GetAppVersion') } catch { /* keep fallback */ } }) async function installUpdate() { + if (updateBusy.value) return updateBusy.value = true + updateProgress.value = { received: 0, total: Number(updateInfo.value?.sizeBytes) || 0 } try { await call('DownloadAndInstallUpdate') } catch (e) { - store.showToast({ type: 'error', text: String(e) }) + store.showToast({ type: 'error', text: errText(e) }) updateBusy.value = false } } async function skipUpdate() { + if (updateBusy.value) return try { await call('SkipAppUpdate', updateInfo.value?.latest || '') } catch {} updateInfo.value = null } @@ -285,7 +319,8 @@ watch(activeTask, task => {