344 lines
11 KiB
Go
344 lines
11 KiB
Go
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()
|
||
}
|