2026-08-11 19:07:05 +08:00
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-14 07:51:46 +08:00
|
|
|
|
"bytes"
|
2026-08-11 19:07:05 +08:00
|
|
|
|
"context"
|
|
|
|
|
|
"database/sql"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
2026-08-14 07:51:46 +08:00
|
|
|
|
"io"
|
2026-08-11 19:07:05 +08:00
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"sync"
|
2026-08-14 07:51:46 +08:00
|
|
|
|
"sync/atomic"
|
2026-08-11 19:07:05 +08:00
|
|
|
|
"time"
|
|
|
|
|
|
"view/platform"
|
|
|
|
|
|
"view/service"
|
|
|
|
|
|
|
2026-08-14 07:51:46 +08:00
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
2026-08-11 19:07:05 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type App struct {
|
|
|
|
|
|
ctx context.Context
|
|
|
|
|
|
store *Store
|
|
|
|
|
|
bootstrapFile string
|
|
|
|
|
|
bootstrap BootstrapStatus
|
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
|
tasks map[string]context.CancelFunc
|
2026-08-14 07:51:46 +08:00
|
|
|
|
taskResults map[string]string
|
|
|
|
|
|
aiStreams map[int64]context.CancelFunc
|
|
|
|
|
|
shell *shellState
|
|
|
|
|
|
notifier *notifications.NotificationService
|
|
|
|
|
|
syncing atomic.Bool
|
|
|
|
|
|
syncOnline atomic.Bool
|
|
|
|
|
|
syncMu sync.Mutex
|
|
|
|
|
|
syncLastErr string
|
|
|
|
|
|
// 全局文件存储配置的实时读取缓存(见 filestorage.go currentFileStorage)。
|
|
|
|
|
|
fsMu sync.Mutex
|
|
|
|
|
|
fsCfg FileStorageConfig
|
|
|
|
|
|
fsCfgAt time.Time
|
2026-08-15 17:18:00 +08:00
|
|
|
|
// 管理员敏感操作二次验证(内存,进程内有效)
|
|
|
|
|
|
stepupMu sync.Mutex
|
|
|
|
|
|
stepupToken string
|
|
|
|
|
|
stepupExp time.Time
|
2026-08-14 07:51:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func NewApp() *App {
|
|
|
|
|
|
return &App{tasks: map[string]context.CancelFunc{}, taskResults: map[string]string{}, aiStreams: map[int64]context.CancelFunc{}}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// emit 向前端广播事件;纯测试环境(无 Wails 应用实例)下静默忽略。
|
|
|
|
|
|
func (a *App) emit(name string, data any) {
|
|
|
|
|
|
if app := application.Get(); app != nil {
|
|
|
|
|
|
app.Event.Emit(name, data)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ServiceStartup 由 Wails v3 在应用启动时调用,执行数据库引导流程。
|
|
|
|
|
|
func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {
|
|
|
|
|
|
a.startup(ctx)
|
|
|
|
|
|
a.RefreshShell()
|
|
|
|
|
|
go a.runAutoUpdateLoop()
|
2026-08-15 17:18:00 +08:00
|
|
|
|
go a.runAppUpdateLoop()
|
2026-08-14 07:51:46 +08:00
|
|
|
|
go a.runReminderLoop()
|
|
|
|
|
|
go a.runSyncLoop()
|
|
|
|
|
|
go a.runTeamDigestLoop()
|
2026-08-15 17:18:00 +08:00
|
|
|
|
go a.runActivityPingLoop()
|
2026-08-14 07:51:46 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ServiceShutdown 由 Wails v3 在应用退出时调用。
|
|
|
|
|
|
func (a *App) ServiceShutdown() error {
|
|
|
|
|
|
a.shutdown(context.Background())
|
|
|
|
|
|
return nil
|
2026-08-11 19:07:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) startup(ctx context.Context) {
|
|
|
|
|
|
a.ctx = ctx
|
|
|
|
|
|
defaultPath, e := defaultDBPath()
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
a.bootstrap = BootstrapStatus{State: BootstrapRecovery, ErrorCode: "DB_DEFAULT_PATH_FAILED", ErrorDetail: e.Error()}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
a.bootstrapFile, e = bootstrapPath()
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
a.bootstrap = BootstrapStatus{State: BootstrapRecovery, DefaultPath: defaultPath, ErrorCode: "BOOTSTRAP_PATH_FAILED", ErrorDetail: e.Error()}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
c, e := readBootstrap(a.bootstrapFile)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
if os.IsNotExist(e) {
|
|
|
|
|
|
if _, se := os.Stat(defaultPath); se == nil {
|
|
|
|
|
|
c = BootstrapConfig{DatabasePath: defaultPath, Initialized: true}
|
|
|
|
|
|
_ = writeBootstrap(a.bootstrapFile, c)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
a.bootstrap = BootstrapStatus{State: BootstrapSetup, DefaultPath: defaultPath, DatabasePath: defaultPath}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
a.bootstrap = BootstrapStatus{State: BootstrapRecovery, DefaultPath: defaultPath, DatabasePath: defaultPath, ErrorCode: "BOOTSTRAP_INVALID", ErrorDetail: e.Error()}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, e = os.Stat(c.DatabasePath); e != nil {
|
|
|
|
|
|
code := "DB_FILE_UNREADABLE"
|
|
|
|
|
|
if os.IsNotExist(e) {
|
|
|
|
|
|
code = "DB_FILE_MISSING"
|
|
|
|
|
|
}
|
|
|
|
|
|
a.bootstrap = BootstrapStatus{State: BootstrapRecovery, DefaultPath: defaultPath, DatabasePath: c.DatabasePath, ErrorCode: code, ErrorDetail: e.Error()}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
s, e := OpenStore(c.DatabasePath)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
a.bootstrap = bootstrapFailure(defaultPath, c.DatabasePath, e)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
a.store = s
|
|
|
|
|
|
a.bootstrap = BootstrapStatus{State: BootstrapReady, DefaultPath: defaultPath, DatabasePath: c.DatabasePath}
|
2026-08-15 07:29:45 +08:00
|
|
|
|
a.applyPackagedSyncConfig()
|
2026-08-11 19:07:05 +08:00
|
|
|
|
a.store.Log("info", "系统", "应用程序启动", "")
|
|
|
|
|
|
a.store.Log("info", "数据库", "桌面运行时已连接", s.path)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) GetBootstrapStatus() BootstrapStatus { return a.bootstrap }
|
|
|
|
|
|
func (a *App) SelectInitialDatabaseFile(defaultPath string) (string, error) {
|
|
|
|
|
|
if strings.TrimSpace(defaultPath) == "" {
|
|
|
|
|
|
defaultPath = a.bootstrap.DefaultPath
|
|
|
|
|
|
}
|
2026-08-14 07:51:46 +08:00
|
|
|
|
return application.Get().Dialog.SaveFile().
|
|
|
|
|
|
SetMessage("Initialize 年糕崽崽 PMS database").
|
|
|
|
|
|
SetDirectory(filepath.Dir(defaultPath)).
|
|
|
|
|
|
SetFilename(filepath.Base(defaultPath)).
|
|
|
|
|
|
AddFilter("SQLite Database", "*.db").
|
|
|
|
|
|
PromptForSingleSelection()
|
2026-08-11 19:07:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
func (a *App) InitializeDatabase(path string) (BootstrapStatus, error) {
|
|
|
|
|
|
a.mu.Lock()
|
|
|
|
|
|
defer a.mu.Unlock()
|
|
|
|
|
|
s, e := createValidatedStore(path)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
a.bootstrap = bootstrapFailure(a.bootstrap.DefaultPath, path, e)
|
|
|
|
|
|
return a.bootstrap, e
|
|
|
|
|
|
}
|
|
|
|
|
|
if a.store != nil {
|
|
|
|
|
|
_ = a.store.db.Close()
|
|
|
|
|
|
}
|
|
|
|
|
|
a.store = s
|
|
|
|
|
|
if e = writeBootstrap(a.bootstrapFile, BootstrapConfig{DatabasePath: s.path, Initialized: true}); e != nil {
|
|
|
|
|
|
_ = s.db.Close()
|
|
|
|
|
|
a.store = nil
|
|
|
|
|
|
a.bootstrap = bootstrapFailure(a.bootstrap.DefaultPath, path, coded("BOOTSTRAP_WRITE_FAILED", e))
|
|
|
|
|
|
return a.bootstrap, e
|
|
|
|
|
|
}
|
|
|
|
|
|
a.bootstrap = BootstrapStatus{State: BootstrapReady, DefaultPath: a.bootstrap.DefaultPath, DatabasePath: s.path}
|
2026-08-15 07:29:45 +08:00
|
|
|
|
a.applyPackagedSyncConfig()
|
2026-08-11 19:07:05 +08:00
|
|
|
|
a.store.Log("info", "数据库", "数据库初始化成功", s.path)
|
|
|
|
|
|
return a.bootstrap, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) RetryDatabase() (BootstrapStatus, error) {
|
|
|
|
|
|
return a.InitializeDatabase(a.bootstrap.DatabasePath)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) shutdown(context.Context) {
|
|
|
|
|
|
if a.store != nil && a.store.db != nil {
|
|
|
|
|
|
_ = a.store.db.Close()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) ready() error {
|
|
|
|
|
|
if a.store == nil {
|
|
|
|
|
|
return errors.New("DATABASE_NOT_READY")
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) SelectDirectory() (string, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return "", e
|
|
|
|
|
|
}
|
2026-08-14 07:51:46 +08:00
|
|
|
|
return application.Get().Dialog.OpenFile().
|
|
|
|
|
|
SetTitle(a.localized("选择代码目录", "Select code directory")).
|
|
|
|
|
|
CanChooseDirectories(true).
|
|
|
|
|
|
CanChooseFiles(false).
|
|
|
|
|
|
CanCreateDirectories(true).
|
|
|
|
|
|
PromptForSingleSelection()
|
2026-08-11 19:07:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ListWSLDistros 返回本机可用的 WSL 发行版,供前端创建 UNC 项目路径。
|
|
|
|
|
|
func (a *App) ListWSLDistros() ([]string, error) { return platform.ListWSLDistros(a.ctx) }
|
|
|
|
|
|
|
|
|
|
|
|
// SelectWSLDirectory 从指定发行版根目录打开原生目录选择器。
|
|
|
|
|
|
func (a *App) SelectWSLDirectory(distro string) (string, error) {
|
|
|
|
|
|
if strings.TrimSpace(distro) == "" {
|
|
|
|
|
|
return "", errors.New("WSL_DISTRO_REQUIRED")
|
|
|
|
|
|
}
|
|
|
|
|
|
root := "\\\\wsl.localhost\\" + distro + "\\"
|
2026-08-14 07:51:46 +08:00
|
|
|
|
return application.Get().Dialog.OpenFile().
|
|
|
|
|
|
SetTitle(a.localized("选择 WSL 代码目录", "Select WSL code directory")).
|
|
|
|
|
|
SetDirectory(root).
|
|
|
|
|
|
CanChooseDirectories(true).
|
|
|
|
|
|
CanChooseFiles(false).
|
|
|
|
|
|
PromptForSingleSelection()
|
2026-08-11 19:07:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) localized(zh, en string) string {
|
|
|
|
|
|
if a.store != nil {
|
|
|
|
|
|
if s, e := a.store.Settings(); e == nil && s.Locale == "en" {
|
|
|
|
|
|
return en
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return zh
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) SelectDatabaseFile() (string, error) {
|
2026-08-14 07:51:46 +08:00
|
|
|
|
return application.Get().Dialog.SaveFile().
|
|
|
|
|
|
SetMessage("Select database location").
|
|
|
|
|
|
SetFilename("code-count.db").
|
|
|
|
|
|
AddFilter("SQLite Database", "*.db").
|
|
|
|
|
|
PromptForSingleSelection()
|
2026-08-11 19:07:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
func (a *App) ListProjects() ([]Project, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.ListProjects(0)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) ListProjectsByGroup(groupID int64) ([]Project, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.ListProjects(groupID)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) ListProjectGroups() ([]ProjectGroup, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.ListProjectGroups()
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) SaveProjectGroup(id int64, name string) (ProjectGroup, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return ProjectGroup{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
g, e := a.store.SaveProjectGroup(id, name)
|
|
|
|
|
|
if e == nil {
|
|
|
|
|
|
a.store.Log("info", "project", "Project group saved", g.Name)
|
|
|
|
|
|
}
|
|
|
|
|
|
return g, e
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) DeleteProjectGroup(id int64) error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
e := a.store.DeleteProjectGroup(id)
|
|
|
|
|
|
if e == nil {
|
|
|
|
|
|
a.store.Log("info", "project", "Project group deleted", fmt.Sprint(id))
|
|
|
|
|
|
}
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) GetProject(id int64) (Project, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return Project{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.GetProject(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) SaveProject(id int64, in ProjectInput) (Project, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return Project{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
p, e := a.store.SaveProject(id, in)
|
|
|
|
|
|
if e == nil {
|
|
|
|
|
|
a.store.Log("info", "项目", "项目保存成功", p.Path)
|
2026-08-14 07:51:46 +08:00
|
|
|
|
a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表
|
2026-08-11 19:07:05 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
a.store.Log("error", "项目", "项目保存失败", in.Path+" | "+e.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
return p, e
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) ReportClientError(category, message, detail string) {
|
|
|
|
|
|
if a.store != nil {
|
|
|
|
|
|
a.store.Log("error", category, message, detail)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) DeleteProject(id int64) error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
e := a.store.DeleteProject(id)
|
|
|
|
|
|
if e == nil {
|
|
|
|
|
|
a.store.Log("info", "项目", "项目删除成功", fmt.Sprint(id))
|
2026-08-14 07:51:46 +08:00
|
|
|
|
a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表
|
2026-08-11 19:07:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) GetDashboard() (Dashboard, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return Dashboard{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.Dashboard(0)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) GetDashboardByGroup(groupID int64) (Dashboard, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return Dashboard{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.Dashboard(groupID)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) GetStructure(id int64) (StructureStats, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return StructureStats{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.Structure(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) GetProjectInsights(id int64) (ProjectInsights, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return ProjectInsights{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
x, e := a.store.Insights(id)
|
|
|
|
|
|
if e == nil {
|
|
|
|
|
|
return x, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if errors.Is(e, sql.ErrNoRows) {
|
|
|
|
|
|
return a.RefreshProjectInsights(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
return ProjectInsights{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) RefreshProjectInsights(id int64) (ProjectInsights, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return ProjectInsights{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
p, e := a.store.GetProject(id)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return ProjectInsights{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
structure, e := a.store.Structure(id)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return ProjectInsights{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
git, _ := a.store.GitStats(id)
|
|
|
|
|
|
x, e := (service.InsightService{}).Analyze(a.ctx, p, structure, git)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
a.store.Log("error", "项目检查", "深度检查失败", p.Name+" | "+e.Error())
|
|
|
|
|
|
return x, e
|
|
|
|
|
|
}
|
|
|
|
|
|
if e = a.store.ReplaceInsights(id, x); e != nil {
|
|
|
|
|
|
a.store.Log("error", "项目检查", "保存检查结果失败", p.Name+" | "+e.Error())
|
|
|
|
|
|
return x, e
|
|
|
|
|
|
}
|
|
|
|
|
|
a.store.Log("info", "项目检查", "深度检查完成", fmt.Sprintf("%s | %d 分 | %d 个问题", p.Name, x.HealthScore, len(x.Issues)))
|
|
|
|
|
|
return x, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) GetGitStats(id int64) (GitStats, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return GitStats{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.GitStats(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetGitDiagnostics 分步检查 Git/WSL 状态,用于解释 Git 统计为空或失败的原因。
|
|
|
|
|
|
func (a *App) GetGitDiagnostics(id int64) (GitDiagnostics, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return GitDiagnostics{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
p, e := a.store.GetProject(id)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return GitDiagnostics{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
d := (GitAnalyzer{}).Diagnostics(a.ctx, p.Path, "")
|
|
|
|
|
|
if d.Available {
|
|
|
|
|
|
a.store.Log("info", "Git分析", "Git 诊断完成", p.Name+" | "+d.WorkspaceBranch)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
a.store.Log("warning", "Git分析", "Git 诊断失败", p.Name+" | "+d.ErrorCode+" | "+d.Detail)
|
|
|
|
|
|
}
|
|
|
|
|
|
return d, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetGitStatsForRef 只改变统计视图,不执行 checkout。
|
|
|
|
|
|
func (a *App) GetGitStatsForRef(id int64, ref string) (GitStats, error) {
|
2026-08-14 07:51:46 +08:00
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return GitStats{}, e
|
|
|
|
|
|
}
|
2026-08-11 19:07:05 +08:00
|
|
|
|
p, e := a.store.GetProject(id)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return GitStats{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
a.store.Log("info", "Git分析", "查询分支统计", p.Name+" | "+ref)
|
|
|
|
|
|
g, e := (GitAnalyzer{}).AnalyzeRef(a.ctx, p.Path, ref)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
a.store.Log("error", "Git分析", "查询分支统计失败", e.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
return g, e
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetCommitDetails 按需查询提交涉及的文件,避免 Git 首页一次加载所有明细。
|
|
|
|
|
|
func (a *App) GetCommitDetails(id int64, hash string) (GitCommitDetail, error) {
|
2026-08-14 07:51:46 +08:00
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return GitCommitDetail{}, e
|
|
|
|
|
|
}
|
2026-08-11 19:07:05 +08:00
|
|
|
|
p, e := a.store.GetProject(id)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return GitCommitDetail{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return (GitAnalyzer{}).CommitDetail(a.ctx, p.Path, hash)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CheckoutBranch 安全切换工作区分支;服务层会拒绝任何脏工作区。
|
|
|
|
|
|
func (a *App) CheckoutBranch(id int64, ref string) (CheckoutResult, error) {
|
2026-08-14 07:51:46 +08:00
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return CheckoutResult{}, e
|
|
|
|
|
|
}
|
2026-08-11 19:07:05 +08:00
|
|
|
|
p, e := a.store.GetProject(id)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return CheckoutResult{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
result, e := (GitAnalyzer{}).CheckoutBranch(a.ctx, p.Path, ref)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
a.store.Log("error", "Git分析", "切换分支失败", p.Name+" | "+ref+" | "+e.Error())
|
|
|
|
|
|
return result, e
|
|
|
|
|
|
}
|
|
|
|
|
|
a.store.Log("info", "Git分析", "切换分支成功", p.Name+" | "+result.Branch)
|
|
|
|
|
|
return result, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) GetRules() ([]ExclusionRule, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.Rules()
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) AddRule(pattern, category string) (ExclusionRule, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return ExclusionRule{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.AddRule(pattern, category)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) DeleteRule(id int64) error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.DeleteRule(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) GetLogs(level string) ([]LogEntry, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.Logs(level)
|
|
|
|
|
|
}
|
2026-08-14 07:51:46 +08:00
|
|
|
|
func (a *App) SearchLogs(query, level, category string, offset, limit int64) (LogPage, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return LogPage{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.SearchLogs(query, level, category, offset, limit)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) GetLogCategories() ([]string, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.LogCategories()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// previewMaxBytes 是文件预览读取上限(1MB),超出部分截断。
|
|
|
|
|
|
const previewMaxBytes = 1 << 20
|
|
|
|
|
|
|
|
|
|
|
|
// ReadProjectFile 读取项目内的相对路径文件用于预览:限制大小、检测二进制、阻止路径穿越。
|
|
|
|
|
|
func (a *App) ReadProjectFile(projectID int64, relPath string) (FilePreview, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return FilePreview{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
p, e := a.store.GetProject(projectID)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return FilePreview{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
rel := strings.TrimSpace(relPath)
|
|
|
|
|
|
if rel == "" {
|
|
|
|
|
|
return FilePreview{}, errors.New("FILE_PATH_REQUIRED")
|
|
|
|
|
|
}
|
|
|
|
|
|
desc, e := platform.ResolvePath(p.Path)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return FilePreview{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
root := desc.WindowsPath
|
|
|
|
|
|
full := filepath.Join(root, filepath.FromSlash(rel))
|
|
|
|
|
|
if back, e := filepath.Rel(root, full); e != nil || back == ".." || strings.HasPrefix(back, ".."+string(filepath.Separator)) {
|
|
|
|
|
|
return FilePreview{}, errors.New("FILE_PATH_INVALID")
|
|
|
|
|
|
}
|
|
|
|
|
|
st, e := os.Stat(full)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
if os.IsNotExist(e) {
|
|
|
|
|
|
return FilePreview{}, coded("FILE_NOT_FOUND", e)
|
|
|
|
|
|
}
|
|
|
|
|
|
return FilePreview{}, coded("FILE_READ_FAILED", e)
|
|
|
|
|
|
}
|
|
|
|
|
|
if st.IsDir() {
|
|
|
|
|
|
return FilePreview{}, errors.New("FILE_IS_DIRECTORY")
|
|
|
|
|
|
}
|
|
|
|
|
|
out := FilePreview{Path: filepath.ToSlash(rel), Name: st.Name(), Extension: strings.ToLower(filepath.Ext(st.Name())), Size: st.Size()}
|
|
|
|
|
|
f, e := os.Open(full)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return out, coded("FILE_READ_FAILED", e)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer f.Close()
|
|
|
|
|
|
buf := make([]byte, min(st.Size(), previewMaxBytes))
|
|
|
|
|
|
n, e := io.ReadFull(f, buf)
|
|
|
|
|
|
if e != nil && !errors.Is(e, io.ErrUnexpectedEOF) && !errors.Is(e, io.EOF) {
|
|
|
|
|
|
return out, coded("FILE_READ_FAILED", e)
|
|
|
|
|
|
}
|
|
|
|
|
|
buf = buf[:n]
|
|
|
|
|
|
out.Truncated = st.Size() > previewMaxBytes
|
|
|
|
|
|
probe := buf
|
|
|
|
|
|
if len(probe) > 8192 {
|
|
|
|
|
|
probe = probe[:8192]
|
|
|
|
|
|
}
|
|
|
|
|
|
if bytes.IndexByte(probe, 0) >= 0 {
|
|
|
|
|
|
out.Binary = true
|
|
|
|
|
|
return out, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
out.Content = string(buf)
|
|
|
|
|
|
if out.Content != "" {
|
|
|
|
|
|
out.Lines = strings.Count(out.Content, "\n") + 1
|
|
|
|
|
|
}
|
|
|
|
|
|
return out, nil
|
|
|
|
|
|
}
|
2026-08-11 19:07:05 +08:00
|
|
|
|
func (a *App) ClearLogs() error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.ClearLogs()
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) GetSettings() (AppSettings, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return AppSettings{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.Settings()
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) SaveSettings(x AppSettings) error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
2026-08-14 07:51:46 +08:00
|
|
|
|
if e := a.store.SaveSettings(x); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
a.RefreshShell()
|
|
|
|
|
|
return nil
|
2026-08-11 19:07:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
func (a *App) ClearData(mode string, projectID int64) error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
2026-08-14 07:51:46 +08:00
|
|
|
|
e := a.store.ClearData(mode, projectID)
|
|
|
|
|
|
if e == nil {
|
|
|
|
|
|
a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表
|
|
|
|
|
|
}
|
|
|
|
|
|
return e
|
2026-08-11 19:07:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 17:54:06 +08:00
|
|
|
|
// MigrateDatabase 仅把本机 SQLite 文件搬到新路径并切换连接。
|
|
|
|
|
|
// 不做任何 schema DDL;云端 MySQL 建表/升级只允许通过仓库根目录 init.sql(或 tools/applyinit)执行。
|
2026-08-11 19:07:05 +08:00
|
|
|
|
func (a *App) MigrateDatabase(target string) error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
if target == "" {
|
|
|
|
|
|
return errors.New("PATH_REQUIRED")
|
|
|
|
|
|
}
|
|
|
|
|
|
target = filepath.Clean(target)
|
|
|
|
|
|
if target == a.store.path {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if e := os.MkdirAll(filepath.Dir(target), 0755); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
tmp := target + ".tmp"
|
|
|
|
|
|
_ = os.Remove(tmp)
|
|
|
|
|
|
_, _ = a.store.db.Exec(`PRAGMA wal_checkpoint(FULL)`)
|
|
|
|
|
|
src, e := os.Open(a.store.path)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
defer src.Close()
|
|
|
|
|
|
dst, e := os.Create(tmp)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, e = dst.ReadFrom(src); e != nil {
|
|
|
|
|
|
dst.Close()
|
|
|
|
|
|
os.Remove(tmp)
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = dst.Sync()
|
|
|
|
|
|
dst.Close()
|
|
|
|
|
|
check, e := OpenStore(tmp)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
os.Remove(tmp)
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
var result string
|
|
|
|
|
|
e = check.db.QueryRow(`PRAGMA integrity_check`).Scan(&result)
|
|
|
|
|
|
check.db.Close()
|
|
|
|
|
|
if e != nil || result != "ok" {
|
|
|
|
|
|
os.Remove(tmp)
|
|
|
|
|
|
return errors.New("DATABASE_INTEGRITY_FAILED")
|
|
|
|
|
|
}
|
|
|
|
|
|
if e = os.Rename(tmp, target); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
next, e := OpenStore(target)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
old := a.store
|
|
|
|
|
|
a.store = next
|
|
|
|
|
|
_ = old.db.Close()
|
|
|
|
|
|
if e = writeBootstrap(a.bootstrapFile, BootstrapConfig{DatabasePath: target, Initialized: true}); e != nil {
|
|
|
|
|
|
return coded("BOOTSTRAP_WRITE_FAILED", e)
|
|
|
|
|
|
}
|
|
|
|
|
|
a.bootstrap = BootstrapStatus{State: BootstrapReady, DefaultPath: a.bootstrap.DefaultPath, DatabasePath: target}
|
2026-08-15 07:29:45 +08:00
|
|
|
|
a.applyPackagedSyncConfig()
|
2026-08-11 19:07:05 +08:00
|
|
|
|
a.store.Log("info", "数据库", "数据库迁移成功", target)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) StartAnalysis(projectID int64, kind string) (string, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return "", e
|
|
|
|
|
|
}
|
|
|
|
|
|
p, e := a.store.GetProject(projectID)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return "", e
|
|
|
|
|
|
}
|
|
|
|
|
|
taskID := fmt.Sprintf("%d-%s", projectID, kind)
|
|
|
|
|
|
a.mu.Lock()
|
|
|
|
|
|
if _, ok := a.tasks[taskID]; ok {
|
|
|
|
|
|
a.mu.Unlock()
|
|
|
|
|
|
return "", errors.New("TASK_ALREADY_RUNNING")
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx, cancel := context.WithCancel(a.ctx)
|
|
|
|
|
|
a.tasks[taskID] = cancel
|
|
|
|
|
|
a.mu.Unlock()
|
|
|
|
|
|
go func() {
|
2026-08-14 07:51:46 +08:00
|
|
|
|
finalStage := "completed"
|
2026-08-11 19:07:05 +08:00
|
|
|
|
defer func() {
|
|
|
|
|
|
if recovered := recover(); recovered != nil {
|
|
|
|
|
|
detail := fmt.Sprintf("%v", recovered)
|
2026-08-14 07:51:46 +08:00
|
|
|
|
finalStage = "error"
|
2026-08-11 19:07:05 +08:00
|
|
|
|
a.store.Log("error", "代码分析", "后台分析异常", p.Name+" | "+detail)
|
2026-08-14 07:51:46 +08:00
|
|
|
|
a.emit("analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: "error", Progress: 100, MessageKey: "task.failed", Params: map[string]any{"project": p.Name}})
|
2026-08-11 19:07:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
a.mu.Lock()
|
2026-08-14 07:51:46 +08:00
|
|
|
|
if a.taskResults != nil {
|
|
|
|
|
|
a.taskResults[taskID] = finalStage
|
|
|
|
|
|
}
|
2026-08-11 19:07:05 +08:00
|
|
|
|
delete(a.tasks, taskID)
|
|
|
|
|
|
a.mu.Unlock()
|
|
|
|
|
|
}()
|
|
|
|
|
|
emit := func(stage string, progress int, messageKey string) {
|
2026-08-14 07:51:46 +08:00
|
|
|
|
a.emit("analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: stage, Progress: progress, MessageKey: messageKey, Params: map[string]any{"project": p.Name}})
|
2026-08-11 19:07:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
emit("start", 2, "task.start")
|
|
|
|
|
|
a.store.Log("info", "代码分析", "开始分析项目", p.Name)
|
|
|
|
|
|
var err error
|
|
|
|
|
|
if kind == "all" || kind == "code" {
|
|
|
|
|
|
rules, _ := a.store.Rules()
|
|
|
|
|
|
var ls []LanguageStat
|
|
|
|
|
|
var fs []FileEntry
|
|
|
|
|
|
ls, fs, err = (Scanner{}).Analyze(ctx, p.Path, rules, func(n int, s string) { emit(s, n, s) })
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
err = a.store.ReplaceScan(projectID, ls, fs)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err == nil && (kind == "all" || kind == "git") {
|
|
|
|
|
|
emit("git", 80, "task.git")
|
2026-08-14 07:51:46 +08:00
|
|
|
|
scopeAll := false
|
|
|
|
|
|
if st, se := a.store.Settings(); se == nil && st.GitScope == "all" {
|
|
|
|
|
|
scopeAll = true
|
|
|
|
|
|
}
|
2026-08-11 19:07:05 +08:00
|
|
|
|
var g GitStats
|
2026-08-14 07:51:46 +08:00
|
|
|
|
var incremental bool
|
|
|
|
|
|
g, incremental, err = (GitAnalyzer{}).AnalyzeWithOptions(ctx, p.Path, service.GitAnalyzeOptions{
|
|
|
|
|
|
AllBranches: scopeAll,
|
|
|
|
|
|
SinceHash: a.store.LatestCommitHash(projectID),
|
|
|
|
|
|
})
|
2026-08-11 19:07:05 +08:00
|
|
|
|
if err == nil {
|
2026-08-14 07:51:46 +08:00
|
|
|
|
if incremental {
|
|
|
|
|
|
err = a.store.MergeGit(projectID, g)
|
|
|
|
|
|
a.store.Log("info", "Git分析", "增量分析完成", fmt.Sprintf("%s | 新增 %d 个提交", p.Name, len(g.Commits)))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
err = a.store.ReplaceGit(projectID, g)
|
|
|
|
|
|
}
|
2026-08-11 19:07:05 +08:00
|
|
|
|
} else if kind == "all" && (err.Error() == "NOT_GIT_REPOSITORY" || err.Error() == "GIT_NOT_INSTALLED") {
|
|
|
|
|
|
a.store.Log("warning", "Git分析", "已跳过 Git 分析", err.Error())
|
|
|
|
|
|
err = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
level, stage := "error", "error"
|
|
|
|
|
|
if errors.Is(err, context.Canceled) {
|
|
|
|
|
|
level, stage = "warning", "cancelled"
|
|
|
|
|
|
}
|
2026-08-14 07:51:46 +08:00
|
|
|
|
finalStage = stage
|
2026-08-11 19:07:05 +08:00
|
|
|
|
a.store.Log(level, "代码分析", "项目分析失败", p.Name+" | "+err.Error())
|
|
|
|
|
|
emit(stage, 100, "task.failed")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
a.store.Log("info", "代码分析", "项目分析完成", p.Name)
|
|
|
|
|
|
emit("completed", 100, "task.completed")
|
2026-08-14 07:51:46 +08:00
|
|
|
|
// 数据就绪后异步生成各模块的 AI 分析(未配置 Key 时静默跳过)。
|
|
|
|
|
|
// 代码数据变化会使洞察过期,先重算再交给 AI 解读。
|
|
|
|
|
|
switch kind {
|
|
|
|
|
|
case "code":
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
a.RefreshProjectInsights(projectID)
|
|
|
|
|
|
a.generateAISummaries(projectID, "project", "structure", "insights")
|
|
|
|
|
|
}()
|
|
|
|
|
|
case "git":
|
|
|
|
|
|
go a.generateAISummaries(projectID, "git")
|
|
|
|
|
|
default:
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
a.RefreshProjectInsights(projectID)
|
|
|
|
|
|
a.generateAISummaries(projectID, "project", "git", "structure", "insights")
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
2026-08-11 19:07:05 +08:00
|
|
|
|
}()
|
|
|
|
|
|
return taskID, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) CancelAnalysis(taskID string) error {
|
|
|
|
|
|
a.mu.Lock()
|
|
|
|
|
|
defer a.mu.Unlock()
|
|
|
|
|
|
if c, ok := a.tasks[taskID]; ok {
|
|
|
|
|
|
c()
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return errors.New("TASK_NOT_FOUND")
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) StartBatchAnalysis() ([]string, error) {
|
|
|
|
|
|
return a.StartBatchAnalysisByGroup(0)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) StartBatchAnalysisByGroup(groupID int64) ([]string, error) {
|
2026-08-14 07:51:46 +08:00
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
2026-08-11 19:07:05 +08:00
|
|
|
|
ps, e := a.store.ListProjects(groupID)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
|
|
|
|
|
ids := make([]string, 0, len(ps))
|
|
|
|
|
|
for _, p := range ps {
|
|
|
|
|
|
ids = append(ids, fmt.Sprintf("%d-all", p.ID))
|
|
|
|
|
|
}
|
|
|
|
|
|
go func() {
|
2026-08-14 07:51:46 +08:00
|
|
|
|
summary := BatchSummary{Total: len(ps), Failures: []string{}}
|
2026-08-11 19:07:05 +08:00
|
|
|
|
for _, p := range ps {
|
|
|
|
|
|
tid, e := a.StartAnalysis(p.ID, "all")
|
|
|
|
|
|
if e != nil {
|
2026-08-14 07:51:46 +08:00
|
|
|
|
summary.Failed++
|
|
|
|
|
|
summary.Failures = append(summary.Failures, p.Name+": "+e.Error())
|
2026-08-11 19:07:05 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
for {
|
|
|
|
|
|
a.mu.Lock()
|
|
|
|
|
|
_, running := a.tasks[tid]
|
|
|
|
|
|
a.mu.Unlock()
|
|
|
|
|
|
if !running {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-a.ctx.Done():
|
|
|
|
|
|
return
|
|
|
|
|
|
case <-time.After(100 * time.Millisecond):
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-14 07:51:46 +08:00
|
|
|
|
a.mu.Lock()
|
|
|
|
|
|
result := a.taskResults[tid]
|
|
|
|
|
|
delete(a.taskResults, tid)
|
|
|
|
|
|
a.mu.Unlock()
|
|
|
|
|
|
switch result {
|
|
|
|
|
|
case "cancelled":
|
|
|
|
|
|
summary.Cancelled++
|
|
|
|
|
|
case "error":
|
|
|
|
|
|
summary.Failed++
|
|
|
|
|
|
summary.Failures = append(summary.Failures, p.Name)
|
|
|
|
|
|
default:
|
|
|
|
|
|
summary.Completed++
|
|
|
|
|
|
}
|
2026-08-11 19:07:05 +08:00
|
|
|
|
}
|
2026-08-14 07:51:46 +08:00
|
|
|
|
a.store.Log("info", "代码分析", "批量统计完成",
|
|
|
|
|
|
fmt.Sprintf("共 %d 个 | 成功 %d | 失败 %d | 取消 %d | %s", summary.Total, summary.Completed, summary.Failed, summary.Cancelled, strings.Join(summary.Failures, ";")))
|
|
|
|
|
|
a.emit("batch:done", summary)
|
|
|
|
|
|
a.notifyBatchDone(summary)
|
2026-08-11 19:07:05 +08:00
|
|
|
|
}()
|
|
|
|
|
|
return ids, nil
|
|
|
|
|
|
}
|
2026-08-14 07:51:46 +08:00
|
|
|
|
|
|
|
|
|
|
// notifyBatchDone 将批量统计结果写入消息中心,失败时附系统通知。
|
|
|
|
|
|
func (a *App) notifyBatchDone(s BatchSummary) {
|
|
|
|
|
|
locale := "zh-CN"
|
|
|
|
|
|
if st, e := a.store.Settings(); e == nil {
|
|
|
|
|
|
locale = st.Locale
|
|
|
|
|
|
}
|
|
|
|
|
|
title := "批量统计完成"
|
|
|
|
|
|
body := fmt.Sprintf("共 %d 个项目:成功 %d,失败 %d,取消 %d", s.Total, s.Completed, s.Failed, s.Cancelled)
|
|
|
|
|
|
if locale == "en" {
|
|
|
|
|
|
title = "Batch analysis finished"
|
|
|
|
|
|
body = fmt.Sprintf("%d projects: %d succeeded, %d failed, %d cancelled", s.Total, s.Completed, s.Failed, s.Cancelled)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(s.Failures) > 0 {
|
|
|
|
|
|
sep := ";失败:"
|
|
|
|
|
|
if locale == "en" {
|
|
|
|
|
|
sep = "; failed: "
|
|
|
|
|
|
}
|
|
|
|
|
|
body += sep + strings.Join(s.Failures, ", ")
|
|
|
|
|
|
}
|
|
|
|
|
|
a.pushMessage("analysis", title, body, "", 0, s.Failed > 0)
|
|
|
|
|
|
}
|