Files
code-utils/shell.go
2026-08-14 07:52:01 +08:00

344 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
_ "embed"
"errors"
"fmt"
"sync/atomic"
"time"
"view/service"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
)
//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
}
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",
}
}
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: "开机启动",
}
}
// 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)
a.RefreshShell()
}
// 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)
a.shell.wapp.Menu.SetApplicationMenu(menu)
// Windows/Linux 的菜单栏挂在窗口上macOS 用全局应用菜单SetMenu 是 no-op
if a.shell.win != nil {
a.shell.win.SetMenu(menu)
}
a.shell.tray.SetTooltip("年糕崽崽项目管理PMS")
a.shell.tray.SetMenu(a.buildTrayMenu(t))
a.shell.tray.OnClick(a.showMainWindow)
}
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 是托盘「打开项目」子菜单最多列出的项目数,超出走「全部项目…」。
const trayProjectLimit = 12
func (a *App) buildTrayMenu(t shellText) *application.Menu {
menu := a.shell.wapp.Menu.New()
menu.Add(t.trayShow).OnClick(func(*application.Context) { a.showMainWindow() })
menu.AddSeparator()
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") })
}
}
menu.Add(t.trayTodos).OnClick(func(*application.Context) { a.navigateTo("/todos") })
menu.Add(t.trayTickets).OnClick(func(*application.Context) { a.navigateTo("/tickets") })
menu.Add(t.trayMessages).OnClick(func(*application.Context) { a.navigateTo("/messages") })
menu.AddSeparator()
menu.Add(t.trayBatch).OnClick(func(*application.Context) { _, _ = a.StartBatchAnalysis() })
menu.Add(t.traySync).OnClick(func(*application.Context) { a.trayQuickSync() })
menu.AddSeparator()
// 勾选态取自系统注册状态;点击写入后重建托盘,让勾选跟随真实结果。
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
}
// 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()
}