Files
code-utils/shell.go

463 lines
15 KiB
Go
Raw Normal View History

2026-08-14 07:52:01 +08:00
package main
import (
_ "embed"
"errors"
"fmt"
2026-08-14 17:54:06 +08:00
"strings"
2026-08-14 07:52:01 +08:00
"sync/atomic"
"time"
"view/service"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
2026-08-14 17:54:06 +08:00
"github.com/wailsapp/wails/v3/pkg/services/notifications"
2026-08-14 07:52:01 +08:00
)
//go:embed build/appicon.png
var appIcon []byte
const appVersion = "2.0.0"
// shellText 是原生菜单/托盘的本地化文案。
type shellText struct {
file, newProject, batchAnalyze, database, quit string
view, theme, themeDark, themeLight, themeSystem, language string
tools, analyzeNow, settings, logsPage, help, about string
trayShow, trayBatch, trayQuit, aiHub string
account, accountLogin, traySync, profile, logout string
trayProjects, trayAllProjects string
trayTodos, trayTickets, trayMessages, trayAutostart string
2026-08-14 17:54:06 +08:00
trayToday, trayQuick, trayStatusSyncing string
trayStatusOnline, trayStatusOffline, trayStatusSignedOut string
trayStatusError, trayPending, trayUnread, trayDueToday string
2026-08-14 07:52:01 +08:00
}
func shellTexts(locale string) shellText {
if locale == "en" {
return shellText{
file: "File", newProject: "New Project", batchAnalyze: "Analyze All", database: "Data Management", quit: "Quit",
view: "View", theme: "Theme", themeDark: "Dark", themeLight: "Light", themeSystem: "System", language: "Language",
tools: "Tools", analyzeNow: "Analyze All Now", settings: "Settings", logsPage: "Activity Log", help: "Help", about: "About",
trayShow: "Show Main Window", trayBatch: "Analyze All Projects", trayQuit: "Quit", aiHub: "AI Analysis",
account: "Account", accountLogin: "Sign In / Register", traySync: "Sync Now", profile: "Profile", logout: "Sign Out",
trayProjects: "Open Project", trayAllProjects: "All Projects…",
trayTodos: "Todos", trayTickets: "Tickets", trayMessages: "Messages", trayAutostart: "Start at Login",
2026-08-14 17:54:06 +08:00
trayToday: "Today", trayQuick: "Quick Open",
trayStatusSyncing: "Syncing…", trayStatusOnline: "Online", trayStatusOffline: "Offline",
trayStatusSignedOut: "Signed out", trayStatusError: "Sync error",
trayPending: "Pending %d", trayUnread: "Unread %d", trayDueToday: "Due today %d",
2026-08-14 07:52:01 +08:00
}
}
return shellText{
file: "文件", newProject: "新建项目", batchAnalyze: "批量统计", database: "数据管理", quit: "退出",
view: "视图", theme: "主题", themeDark: "暗色", themeLight: "浅色", themeSystem: "跟随系统", language: "语言",
tools: "工具", analyzeNow: "立即分析全部", settings: "设置", logsPage: "运行日志", help: "帮助", about: "关于",
trayShow: "显示主窗口", trayBatch: "批量统计全部项目", trayQuit: "退出", aiHub: "AI 分析",
account: "账号", accountLogin: "登录 / 注册", traySync: "立即同步", profile: "个人中心", logout: "退出登录",
trayProjects: "打开项目", trayAllProjects: "全部项目…",
trayTodos: "待办事项", trayTickets: "需求工单", trayMessages: "消息中心", trayAutostart: "开机启动",
2026-08-14 17:54:06 +08:00
trayToday: "今日任务", trayQuick: "快捷入口",
trayStatusSyncing: "同步中…", trayStatusOnline: "在线", trayStatusOffline: "离线",
trayStatusSignedOut: "未登录", trayStatusError: "同步异常",
trayPending: "待推送 %d", trayUnread: "未读消息 %d", trayDueToday: "今日到期 %d",
2026-08-14 07:52:01 +08:00
}
}
// shellState 保存原生外壳(窗口/托盘/菜单)的运行引用。
type shellState struct {
wapp *application.App
win *application.WebviewWindow
tray *application.SystemTray
quitting atomic.Bool
}
// SetupShell 在窗口创建后接管关闭行为,并挂载原生菜单与系统托盘。
func (a *App) SetupShell(wapp *application.App, win *application.WebviewWindow) {
a.shell = &shellState{wapp: wapp, win: win}
win.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
if a.shell.quitting.Load() {
return
}
if a.minimizeToTrayEnabled() {
win.Hide()
e.Cancel()
}
})
a.shell.tray = wapp.SystemTray.New()
a.shell.tray.SetIcon(appIcon)
2026-08-14 17:54:06 +08:00
a.shell.tray.OnClick(a.showMainWindow)
a.shell.tray.OnDoubleClick(a.showMainWindow)
a.wireNotificationClicks()
2026-08-14 07:52:01 +08:00
a.RefreshShell()
}
2026-08-14 17:54:06 +08:00
// wireNotificationClicks 点击系统通知时唤起窗口并跳到对应页面。
func (a *App) wireNotificationClicks() {
if a.notifier == nil {
return
}
a.notifier.OnNotificationResponse(func(result notifications.NotificationResult) {
if result.Error != nil {
return
}
application.InvokeAsync(func() {
a.showMainWindow()
path := notificationNavPath(result.Response)
if path != "" {
a.emit("menu:navigate", path)
}
})
})
}
func notificationNavPath(r notifications.NotificationResponse) string {
if r.UserInfo != nil {
if p, ok := r.UserInfo["path"].(string); ok && p != "" {
return p
}
}
switch {
case strings.HasPrefix(r.ID, "cc-todo_due-"):
return "/todos"
case strings.HasPrefix(r.ID, "cc-ticket_due-"):
return "/tickets"
case strings.HasPrefix(r.ID, "cc-team-"):
return "/team/tasks"
case strings.HasPrefix(r.ID, "cc-sync-"):
return "/messages"
case strings.HasPrefix(r.ID, "cc-analysis-"):
return "/logs"
default:
return "/messages"
}
}
2026-08-14 07:52:01 +08:00
// RefreshShell 按当前语言与登录态重建原生菜单与托盘菜单(设置/登录变更后调用)。
func (a *App) RefreshShell() {
if a.shell == nil {
return
}
locale, theme := "zh-CN", "dark"
if a.store != nil {
if st, e := a.store.Settings(); e == nil {
locale, theme = st.Locale, st.Theme
}
}
t := shellTexts(locale)
menu := a.buildAppMenu(t, theme, locale)
2026-08-14 17:54:06 +08:00
// macOS 用全局应用菜单Windows 已走无边框自定义顶栏,不再 SetMenu。
2026-08-14 07:52:01 +08:00
a.shell.wapp.Menu.SetApplicationMenu(menu)
2026-08-14 17:54:06 +08:00
a.refreshTray(t, locale)
}
// refreshTray 更新托盘 tooltip / 状态色点图标 / 右键菜单。
func (a *App) refreshTray(t shellText, locale string) {
if a.shell == nil || a.shell.tray == nil {
return
2026-08-14 07:52:01 +08:00
}
2026-08-14 17:54:06 +08:00
st := a.collectTrayStatus()
a.shell.tray.SetTooltip(st.tooltip(locale))
a.shell.tray.SetIcon(trayIconWithBadge(appIcon, st.badgeColor()))
a.shell.tray.SetMenu(a.buildTrayMenu(t, st))
2026-08-14 07:52:01 +08:00
a.shell.tray.OnClick(a.showMainWindow)
2026-08-14 17:54:06 +08:00
a.shell.tray.OnDoubleClick(a.showMainWindow)
}
// RefreshTrayStatus 轻量刷新托盘状态(同步/消息变更时调用,不重建应用菜单)。
func (a *App) RefreshTrayStatus() {
if a.shell == nil {
return
}
locale := "zh-CN"
if a.store != nil {
if st, e := a.store.Settings(); e == nil {
locale = st.Locale
}
}
a.refreshTray(shellTexts(locale), locale)
2026-08-14 07:52:01 +08:00
}
func (a *App) buildAppMenu(t shellText, theme, locale string) *application.Menu {
menu := a.shell.wapp.Menu.New()
file := menu.AddSubmenu(t.file)
file.Add(t.newProject).SetAccelerator("CmdOrCtrl+N").OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:action", "addProject")
})
file.Add(t.batchAnalyze).SetAccelerator("CmdOrCtrl+Shift+A").OnClick(func(*application.Context) {
_, _ = a.StartBatchAnalysis()
})
file.Add(t.database).OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:navigate", "/settings?tab=database")
})
file.AddSeparator()
file.Add(t.quit).SetAccelerator("CmdOrCtrl+Q").OnClick(func(*application.Context) { a.QuitApp() })
view := menu.AddSubmenu(t.view)
themeMenu := view.AddSubmenu(t.theme)
for _, x := range []struct{ label, value string }{{t.themeDark, "dark"}, {t.themeLight, "light"}, {t.themeSystem, "system"}} {
v := x.value
themeMenu.AddRadio(x.label, theme == v).OnClick(func(*application.Context) {
a.emit("menu:set", map[string]string{"key": "theme", "value": v})
})
}
langMenu := view.AddSubmenu(t.language)
for _, x := range []struct{ label, value string }{{"简体中文", "zh-CN"}, {"English", "en"}} {
v := x.value
langMenu.AddRadio(x.label, locale == v).OnClick(func(*application.Context) {
a.emit("menu:set", map[string]string{"key": "locale", "value": v})
})
}
tools := menu.AddSubmenu(t.tools)
tools.Add(t.analyzeNow).OnClick(func(*application.Context) { _, _ = a.StartBatchAnalysis() })
tools.Add(t.aiHub).SetAccelerator("CmdOrCtrl+I").OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:navigate", "/ai")
})
tools.Add(t.settings).SetAccelerator("CmdOrCtrl+,").OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:navigate", "/settings")
})
tools.Add(t.logsPage).OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:navigate", "/logs")
})
account := menu.AddSubmenu(t.account)
if a.store != nil && a.syncUserID() > 0 {
name := a.store.Meta("sync_username")
if name == "" {
name = t.profile
}
account.Add(name + "" + t.profile + "").OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:navigate", "/profile")
})
account.Add(t.traySync).OnClick(func(*application.Context) { a.trayQuickSync() })
account.AddSeparator()
account.Add(t.logout).OnClick(func(*application.Context) {
_ = a.SyncLogout()
a.emit("menu:action", "sync-refresh")
})
} else {
account.Add(t.accountLogin).OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:action", "account")
})
}
help := menu.AddSubmenu(t.help)
help.Add(t.about).OnClick(func(*application.Context) {
a.showMainWindow()
a.emit("menu:action", "about")
})
return menu
}
// trayQuickSync 托盘/菜单快捷同步:已登录直接后台同步一轮,未登录弹出登录层。
func (a *App) trayQuickSync() {
if a.store != nil && a.syncUserID() > 0 {
go a.syncOnce(true)
return
}
a.showMainWindow()
a.emit("menu:action", "account")
}
// trayProjectLimit 是托盘「打开项目」子菜单最多列出的项目数,超出走「全部项目…」。
2026-08-14 17:54:06 +08:00
const trayProjectLimit = 8
2026-08-14 07:52:01 +08:00
2026-08-14 17:54:06 +08:00
func (a *App) buildTrayMenu(t shellText, st trayStatus) *application.Menu {
2026-08-14 07:52:01 +08:00
menu := a.shell.wapp.Menu.New()
menu.Add(t.trayShow).OnClick(func(*application.Context) { a.showMainWindow() })
menu.AddSeparator()
2026-08-14 17:54:06 +08:00
// 状态区:点击状态行触发同步(或登录)。
statusLabel := trayStatusLabel(t, st)
menu.Add(statusLabel).OnClick(func(*application.Context) { a.trayQuickSync() })
if st.unread > 0 {
menu.Add(fmt.Sprintf(t.trayUnread, st.unread)).OnClick(func(*application.Context) { a.navigateTo("/messages") })
}
if st.todayDue > 0 {
menu.Add(fmt.Sprintf(t.trayDueToday, st.todayDue)).OnClick(func(*application.Context) { a.navigateTo("/today") })
}
menu.AddSeparator()
2026-08-14 07:52:01 +08:00
if projects, e := a.ListProjects(); e == nil && len(projects) > 0 {
sub := menu.AddSubmenu(t.trayProjects)
for i, p := range projects {
if i >= trayProjectLimit {
break
}
path := fmt.Sprintf("/project/%d", p.ID)
sub.Add(p.Name).OnClick(func(*application.Context) { a.navigateTo(path) })
}
if len(projects) > trayProjectLimit {
sub.AddSeparator()
sub.Add(t.trayAllProjects).OnClick(func(*application.Context) { a.navigateTo("/projects") })
}
}
2026-08-14 17:54:06 +08:00
quick := menu.AddSubmenu(t.trayQuick)
quick.Add(t.trayToday).OnClick(func(*application.Context) { a.navigateTo("/today") })
quick.Add(t.trayTodos).OnClick(func(*application.Context) { a.navigateTo("/todos") })
quick.Add(t.trayTickets).OnClick(func(*application.Context) { a.navigateTo("/tickets") })
quick.Add(t.trayMessages).OnClick(func(*application.Context) { a.navigateTo("/messages") })
quick.Add(t.aiHub).OnClick(func(*application.Context) { a.navigateTo("/ai") })
2026-08-14 07:52:01 +08:00
menu.AddSeparator()
menu.Add(t.traySync).OnClick(func(*application.Context) { a.trayQuickSync() })
2026-08-14 17:54:06 +08:00
menu.Add(t.trayBatch).OnClick(func(*application.Context) { _, _ = a.StartBatchAnalysis() })
2026-08-14 07:52:01 +08:00
menu.AddSeparator()
2026-08-14 17:54:06 +08:00
2026-08-14 07:52:01 +08:00
autostart, autoErr := a.GetAutostart()
menu.AddCheckbox(t.trayAutostart, autoErr == nil && autostart).OnClick(func(*application.Context) {
_ = a.SetAutostart(!autostart)
a.RefreshShell()
})
menu.AddSeparator()
menu.Add(t.trayQuit).OnClick(func(*application.Context) { a.QuitApp() })
return menu
}
2026-08-14 17:54:06 +08:00
func trayStatusLabel(t shellText, st trayStatus) string {
var core string
switch {
case st.syncing:
core = t.trayStatusSyncing
case !st.loggedIn:
core = t.trayStatusSignedOut
case st.lastErr != "":
core = t.trayStatusError
case st.online:
core = t.trayStatusOnline
if st.username != "" {
core = st.username + " · " + core
}
default:
core = t.trayStatusOffline
}
if st.pending > 0 {
core += " · " + fmt.Sprintf(t.trayPending, st.pending)
}
return core
}
2026-08-14 07:52:01 +08:00
// navigateTo 唤起主窗口并让前端路由跳到指定页面(托盘/原生菜单共用)。
func (a *App) navigateTo(path string) {
a.showMainWindow()
a.emit("menu:navigate", path)
}
func (a *App) showMainWindow() {
if a.shell == nil || a.shell.win == nil {
return
}
a.shell.win.Show()
a.shell.win.Restore()
a.shell.win.Focus()
}
func (a *App) minimizeToTrayEnabled() bool {
if a.store == nil {
return true
}
st, e := a.store.Settings()
if e != nil {
return true
}
return st.MinimizeToTray
}
// QuitApp 退出应用(绕过最小化到托盘逻辑)。
func (a *App) QuitApp() {
if a.shell != nil {
a.shell.quitting.Store(true)
a.shell.wapp.Quit()
}
}
func (a *App) GetAppVersion() string { return appVersion }
// GetAutostart 查询开机自启是否已注册。
func (a *App) GetAutostart() (bool, error) {
app := application.Get()
if app == nil {
return false, errors.New("AUTOSTART_UNAVAILABLE")
}
return app.Autostart.IsEnabled()
}
// SetAutostart 注册/注销开机自启。
func (a *App) SetAutostart(enable bool) error {
app := application.Get()
if app == nil {
return errors.New("AUTOSTART_UNAVAILABLE")
}
var e error
if enable {
e = app.Autostart.Enable()
} else {
e = app.Autostart.Disable()
}
if e != nil {
return coded("AUTOSTART_FAILED", e)
}
if a.store != nil {
state := "已开启"
if !enable {
state = "已关闭"
}
a.store.Log("info", "系统", "开机自启"+state, "")
}
return nil
}
// runAutoUpdateLoop 周期检查是否到达自动更新时间点。
func (a *App) runAutoUpdateLoop() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-t.C:
a.checkAutoUpdate()
}
}
}
func (a *App) checkAutoUpdate() {
if a.store == nil || a.bootstrap.State != BootstrapReady {
return
}
st, e := a.store.Settings()
if e != nil || !st.AutoUpdateEnabled {
return
}
now := time.Now()
last, _ := time.Parse(time.RFC3339, a.store.Meta("lastAutoUpdateAt"))
if last.IsZero() {
// 首次启用:以当前时间为基线,避免立即触发一轮全量统计。
_ = a.store.SetMeta("lastAutoUpdateAt", now.Format(time.RFC3339))
return
}
next := service.NextAutoUpdate(st.AutoUpdateMode, st.AutoUpdateInterval, st.AutoUpdateTime, last, now)
if now.Before(next) {
return
}
a.mu.Lock()
busy := len(a.tasks) > 0
a.mu.Unlock()
if busy {
return
}
_ = a.store.SetMeta("lastAutoUpdateAt", now.Format(time.RFC3339))
a.store.Log("info", "自动更新", "定时批量统计开始", "模式: "+st.AutoUpdateMode)
_, _ = a.StartBatchAnalysis()
}