更新若干功能
This commit is contained in:
343
shell.go
Normal file
343
shell.go
Normal file
@@ -0,0 +1,343 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user