diff --git a/.gitignore b/.gitignore index 129d522..3e23352 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ build/bin +bin/ node_modules frontend/dist +frontend/bindings +*.syso diff --git a/app.go b/app.go index 2f40642..8fbea11 100644 --- a/app.go +++ b/app.go @@ -1,19 +1,23 @@ 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/v2/pkg/runtime" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/services/notifications" ) type App struct { @@ -21,15 +25,52 @@ type App struct { store *Store bootstrapFile string bootstrap BootstrapStatus - runtimeReady bool 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 +} + +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.runReminderLoop() + go a.runSyncLoop() + go a.runTeamDigestLoop() + return nil +} + +// ServiceShutdown 由 Wails v3 在应用退出时调用。 +func (a *App) ServiceShutdown() error { + a.shutdown(context.Background()) + return nil } -func NewApp() *App { return &App{tasks: map[string]context.CancelFunc{}} } func (a *App) startup(ctx context.Context) { a.ctx = ctx - a.runtimeReady = true defaultPath, e := defaultDBPath() if e != nil { a.bootstrap = BootstrapStatus{State: BootstrapRecovery, ErrorCode: "DB_DEFAULT_PATH_FAILED", ErrorDetail: e.Error()} @@ -66,7 +107,6 @@ func (a *App) startup(ctx context.Context) { s, e := OpenStore(c.DatabasePath) if e != nil { a.bootstrap = bootstrapFailure(defaultPath, c.DatabasePath, e) - runtime.LogError(ctx, e.Error()) return } a.store = s @@ -80,7 +120,12 @@ func (a *App) SelectInitialDatabaseFile(defaultPath string) (string, error) { if strings.TrimSpace(defaultPath) == "" { defaultPath = a.bootstrap.DefaultPath } - return runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{Title: "Initialize Code Count database", DefaultDirectory: filepath.Dir(defaultPath), DefaultFilename: filepath.Base(defaultPath), Filters: []runtime.FileFilter{{DisplayName: "SQLite Database", Pattern: "*.db"}}}) + 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() @@ -122,7 +167,12 @@ func (a *App) SelectDirectory() (string, error) { if e := a.ready(); e != nil { return "", e } - return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{Title: a.localized("选择代码目录", "Select code directory")}) + return application.Get().Dialog.OpenFile(). + SetTitle(a.localized("选择代码目录", "Select code directory")). + CanChooseDirectories(true). + CanChooseFiles(false). + CanCreateDirectories(true). + PromptForSingleSelection() } // ListWSLDistros 返回本机可用的 WSL 发行版,供前端创建 UNC 项目路径。 @@ -134,7 +184,12 @@ func (a *App) SelectWSLDirectory(distro string) (string, error) { return "", errors.New("WSL_DISTRO_REQUIRED") } root := "\\\\wsl.localhost\\" + distro + "\\" - return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{Title: a.localized("选择 WSL 代码目录", "Select WSL code directory"), DefaultDirectory: root}) + 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 { @@ -146,7 +201,11 @@ func (a *App) localized(zh, en string) string { return zh } func (a *App) SelectDatabaseFile() (string, error) { - return runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{Title: "Select database location", DefaultFilename: "code-count.db", Filters: []runtime.FileFilter{{DisplayName: "SQLite Database", Pattern: "*.db"}}}) + 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 { @@ -194,22 +253,14 @@ func (a *App) GetProject(id int64) (Project, error) { } func (a *App) SaveProject(id int64, in ProjectInput) (Project, error) { if e := a.ready(); e != nil { - if a.runtimeReady { - runtime.LogError(a.ctx, "SaveProject: "+e.Error()) - } return Project{}, e } p, e := a.store.SaveProject(id, in) if e == nil { a.store.Log("info", "项目", "项目保存成功", p.Path) - if a.runtimeReady { - runtime.LogInfo(a.ctx, "Project saved: "+p.Path) - } + a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表 } else { a.store.Log("error", "项目", "项目保存失败", in.Path+" | "+e.Error()) - if a.runtimeReady { - runtime.LogError(a.ctx, "Project save failed: "+in.Path+" | "+e.Error()) - } } return p, e } @@ -218,9 +269,6 @@ func (a *App) ReportClientError(category, message, detail string) { if a.store != nil { a.store.Log("error", category, message, detail) } - if a.runtimeReady { - runtime.LogError(a.ctx, category+": "+message+" | "+detail) - } } func (a *App) DeleteProject(id int64) error { if e := a.ready(); e != nil { @@ -229,6 +277,7 @@ func (a *App) DeleteProject(id int64) error { e := a.store.DeleteProject(id) if e == nil { a.store.Log("info", "项目", "项目删除成功", fmt.Sprint(id)) + a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表 } return e } @@ -318,6 +367,9 @@ func (a *App) GetGitDiagnostics(id int64) (GitDiagnostics, error) { // 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 @@ -332,6 +384,9 @@ func (a *App) GetGitStatsForRef(id int64, ref string) (GitStats, error) { // 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 @@ -341,6 +396,9 @@ func (a *App) GetCommitDetails(id int64, hash string) (GitCommitDetail, error) { // 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 @@ -377,6 +435,81 @@ func (a *App) GetLogs(level string) ([]LogEntry, error) { } 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 @@ -393,13 +526,21 @@ func (a *App) SaveSettings(x AppSettings) error { if e := a.ready(); e != nil { return e } - return a.store.SaveSettings(x) + 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 } - return a.store.ClearData(mode, projectID) + e := a.store.ClearData(mode, projectID) + if e == nil { + a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表 + } + return e } func (a *App) MigrateDatabase(target string) error { @@ -483,18 +624,23 @@ func (a *App) StartAnalysis(projectID int64, kind string) (string, error) { 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) - runtime.EventsEmit(a.ctx, "analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: "error", Progress: 100, MessageKey: "task.failed", Params: map[string]any{"project": p.Name}}) + 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) { - runtime.EventsEmit(a.ctx, "analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: stage, Progress: progress, MessageKey: messageKey, Params: map[string]any{"project": p.Name}}) + 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) @@ -510,10 +656,23 @@ func (a *App) StartAnalysis(projectID int64, kind string) (string, error) { } 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 - g, err = (GitAnalyzer{}).Analyze(ctx, p.Path) + var incremental bool + g, incremental, err = (GitAnalyzer{}).AnalyzeWithOptions(ctx, p.Path, service.GitAnalyzeOptions{ + AllBranches: scopeAll, + SinceHash: a.store.LatestCommitHash(projectID), + }) if err == nil { - err = a.store.ReplaceGit(projectID, g) + 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 @@ -524,12 +683,29 @@ func (a *App) StartAnalysis(projectID int64, kind string) (string, 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 } @@ -546,6 +722,9 @@ 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 @@ -555,9 +734,12 @@ func (a *App) StartBatchAnalysisByGroup(groupID int64) ([]string, error) { 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 { @@ -573,7 +755,46 @@ func (a *App) StartBatchAnalysisByGroup(groupID int64) ([]string, error) { 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) +} diff --git a/build/README.md b/build/README.md deleted file mode 100644 index 1ae2f67..0000000 --- a/build/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Build Directory - -The build directory is used to house all the build files and assets for your application. - -The structure is: - -* bin - Output directory -* darwin - macOS specific files -* windows - Windows specific files - -## Mac - -The `darwin` directory holds files specific to Mac builds. -These may be customised and used as part of the build. To return these files to the default state, simply delete them -and -build with `wails build`. - -The directory contains the following files: - -- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`. -- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`. - -## Windows - -The `windows` directory contains the manifest and rc files used when building with `wails build`. -These may be customised for your application. To return these files to the default state, simply delete them and -build with `wails build`. - -- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to - use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file - will be created using the `appicon.png` file in the build directory. -- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`. -- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer, - as well as the application itself (right click the exe -> properties -> details) -- `wails.exe.manifest` - The main application manifest file. \ No newline at end of file diff --git a/build/appicon.png b/build/appicon.png index 63617fe..c12c325 100644 Binary files a/build/appicon.png and b/build/appicon.png differ diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist index 14121ef..eef67e3 100644 --- a/build/darwin/Info.dev.plist +++ b/build/darwin/Info.dev.plist @@ -2,67 +2,33 @@ CFBundlePackageType - APPL + APPL CFBundleName - {{.Info.ProductName}} + My Product CFBundleExecutable - {{.OutputFilename}} + code-count.exe CFBundleIdentifier - com.wails.{{.Name}} + com.wails.code-count CFBundleVersion - {{.Info.ProductVersion}} + 0.1.0 CFBundleGetInfoString - {{.Info.Comments}} + 本地代码统计与项目工作台 CFBundleShortVersionString - {{.Info.ProductVersion}} + 0.1.0 CFBundleIconFile - iconfile + icons + CFBundleIconName + appicon LSMinimumSystemVersion - 10.13.0 + 12.0.0 NSHighResolutionCapable - true + true NSHumanReadableCopyright - {{.Info.Copyright}} - {{if .Info.FileAssociations}} - CFBundleDocumentTypes - - {{range .Info.FileAssociations}} - - CFBundleTypeExtensions - - {{.Ext}} - - CFBundleTypeName - {{.Name}} - CFBundleTypeRole - {{.Role}} - CFBundleTypeIconFile - {{.IconName}} - - {{end}} - - {{end}} - {{if .Info.Protocols}} - CFBundleURLTypes - - {{range .Info.Protocols}} - - CFBundleURLName - com.wails.{{.Scheme}} - CFBundleURLSchemes - - {{.Scheme}} - - CFBundleTypeRole - {{.Role}} - - {{end}} - - {{end}} + © now, My Company NSAppTransportSecurity NSAllowsLocalNetworking - + \ No newline at end of file diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist index d17a747..b347a77 100644 --- a/build/darwin/Info.plist +++ b/build/darwin/Info.plist @@ -2,62 +2,28 @@ CFBundlePackageType - APPL + APPL CFBundleName - {{.Info.ProductName}} + My Product CFBundleExecutable - {{.OutputFilename}} + code-count.exe CFBundleIdentifier - com.wails.{{.Name}} + com.wails.code-count CFBundleVersion - {{.Info.ProductVersion}} + 0.1.0 CFBundleGetInfoString - {{.Info.Comments}} + 本地代码统计与项目工作台 CFBundleShortVersionString - {{.Info.ProductVersion}} + 0.1.0 CFBundleIconFile - iconfile + icons + CFBundleIconName + appicon LSMinimumSystemVersion - 10.13.0 + 12.0.0 NSHighResolutionCapable - true + true NSHumanReadableCopyright - {{.Info.Copyright}} - {{if .Info.FileAssociations}} - CFBundleDocumentTypes - - {{range .Info.FileAssociations}} - - CFBundleTypeExtensions - - {{.Ext}} - - CFBundleTypeName - {{.Name}} - CFBundleTypeRole - {{.Role}} - CFBundleTypeIconFile - {{.IconName}} - - {{end}} - - {{end}} - {{if .Info.Protocols}} - CFBundleURLTypes - - {{range .Info.Protocols}} - - CFBundleURLName - com.wails.{{.Scheme}} - CFBundleURLSchemes - - {{.Scheme}} - - CFBundleTypeRole - {{.Role}} - - {{end}} - - {{end}} + © now, My Company - + \ No newline at end of file diff --git a/build/windows/icon.ico b/build/windows/icon.ico index f334798..d5da220 100644 Binary files a/build/windows/icon.ico and b/build/windows/icon.ico differ diff --git a/build/windows/info.json b/build/windows/info.json index 9727946..d336bef 100644 --- a/build/windows/info.json +++ b/build/windows/info.json @@ -1,15 +1,15 @@ { "fixed": { - "file_version": "{{.Info.ProductVersion}}" + "file_version": "0.1.0" }, "info": { "0000": { - "ProductVersion": "{{.Info.ProductVersion}}", - "CompanyName": "{{.Info.CompanyName}}", - "FileDescription": "{{.Info.ProductName}}", - "LegalCopyright": "{{.Info.Copyright}}", - "ProductName": "{{.Info.ProductName}}", - "Comments": "{{.Info.Comments}}" + "ProductVersion": "0.1.0", + "CompanyName": "liqi", + "FileDescription": "My Product Description", + "LegalCopyright": "© now, My Company", + "ProductName": "My Product", + "Comments": "本地代码统计与项目工作台" } } } \ No newline at end of file diff --git a/build/windows/installer/project.nsi b/build/windows/installer/project.nsi deleted file mode 100644 index 654ae2e..0000000 --- a/build/windows/installer/project.nsi +++ /dev/null @@ -1,114 +0,0 @@ -Unicode true - -#### -## Please note: Template replacements don't work in this file. They are provided with default defines like -## mentioned underneath. -## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo. -## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually -## from outside of Wails for debugging and development of the installer. -## -## For development first make a wails nsis build to populate the "wails_tools.nsh": -## > wails build --target windows/amd64 --nsis -## Then you can call makensis on this file with specifying the path to your binary: -## For a AMD64 only installer: -## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe -## For a ARM64 only installer: -## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe -## For a installer with both architectures: -## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe -#### -## The following information is taken from the ProjectInfo file, but they can be overwritten here. -#### -## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}" -## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}" -## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}" -## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}" -## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}" -### -## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe" -## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" -#### -## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html -#### -## Include the wails tools -#### -!include "wails_tools.nsh" - -# The version information for this two must consist of 4 parts -VIProductVersion "${INFO_PRODUCTVERSION}.0" -VIFileVersion "${INFO_PRODUCTVERSION}.0" - -VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}" -VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer" -VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}" -VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}" -VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}" -VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}" - -# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware -ManifestDPIAware true - -!include "MUI.nsh" - -!define MUI_ICON "..\icon.ico" -!define MUI_UNICON "..\icon.ico" -# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314 -!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps -!define MUI_ABORTWARNING # This will warn the user if they exit from the installer. - -!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page. -# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer -!insertmacro MUI_PAGE_DIRECTORY # In which folder install page. -!insertmacro MUI_PAGE_INSTFILES # Installing page. -!insertmacro MUI_PAGE_FINISH # Finished installation page. - -!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page - -!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer - -## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1 -#!uninstfinalize 'signtool --file "%1"' -#!finalize 'signtool --file "%1"' - -Name "${INFO_PRODUCTNAME}" -OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file. -InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder). -ShowInstDetails show # This will always show the installation details. - -Function .onInit - !insertmacro wails.checkArchitecture -FunctionEnd - -Section - !insertmacro wails.setShellContext - - !insertmacro wails.webview2runtime - - SetOutPath $INSTDIR - - !insertmacro wails.files - - CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" - CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" - - !insertmacro wails.associateFiles - !insertmacro wails.associateCustomProtocols - - !insertmacro wails.writeUninstaller -SectionEnd - -Section "uninstall" - !insertmacro wails.setShellContext - - RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath - - RMDir /r $INSTDIR - - Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" - Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" - - !insertmacro wails.unassociateFiles - !insertmacro wails.unassociateCustomProtocols - - !insertmacro wails.deleteUninstaller -SectionEnd diff --git a/build/windows/installer/wails_tools.nsh b/build/windows/installer/wails_tools.nsh deleted file mode 100644 index 2f6d321..0000000 --- a/build/windows/installer/wails_tools.nsh +++ /dev/null @@ -1,249 +0,0 @@ -# DO NOT EDIT - Generated automatically by `wails build` - -!include "x64.nsh" -!include "WinVer.nsh" -!include "FileFunc.nsh" - -!ifndef INFO_PROJECTNAME - !define INFO_PROJECTNAME "{{.Name}}" -!endif -!ifndef INFO_COMPANYNAME - !define INFO_COMPANYNAME "{{.Info.CompanyName}}" -!endif -!ifndef INFO_PRODUCTNAME - !define INFO_PRODUCTNAME "{{.Info.ProductName}}" -!endif -!ifndef INFO_PRODUCTVERSION - !define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}" -!endif -!ifndef INFO_COPYRIGHT - !define INFO_COPYRIGHT "{{.Info.Copyright}}" -!endif -!ifndef PRODUCT_EXECUTABLE - !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" -!endif -!ifndef UNINST_KEY_NAME - !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" -!endif -!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}" - -!ifndef REQUEST_EXECUTION_LEVEL - !define REQUEST_EXECUTION_LEVEL "admin" -!endif - -RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" - -!ifdef ARG_WAILS_AMD64_BINARY - !define SUPPORTS_AMD64 -!endif - -!ifdef ARG_WAILS_ARM64_BINARY - !define SUPPORTS_ARM64 -!endif - -!ifdef SUPPORTS_AMD64 - !ifdef SUPPORTS_ARM64 - !define ARCH "amd64_arm64" - !else - !define ARCH "amd64" - !endif -!else - !ifdef SUPPORTS_ARM64 - !define ARCH "arm64" - !else - !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY" - !endif -!endif - -!macro wails.checkArchitecture - !ifndef WAILS_WIN10_REQUIRED - !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later." - !endif - - !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED - !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}" - !endif - - ${If} ${AtLeastWin10} - !ifdef SUPPORTS_AMD64 - ${if} ${IsNativeAMD64} - Goto ok - ${EndIf} - !endif - - !ifdef SUPPORTS_ARM64 - ${if} ${IsNativeARM64} - Goto ok - ${EndIf} - !endif - - IfSilent silentArch notSilentArch - silentArch: - SetErrorLevel 65 - Abort - notSilentArch: - MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}" - Quit - ${else} - IfSilent silentWin notSilentWin - silentWin: - SetErrorLevel 64 - Abort - notSilentWin: - MessageBox MB_OK "${WAILS_WIN10_REQUIRED}" - Quit - ${EndIf} - - ok: -!macroend - -!macro wails.files - !ifdef SUPPORTS_AMD64 - ${if} ${IsNativeAMD64} - File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}" - ${EndIf} - !endif - - !ifdef SUPPORTS_ARM64 - ${if} ${IsNativeARM64} - File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}" - ${EndIf} - !endif -!macroend - -!macro wails.writeUninstaller - WriteUninstaller "$INSTDIR\uninstall.exe" - - SetRegView 64 - WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" - WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" - WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" - WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" - WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" - WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" - - ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 - IntFmt $0 "0x%08X" $0 - WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" -!macroend - -!macro wails.deleteUninstaller - Delete "$INSTDIR\uninstall.exe" - - SetRegView 64 - DeleteRegKey HKLM "${UNINST_KEY}" -!macroend - -!macro wails.setShellContext - ${If} ${REQUEST_EXECUTION_LEVEL} == "admin" - SetShellVarContext all - ${else} - SetShellVarContext current - ${EndIf} -!macroend - -# Install webview2 by launching the bootstrapper -# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment -!macro wails.webview2runtime - !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT - !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime" - !endif - - SetRegView 64 - # If the admin key exists and is not empty then webview2 is already installed - ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" - ${If} $0 != "" - Goto ok - ${EndIf} - - ${If} ${REQUEST_EXECUTION_LEVEL} == "user" - # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed - ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" - ${If} $0 != "" - Goto ok - ${EndIf} - ${EndIf} - - SetDetailsPrint both - DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}" - SetDetailsPrint listonly - - InitPluginsDir - CreateDirectory "$pluginsdir\webview2bootstrapper" - SetOutPath "$pluginsdir\webview2bootstrapper" - File "tmp\MicrosoftEdgeWebview2Setup.exe" - ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' - - SetDetailsPrint both - ok: -!macroend - -# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b -!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND - ; Backup the previously associated file class - ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" "" - WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0" - - WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}" - - WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}` - WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}` - WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open" - WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}` - WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}` -!macroend - -!macro APP_UNASSOCIATE EXT FILECLASS - ; Backup the previously associated file class - ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup` - WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0" - - DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}` -!macroend - -!macro wails.associateFiles - ; Create file associations - {{range .Info.FileAssociations}} - !insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" - - File "..\{{.IconName}}.ico" - {{end}} -!macroend - -!macro wails.unassociateFiles - ; Delete app associations - {{range .Info.FileAssociations}} - !insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}" - - Delete "$INSTDIR\{{.IconName}}.ico" - {{end}} -!macroend - -!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND - DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" - WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}" - WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" "" - WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}" - WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" "" - WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" "" - WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}" -!macroend - -!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL - DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" -!macroend - -!macro wails.associateCustomProtocols - ; Create custom protocols associations - {{range .Info.Protocols}} - !insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" - - {{end}} -!macroend - -!macro wails.unassociateCustomProtocols - ; Delete app custom protocol associations - {{range .Info.Protocols}} - !insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}" - {{end}} -!macroend diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest index 17e1a23..3754c6b 100644 --- a/build/windows/wails.exe.manifest +++ b/build/windows/wails.exe.manifest @@ -1,6 +1,6 @@ - + @@ -12,4 +12,11 @@ permonitorv2,permonitor + + + + + + + \ No newline at end of file diff --git a/database.go b/database.go index 4299a5b..8cebdd5 100644 --- a/database.go +++ b/database.go @@ -58,9 +58,32 @@ func OpenStore(path string) (*Store, error) { return s, nil } +// schemaVersion 是当前本地库结构版本;仅当已记录版本低于它时才执行迁移语句, +// 避免应用每次启动都重复执行建表/回填(版本一致时启动零迁移)。 +// v3:新增 festival_images(节日背景图本地缓存)。 +// v4:todos/tickets 新增 history 生命周期轨迹列。 +// v5:新增 ai_summaries(分析完成后 AI 生成的模块介绍)。 +// v6:新增 day_briefs(工作台 AI 今日规划 / 下班日报)。 +// v7:新增 launch_apps(启动台保存的应用与启停命令)。 +// v8:todos/tickets 新增 team_id 团队共享列。 +const schemaVersion = 8 + func (s *Store) migrate() error { + // PRAGMA 是连接级/文件级配置,不属于迁移,每次打开都需执行。 + for _, q := range []string{`PRAGMA journal_mode=WAL`, `PRAGMA foreign_keys=ON`} { + if _, err := s.db.Exec(q); err != nil { + return fmt.Errorf("database migration: %w", err) + } + } + // 历史数据修正:早期同步日志把警告级别写成了 "warn",统一为 "warning"。 + // 全新数据库此时表还不存在,忽略错误即可。 + _, _ = s.db.Exec(`UPDATE app_logs SET level='warning' WHERE level='warn'`) + var applied int + _ = s.db.QueryRow(`SELECT COALESCE(MAX(version),0) FROM schema_migrations`).Scan(&applied) + if applied >= schemaVersion { + return nil + } stmts := []string{ - `PRAGMA journal_mode=WAL`, `PRAGMA foreign_keys=ON`, `CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS project_groups(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, @@ -70,12 +93,28 @@ func (s *Store) migrate() error { `CREATE TABLE IF NOT EXISTS file_entries(project_id INTEGER NOT NULL, path TEXT NOT NULL, name TEXT NOT NULL, extension TEXT NOT NULL, size INTEGER NOT NULL, is_dir INTEGER NOT NULL, parent TEXT NOT NULL, PRIMARY KEY(project_id,path), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS git_commits(project_id INTEGER NOT NULL, hash TEXT NOT NULL, author TEXT NOT NULL, email TEXT NOT NULL, message TEXT NOT NULL, committed_at TEXT NOT NULL, added INTEGER NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(project_id,hash), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS git_refs(project_id INTEGER NOT NULL, name TEXT NOT NULL, hash TEXT NOT NULL, kind TEXT NOT NULL, is_current INTEGER NOT NULL, PRIMARY KEY(project_id,name), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, - `CREATE TABLE IF NOT EXISTS commit_refs(project_id INTEGER NOT NULL, commit_hash TEXT NOT NULL, ref_name TEXT NOT NULL, PRIMARY KEY(project_id,commit_hash,ref_name), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, + `DROP TABLE IF EXISTS commit_refs`, `CREATE TABLE IF NOT EXISTS contributors(project_id INTEGER NOT NULL, email TEXT NOT NULL, name TEXT NOT NULL, commits INTEGER NOT NULL, added INTEGER NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(project_id,email), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS project_insights(project_id INTEGER PRIMARY KEY, health_score INTEGER NOT NULL, generated_at TEXT NOT NULL, high INTEGER NOT NULL, medium INTEGER NOT NULL, low INTEGER NOT NULL, todo_count INTEGER NOT NULL, long_files INTEGER NOT NULL, large_files INTEGER NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS insight_issues(project_id INTEGER NOT NULL, idx INTEGER NOT NULL, severity TEXT NOT NULL, type TEXT NOT NULL, title TEXT NOT NULL, detail TEXT NOT NULL, path TEXT NOT NULL DEFAULT '', line INTEGER NOT NULL DEFAULT 0, suggestion TEXT NOT NULL DEFAULT '', evidence TEXT NOT NULL DEFAULT '', PRIMARY KEY(project_id,idx), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS exclusion_rules(id INTEGER PRIMARY KEY AUTOINCREMENT, pattern TEXT NOT NULL UNIQUE, category TEXT NOT NULL, builtin INTEGER NOT NULL DEFAULT 0)`, `CREATE TABLE IF NOT EXISTS app_logs(id INTEGER PRIMARY KEY AUTOINCREMENT, level TEXT NOT NULL, category TEXT NOT NULL, message TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS git_hotspots(project_id INTEGER NOT NULL, path TEXT NOT NULL, changes INTEGER NOT NULL, added INTEGER NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(project_id,path), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, + `CREATE TABLE IF NOT EXISTS todos(id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', project_id INTEGER NOT NULL DEFAULT 0, due_at TEXT NOT NULL DEFAULT '', priority TEXT NOT NULL DEFAULT 'medium', status TEXT NOT NULL DEFAULT 'open', reminded INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 1, history TEXT NOT NULL DEFAULT '', team_id INTEGER NOT NULL DEFAULT 0)`, + `CREATE TABLE IF NOT EXISTS tickets(id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', type TEXT NOT NULL DEFAULT 'task', project_id INTEGER NOT NULL, start_at TEXT NOT NULL, due_at TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', priority TEXT NOT NULL DEFAULT 'medium', reminded INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 1, history TEXT NOT NULL DEFAULT '', team_id INTEGER NOT NULL DEFAULT 0)`, + `CREATE TABLE IF NOT EXISTS notes(id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, content TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 1)`, + `CREATE TABLE IF NOT EXISTS favorites(project_id INTEGER PRIMARY KEY, created_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS messages(id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', source_type TEXT NOT NULL DEFAULT '', source_id INTEGER NOT NULL DEFAULT 0, is_read INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS ai_conversations(id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL DEFAULT 0, provider TEXT NOT NULL DEFAULT '', title TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS ai_messages(id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS festival_images(fest_key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '', dirty INTEGER NOT NULL DEFAULT 0)`, + `CREATE TABLE IF NOT EXISTS ai_summaries(project_id INTEGER NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', generated_at TEXT NOT NULL, PRIMARY KEY(project_id,kind), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, + `CREATE TABLE IF NOT EXISTS day_briefs(kind TEXT PRIMARY KEY, content TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', generated_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS launch_apps(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'other', port INTEGER NOT NULL DEFAULT 0, dir TEXT NOT NULL DEFAULT '', start_cmd TEXT NOT NULL DEFAULT '', stop_cmd TEXT NOT NULL DEFAULT '', last_pid INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, + `CREATE INDEX IF NOT EXISTS idx_ai_messages_conv ON ai_messages(conversation_id)`, + `CREATE INDEX IF NOT EXISTS idx_messages_read ON messages(is_read)`, + `CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(deleted,status)`, + `CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(deleted,status)`, } for _, q := range stmts { if _, err := s.db.Exec(q); err != nil { @@ -89,6 +128,28 @@ func (s *Store) migrate() error { return fmt.Errorf("database migration: %w", err) } } + // 待办/工单生命周期:history 存 JSON 状态轨迹(进入某状态的时间点)。 + for _, table := range []string{"todos", "tickets"} { + if ok, err := s.columnExists(table, "history"); err != nil { + return err + } else if !ok { + if _, err = s.db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN history TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("database migration: %w", err) + } + // 存量数据补一个创建节点,保证时间线至少有起点。 + _, _ = s.db.Exec(`UPDATE ` + table + ` SET history='[{"status":"open","at":"'||created_at||'"}]' WHERE history=''`) + } + } + // 团队共享:team_id>0 表示该条对团队管理员可见(0 私密)。 + for _, table := range []string{"todos", "tickets"} { + if ok, err := s.columnExists(table, "team_id"); err != nil { + return err + } else if !ok { + if _, err = s.db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN team_id INTEGER NOT NULL DEFAULT 0`); err != nil { + return fmt.Errorf("database migration: %w", err) + } + } + } _, _ = s.db.Exec(`INSERT OR IGNORE INTO project_groups(id,name,created_at,updated_at) VALUES(1,'My Projects',?,?)`, time.Now().Format(time.RFC3339), time.Now().Format(time.RFC3339)) _, _ = s.db.Exec(`UPDATE projects SET group_id=1 WHERE group_id IS NULL OR group_id=0`) defaults := map[string][]string{"general": {".git", ".idea", ".vscode", "node_modules", "dist", "coverage", "*.min.js", "*.min.css", ".DS_Store"}, "php": {"vendor", "storage/framework", "bootstrap/cache", ".phpunit.cache"}, "go": {"go.sum"}, "vue": {".nuxt", ".next", ".output", "unpackage"}} @@ -99,7 +160,7 @@ func (s *Store) migrate() error { } _, _ = s.db.Exec(`INSERT OR IGNORE INTO settings(key,value) VALUES('theme','dark'),('locale','zh-CN'),('gitScope','current'),('autoRefresh','true'),('glassOpacity','55'),('loadingStyle','fullscreen-orbit')`) _, _ = s.db.Exec(`UPDATE settings SET value='fullscreen-orbit' WHERE key='loadingStyle' AND value IN ('bar','fullscreen') AND NOT EXISTS(SELECT 1 FROM settings WHERE key='loadingStyleSet' AND value='true')`) - _, _ = s.db.Exec(`INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(1,?)`, time.Now().Format(time.RFC3339)) + _, _ = s.db.Exec(`INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(?,?)`, schemaVersion, time.Now().Format(time.RFC3339)) // 旧版本曾写入英文日志;迁移时转换已知固定文案,Git 提交消息等用户内容不做修改。 translations := map[string]string{"Application started": "应用程序启动", "Desktop runtime connected": "桌面运行时已连接", "Project saved": "项目保存成功", "Project save failed": "项目保存失败", "Project deleted": "项目删除成功", "Analysis completed": "项目分析完成", "Analysis failed": "项目分析失败", "Database initialized": "数据库初始化成功", "Database migrated": "数据库迁移成功"} for en, zh := range translations { @@ -343,14 +404,62 @@ func (s *Store) languages(id int64) ([]LanguageStat, error) { } func (s *Store) Dashboard(groupID int64) (Dashboard, error) { var d Dashboard + var e error if groupID > 0 { - e := s.db.QueryRow(`SELECT COUNT(*),COALESCE((SELECT SUM(ls.code+ls.comments+ls.blanks) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0),COALESCE((SELECT COUNT(*) FROM git_commits gc JOIN projects p ON p.id=gc.project_id WHERE p.group_id=?),0) FROM projects WHERE group_id=?`, groupID, groupID, groupID).Scan(&d.Projects, &d.TotalLines, &d.Commits) + e = s.db.QueryRow(`SELECT COUNT(*), + COALESCE((SELECT SUM(ls.code+ls.comments+ls.blanks) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0), + COALESCE((SELECT SUM(ls.code) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0), + COALESCE((SELECT SUM(ls.comments) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0), + COALESCE((SELECT SUM(ls.blanks) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0), + COALESCE((SELECT SUM(ls.files) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0), + COALESCE((SELECT COUNT(*) FROM git_commits gc JOIN projects p ON p.id=gc.project_id WHERE p.group_id=?),0), + COALESCE((SELECT COUNT(DISTINCT gc.email) FROM git_commits gc JOIN projects p ON p.id=gc.project_id WHERE p.group_id=?),0) + FROM projects WHERE group_id=?`, groupID, groupID, groupID, groupID, groupID, groupID, groupID, groupID). + Scan(&d.Projects, &d.TotalLines, &d.CodeLines, &d.CommentLines, &d.BlankLines, &d.FileCount, &d.Commits, &d.Contributors) + } else { + e = s.db.QueryRow(`SELECT COUNT(*), + COALESCE((SELECT SUM(code+comments+blanks) FROM language_stats),0), + COALESCE((SELECT SUM(code) FROM language_stats),0), + COALESCE((SELECT SUM(comments) FROM language_stats),0), + COALESCE((SELECT SUM(blanks) FROM language_stats),0), + COALESCE((SELECT SUM(files) FROM language_stats),0), + COALESCE((SELECT COUNT(*) FROM git_commits),0), + COALESCE((SELECT COUNT(DISTINCT email) FROM git_commits),0) + FROM projects`). + Scan(&d.Projects, &d.TotalLines, &d.CodeLines, &d.CommentLines, &d.BlankLines, &d.FileCount, &d.Commits, &d.Contributors) + } + if e != nil { return d, e } - e := s.db.QueryRow(`SELECT COUNT(*),COALESCE((SELECT SUM(code+comments+blanks) FROM language_stats),0),COALESCE((SELECT COUNT(*) FROM git_commits),0) FROM projects`).Scan(&d.Projects, &d.TotalLines, &d.Commits) + d.Languages, e = s.AggregateLanguages(groupID) return d, e } +// AggregateLanguages 跨项目聚合语言分布;groupID>0 时仅统计该项目组。 +func (s *Store) AggregateLanguages(groupID int64) ([]LanguageStat, error) { + q := `SELECT ls.name,SUM(ls.files),SUM(ls.code),SUM(ls.comments),SUM(ls.blanks) FROM language_stats ls` + args := []any{} + if groupID > 0 { + q += ` JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?` + args = append(args, groupID) + } + q += ` GROUP BY ls.name ORDER BY SUM(ls.code) DESC` + r, e := s.db.Query(q, args...) + if e != nil { + return nil, e + } + defer r.Close() + out := []LanguageStat{} + for r.Next() { + var x LanguageStat + if e = r.Scan(&x.Name, &x.Files, &x.Code, &x.Comments, &x.Blanks); e != nil { + return nil, e + } + out = append(out, x) + } + return out, r.Err() +} + func (s *Store) ReplaceScan(id int64, langs []LanguageStat, files []FileEntry) error { tx, e := s.db.Begin() if e != nil { @@ -487,6 +596,9 @@ func (s *Store) DeleteRule(id int64) error { return nil } func (s *Store) Log(level, category, message, detail string) { + if level == "warn" { + level = "warning" // UI 与统计只认 info/warning/error + } _, _ = s.db.Exec(`INSERT INTO app_logs(level,category,message,detail,created_at) VALUES(?,?,?,?,?)`, level, category, message, detail, now()) } func (s *Store) Logs(level string) ([]LogEntry, error) { @@ -513,8 +625,90 @@ func (s *Store) Logs(level string) ([]LogEntry, error) { return o, r.Err() } func (s *Store) ClearLogs() error { _, e := s.db.Exec(`DELETE FROM app_logs`); return e } + +// SearchLogs 支持关键词(消息/详情/分类)、级别、分类过滤和分页。 +// 计数按当前关键词+分类统计(不含级别),供前端标签页展示。 +func (s *Store) SearchLogs(query, level, category string, offset, limit int64) (LogPage, error) { + page := LogPage{Items: []LogEntry{}} + where, args := []string{}, []any{} + if q := strings.TrimSpace(query); q != "" { + where = append(where, `(message LIKE ? OR detail LIKE ? OR category LIKE ?)`) + like := "%" + q + "%" + args = append(args, like, like, like) + } + if category != "" && category != "all" { + where = append(where, `category=?`) + args = append(args, category) + } + base := "" + if len(where) > 0 { + base = " WHERE " + strings.Join(where, " AND ") + } + e := s.db.QueryRow(`SELECT COUNT(*),COALESCE(SUM(level='info'),0),COALESCE(SUM(level='warning'),0),COALESCE(SUM(level='error'),0) FROM app_logs`+base, args...). + Scan(&page.Total, &page.Info, &page.Warning, &page.Error) + if e != nil { + return page, e + } + itemWhere, itemArgs := where, args + if level != "" && level != "all" { + itemWhere = append(append([]string{}, where...), `level=?`) + itemArgs = append(append([]any{}, args...), level) + switch level { + case "info": + page.Total = page.Info + case "warning": + page.Total = page.Warning + case "error": + page.Total = page.Error + } + } + q := `SELECT id,level,category,message,detail,created_at FROM app_logs` + if len(itemWhere) > 0 { + q += ` WHERE ` + strings.Join(itemWhere, " AND ") + } + if limit <= 0 || limit > 200 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + q += ` ORDER BY id DESC LIMIT ? OFFSET ?` + itemArgs = append(itemArgs, limit, offset) + r, e := s.db.Query(q, itemArgs...) + if e != nil { + return page, e + } + defer r.Close() + for r.Next() { + var x LogEntry + if e = r.Scan(&x.ID, &x.Level, &x.Category, &x.Message, &x.Detail, &x.CreatedAt); e != nil { + return page, e + } + page.Items = append(page.Items, x) + } + return page, r.Err() +} + +// LogCategories 返回日志中出现过的分类,供筛选下拉框使用。 +func (s *Store) LogCategories() ([]string, error) { + r, e := s.db.Query(`SELECT DISTINCT category FROM app_logs ORDER BY category`) + if e != nil { + return nil, e + } + defer r.Close() + out := []string{} + for r.Next() { + var c string + if e = r.Scan(&c); e != nil { + return nil, e + } + out = append(out, c) + } + return out, r.Err() +} func (s *Store) Settings() (AppSettings, error) { - x := AppSettings{DatabasePath: s.path, Theme: "dark", Locale: "zh-CN", GitScope: "current", AutoRefresh: true, GlassOpacity: 55, LoadingStyle: "fullscreen-orbit"} + x := AppSettings{DatabasePath: s.path, Theme: "dark", Locale: "zh-CN", GitScope: "current", AutoRefresh: true, GlassOpacity: 55, LoadingStyle: "fullscreen-orbit", + MinimizeToTray: true, AutoUpdateMode: "daily", AutoUpdateInterval: 1, AutoUpdateTime: "09:00", AIProvider: "spark", ImageMode: "base64"} r, e := s.db.Query(`SELECT key,value FROM settings`) if e != nil { return x, e @@ -540,6 +734,42 @@ func (s *Store) Settings() (AppSettings, error) { if validLoadingStyle(v) { x.LoadingStyle = v } + case "minimizeToTray": + x.MinimizeToTray = v == "true" + case "autoUpdateEnabled": + x.AutoUpdateEnabled = v == "true" + case "autoUpdateMode": + if v == "daily" || v == "everyNDays" || v == "everyNHours" { + x.AutoUpdateMode = v + } + case "autoUpdateInterval": + if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 720 { + x.AutoUpdateInterval = n + } + case "aiProvider": + if v == "spark" || v == "deepseek" { + x.AIProvider = v + } + case "sparkKey": + x.SparkKey = v + case "deepSeekKey": + x.DeepSeekKey = v + case "syncApiKeys": + x.SyncAPIKeys = v == "true" + case "avatarMode": + if v == "base64" || v == "url" || v == "path" { + x.AvatarMode = v + } + case "avatarValue": + x.AvatarValue = v + case "imageMode": + if v == "base64" || v == "path" || v == "server" { + x.ImageMode = v + } + case "autoUpdateTime": + if len(v) == 5 && v[2] == ':' { + x.AutoUpdateTime = v + } } } return x, nil @@ -551,7 +781,37 @@ func (s *Store) SaveSettings(x AppSettings) error { if !validLoadingStyle(x.LoadingStyle) { x.LoadingStyle = "fullscreen-orbit" } - vals := map[string]string{"theme": x.Theme, "locale": x.Locale, "gitScope": x.GitScope, "autoRefresh": fmt.Sprint(x.AutoRefresh), "glassOpacity": strconv.Itoa(x.GlassOpacity), "loadingStyle": x.LoadingStyle, "loadingStyleSet": "true"} + if x.AutoUpdateInterval < 1 || x.AutoUpdateInterval > 720 { + x.AutoUpdateInterval = 1 + } + if x.AutoUpdateMode != "daily" && x.AutoUpdateMode != "everyNDays" && x.AutoUpdateMode != "everyNHours" { + x.AutoUpdateMode = "daily" + } + if len(x.AutoUpdateTime) != 5 || x.AutoUpdateTime[2] != ':' { + x.AutoUpdateTime = "09:00" + } + if x.AIProvider != "spark" && x.AIProvider != "deepseek" { + x.AIProvider = "spark" + } + if x.ImageMode != "base64" && x.ImageMode != "path" && x.ImageMode != "server" { + x.ImageMode = "base64" + } + vals := map[string]string{"theme": x.Theme, "locale": x.Locale, "gitScope": x.GitScope, "autoRefresh": fmt.Sprint(x.AutoRefresh), "glassOpacity": strconv.Itoa(x.GlassOpacity), "loadingStyle": x.LoadingStyle, "loadingStyleSet": "true", + "minimizeToTray": fmt.Sprint(x.MinimizeToTray), "autoUpdateEnabled": fmt.Sprint(x.AutoUpdateEnabled), "autoUpdateMode": x.AutoUpdateMode, "autoUpdateInterval": strconv.Itoa(x.AutoUpdateInterval), "autoUpdateTime": x.AutoUpdateTime, + "aiProvider": x.AIProvider, "sparkKey": x.SparkKey, "deepSeekKey": x.DeepSeekKey, "syncApiKeys": fmt.Sprint(x.SyncAPIKeys), + "avatarMode": x.AvatarMode, "avatarValue": x.AvatarValue, "imageMode": x.ImageMode} + if x.AvatarMode != "base64" && x.AvatarMode != "url" && x.AvatarMode != "path" { + vals["avatarMode"], vals["avatarValue"] = "", "" + } + // API Key / 头像变化时记录修改时间,作为同步的 LWW 依据。 + if prev, e := s.Settings(); e == nil { + if prev.SparkKey != x.SparkKey || prev.DeepSeekKey != x.DeepSeekKey { + vals["api_keys_updated_at"] = nowRFC() + } + if prev.AvatarMode != vals["avatarMode"] || prev.AvatarValue != vals["avatarValue"] { + vals["avatar_updated_at"] = nowRFC() + } + } tx, e := s.db.Begin() if e != nil { return e @@ -565,6 +825,19 @@ func (s *Store) SaveSettings(x AppSettings) error { return tx.Commit() } +// Meta 读取 settings 表中的任意键(用于内部状态,如上次自动更新时间)。 +func (s *Store) Meta(key string) string { + var v string + _ = s.db.QueryRow(`SELECT value FROM settings WHERE key=?`, key).Scan(&v) + return v +} + +// SetMeta 写入 settings 表中的任意键。 +func (s *Store) SetMeta(key, value string) error { + _, e := s.db.Exec(`INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value) + return e +} + func validLoadingStyle(v string) bool { switch v { case "bar", "fullscreen", "fullscreen-orbit", "fullscreen-grid", "fullscreen-warp": @@ -579,9 +852,9 @@ func (s *Store) ClearData(mode string, projectID int64) error { return e } defer tx.Rollback() - tables := []string{"language_stats", "file_entries", "git_commits", "git_refs", "commit_refs", "contributors", "project_insights", "insight_issues", "analysis_runs"} + tables := []string{"language_stats", "file_entries", "git_commits", "git_refs", "contributors", "git_hotspots", "project_insights", "insight_issues", "analysis_runs"} if mode == "all" { - tables = append(tables, "projects") + tables = append(tables, "projects", "todos", "tickets", "notes", "favorites", "messages", "ai_conversations", "ai_messages") } for _, t := range tables { q := "DELETE FROM " + t @@ -608,7 +881,7 @@ func (s *Store) ReplaceGit(id int64, g GitStats) error { return e } defer tx.Rollback() - for _, t := range []string{"git_commits", "git_refs", "commit_refs", "contributors"} { + for _, t := range []string{"git_commits", "git_refs", "contributors", "git_hotspots"} { if _, e = tx.Exec("DELETE FROM "+t+" WHERE project_id=?", id); e != nil { return e } @@ -628,14 +901,69 @@ func (s *Store) ReplaceGit(id int64, g GitStats) error { return e } } + for _, h := range g.Hotspots { + if _, e = tx.Exec(`INSERT INTO git_hotspots VALUES(?,?,?,?,?)`, id, h.Path, h.Changes, h.Added, h.Deleted); e != nil { + return e + } + } _, e = tx.Exec(`INSERT INTO analysis_runs(project_id,kind,status,started_at,completed_at) VALUES(?,'git','completed',?,?)`, id, now(), now()) if e != nil { return e } return tx.Commit() } + +// MergeGit 增量合并新提交:追加提交与热点,替换引用,再从提交表重算贡献者。 +func (s *Store) MergeGit(id int64, g GitStats) error { + tx, e := s.db.Begin() + if e != nil { + return e + } + defer tx.Rollback() + for _, c := range g.Commits { + if _, e = tx.Exec(`INSERT INTO git_commits VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(project_id,hash) DO UPDATE SET author=excluded.author,email=excluded.email,message=excluded.message,committed_at=excluded.committed_at,added=excluded.added,deleted=excluded.deleted`, + id, c.Hash, c.Author, c.Email, c.Message, c.Date, c.Added, c.Deleted); e != nil { + return e + } + } + if _, e = tx.Exec(`DELETE FROM git_refs WHERE project_id=?`, id); e != nil { + return e + } + for _, r := range g.Refs { + if _, e = tx.Exec(`INSERT INTO git_refs VALUES(?,?,?,?,?)`, id, r.Name, r.Hash, r.Kind, boolInt(r.Current)); e != nil { + return e + } + } + for _, h := range g.Hotspots { + if _, e = tx.Exec(`INSERT INTO git_hotspots VALUES(?,?,?,?,?) + ON CONFLICT(project_id,path) DO UPDATE SET changes=changes+excluded.changes,added=added+excluded.added,deleted=deleted+excluded.deleted`, + id, h.Path, h.Changes, h.Added, h.Deleted); e != nil { + return e + } + } + if _, e = tx.Exec(`DELETE FROM contributors WHERE project_id=?`, id); e != nil { + return e + } + if _, e = tx.Exec(`INSERT INTO contributors(project_id,email,name,commits,added,deleted) + SELECT project_id,email,MAX(author),COUNT(*),SUM(added),SUM(deleted) FROM git_commits WHERE project_id=? GROUP BY email`, id); e != nil { + return e + } + _, e = tx.Exec(`INSERT INTO analysis_runs(project_id,kind,status,started_at,completed_at) VALUES(?,'git','completed',?,?)`, id, now(), now()) + if e != nil { + return e + } + return tx.Commit() +} + +// LatestCommitHash 返回项目已入库的最新提交哈希,用于增量分析起点。 +func (s *Store) LatestCommitHash(id int64) string { + var h string + _ = s.db.QueryRow(`SELECT hash FROM git_commits WHERE project_id=? ORDER BY committed_at DESC LIMIT 1`, id).Scan(&h) + return h +} func (s *Store) GitStats(id int64) (GitStats, error) { - g := GitStats{Available: true, Commits: []GitCommit{}, Refs: []GitRef{}, Contributors: []Contributor{}, Heatmap: []HeatDay{}} + g := GitStats{Available: true, Commits: []GitCommit{}, Refs: []GitRef{}, Contributors: []Contributor{}, Heatmap: []HeatDay{}, Hotspots: []GitFileHotspot{}} r, e := s.db.Query(`SELECT hash,author,email,message,committed_at,added,deleted FROM git_commits WHERE project_id=? ORDER BY committed_at DESC`, id) if e != nil { return g, e @@ -681,16 +1009,34 @@ func (s *Store) GitStats(id int64) (GitStats, error) { g.CommitCount = int64(len(g.Commits)) g.ContributorCount = int64(len(g.Contributors)) cut := time.Now().AddDate(-1, 0, 0) - hm := map[string]int64{} + hm := map[string]*HeatDay{} for _, c := range g.Commits { if t, e := time.Parse(time.RFC3339, c.Date); e == nil && t.After(cut) { - hm[t.Local().Format("2006-01-02")]++ + key := t.Local().Format("2006-01-02") + d := hm[key] + if d == nil { + d = &HeatDay{Date: key} + hm[key] = d + } + d.Count++ + d.Added += c.Added + d.Deleted += c.Deleted } } - for d, c := range hm { - g.Heatmap = append(g.Heatmap, HeatDay{Date: d, Count: c}) + for _, d := range hm { + g.Heatmap = append(g.Heatmap, *d) } sort.Slice(g.Heatmap, func(i, j int) bool { return g.Heatmap[i].Date < g.Heatmap[j].Date }) + hr, e := s.db.Query(`SELECT path,changes,added,deleted FROM git_hotspots WHERE project_id=? ORDER BY changes DESC,added+deleted DESC LIMIT 20`, id) + if e != nil { + return g, e + } + for hr.Next() { + var h GitFileHotspot + _ = hr.Scan(&h.Path, &h.Changes, &h.Added, &h.Deleted) + g.Hotspots = append(g.Hotspots, h) + } + hr.Close() return g, nil } diff --git a/database_test.go b/database_test.go index 44a98f8..71eaeb9 100644 --- a/database_test.go +++ b/database_test.go @@ -34,6 +34,62 @@ func TestStoreProjectAndSnapshot(t *testing.T) { } } +func TestAISummaryRoundTrip(t *testing.T) { + s, e := OpenStore(filepath.Join(t.TempDir(), "test.db")) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + p, e := s.SaveProject(0, ProjectInput{Name: "demo", Path: t.TempDir()}) + if e != nil { + t.Fatal(e) + } + if e = s.SaveAISummary(p.ID, "project", "spark", "第一版介绍"); e != nil { + t.Fatal(e) + } + // 覆盖更新(同项目同模块只保留最新一份) + if e = s.SaveAISummary(p.ID, "project", "deepseek", "第二版介绍"); e != nil { + t.Fatal(e) + } + if e = s.SaveAISummary(p.ID, "git", "spark", "Git 介绍"); e != nil { + t.Fatal(e) + } + got, e := s.GetAISummaries(p.ID) + if e != nil || len(got) != 2 { + t.Fatalf("expected 2 summaries, got %d (%v)", len(got), e) + } + for _, x := range got { + switch x.Kind { + case "project": + if x.Content != "第二版介绍" || x.Provider != "deepseek" { + t.Fatalf("project summary mismatch: %#v", x) + } + case "git": + if x.Content != "Git 介绍" || x.Provider != "spark" { + t.Fatalf("git summary mismatch: %#v", x) + } + } + } +} + +func TestAISummaryCleanup(t *testing.T) { + if got := stripFence("```markdown\n介绍正文\n- 列表\n```"); got != "介绍正文\n- 列表" { + t.Fatalf("stripFence: %q", got) + } + if got := stripFence("普通文本"); got != "普通文本" { + t.Fatalf("stripFence should keep plain text: %q", got) + } + if got := stripFence("```\n# 标题\n正文没闭合"); got != "# 标题\n正文没闭合" { + t.Fatalf("stripFence should drop unclosed opening fence: %q", got) + } + if got := dedentCommon(" 第一行\n 第二行"); got != "第一行\n第二行" { + t.Fatalf("dedentCommon: %q", got) + } + if got := dedentCommon("第一行\n 嵌套保留"); got != "第一行\n 嵌套保留" { + t.Fatalf("dedentCommon should keep relative indent: %q", got) + } +} + func TestNormalizePath(t *testing.T) { if _, e := normalizePath(filepath.Join(t.TempDir(), "missing")); e == nil { t.Fatal("missing directory accepted") @@ -199,6 +255,43 @@ func TestLoadingStylePersistsAndValidates(t *testing.T) { } } +func TestMigrateSkippedWhenVersionCurrent(t *testing.T) { + db := filepath.Join(t.TempDir(), "versioned.db") + s, e := OpenStore(db) + if e != nil { + t.Fatal(e) + } + // 删除一张迁移会创建的表:若重开时迁移被跳过,该表应保持缺失。 + if _, e = s.db.Exec(`DROP TABLE git_hotspots`); e != nil { + t.Fatal(e) + } + s.db.Close() + s, e = OpenStore(db) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + var n int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='git_hotspots'`).Scan(&n) + if n != 0 { + t.Fatal("migration ran again despite current schema version") + } + // 回退版本号后重开,迁移应重新执行并补回该表。 + if _, e = s.db.Exec(`DELETE FROM schema_migrations`); e != nil { + t.Fatal(e) + } + s.db.Close() + s, e = OpenStore(db) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + _ = s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='git_hotspots'`).Scan(&n) + if n != 1 { + t.Fatal("migration did not run after version reset") + } +} + func TestLegacyLoadingStyleDefaultMigratesOnce(t *testing.T) { db := filepath.Join(t.TempDir(), "settings.db") s, e := OpenStore(db) @@ -208,6 +301,10 @@ func TestLegacyLoadingStyleDefaultMigratesOnce(t *testing.T) { if _, e = s.db.Exec(`UPDATE settings SET value='bar' WHERE key='loadingStyle'`); e != nil { t.Fatal(e) } + // 模拟旧版本数据库:回退已记录的结构版本,重开时才会执行一次迁移转换。 + if _, e = s.db.Exec(`DELETE FROM schema_migrations`); e != nil { + t.Fatal(e) + } s.db.Close() s, e = OpenStore(db) if e != nil { @@ -237,3 +334,52 @@ func TestLegacyLoadingStyleDefaultMigratesOnce(t *testing.T) { t.Fatalf("explicit loadingStyle=%q", got.LoadingStyle) } } + +func TestMultiNoteCRUD(t *testing.T) { + s, e := OpenStore(filepath.Join(t.TempDir(), "notes.db")) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + a, e := s.SaveNoteByID(0, "第一条") + if e != nil || a.ID == 0 || a.UUID == "" { + t.Fatalf("create note: %#v %v", a, e) + } + b, e := s.SaveNoteByID(0, "第二条") + if e != nil || b.ID == a.ID { + t.Fatalf("second note: %#v %v", b, e) + } + notes, e := s.ListNotes(0) + if e != nil || len(notes) != 2 { + t.Fatalf("list notes: %d %v", len(notes), e) + } + if notes[0].ID != b.ID { + t.Fatalf("latest first, got %#v", notes[0]) + } + // GetNote 返回最近更新的一条;SaveNote 兼容入口更新它 + if n, _ := s.GetNote(); n.ID != b.ID { + t.Fatalf("GetNote should return latest, got %d", n.ID) + } + if a2, e := s.SaveNoteByID(a.ID, "第一条改"); e != nil || a2.Content != "第一条改" { + t.Fatalf("update note: %#v %v", a2, e) + } + // 更新过的 a 变为最近一条 + if n, _ := s.GetNote(); n.ID != a.ID { + t.Fatalf("GetNote should follow update, got %d", n.ID) + } + if _, e := s.SaveNoteByID(99999, "missing"); e == nil || e.Error() != "NOTE_NOT_FOUND" { + t.Fatalf("expect NOTE_NOT_FOUND, got %v", e) + } + if e := s.DeleteNote(b.ID); e != nil { + t.Fatal(e) + } + if notes, _ = s.ListNotes(0); len(notes) != 1 || notes[0].ID != a.ID { + t.Fatalf("after delete: %#v", notes) + } + // 软删行仍在表中且 dirty(供同步推送) + var deleted, dirty int + _ = s.db.QueryRow(`SELECT deleted,dirty FROM notes WHERE id=?`, b.ID).Scan(&deleted, &dirty) + if deleted != 1 || dirty != 1 { + t.Fatalf("soft delete flags: deleted=%d dirty=%d", deleted, dirty) + } +} diff --git a/frontend/index.html b/frontend/index.html index 0011c98..695289c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,8 @@ - view + + 年糕崽崽项目管理(PMS)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d866e0e..af24c55 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,12 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@wailsio/runtime": "^3.0.0-beta.5", "echarts": "^6.1.0", + "highlight.js": "^11.11.2", "lucide-vue-next": "^1.0.0", + "lunar-javascript": "^1.7.7", + "marked": "^18.0.9", "pinia": "^4.0.1", "vue": "^3.2.37", "vue-i18n": "^9.14.5", @@ -271,6 +275,12 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==" }, + "node_modules/@wailsio/runtime": { + "version": "3.0.0-beta.5", + "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-beta.5.tgz", + "integrity": "sha512-WPJcKfD/iN5EbzLAE0B2AjwRVVSSnhpGQSjgFORtIA3h1bkiw/k7rTd06emNsI1ZFGFgMiUEEmXgEcVfeWoRVQ==", + "license": "MIT" + }, "node_modules/birpc": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", @@ -711,6 +721,15 @@ "node": ">= 0.4" } }, + "node_modules/highlight.js": { + "version": "11.11.2", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz", + "integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", @@ -741,6 +760,12 @@ "vue": ">=3.0.1" } }, + "node_modules/lunar-javascript": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/lunar-javascript/-/lunar-javascript-1.7.7.tgz", + "integrity": "sha512-u/KYiwPIBo/0bT+WWfU7qO1d+aqeB90Tuy4ErXenr2Gam0QcWeezUvtiOIyXR7HbVnW2I1DKfU0NBvzMZhbVQw==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -749,6 +774,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.9.tgz", + "integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", diff --git a/frontend/package.json b/frontend/package.json index 44c1032..3a5eec4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,11 +6,16 @@ "scripts": { "dev": "vite", "build": "vite build", + "build:dev": "vite build --mode development --minify false", "preview": "vite preview" }, "dependencies": { + "@wailsio/runtime": "^3.0.0-beta.5", "echarts": "^6.1.0", + "highlight.js": "^11.11.2", "lucide-vue-next": "^1.0.0", + "lunar-javascript": "^1.7.7", + "marked": "^18.0.9", "pinia": "^4.0.1", "vue": "^3.2.37", "vue-i18n": "^9.14.5", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c95a3c2..f461df6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,12 +2,20 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' -import { LayoutDashboard, ScrollText, Settings, X, Database, SlidersHorizontal } from 'lucide-vue-next' +import { LayoutDashboard, ScrollText, Settings, X, Database, SlidersHorizontal, Home, ListTodo, TicketCheck, CalendarDays, CalendarCheck2, Sparkles, UserRound, ClipboardList, Wrench, Rocket, ChevronDown, Bell, StickyNote, ListFilter, Palette, Bot, Users, NotebookPen, CloudUpload, Power } from 'lucide-vue-next' import { useAppStore } from './store' import DatabaseSetup from './components/DatabaseSetup.vue' import BrowserBlocked from './components/BrowserBlocked.vue' import AnalysisCanvas from './components/AnalysisCanvas.vue' -import { isNative } from './api' +import LoginModal from './components/LoginModal.vue' +import CommandPalette from './components/CommandPalette.vue' +import DailyCard from './components/DailyCard.vue' +import AboutModal from './components/AboutModal.vue' +import MessageBell from './components/MessageBell.vue' +import TaskCenter from './components/TaskCenter.vue' +import NoteCenter from './components/NoteCenter.vue' +import TeamSwitcher from './components/TeamSwitcher.vue' +import { call, isNative, on } from './api' const route = useRoute() const router = useRouter() @@ -15,8 +23,11 @@ const store = useAppStore() const { t, locale } = useI18n() const native = isNative() const quickOpen = ref(false) +const aboutOpen = ref(false) +const appVersion = ref('1.0.0') const displayedTask = ref(null) let off +let offMenus = [] let loadingHoldTimer const activeTask = computed(() => Object.values(store.tasks).find(x => !['completed', 'error', 'cancelled'].includes(x.stage))) @@ -35,19 +46,138 @@ function openSettings() { router.push('/settings') } +// 侧边栏底部退出:与原生菜单/托盘「退出」一致,绕过最小化到托盘直接退出应用。 +async function quitApp() { + if (!confirm(t('quitConfirm'))) return + try { await call('QuitApp') } catch { /* 预览模式无原生桥 */ } +} + +// ---- 双列导航:一级分类(rail)+ 二级菜单(sub),窄屏点一级弹浮层 ---- +// 设置页无 query 时沿用本地记忆的分区,保证二级高亮与页面内容一致。 +const settingsTab = r => String(r.query.tab || localStorage.getItem('cc-settings-tab') || 'rules') +const navGroups = [ + { id: 'overview', icon: Home, label: 'navOverview', items: [ + { to: '/', icon: Home, label: 'workbench', match: r => r.path === '/' }, + { to: '/today', icon: CalendarCheck2, label: 'todayTasks', badge: 'todayDue' }, + { to: '/calendar', icon: CalendarDays, label: 'calendar', dot: 'calendarDot' }, + { to: '/messages', icon: Bell, label: 'messages', badge: 'unread' } + ] }, + { id: 'projects', icon: LayoutDashboard, label: 'navProjects', items: [ + { to: '/projects', icon: LayoutDashboard, label: 'projects', match: r => r.path === '/projects' || r.path.startsWith('/project/') }, + { to: '/ai', icon: Sparkles, label: 'aiChat' } + ] }, + { id: 'work', icon: ClipboardList, label: 'navWork', items: [ + { to: '/todos', icon: ListTodo, label: 'todos', badge: 'todosOpen' }, + { to: '/tickets', icon: TicketCheck, label: 'tickets', badge: 'ticketsActive' }, + { to: '/notes', icon: StickyNote, label: 'notesPage' } + ] }, + { id: 'team', icon: Users, label: 'navTeam', items: [ + { to: '/team', icon: Users, label: 'teamHome', match: r => r.path === '/team' }, + { to: '/team/tasks', icon: ClipboardList, label: 'teamTasks', badge: 'teamAssigned' }, + { to: '/team/reports', icon: NotebookPen, label: 'teamReports' } + ] }, + { id: 'system', icon: Wrench, label: 'navSystem', items: [ + { to: '/launchpad', icon: Rocket, label: 'launchpad' }, + { to: '/logs', icon: ScrollText, label: 'logs' } + ] }, + { id: 'settings', icon: Settings, label: 'settings', items: [ + { to: '/settings?tab=rules', icon: ListFilter, label: 'tabRules', match: r => r.path === '/settings' && settingsTab(r) === 'rules' }, + { to: '/settings?tab=appearance', icon: Palette, label: 'tabAppearance', match: r => r.path === '/settings' && settingsTab(r) === 'appearance' }, + { to: '/settings?tab=ai', icon: Bot, label: 'aiAnalysis', match: r => r.path === '/settings' && settingsTab(r) === 'ai' }, + // 全局文件存储配置:权威数据在服务器,仅管理员(云端账号 id=1)可见可改 + { to: '/settings?tab=filestorage', icon: CloudUpload, label: 'tabFileStorage', adminOnly: true, match: r => r.path === '/settings' && settingsTab(r) === 'filestorage' }, + { to: '/settings?tab=database', icon: Database, label: 'tabDatabase', match: r => r.path === '/settings' && settingsTab(r) === 'database' } + ] } +] +// adminOnly 项只对云端管理员(id=1)展示 +const visibleItems = g => g.items.filter(it => !it.adminOnly || store.syncStatus.userId === 1) +const itemActive = it => it.match ? it.match(route) : route.path === it.to.split('?')[0] +// 导航徽标:badge 显示数字,dot 只显示小圆点;一级 rail 在组内任一非零时亮点 +const badgeVal = it => it.badge ? (store.badges[it.badge] || 0) : 0 +const badgeText = it => { const n = badgeVal(it); return n > 99 ? '99+' : String(n) } +const dotVal = it => it.dot ? !!store.badges[it.dot] : false +const groupDot = g => g.items.some(it => badgeVal(it) > 0 || dotVal(it)) +const childActive = c => route.path === '/settings' && String(route.query.tab || '') === c.tab +const groupOfRoute = () => navGroups.find(g => g.items.some(it => itemActive(it)))?.id +const activeGroupId = ref(groupOfRoute() || 'overview') +const activeGroup = computed(() => navGroups.find(g => g.id === activeGroupId.value) || navGroups[0]) +const expandedParents = ref({}) +const railFlyout = ref(null) // 窄屏浮层 { group, top } + +watch(() => route.fullPath, () => { + const g = groupOfRoute() + if (g) activeGroupId.value = g + railFlyout.value = null + for (const grp of navGroups) { + for (const it of grp.items) if (it.children && itemActive(it)) expandedParents.value[it.label] = true + } +}, { immediate: true }) + +const isNarrow = () => window.matchMedia('(max-width: 1150px)').matches +function clickGroup(g, ev) { + if (isNarrow()) { + railFlyout.value = railFlyout.value?.group.id === g.id + ? null + : { group: g, top: Math.min(ev.currentTarget.getBoundingClientRect().top, innerHeight - 320) } + return + } + activeGroupId.value = g.id + const first = g.items[0] + if (first && !itemActive(first)) router.push(first.to) +} +function toggleParent(it, ev) { + ev.preventDefault() + ev.stopPropagation() + expandedParents.value[it.label] = !expandedParents.value[it.label] +} +function onFlyoutAway(e) { + if (railFlyout.value && !e.target.closest('.rail-flyout') && !e.target.closest('.rail-item')) railFlyout.value = null +} + +// Ctrl+K / Cmd+K 呼出全局搜索面板 +function onGlobalKey(e) { + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') { + e.preventDefault() + store.paletteOpen = !store.paletteOpen + } +} onMounted(async () => { + addEventListener('keydown', onGlobalKey) + addEventListener('click', onFlyoutAway) if (!native) return off = store.listen() + offMenus = [ + on('menu:navigate', p => router.push(String(p))), + on('menu:action', k => { + if (k === 'addProject') { + store.pendingAction = 'addProject' + router.push('/projects') + } else if (k === 'account') { + store.openAccount(router) + } else if (k === 'about') { + aboutOpen.value = true + } else if (k === 'sync-refresh') { + store.refreshSyncStatus() + } + }), + on('menu:set', p => p?.key && updateSetting(p.key, p.value)) + ] await store.boot() locale.value = store.settings.locale || 'zh-CN' + try { appVersion.value = await call('GetAppVersion') } catch { /* keep fallback */ } }) onUnmounted(() => { + removeEventListener('keydown', onGlobalKey) + removeEventListener('click', onFlyoutAway) clearTimeout(loadingHoldTimer) off?.() + offMenus.forEach(f => f?.()) }) watch(() => store.settings.locale, v => { if (v) locale.value = v }) +// 切页时顺带刷新同步徽标(待推送数在本地增删改后保持新鲜) +watch(() => route.path, () => { if (store.syncStatus.loggedIn) store.refreshSyncStatus() }) watch(activeTask, task => { clearTimeout(loadingHoldTimer) if (task) { @@ -65,8 +195,8 @@ watch(activeTask, task => {