Files
code-utils/app.go
李琦 c430a0f6da 1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。
2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。

3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。

4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。

5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。

6. 项目云同步与认领

全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。
统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。
个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。
途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00

832 lines
24 KiB
Go
Raw Permalink 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 (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"view/platform"
"view/service"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
)
type App struct {
ctx context.Context
store *Store
bootstrapFile string
bootstrap BootstrapStatus
mu sync.Mutex
tasks map[string]context.CancelFunc
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
// 管理员敏感操作二次验证(内存,进程内有效)
stepupMu sync.Mutex
stepupToken string
stepupExp time.Time
}
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()
go a.runAppUpdateLoop()
go a.runReminderLoop()
go a.runSyncLoop()
go a.runTeamDigestLoop()
go a.runActivityPingLoop()
return nil
}
// ServiceShutdown 由 Wails v3 在应用退出时调用。
func (a *App) ServiceShutdown() error {
a.shutdown(context.Background())
return nil
}
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}
a.applyPackagedSyncConfig()
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
}
return application.Get().Dialog.SaveFile().
SetMessage("Initialize 年糕崽崽 PMS database").
SetDirectory(filepath.Dir(defaultPath)).
SetFilename(filepath.Base(defaultPath)).
AddFilter("SQLite Database", "*.db").
PromptForSingleSelection()
}
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}
a.applyPackagedSyncConfig()
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
}
return application.Get().Dialog.OpenFile().
SetTitle(a.localized("选择代码目录", "Select code directory")).
CanChooseDirectories(true).
CanChooseFiles(false).
CanCreateDirectories(true).
PromptForSingleSelection()
}
// 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 + "\\"
return application.Get().Dialog.OpenFile().
SetTitle(a.localized("选择 WSL 代码目录", "Select WSL code directory")).
SetDirectory(root).
CanChooseDirectories(true).
CanChooseFiles(false).
PromptForSingleSelection()
}
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) {
return application.Get().Dialog.SaveFile().
SetMessage("Select database location").
SetFilename("code-count.db").
AddFilter("SQLite Database", "*.db").
PromptForSingleSelection()
}
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)
a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表
} 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))
a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表
}
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) {
if e := a.ready(); e != nil {
return GitStats{}, e
}
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) {
if e := a.ready(); e != nil {
return GitCommitDetail{}, e
}
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) {
if e := a.ready(); e != nil {
return CheckoutResult{}, e
}
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)
}
// GetProjectRules 项目专属排除规则(不含全局)。
func (a *App) GetProjectRules(projectID int64) ([]ExclusionRule, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.ProjectRules(projectID)
}
// AddProjectRule 给某项目添加专属排除规则。
func (a *App) AddProjectRule(projectID int64, pattern, category string) (ExclusionRule, error) {
if e := a.ready(); e != nil {
return ExclusionRule{}, e
}
if projectID <= 0 {
return ExclusionRule{}, errors.New("PROJECT_REQUIRED")
}
return a.store.AddProjectRule(projectID, pattern, category)
}
func (a *App) GetLogs(level string) ([]LogEntry, error) {
if e := a.ready(); e != nil {
return nil, e
}
return a.store.Logs(level)
}
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
}
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
}
if e := a.store.SaveSettings(x); e != nil {
return e
}
a.RefreshShell()
return nil
}
func (a *App) ClearData(mode string, projectID int64) error {
if e := a.ready(); e != nil {
return e
}
e := a.store.ClearData(mode, projectID)
if e == nil {
a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表
}
return e
}
// MigrateDatabase 仅把本机 SQLite 文件搬到新路径并切换连接。
// 不做任何 schema DDL云端 MySQL 建表/升级只允许通过仓库根目录 init.sql或 tools/applyinit执行。
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}
a.applyPackagedSyncConfig()
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() {
finalStage := "completed"
defer func() {
if recovered := recover(); recovered != nil {
detail := fmt.Sprintf("%v", recovered)
finalStage = "error"
a.store.Log("error", "代码分析", "后台分析异常", p.Name+" | "+detail)
a.emit("analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: "error", Progress: 100, MessageKey: "task.failed", Params: map[string]any{"project": p.Name}})
}
a.mu.Lock()
if a.taskResults != nil {
a.taskResults[taskID] = finalStage
}
delete(a.tasks, taskID)
a.mu.Unlock()
}()
emit := func(stage string, progress int, messageKey string) {
a.emit("analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: stage, Progress: progress, MessageKey: messageKey, Params: map[string]any{"project": p.Name}})
}
emit("start", 2, "task.start")
a.store.Log("info", "代码分析", "开始分析项目", p.Name)
var err error
if kind == "all" || kind == "code" {
// 全局规则 + 项目专属规则叠加
rules, _ := a.store.RulesForProject(projectID)
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")
scopeAll := false
if st, se := a.store.Settings(); se == nil && st.GitScope == "all" {
scopeAll = true
}
var g GitStats
var incremental bool
g, incremental, err = (GitAnalyzer{}).AnalyzeWithOptions(ctx, p.Path, service.GitAnalyzeOptions{
AllBranches: scopeAll,
SinceHash: a.store.LatestCommitHash(projectID),
})
if err == nil {
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)
}
} 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"
}
finalStage = stage
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")
// 数据就绪后异步生成各模块的 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")
}()
}
}()
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) {
if e := a.ready(); e != nil {
return nil, e
}
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() {
summary := BatchSummary{Total: len(ps), Failures: []string{}}
for _, p := range ps {
tid, e := a.StartAnalysis(p.ID, "all")
if e != nil {
summary.Failed++
summary.Failures = append(summary.Failures, p.Name+": "+e.Error())
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):
}
}
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++
}
}
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)
}()
return ids, nil
}
// 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)
}