更新若干功能

This commit is contained in:
李琦
2026-08-14 07:51:46 +08:00
parent d2aeb13a09
commit 153c7ed448
48 changed files with 5567 additions and 2673 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
build/bin build/bin
bin/
node_modules node_modules
frontend/dist frontend/dist
frontend/bindings
*.syso

275
app.go
View File

@@ -1,19 +1,23 @@
package main package main
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"view/platform" "view/platform"
"view/service" "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 { type App struct {
@@ -21,15 +25,52 @@ type App struct {
store *Store store *Store
bootstrapFile string bootstrapFile string
bootstrap BootstrapStatus bootstrap BootstrapStatus
runtimeReady bool
mu sync.Mutex mu sync.Mutex
tasks map[string]context.CancelFunc 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) { func (a *App) startup(ctx context.Context) {
a.ctx = ctx a.ctx = ctx
a.runtimeReady = true
defaultPath, e := defaultDBPath() defaultPath, e := defaultDBPath()
if e != nil { if e != nil {
a.bootstrap = BootstrapStatus{State: BootstrapRecovery, ErrorCode: "DB_DEFAULT_PATH_FAILED", ErrorDetail: e.Error()} 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) s, e := OpenStore(c.DatabasePath)
if e != nil { if e != nil {
a.bootstrap = bootstrapFailure(defaultPath, c.DatabasePath, e) a.bootstrap = bootstrapFailure(defaultPath, c.DatabasePath, e)
runtime.LogError(ctx, e.Error())
return return
} }
a.store = s a.store = s
@@ -80,7 +120,12 @@ func (a *App) SelectInitialDatabaseFile(defaultPath string) (string, error) {
if strings.TrimSpace(defaultPath) == "" { if strings.TrimSpace(defaultPath) == "" {
defaultPath = a.bootstrap.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) { func (a *App) InitializeDatabase(path string) (BootstrapStatus, error) {
a.mu.Lock() a.mu.Lock()
@@ -122,7 +167,12 @@ func (a *App) SelectDirectory() (string, error) {
if e := a.ready(); e != nil { if e := a.ready(); e != nil {
return "", e 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 项目路径。 // ListWSLDistros 返回本机可用的 WSL 发行版,供前端创建 UNC 项目路径。
@@ -134,7 +184,12 @@ func (a *App) SelectWSLDirectory(distro string) (string, error) {
return "", errors.New("WSL_DISTRO_REQUIRED") return "", errors.New("WSL_DISTRO_REQUIRED")
} }
root := "\\\\wsl.localhost\\" + distro + "\\" 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 { func (a *App) localized(zh, en string) string {
@@ -146,7 +201,11 @@ func (a *App) localized(zh, en string) string {
return zh return zh
} }
func (a *App) SelectDatabaseFile() (string, error) { 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) { func (a *App) ListProjects() ([]Project, error) {
if e := a.ready(); e != nil { 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) { func (a *App) SaveProject(id int64, in ProjectInput) (Project, error) {
if e := a.ready(); e != nil { if e := a.ready(); e != nil {
if a.runtimeReady {
runtime.LogError(a.ctx, "SaveProject: "+e.Error())
}
return Project{}, e return Project{}, e
} }
p, e := a.store.SaveProject(id, in) p, e := a.store.SaveProject(id, in)
if e == nil { if e == nil {
a.store.Log("info", "项目", "项目保存成功", p.Path) a.store.Log("info", "项目", "项目保存成功", p.Path)
if a.runtimeReady { a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表
runtime.LogInfo(a.ctx, "Project saved: "+p.Path)
}
} else { } else {
a.store.Log("error", "项目", "项目保存失败", in.Path+" | "+e.Error()) 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 return p, e
} }
@@ -218,9 +269,6 @@ func (a *App) ReportClientError(category, message, detail string) {
if a.store != nil { if a.store != nil {
a.store.Log("error", category, message, detail) a.store.Log("error", category, message, detail)
} }
if a.runtimeReady {
runtime.LogError(a.ctx, category+": "+message+" | "+detail)
}
} }
func (a *App) DeleteProject(id int64) error { func (a *App) DeleteProject(id int64) error {
if e := a.ready(); e != nil { if e := a.ready(); e != nil {
@@ -229,6 +277,7 @@ func (a *App) DeleteProject(id int64) error {
e := a.store.DeleteProject(id) e := a.store.DeleteProject(id)
if e == nil { if e == nil {
a.store.Log("info", "项目", "项目删除成功", fmt.Sprint(id)) a.store.Log("info", "项目", "项目删除成功", fmt.Sprint(id))
a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表
} }
return e return e
} }
@@ -318,6 +367,9 @@ func (a *App) GetGitDiagnostics(id int64) (GitDiagnostics, error) {
// GetGitStatsForRef 只改变统计视图,不执行 checkout。 // GetGitStatsForRef 只改变统计视图,不执行 checkout。
func (a *App) GetGitStatsForRef(id int64, ref string) (GitStats, error) { 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) p, e := a.store.GetProject(id)
if e != nil { if e != nil {
return GitStats{}, e return GitStats{}, e
@@ -332,6 +384,9 @@ func (a *App) GetGitStatsForRef(id int64, ref string) (GitStats, error) {
// GetCommitDetails 按需查询提交涉及的文件,避免 Git 首页一次加载所有明细。 // GetCommitDetails 按需查询提交涉及的文件,避免 Git 首页一次加载所有明细。
func (a *App) GetCommitDetails(id int64, hash string) (GitCommitDetail, error) { 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) p, e := a.store.GetProject(id)
if e != nil { if e != nil {
return GitCommitDetail{}, e return GitCommitDetail{}, e
@@ -341,6 +396,9 @@ func (a *App) GetCommitDetails(id int64, hash string) (GitCommitDetail, error) {
// CheckoutBranch 安全切换工作区分支;服务层会拒绝任何脏工作区。 // CheckoutBranch 安全切换工作区分支;服务层会拒绝任何脏工作区。
func (a *App) CheckoutBranch(id int64, ref string) (CheckoutResult, error) { 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) p, e := a.store.GetProject(id)
if e != nil { if e != nil {
return CheckoutResult{}, e return CheckoutResult{}, e
@@ -377,6 +435,81 @@ func (a *App) GetLogs(level string) ([]LogEntry, error) {
} }
return a.store.Logs(level) 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 { func (a *App) ClearLogs() error {
if e := a.ready(); e != nil { if e := a.ready(); e != nil {
return e return e
@@ -393,13 +526,21 @@ func (a *App) SaveSettings(x AppSettings) error {
if e := a.ready(); e != nil { if e := a.ready(); e != nil {
return e 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 { func (a *App) ClearData(mode string, projectID int64) error {
if e := a.ready(); e != nil { if e := a.ready(); e != nil {
return e 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 { 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.tasks[taskID] = cancel
a.mu.Unlock() a.mu.Unlock()
go func() { go func() {
finalStage := "completed"
defer func() { defer func() {
if recovered := recover(); recovered != nil { if recovered := recover(); recovered != nil {
detail := fmt.Sprintf("%v", recovered) detail := fmt.Sprintf("%v", recovered)
finalStage = "error"
a.store.Log("error", "代码分析", "后台分析异常", p.Name+" | "+detail) 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() a.mu.Lock()
if a.taskResults != nil {
a.taskResults[taskID] = finalStage
}
delete(a.tasks, taskID) delete(a.tasks, taskID)
a.mu.Unlock() a.mu.Unlock()
}() }()
emit := func(stage string, progress int, messageKey string) { 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") emit("start", 2, "task.start")
a.store.Log("info", "代码分析", "开始分析项目", p.Name) 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") { if err == nil && (kind == "all" || kind == "git") {
emit("git", 80, "task.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 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 { 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") { } else if kind == "all" && (err.Error() == "NOT_GIT_REPOSITORY" || err.Error() == "GIT_NOT_INSTALLED") {
a.store.Log("warning", "Git分析", "已跳过 Git 分析", err.Error()) a.store.Log("warning", "Git分析", "已跳过 Git 分析", err.Error())
err = nil err = nil
@@ -524,12 +683,29 @@ func (a *App) StartAnalysis(projectID int64, kind string) (string, error) {
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
level, stage = "warning", "cancelled" level, stage = "warning", "cancelled"
} }
finalStage = stage
a.store.Log(level, "代码分析", "项目分析失败", p.Name+" | "+err.Error()) a.store.Log(level, "代码分析", "项目分析失败", p.Name+" | "+err.Error())
emit(stage, 100, "task.failed") emit(stage, 100, "task.failed")
return return
} }
a.store.Log("info", "代码分析", "项目分析完成", p.Name) a.store.Log("info", "代码分析", "项目分析完成", p.Name)
emit("completed", 100, "task.completed") 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 return taskID, nil
} }
@@ -546,6 +722,9 @@ func (a *App) StartBatchAnalysis() ([]string, error) {
return a.StartBatchAnalysisByGroup(0) return a.StartBatchAnalysisByGroup(0)
} }
func (a *App) StartBatchAnalysisByGroup(groupID int64) ([]string, error) { func (a *App) StartBatchAnalysisByGroup(groupID int64) ([]string, error) {
if e := a.ready(); e != nil {
return nil, e
}
ps, e := a.store.ListProjects(groupID) ps, e := a.store.ListProjects(groupID)
if e != nil { if e != nil {
return nil, e return nil, e
@@ -555,9 +734,12 @@ func (a *App) StartBatchAnalysisByGroup(groupID int64) ([]string, error) {
ids = append(ids, fmt.Sprintf("%d-all", p.ID)) ids = append(ids, fmt.Sprintf("%d-all", p.ID))
} }
go func() { go func() {
summary := BatchSummary{Total: len(ps), Failures: []string{}}
for _, p := range ps { for _, p := range ps {
tid, e := a.StartAnalysis(p.ID, "all") tid, e := a.StartAnalysis(p.ID, "all")
if e != nil { if e != nil {
summary.Failed++
summary.Failures = append(summary.Failures, p.Name+": "+e.Error())
continue continue
} }
for { for {
@@ -573,7 +755,46 @@ func (a *App) StartBatchAnalysisByGroup(groupID int64) ([]string, error) {
case <-time.After(100 * time.Millisecond): 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 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)
}

View File

@@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -2,67 +2,33 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>{{.Info.ProductName}}</string> <string>My Product</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string> <string>code-count.exe</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string> <string>com.wails.code-count</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string> <string>0.1.0</string>
<key>CFBundleGetInfoString</key> <key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string> <string>本地代码统计与项目工作台</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string> <string>0.1.0</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>iconfile</string> <string>icons</string>
<key>CFBundleIconName</key>
<string>appicon</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>10.13.0</string> <string>12.0.0</string>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>
<string>true</string> <string>true</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string> <string>© now, My Company</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsLocalNetworking</key> <key>NSAllowsLocalNetworking</key>
<true/> <true/>
</dict> </dict>
</dict> </dict>
</plist> </plist>

View File

@@ -2,62 +2,28 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>{{.Info.ProductName}}</string> <string>My Product</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string> <string>code-count.exe</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string> <string>com.wails.code-count</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string> <string>0.1.0</string>
<key>CFBundleGetInfoString</key> <key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string> <string>本地代码统计与项目工作台</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string> <string>0.1.0</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>iconfile</string> <string>icons</string>
<key>CFBundleIconName</key>
<string>appicon</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>10.13.0</string> <string>12.0.0</string>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>
<string>true</string> <string>true</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string> <string>© now, My Company</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
</dict> </dict>
</plist> </plist>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -1,15 +1,15 @@
{ {
"fixed": { "fixed": {
"file_version": "{{.Info.ProductVersion}}" "file_version": "0.1.0"
}, },
"info": { "info": {
"0000": { "0000": {
"ProductVersion": "{{.Info.ProductVersion}}", "ProductVersion": "0.1.0",
"CompanyName": "{{.Info.CompanyName}}", "CompanyName": "liqi",
"FileDescription": "{{.Info.ProductName}}", "FileDescription": "My Product Description",
"LegalCopyright": "{{.Info.Copyright}}", "LegalCopyright": "© now, My Company",
"ProductName": "{{.Info.ProductName}}", "ProductName": "My Product",
"Comments": "{{.Info.Comments}}" "Comments": "本地代码统计与项目工作台"
} }
} }
} }

View File

@@ -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

View File

@@ -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

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3"> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/> <assemblyIdentity type="win32" name="com.wails.code-count" version="0.1.0" processorArchitecture="*"/>
<dependency> <dependency>
<dependentAssembly> <dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/> <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
@@ -12,4 +12,11 @@
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported --> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings> </asmv3:windowsSettings>
</asmv3:application> </asmv3:application>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly> </assembly>

View File

@@ -58,9 +58,32 @@ func OpenStore(path string) (*Store, error) {
return s, nil return s, nil
} }
// schemaVersion 是当前本地库结构版本;仅当已记录版本低于它时才执行迁移语句,
// 避免应用每次启动都重复执行建表/回填(版本一致时启动零迁移)。
// v3新增 festival_images节日背景图本地缓存
// v4todos/tickets 新增 history 生命周期轨迹列。
// v5新增 ai_summaries分析完成后 AI 生成的模块介绍)。
// v6新增 day_briefs工作台 AI 今日规划 / 下班日报)。
// v7新增 launch_apps启动台保存的应用与启停命令
// v8todos/tickets 新增 team_id 团队共享列。
const schemaVersion = 8
func (s *Store) migrate() error { 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{ 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 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 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)`, `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 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_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 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 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 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 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 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 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 { for _, q := range stmts {
if _, err := s.db.Exec(q); err != nil { if _, err := s.db.Exec(q); err != nil {
@@ -89,6 +128,28 @@ func (s *Store) migrate() error {
return fmt.Errorf("database migration: %w", err) 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(`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`) _, _ = 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"}} 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(`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(`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 提交消息等用户内容不做修改。 // 旧版本曾写入英文日志迁移时转换已知固定文案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": "数据库迁移成功"} 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 { 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) { func (s *Store) Dashboard(groupID int64) (Dashboard, error) {
var d Dashboard var d Dashboard
var e error
if groupID > 0 { 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 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 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 { func (s *Store) ReplaceScan(id int64, langs []LanguageStat, files []FileEntry) error {
tx, e := s.db.Begin() tx, e := s.db.Begin()
if e != nil { if e != nil {
@@ -487,6 +596,9 @@ func (s *Store) DeleteRule(id int64) error {
return nil return nil
} }
func (s *Store) Log(level, category, message, detail string) { 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()) _, _ = 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) { func (s *Store) Logs(level string) ([]LogEntry, error) {
@@ -513,8 +625,90 @@ func (s *Store) Logs(level string) ([]LogEntry, error) {
return o, r.Err() return o, r.Err()
} }
func (s *Store) ClearLogs() error { _, e := s.db.Exec(`DELETE FROM app_logs`); return e } 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) { 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`) r, e := s.db.Query(`SELECT key,value FROM settings`)
if e != nil { if e != nil {
return x, e return x, e
@@ -540,6 +734,42 @@ func (s *Store) Settings() (AppSettings, error) {
if validLoadingStyle(v) { if validLoadingStyle(v) {
x.LoadingStyle = 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 return x, nil
@@ -551,7 +781,37 @@ func (s *Store) SaveSettings(x AppSettings) error {
if !validLoadingStyle(x.LoadingStyle) { if !validLoadingStyle(x.LoadingStyle) {
x.LoadingStyle = "fullscreen-orbit" 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() tx, e := s.db.Begin()
if e != nil { if e != nil {
return e return e
@@ -565,6 +825,19 @@ func (s *Store) SaveSettings(x AppSettings) error {
return tx.Commit() 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 { func validLoadingStyle(v string) bool {
switch v { switch v {
case "bar", "fullscreen", "fullscreen-orbit", "fullscreen-grid", "fullscreen-warp": case "bar", "fullscreen", "fullscreen-orbit", "fullscreen-grid", "fullscreen-warp":
@@ -579,9 +852,9 @@ func (s *Store) ClearData(mode string, projectID int64) error {
return e return e
} }
defer tx.Rollback() 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" { if mode == "all" {
tables = append(tables, "projects") tables = append(tables, "projects", "todos", "tickets", "notes", "favorites", "messages", "ai_conversations", "ai_messages")
} }
for _, t := range tables { for _, t := range tables {
q := "DELETE FROM " + t q := "DELETE FROM " + t
@@ -608,7 +881,7 @@ func (s *Store) ReplaceGit(id int64, g GitStats) error {
return e return e
} }
defer tx.Rollback() 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 { if _, e = tx.Exec("DELETE FROM "+t+" WHERE project_id=?", id); e != nil {
return e return e
} }
@@ -628,14 +901,69 @@ func (s *Store) ReplaceGit(id int64, g GitStats) error {
return e 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()) _, e = tx.Exec(`INSERT INTO analysis_runs(project_id,kind,status,started_at,completed_at) VALUES(?,'git','completed',?,?)`, id, now(), now())
if e != nil { if e != nil {
return e return e
} }
return tx.Commit() 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) { 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) 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 { if e != nil {
return g, e return g, e
@@ -681,16 +1009,34 @@ func (s *Store) GitStats(id int64) (GitStats, error) {
g.CommitCount = int64(len(g.Commits)) g.CommitCount = int64(len(g.Commits))
g.ContributorCount = int64(len(g.Contributors)) g.ContributorCount = int64(len(g.Contributors))
cut := time.Now().AddDate(-1, 0, 0) cut := time.Now().AddDate(-1, 0, 0)
hm := map[string]int64{} hm := map[string]*HeatDay{}
for _, c := range g.Commits { for _, c := range g.Commits {
if t, e := time.Parse(time.RFC3339, c.Date); e == nil && t.After(cut) { 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 { for _, d := range hm {
g.Heatmap = append(g.Heatmap, HeatDay{Date: d, Count: c}) g.Heatmap = append(g.Heatmap, *d)
} }
sort.Slice(g.Heatmap, func(i, j int) bool { return g.Heatmap[i].Date < g.Heatmap[j].Date }) 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 return g, nil
} }

View File

@@ -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) { func TestNormalizePath(t *testing.T) {
if _, e := normalizePath(filepath.Join(t.TempDir(), "missing")); e == nil { if _, e := normalizePath(filepath.Join(t.TempDir(), "missing")); e == nil {
t.Fatal("missing directory accepted") 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) { func TestLegacyLoadingStyleDefaultMigratesOnce(t *testing.T) {
db := filepath.Join(t.TempDir(), "settings.db") db := filepath.Join(t.TempDir(), "settings.db")
s, e := OpenStore(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 { if _, e = s.db.Exec(`UPDATE settings SET value='bar' WHERE key='loadingStyle'`); e != nil {
t.Fatal(e) t.Fatal(e)
} }
// 模拟旧版本数据库:回退已记录的结构版本,重开时才会执行一次迁移转换。
if _, e = s.db.Exec(`DELETE FROM schema_migrations`); e != nil {
t.Fatal(e)
}
s.db.Close() s.db.Close()
s, e = OpenStore(db) s, e = OpenStore(db)
if e != nil { if e != nil {
@@ -237,3 +334,52 @@ func TestLegacyLoadingStyleDefaultMigratesOnce(t *testing.T) {
t.Fatalf("explicit loadingStyle=%q", got.LoadingStyle) 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)
}
}

View File

@@ -3,7 +3,8 @@
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>view</title> <link rel="icon" type="image/png" href="/favicon.png"/>
<title>年糕崽崽项目管理PMS</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -8,8 +8,12 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@wailsio/runtime": "^3.0.0-beta.5",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"highlight.js": "^11.11.2",
"lucide-vue-next": "^1.0.0", "lucide-vue-next": "^1.0.0",
"lunar-javascript": "^1.7.7",
"marked": "^18.0.9",
"pinia": "^4.0.1", "pinia": "^4.0.1",
"vue": "^3.2.37", "vue": "^3.2.37",
"vue-i18n": "^9.14.5", "vue-i18n": "^9.14.5",
@@ -271,6 +275,12 @@
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==" "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": { "node_modules/birpc": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
@@ -711,6 +721,15 @@
"node": ">= 0.4" "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": { "node_modules/hookable": {
"version": "5.5.3", "version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
@@ -741,6 +760,12 @@
"vue": ">=3.0.1" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -749,6 +774,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",

View File

@@ -6,11 +6,16 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"build:dev": "vite build --mode development --minify false",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@wailsio/runtime": "^3.0.0-beta.5",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"highlight.js": "^11.11.2",
"lucide-vue-next": "^1.0.0", "lucide-vue-next": "^1.0.0",
"lunar-javascript": "^1.7.7",
"marked": "^18.0.9",
"pinia": "^4.0.1", "pinia": "^4.0.1",
"vue": "^3.2.37", "vue": "^3.2.37",
"vue-i18n": "^9.14.5", "vue-i18n": "^9.14.5",

View File

@@ -2,12 +2,20 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' 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 { useAppStore } from './store'
import DatabaseSetup from './components/DatabaseSetup.vue' import DatabaseSetup from './components/DatabaseSetup.vue'
import BrowserBlocked from './components/BrowserBlocked.vue' import BrowserBlocked from './components/BrowserBlocked.vue'
import AnalysisCanvas from './components/AnalysisCanvas.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 route = useRoute()
const router = useRouter() const router = useRouter()
@@ -15,8 +23,11 @@ const store = useAppStore()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const native = isNative() const native = isNative()
const quickOpen = ref(false) const quickOpen = ref(false)
const aboutOpen = ref(false)
const appVersion = ref('1.0.0')
const displayedTask = ref(null) const displayedTask = ref(null)
let off let off
let offMenus = []
let loadingHoldTimer let loadingHoldTimer
const activeTask = computed(() => Object.values(store.tasks).find(x => !['completed', 'error', 'cancelled'].includes(x.stage))) const activeTask = computed(() => Object.values(store.tasks).find(x => !['completed', 'error', 'cancelled'].includes(x.stage)))
@@ -35,19 +46,138 @@ function openSettings() {
router.push('/settings') 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 () => { onMounted(async () => {
addEventListener('keydown', onGlobalKey)
addEventListener('click', onFlyoutAway)
if (!native) return if (!native) return
off = store.listen() 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() await store.boot()
locale.value = store.settings.locale || 'zh-CN' locale.value = store.settings.locale || 'zh-CN'
try { appVersion.value = await call('GetAppVersion') } catch { /* keep fallback */ }
}) })
onUnmounted(() => { onUnmounted(() => {
removeEventListener('keydown', onGlobalKey)
removeEventListener('click', onFlyoutAway)
clearTimeout(loadingHoldTimer) clearTimeout(loadingHoldTimer)
off?.() off?.()
offMenus.forEach(f => f?.())
}) })
watch(() => store.settings.locale, v => { watch(() => store.settings.locale, v => {
if (v) locale.value = v if (v) locale.value = v
}) })
// 切页时顺带刷新同步徽标(待推送数在本地增删改后保持新鲜)
watch(() => route.path, () => { if (store.syncStatus.loggedIn) store.refreshSyncStatus() })
watch(activeTask, task => { watch(activeTask, task => {
clearTimeout(loadingHoldTimer) clearTimeout(loadingHoldTimer)
if (task) { if (task) {
@@ -65,8 +195,8 @@ watch(activeTask, task => {
<DatabaseSetup v-else-if="store.bootstrap.state !== 'ready' && store.bootstrap.state !== 'loading'" :status="store.bootstrap" /> <DatabaseSetup v-else-if="store.bootstrap.state !== 'ready' && store.bootstrap.state !== 'loading'" :status="store.bootstrap" />
<div v-else-if="store.bootstrap.state === 'ready'" class="shell"> <div v-else-if="store.bootstrap.state === 'ready'" class="shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"> <div class="side-rail">
<span class="brand-mark animated-logo" aria-hidden="true"> <span class="brand-mark animated-logo rail-logo" aria-hidden="true" :title="t('app')">
<svg viewBox="0 0 48 48" role="img"> <svg viewBox="0 0 48 48" role="img">
<defs> <defs>
<linearGradient id="logoGlow" x1="0" x2="1" y1="0" y2="1"> <linearGradient id="logoGlow" x1="0" x2="1" y1="0" y2="1">
@@ -80,17 +210,49 @@ watch(activeTask, task => {
<path class="logo-spark" d="M12 10h8M28 38h8" /> <path class="logo-spark" d="M12 10h8M28 38h8" />
</svg> </svg>
</span> </span>
<b>{{ t('app') }}</b> <nav class="rail-nav" aria-label="Primary">
<button v-for="g in navGroups" :key="g.id" type="button" class="rail-item" :class="{ active: g.id === activeGroupId }" @click="clickGroup(g, $event)">
<component :is="g.icon" /><span>{{ t(g.label) }}</span>
<i v-if="groupDot(g)" class="rail-dot" aria-hidden="true" />
</button>
</nav>
<div class="rail-tools">
<NoteCenter />
<TaskCenter />
<MessageBell />
<TeamSwitcher />
</div>
<button class="rail-user" :class="{ active: route.path === '/profile' }" :title="store.syncStatus.loggedIn ? store.syncStatus.username : t('loginNow')" @click="store.openAccount(router)">
<span class="user-avatar">
<img v-if="store.avatarSrc" :src="store.avatarSrc" alt="" />
<b v-else-if="store.syncStatus.username">{{ store.syncStatus.username[0].toUpperCase() }}</b>
<UserRound v-else />
<i v-if="store.syncStatus.loggedIn" class="user-dot" :class="store.syncStatus.lastError ? 'err' : (store.syncStatus.online ? 'on' : 'off')" :title="store.syncStatus.lastError || ''" />
</span>
<em v-if="store.syncStatus.loggedIn && store.syncStatus.pending > 0" class="user-pending rail-pending" :title="t('pendingSync', { n: store.syncStatus.pending })">{{ store.syncStatus.pending }}</em>
</button>
</div> </div>
<nav aria-label="Primary"> <div class="side-sub">
<RouterLink to="/" :class="{ active: route.path === '/' }"><LayoutDashboard /><span>{{ t('dashboard') }}</span></RouterLink> <div class="sub-brand"><b>{{ t('app') }}</b></div>
<RouterLink to="/logs" :class="{ active: route.path === '/logs' }"><ScrollText /><span>{{ t('logs') }}</span></RouterLink> <div class="sub-title">{{ t(activeGroup.label) }}</div>
<RouterLink to="/settings" :class="{ active: route.path === '/settings' }"><Settings /><span>{{ t('settings') }}</span></RouterLink> <nav class="sub-nav" :aria-label="t(activeGroup.label)">
</nav> <template v-for="it in visibleItems(activeGroup)" :key="it.to">
<div class="sidebar-bottom"> <RouterLink :to="it.to" :class="{ active: itemActive(it) }">
<span class="version"><i />v1.0.0</span> <component :is="it.icon" /><span>{{ t(it.label) }}</span>
<button class="quick-settings-btn" :title="t('quickSettings')" @click="quickOpen = !quickOpen"><SlidersHorizontal /></button> <em v-if="badgeVal(it)" class="nav-badge">{{ badgeText(it) }}</em>
<section v-if="quickOpen" class="quick-settings popover-glass"> <i v-else-if="dotVal(it)" class="nav-dot" aria-hidden="true" />
<button v-if="it.children" type="button" class="sub-caret" :class="{ open: expandedParents[it.label] }" :aria-label="t(it.label)" @click="toggleParent(it, $event)"><ChevronDown /></button>
</RouterLink>
<div v-if="it.children && expandedParents[it.label]" class="sub-children">
<RouterLink v-for="c in it.children" :key="c.to" :to="c.to" :class="{ active: childActive(c) }">{{ t(c.label) }}</RouterLink>
</div>
</template>
</nav>
<div class="sidebar-bottom">
<span class="version"><i />v{{ appVersion }}</span>
<button class="quick-settings-btn quit-app-btn" :title="t('quitApp')" :disabled="!native" @click="quitApp"><Power /></button>
<button class="quick-settings-btn" :title="t('quickSettings')" @click="quickOpen = !quickOpen"><SlidersHorizontal /></button>
<section v-if="quickOpen" class="quick-settings popover-glass">
<header> <header>
<b>{{ t('quickSettings') }}</b> <b>{{ t('quickSettings') }}</b>
<button @click="quickOpen = false" aria-label="Close"><X /></button> <button @click="quickOpen = false" aria-label="Close"><X /></button>
@@ -114,8 +276,20 @@ watch(activeTask, task => {
<span>{{ t('glassOpacity') }} · {{ store.settings.glassOpacity }}%</span> <span>{{ t('glassOpacity') }} · {{ store.settings.glassOpacity }}%</span>
<input type="range" min="30" max="75" :value="store.settings.glassOpacity" @input="updateSetting('glassOpacity', Number($event.target.value))" /> <input type="range" min="30" max="75" :value="store.settings.glassOpacity" @input="updateSetting('glassOpacity', Number($event.target.value))" />
</label> </label>
<button class="btn secondary full" @click="openSettings"><Settings />{{ t('openSettings') }}</button> <button class="btn secondary full" @click="openSettings"><Settings />{{ t('openSettings') }}</button>
</section> </section>
</div>
</div>
<div v-if="railFlyout" class="rail-flyout popover-glass" :style="{ top: railFlyout.top + 'px' }">
<b class="fly-title">{{ t(railFlyout.group.label) }}</b>
<template v-for="it in visibleItems(railFlyout.group)" :key="it.to">
<RouterLink :to="it.to" :class="{ active: itemActive(it) }">
<component :is="it.icon" /><span>{{ t(it.label) }}</span>
<em v-if="badgeVal(it)" class="nav-badge">{{ badgeText(it) }}</em>
<i v-else-if="dotVal(it)" class="nav-dot" aria-hidden="true" />
</RouterLink>
<RouterLink v-for="c in it.children || []" :key="c.to" :to="c.to" class="fly-child" :class="{ active: childActive(c) }">{{ t(c.label) }}</RouterLink>
</template>
</div> </div>
</aside> </aside>
<main><RouterView /></main> <main><RouterView /></main>
@@ -137,6 +311,10 @@ watch(activeTask, task => {
{{ store.toast.key ? t(store.toast.key, store.toast.params || {}) : store.toast.text }} {{ store.toast.key ? t(store.toast.key, store.toast.params || {}) : store.toast.text }}
<button @click="store.closeToast()" aria-label="Close"><X /></button> <button @click="store.closeToast()" aria-label="Close"><X /></button>
</div> </div>
<LoginModal v-if="store.loginOpen" />
<CommandPalette v-if="store.paletteOpen" />
<AboutModal v-if="aboutOpen" @close="aboutOpen = false" />
<DailyCard />
</div> </div>
<div v-else class="boot-loading"><Database class="spin" />正在检查数据库...</div> <div v-else class="boot-loading"><Database class="spin" />正在检查数据库...</div>
</template> </template>

View File

@@ -1,12 +1,34 @@
export const isNative=()=>Boolean(window.go?.main?.App) import { Call, Events } from '@wailsio/runtime'
export async function call(name,...args){ // Wails v3 桌面环境检测Windows 走 WebView2 postMessagemacOS/Linux 走 webkit messageHandlers。
const fn=window.go?.main?.App?.[name] export const isNative = () => Boolean(
if(fn)return fn(...args) window.chrome?.webview?.postMessage || window.webkit?.messageHandlers?.external?.postMessage
throw new Error(`NATIVE_RUNTIME_REQUIRED: ${name}`) )
// call 统一走按名调用main.App.<Method>)。
// 错误统一抛出字符串,保持 `String(e).split(':')[0]` 提取错误码的旧逻辑可用。
export async function call(name, ...args) {
if (!isNative()) throw `NATIVE_RUNTIME_REQUIRED: ${name}`
try {
return await Call.ByName(`main.App.${name}`, ...args)
} catch (e) {
throw String(e?.message ?? e)
}
} }
export function on(name,cb){ // on 订阅后端事件;回调直接拿到事件数据本体(与 v2 行为一致),返回取消函数。
if(window.runtime?.EventsOn)return window.runtime.EventsOn(name,cb) export function on(name, cb) {
return()=>{} if (!isNative()) return () => {}
return Events.On(name, ev => cb(ev?.data))
} }
// 任务数据(待办/工单)跨页面同步:任一入口变更状态后广播,列表页与顶栏任务中心即时刷新。
const TASKS_EVENT = 'cc:tasks-changed'
export const notifyTasksChanged = () => dispatchEvent(new Event(TASKS_EVENT))
export function onTasksChanged(cb) {
addEventListener(TASKS_EVENT, cb)
return () => removeEventListener(TASKS_EVENT, cb)
}
// 调试/自动化钩子:桌面环境内页面 JS 本就能访问 Wails 桥,这里只是给控制台一个入口。
if (typeof window !== 'undefined') window.__cc = { call }

View File

@@ -13,6 +13,11 @@ let dpr = 1
let width = 0 let width = 0
let height = 0 let height = 0
let reduceMotion = false let reduceMotion = false
let lastNow = 0
// 粒子系统状态(尺寸/样式变化时置空重建)
let nebula = null
let stars = null
let rain = null
function resize() { function resize() {
const el = canvas.value const el = canvas.value
@@ -25,32 +30,35 @@ function resize() {
el.height = Math.floor(height * dpr) el.height = Math.floor(height * dpr)
ctx = el.getContext('2d') ctx = el.getContext('2d')
ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
nebula = stars = rain = null
} }
function draw(now = 0) { function draw(now = 0) {
if (!ctx) return if (!ctx) return
const t = now * 0.001 const t = now * 0.001
const dt = Math.min(0.05, Math.max(0.001, (now - lastNow) / 1000))
lastNow = now
const cx = width / 2 const cx = width / 2
const cy = height / 2 const cy = height / 2
const p = Math.max(0, Math.min(100, Number(props.progress || 0))) / 100 const p = Math.max(0, Math.min(100, Number(props.progress || 0))) / 100
ctx.clearRect(0, 0, width, height) ctx.clearRect(0, 0, width, height)
const variant = props.variant === 'fullscreen' ? 'fullscreen-orbit' : props.variant const variant = props.variant === 'fullscreen' ? 'fullscreen-orbit' : props.variant
const bg = ctx.createRadialGradient(cx, cy, 20, cx, cy, Math.max(width, height) * 0.65) if (variant === 'fullscreen-grid') drawNebula(t, p, cx, cy)
bg.addColorStop(0, 'rgba(115,103,245,.28)') else if (variant === 'fullscreen-warp') drawStarfield(t, p, cx, cy, dt)
bg.addColorStop(.52, 'rgba(67,201,150,.12)') else if (variant === 'fullscreen-matrix') drawMatrix(t, p, cx, cy, dt)
bg.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = bg
ctx.fillRect(0, 0, width, height)
if (variant === 'fullscreen-grid') drawGrid(t, p, cx, cy)
else if (variant === 'fullscreen-warp') drawWarp(t, p, cx, cy)
else drawOrbit(t, p, cx, cy) else drawOrbit(t, p, cx, cy)
if (!reduceMotion) frame = requestAnimationFrame(draw) if (!reduceMotion) frame = requestAnimationFrame(draw)
} }
function drawOrbit(t, p, cx, cy) { function drawOrbit(t, p, cx, cy) {
const bg = ctx.createRadialGradient(cx, cy, 20, cx, cy, Math.max(width, height) * 0.65)
bg.addColorStop(0, 'rgba(115,103,245,.28)')
bg.addColorStop(.52, 'rgba(67,201,150,.12)')
bg.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = bg
ctx.fillRect(0, 0, width, height)
ctx.save() ctx.save()
ctx.translate(cx, cy) ctx.translate(cx, cy)
@@ -106,230 +114,331 @@ function drawOrbit(t, p, cx, cy) {
ctx.shadowBlur = 26 ctx.shadowBlur = 26
ctx.fill() ctx.fill()
ctx.restore() ctx.restore()
} }
function drawGrid(t, p, cx, cy) { // —— 数据星云:粒子盘绕核旋转,进度推进时向中心聚拢 ——
const gap = 34 // shadowBlur 逐粒子绘制非常慢,预渲染发光贴图后主循环只做 drawImage
const base = Math.min(width, height) * 0.2 let glowSprites = null
const scanY = reduceMotion ? cy : (height * ((t * 0.2) % 1)) function makeGlowSprite(r, g, b) {
const horizon = cy - Math.min(height * 0.12, 90) const c = document.createElement('canvas')
c.width = c.height = 64
ctx.save() const x = c.getContext('2d')
ctx.translate(cx, horizon) const grad = x.createRadialGradient(32, 32, 0, 32, 32, 32)
ctx.lineWidth = 1 grad.addColorStop(0, 'rgba(255,255,255,.94)')
for (let i = -14; i <= 14; i++) { grad.addColorStop(.22, `rgba(${r},${g},${b},.85)`)
const a = i / 14 grad.addColorStop(.55, `rgba(${r},${g},${b},.2)`)
const x = a * width * 0.74 grad.addColorStop(1, `rgba(${r},${g},${b},0)`)
const g = ctx.createLinearGradient(0, 0, x, height) x.fillStyle = grad
g.addColorStop(0, 'rgba(110,231,255,.26)') x.fillRect(0, 0, 64, 64)
g.addColorStop(1, 'rgba(110,231,255,0)') return c
ctx.strokeStyle = g }
ctx.beginPath() function initNebula() {
ctx.moveTo(0, 0) if (!glowSprites) glowSprites = {
ctx.lineTo(x, height) green: makeGlowSprite(93, 226, 168),
ctx.stroke() cyan: makeGlowSprite(110, 231, 255),
purple: makeGlowSprite(148, 136, 255),
white: makeGlowSprite(222, 230, 255)
} }
for (let i = 1; i < 12; i++) { nebula = {
const y = Math.pow(i / 12, 1.8) * height * 0.78 // 星系盘:绕核旋转的主体粒子
const w = width * (0.12 + i * 0.075) disk: Array.from({ length: 300 }, () => ({
ctx.strokeStyle = `rgba(83,214,162,${0.22 - i * 0.012})` a: Math.random() * Math.PI * 2,
ctx.beginPath() r: Math.pow(Math.random(), 0.72),
ctx.moveTo(-w, y) sp: 0.1 + Math.random() * 0.34,
ctx.lineTo(w, y) sz: 1 + Math.random() * 2.1,
ctx.stroke() hue: Math.random(),
tw: Math.random() * Math.PI * 2
})),
// 星尘:均匀铺满全屏的氛围粒子(归一化坐标)
dust: Array.from({ length: 150 }, () => ({
x: Math.random(),
y: Math.random(),
vx: (Math.random() - 0.5) * 0.014,
vy: (Math.random() - 0.5) * 0.014,
sz: 0.6 + Math.random() * 1.3,
hue: Math.random(),
tw: Math.random() * Math.PI * 2
}))
} }
ctx.restore() }
function nebulaSprite(hue) {
if (hue < 0.48) return glowSprites.cyan
if (hue < 0.8) return glowSprites.purple
return glowSprites.white
}
function drawNebula(t, p, cx, cy) {
if (!nebula) initNebula()
// 盘半径按对角线取值,让粒子铺到屏幕边缘
const R = Math.hypot(width, height) * 0.36
const bg = ctx.createRadialGradient(cx, cy, 10, cx, cy, Math.max(width, height) * 0.72)
bg.addColorStop(0, 'rgba(96,90,240,.22)')
bg.addColorStop(.5, 'rgba(56,180,150,.09)')
bg.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = bg
ctx.fillRect(0, 0, width, height)
ctx.save() const { disk, dust } = nebula
ctx.translate((reduceMotion ? 0 : -t * 34) % gap, (reduceMotion ? 0 : t * 20) % gap) const n = disk.length
ctx.lineWidth = 1 const pos = new Array(n)
for (let x = -gap; x < width + gap; x += gap) { for (let i = 0; i < n; i++) {
const hot = Math.max(0, 1 - Math.abs(x - cx) / (width * 0.48)) const s = disk[i]
ctx.strokeStyle = `rgba(110,231,255,${0.04 + hot * 0.16})` // 内圈转得快,外圈慢,类似星系较差自转
ctx.beginPath(); ctx.moveTo(x, -gap); ctx.lineTo(x, height + gap); ctx.stroke() const rot = reduceMotion ? s.tw : t * s.sp * (1.6 - s.r * 0.9) + s.tw
} const rr = s.r * R * (1 - p * 0.18)
for (let y = -gap; y < height + gap; y += gap) { const wob = Math.sin(t * 1.3 + s.tw * 3) * 7
const hot = Math.max(0, 1 - Math.abs(y - cy) / (height * 0.48)) pos[i] = [cx + Math.cos(s.a + rot) * (rr + wob), cy + Math.sin(s.a + rot) * (rr + wob) * 0.62]
ctx.strokeStyle = `rgba(83,214,162,${0.035 + hot * 0.14})`
ctx.beginPath(); ctx.moveTo(-gap, y); ctx.lineTo(width + gap, y); ctx.stroke()
}
ctx.restore()
const scan = ctx.createLinearGradient(0, scanY - 60, 0, scanY + 60)
scan.addColorStop(0, 'rgba(110,231,255,0)')
scan.addColorStop(.5, 'rgba(110,231,255,.22)')
scan.addColorStop(1, 'rgba(110,231,255,0)')
ctx.fillStyle = scan
ctx.fillRect(0, scanY - 60, width, 120)
const beamX = reduceMotion ? cx : width * ((t * 0.13 + .22) % 1)
const beam = ctx.createLinearGradient(beamX - 90, 0, beamX + 90, 0)
beam.addColorStop(0, 'rgba(83,214,162,0)')
beam.addColorStop(.5, 'rgba(83,214,162,.16)')
beam.addColorStop(1, 'rgba(83,214,162,0)')
ctx.fillStyle = beam
ctx.fillRect(beamX - 90, 0, 180, height)
const count = 132
for (let i = 0; i < count; i++) {
const seed = i * 97.13
const x = (Math.sin(seed) * 0.5 + 0.5) * width
const y = ((Math.cos(seed * 1.7) * 0.5 + 0.5) * height + (reduceMotion ? 0 : t * (18 + i % 7))) % height
const dist = Math.hypot(x - cx, y - cy)
const active = i / count <= p
ctx.beginPath()
ctx.arc(x, y, active ? 2.4 : 1.2, 0, Math.PI * 2)
ctx.fillStyle = active ? 'rgba(83,214,162,.95)' : `rgba(145,136,255,${Math.max(.12, .48 - dist / width)})`
ctx.shadowColor = active ? '#53d6a2' : '#9188ff'
ctx.shadowBlur = active ? 16 : 8
ctx.fill()
} }
// 全屏星尘层
ctx.save() ctx.save()
ctx.globalCompositeOperation = 'lighter' ctx.globalCompositeOperation = 'lighter'
for (let i = 0; i < 34; i++) { for (let i = 0; i < dust.length; i++) {
const a = i * 2.399 const s = dust[i]
const r = base * (1.4 + (i % 9) * 0.13) if (!reduceMotion) {
const x1 = cx + Math.cos(a + t * 0.18) * r s.x = (s.x + s.vx * 0.016 + 1) % 1
const y1 = cy + Math.sin(a + t * 0.12) * r * 0.58 s.y = (s.y + s.vy * 0.016 + 1) % 1
const x2 = cx + Math.cos(a + 1.2 + t * 0.1) * (r + base * 0.45)
const y2 = cy + Math.sin(a + 1.2 + t * 0.16) * (r + base * 0.45) * 0.58
const active = i / 34 <= p
ctx.strokeStyle = active ? 'rgba(83,214,162,.34)' : 'rgba(110,231,255,.12)'
ctx.lineWidth = active ? 1.5 : 1
ctx.beginPath()
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.stroke()
}
ctx.restore()
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(reduceMotion ? 0 : t * 0.28)
for (let i = 0; i < 5; i++) {
const r = base + i * 18 + (reduceMotion ? 0 : Math.sin(t * 1.2 + i) * 5)
ctx.beginPath()
for (let v = 0; v < 6; v++) {
const a = Math.PI / 6 + (Math.PI * 2 * v) / 6
const x = Math.cos(a) * r
const y = Math.sin(a) * r
if (v === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
} }
ctx.closePath() const twk = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 1.6 + s.tw * 6))
ctx.strokeStyle = i % 2 ? 'rgba(83,214,162,.46)' : 'rgba(110,231,255,.52)' const d = s.sz * 6
ctx.lineWidth = 2 ctx.globalAlpha = 0.34 * twk
ctx.shadowColor = i % 2 ? '#53d6a2' : '#6ee7ff' ctx.drawImage(nebulaSprite(s.hue), s.x * width - d / 2, s.y * height - d / 2, d, d)
ctx.shadowBlur = 14
ctx.stroke()
} }
ctx.rotate(reduceMotion ? 0 : -t * 0.64) ctx.restore()
for (let i = 0; i < 16; i++) {
const a = (Math.PI * 2 * i) / 16 // 粒子间短距连线织出星云网
const len = base * (0.42 + (i % 4) * 0.09) ctx.save()
ctx.strokeStyle = i / 16 < p ? 'rgba(83,214,162,.78)' : 'rgba(145,136,255,.22)' ctx.lineWidth = 1
ctx.lineWidth = 2 for (let i = 0; i < n - 1; i += 2) {
ctx.beginPath() const [x1, y1] = pos[i], [x2, y2] = pos[i + 1]
ctx.moveTo(Math.cos(a) * base * 0.35, Math.sin(a) * base * 0.35) const dx = x2 - x1, dy = y2 - y1
ctx.lineTo(Math.cos(a) * (base * 0.35 + len), Math.sin(a) * (base * 0.35 + len)) const d2 = dx * dx + dy * dy
ctx.stroke() if (d2 > 9216) continue
ctx.strokeStyle = `rgba(126,146,255,${((1 - Math.sqrt(d2) / 96) * 0.18).toFixed(3)})`
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke()
} }
const core = ctx.createRadialGradient(0, 0, 0, 0, 0, base * 0.85) ctx.restore()
core.addColorStop(0, 'rgba(255,255,255,.9)')
core.addColorStop(.18, 'rgba(110,231,255,.68)') // 星系盘粒子(发光贴图)
core.addColorStop(.42, 'rgba(83,214,162,.28)') ctx.save()
core.addColorStop(1, 'rgba(83,214,162,0)') ctx.globalCompositeOperation = 'lighter'
for (let i = 0; i < n; i++) {
const s = disk[i]
const [x, y] = pos[i]
const active = i / n <= p
const twk = 0.55 + 0.45 * Math.sin(t * 2 + s.tw * 5)
const d = s.sz * (active ? 8.5 : 6.5)
ctx.globalAlpha = (active ? 0.95 : 0.62) * twk
ctx.drawImage(active ? glowSprites.green : nebulaSprite(s.hue), x - d / 2, y - d / 2, d, d)
}
ctx.restore()
// 偶发流星滑向核心
ctx.save()
ctx.globalCompositeOperation = 'lighter'
for (let k = 0; k < 4; k++) {
const phase = reduceMotion ? 0.35 : (t * 0.16 + k * 0.31) % 1
const a = k * 2.1 + Math.floor(t * 0.16 + k * 0.31) * 1.7
const r1 = R * (1.15 - phase * 1.02)
const r2 = r1 + R * 0.2
const x1 = cx + Math.cos(a) * r1, y1 = cy + Math.sin(a) * r1 * 0.62
const x2 = cx + Math.cos(a) * r2, y2 = cy + Math.sin(a) * r2 * 0.62
const g = ctx.createLinearGradient(x1, y1, x2, y2)
g.addColorStop(0, `rgba(212,238,255,${(0.5 * Math.sin(phase * Math.PI)).toFixed(3)})`)
g.addColorStop(1, 'rgba(212,238,255,0)')
ctx.strokeStyle = g
ctx.lineWidth = 1.6
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke()
}
ctx.restore()
// 中心光核 + 进度弧(尺寸独立于盘半径,避免占屏过大)
ctx.save()
ctx.translate(cx, cy)
const CR = Math.min(width, height) * 0.2
const core = ctx.createRadialGradient(0, 0, 0, 0, 0, CR)
core.addColorStop(0, 'rgba(255,255,255,.92)')
core.addColorStop(.2, 'rgba(110,231,255,.6)')
core.addColorStop(.55, 'rgba(115,103,245,.22)')
core.addColorStop(1, 'rgba(115,103,245,0)')
ctx.fillStyle = core ctx.fillStyle = core
ctx.shadowColor = '#6ee7ff' ctx.beginPath(); ctx.arc(0, 0, CR, 0, Math.PI * 2); ctx.fill()
ctx.shadowBlur = 28
ctx.beginPath() ctx.beginPath()
ctx.arc(0, 0, base * 0.8, 0, Math.PI * 2) ctx.arc(0, 0, CR * 0.85, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * p)
ctx.fill() ctx.strokeStyle = 'rgba(93,226,168,.85)'
ctx.lineWidth = 3
ctx.lineCap = 'round'
ctx.shadowColor = '#53d6a2'
ctx.shadowBlur = 16
ctx.stroke()
ctx.restore() ctx.restore()
} }
function drawWarp(t, p, cx, cy) { // —— 星际穿越:星点从中心向外加速,拉伸成光迹 ——
const rays = 144 function initStars() {
const maxR = Math.hypot(width, height) * 0.58 const maxR = Math.hypot(width, height) * 0.56
stars = Array.from({ length: 460 }, () => ({
a: Math.random() * Math.PI * 2,
r: 4 + Math.random() * maxR,
sp: 26 + Math.random() * 92,
hue: Math.random()
}))
}
function drawStarfield(t, p, cx, cy, dt) {
if (!stars) initStars()
const maxR = Math.hypot(width, height) * 0.56
const bg = ctx.createRadialGradient(cx, cy, 6, cx, cy, maxR)
bg.addColorStop(0, 'rgba(190,214,255,.2)')
bg.addColorStop(.24, 'rgba(96,110,255,.12)')
bg.addColorStop(.7, 'rgba(20,26,60,.08)')
bg.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = bg
ctx.fillRect(0, 0, width, height)
ctx.save()
ctx.globalCompositeOperation = 'lighter'
ctx.lineCap = 'round'
const n = stars.length
for (let i = 0; i < n; i++) {
const s = stars[i]
// 透视加速:越靠外线速度越大,进度越高整体越快
const v = (s.sp + p * 130) * (0.3 + (s.r / maxR) * 2.4)
if (!reduceMotion) {
s.r += v * dt
if (s.r > maxR) { s.a = Math.random() * Math.PI * 2; s.r = 3 + Math.random() * 24; s.sp = 26 + Math.random() * 92; s.hue = Math.random() }
}
const depth = s.r / maxR
const stretch = Math.max(2.5, v * 0.085 * (0.4 + depth))
const cosA = Math.cos(s.a), sinA = Math.sin(s.a)
const r0 = Math.max(2, s.r - stretch)
const alpha = Math.min(0.92, 0.1 + depth * 1.25)
const active = i / n <= p
let color
if (active) color = `rgba(105,235,190,${alpha.toFixed(3)})`
else if (s.hue < 0.5) color = `rgba(214,232,255,${alpha.toFixed(3)})`
else if (s.hue < 0.78) color = `rgba(150,164,255,${alpha.toFixed(3)})`
else color = `rgba(110,231,255,${alpha.toFixed(3)})`
ctx.strokeStyle = color
ctx.lineWidth = 0.7 + depth * 2.4
ctx.beginPath()
ctx.moveTo(cx + cosA * r0, cy + sinA * r0)
ctx.lineTo(cx + cosA * s.r, cy + sinA * s.r)
ctx.stroke()
}
ctx.restore()
// 跃迁环脉冲
ctx.save() ctx.save()
ctx.translate(cx, cy) ctx.translate(cx, cy)
ctx.rotate(reduceMotion ? 0 : Math.sin(t * 0.22) * 0.08) for (let i = 0; i < 4; i++) {
const tunnel = ctx.createRadialGradient(0, 0, 8, 0, 0, Math.min(width, height) * 0.48) const phase = reduceMotion ? i / 4 : (t * 0.34 + i / 4) % 1
tunnel.addColorStop(0, 'rgba(255,255,255,.7)') const r = 26 + phase * Math.min(width, height) * 0.42
tunnel.addColorStop(.12, 'rgba(110,231,255,.24)')
tunnel.addColorStop(.55, 'rgba(124,108,255,.1)')
tunnel.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = tunnel
ctx.fillRect(-width / 2, -height / 2, width, height)
for (let i = 0; i < 12; i++) {
const phase = reduceMotion ? 0 : (t * 0.48 + i * 0.09) % 1
const r = 30 + ((i / 12 + phase) % 1) * maxR
const alpha = Math.max(0, 0.5 - r / maxR * 0.45)
ctx.beginPath() ctx.beginPath()
ctx.ellipse(0, 0, r * 1.34, r * 0.7, t * 0.08 + i * 0.42, 0, Math.PI * 2) ctx.arc(0, 0, r, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(110,231,255,${alpha})` ctx.strokeStyle = `rgba(140,170,255,${(0.34 * (1 - phase)).toFixed(3)})`
ctx.lineWidth = 1 + (1 - r / maxR) * 3 ctx.lineWidth = 1.5
ctx.shadowColor = '#6ee7ff'
ctx.shadowBlur = 14
ctx.stroke() ctx.stroke()
} }
const core = ctx.createRadialGradient(0, 0, 0, 0, 0, Math.min(width, height) * 0.15)
for (let i = 0; i < rays; i++) { core.addColorStop(0, 'rgba(255,255,255,.95)')
const a = (Math.PI * 2 * i) / rays core.addColorStop(.3, 'rgba(178,206,255,.7)')
const speed = reduceMotion ? 0 : (t * (110 + (i % 11) * 10)) core.addColorStop(.7, 'rgba(124,108,255,.2)')
const start = 20 + ((i * 23 + speed) % 210)
const len = 80 + p * 170 + (i % 5) * 18
const alpha = 0.1 + p * 0.52
const g = ctx.createLinearGradient(Math.cos(a) * start, Math.sin(a) * start, Math.cos(a) * (start + len), Math.sin(a) * (start + len))
g.addColorStop(0, 'rgba(110,231,255,0)')
g.addColorStop(.45, `rgba(124,108,255,${alpha})`)
g.addColorStop(.78, `rgba(110,231,255,${alpha * .58})`)
g.addColorStop(1, 'rgba(83,214,162,0)')
ctx.strokeStyle = g
ctx.lineWidth = 1 + (i % 3)
ctx.beginPath()
ctx.moveTo(Math.cos(a) * start, Math.sin(a) * start)
ctx.lineTo(Math.cos(a) * (start + len), Math.sin(a) * (start + len))
ctx.stroke()
}
for (let i = 0; i < 9; i++) {
const r = 34 + i * 31 + (reduceMotion ? 0 : Math.sin(t * 1.5 + i) * 7)
ctx.beginPath()
ctx.ellipse(0, 0, r * 1.42, r * 0.68, (reduceMotion ? 0 : t * 0.32) + i, 0, Math.PI * 2)
ctx.strokeStyle = i % 2 ? 'rgba(83,214,162,.38)' : 'rgba(145,136,255,.45)'
ctx.lineWidth = 2
ctx.shadowColor = i % 2 ? '#53d6a2' : '#9188ff'
ctx.shadowBlur = 15
ctx.stroke()
}
ctx.globalCompositeOperation = 'lighter'
for (let i = 0; i < 48; i++) {
const a = (Math.PI * 2 * i) / 48 + (reduceMotion ? 0 : t * 0.24)
const r = 54 + ((i * 41 + (reduceMotion ? 0 : t * 150)) % 340)
const x = Math.cos(a) * r
const y = Math.sin(a) * r * 0.68
const active = i / 48 < p
ctx.beginPath()
ctx.arc(x, y, active ? 3.4 : 1.7, 0, Math.PI * 2)
ctx.fillStyle = active ? 'rgba(110,231,255,.92)' : 'rgba(145,136,255,.3)'
ctx.shadowColor = active ? '#6ee7ff' : '#9188ff'
ctx.shadowBlur = active ? 18 : 10
ctx.fill()
}
const core = ctx.createRadialGradient(0, 0, 0, 0, 0, Math.min(width, height) * 0.16)
core.addColorStop(0, 'rgba(255,255,255,.94)')
core.addColorStop(.16, 'rgba(110,231,255,.9)')
core.addColorStop(.44, 'rgba(124,108,255,.34)')
core.addColorStop(1, 'rgba(124,108,255,0)') core.addColorStop(1, 'rgba(124,108,255,0)')
ctx.fillStyle = core ctx.fillStyle = core
ctx.beginPath(); ctx.arc(0, 0, Math.min(width, height) * 0.15, 0, Math.PI * 2); ctx.fill()
ctx.beginPath() ctx.beginPath()
ctx.arc(0, 0, Math.min(width, height) * 0.18, 0, Math.PI * 2) ctx.arc(0, 0, Math.min(width, height) * 0.11, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * p)
ctx.fill() ctx.strokeStyle = 'rgba(105,235,190,.9)'
ctx.lineWidth = 3
ctx.lineCap = 'round'
ctx.shadowColor = '#53d6a2'
ctx.shadowBlur = 14
ctx.stroke()
ctx.restore()
}
// —— 黑客帝国:绿色数字雨 ——
const MTX_CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789$+-*/=<>#'
const mtxHash = (i, j) => { const x = Math.sin(i * 127.1 + j * 311.7) * 43758.5453; return x - Math.floor(x) }
function initRain() {
const fs = 18
const count = Math.ceil(width / fs)
rain = {
fs,
cols: Array.from({ length: count }, () => ({
y: -Math.random() * (height / fs),
sp: 4 + Math.random() * 9,
len: 9 + Math.floor(Math.random() * 13)
}))
}
}
function drawMatrix(t, p, cx, cy, dt) {
if (!rain) initRain()
ctx.fillStyle = '#020905'
ctx.fillRect(0, 0, width, height)
const vg = ctx.createRadialGradient(cx, cy, 40, cx, cy, Math.max(width, height) * 0.72)
vg.addColorStop(0, 'rgba(22,86,46,.22)')
vg.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = vg
ctx.fillRect(0, 0, width, height)
const { fs, cols } = rain
const rows = Math.ceil(height / fs)
ctx.font = `600 ${fs - 3}px Consolas,"Courier New",monospace`
ctx.textAlign = 'center'
for (let ci = 0; ci < cols.length; ci++) {
const c = cols[ci]
if (!reduceMotion) c.y += c.sp * (1 + p * 0.9) * dt * 3.4
else if (c.y < 4) c.y = 4 + mtxHash(ci, 1) * rows
if (c.y - c.len > rows + 4) {
c.y = -Math.random() * 18
c.sp = 4 + Math.random() * 9
c.len = 9 + Math.floor(Math.random() * 13)
}
const head = Math.floor(c.y)
const x = ci * fs + fs / 2
for (let k = 0; k < c.len; k++) {
const row = head - k
if (row < -1 || row > rows) continue
// 头部字符高频翻动,尾部低频,制造扫描感
const flick = Math.floor(t * (k === 0 ? 9 : 1.7)) + k
const ch = MTX_CHARS[Math.floor(mtxHash(ci * 3 + 1, row * 7 + flick) * MTX_CHARS.length)]
const fade = 1 - k / c.len
if (k === 0) {
ctx.fillStyle = 'rgba(216,255,230,.96)'
ctx.shadowColor = '#7dffb0'
ctx.shadowBlur = 10
} else {
ctx.fillStyle = `rgba(62,${168 + Math.floor(64 * fade)},${94 + Math.floor(30 * fade)},${(0.07 + fade * 0.62).toFixed(3)})`
ctx.shadowBlur = 0
}
ctx.fillText(ch, x, (row + 1) * fs)
}
}
ctx.shadowBlur = 0
// 中央淡光核与进度弧,让进度在雨幕中可读
ctx.save()
ctx.translate(cx, cy)
const R = Math.min(width, height) * 0.2
const core = ctx.createRadialGradient(0, 0, 0, 0, 0, R)
core.addColorStop(0, 'rgba(8,26,14,.88)')
core.addColorStop(.65, 'rgba(8,26,14,.4)')
core.addColorStop(1, 'rgba(8,26,14,0)')
ctx.fillStyle = core
ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI * 2); ctx.fill()
ctx.beginPath()
ctx.arc(0, 0, R * 0.72, 0, Math.PI * 2)
ctx.strokeStyle = 'rgba(70,190,110,.24)'
ctx.lineWidth = 2
ctx.stroke()
ctx.beginPath()
ctx.arc(0, 0, R * 0.72, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * p)
ctx.strokeStyle = 'rgba(125,255,176,.92)'
ctx.lineWidth = 3
ctx.lineCap = 'round'
ctx.shadowColor = '#7dffb0'
ctx.shadowBlur = 14
ctx.stroke()
ctx.restore() ctx.restore()
} }
@@ -345,6 +454,7 @@ onUnmounted(() => {
window.removeEventListener('resize', resize) window.removeEventListener('resize', resize)
}) })
watch(() => props.variant, () => { nebula = stars = rain = null })
watch(() => props.progress, () => { watch(() => props.progress, () => {
if (reduceMotion) draw() if (reduceMotion) draw()
}) })

View File

@@ -1,2 +1,2 @@
<script setup>import{MonitorX,Box}from'lucide-vue-next'</script> <script setup>import{MonitorX,Box}from'lucide-vue-next'</script>
<template><div class="browser-blocked"><section class="blocked-card"><span><MonitorX/></span><small>DESKTOP RUNTIME REQUIRED</small><h1>请在 Code Count 桌面程序中打开</h1><p>当前页面是普通浏览器预览无法访问本地目录SQLite 数据库或 Git请关闭此页面并运行 <code>build/bin/code-count.exe</code></p><div><Box/>本地功能已在浏览器中停用数据不会被模拟或丢弃</div></section></div></template> <template><div class="browser-blocked"><section class="blocked-card"><span><MonitorX/></span><small>DESKTOP RUNTIME REQUIRED</small><h1>请在年糕崽崽 PMS 桌面程序中打开</h1><p>当前页面是普通浏览器预览无法访问本地目录SQLite 数据库或 Git请关闭此页面并运行 <code>build/bin/code-count.exe</code></p><div><Box/>本地功能已在浏览器中停用数据不会被模拟或丢弃</div></section></div></template>

View File

@@ -6,5 +6,5 @@ const messages={BOOTSTRAP_INVALID:'数据库位置配置已损坏,请重新选
async function browse(){const p=await call('SelectInitialDatabaseFile',path.value);if(p)path.value=p} async function browse(){const p=await call('SelectInitialDatabaseFile',path.value);if(p)path.value=p}
async function initialize(retry=false){busy.value=true;error.value='';try{store.bootstrap=retry?await call('RetryDatabase'):await call('InitializeDatabase',path.value);if(store.bootstrap.state==='ready')await store.refresh()}catch(e){error.value=messages[String(e).split(':')[0]]||String(e);store.bootstrap=await call('GetBootstrapStatus')}finally{busy.value=false}} async function initialize(retry=false){busy.value=true;error.value='';try{store.bootstrap=retry?await call('RetryDatabase'):await call('InitializeDatabase',path.value);if(store.bootstrap.state==='ready')await store.refresh()}catch(e){error.value=messages[String(e).split(':')[0]]||String(e);store.bootstrap=await call('GetBootstrapStatus')}finally{busy.value=false}}
</script> </script>
<template><div class="setup-screen"><section class="setup-card glass"><div class="setup-icon"><Database/></div><span class="setup-kicker"><ShieldCheck/>本地数据存储</span><h1>{{recovery?'恢复数据库连接':'初始化 Code Count'}}</h1><p>{{recovery?'上次使用的数据库当前不可用。你可以重试,或选择新的 SQLite 数据库位置。':'选择统计数据和项目配置的保存位置。后续启动将自动使用此数据库。'}}</p><div v-if="recovery" class="setup-warning"><TriangleAlert/><div><b>{{messages[status.errorCode]||status.errorCode}}</b><small>{{status.errorDetail}}</small></div></div><label>数据库文件<div class="setup-path"><input v-model="path"/><button title="选择位置" @click="browse"><FolderOpen/></button></div></label><small class="setup-default">默认位置:{{status.defaultPath}}</small><p v-if="error" class="form-error">{{error}}</p><div class="setup-actions"><button v-if="recovery" class="btn secondary" :disabled="busy" @click="initialize(true)"><RefreshCw :class="{spin:busy}"/>重试原位置</button><button class="btn primary" :disabled="busy||!path" @click="initialize(false)"><Database/>{{busy?'正在初始化...':'初始化数据库'}}</button></div></section></div></template> <template><div class="setup-screen"><section class="setup-card glass"><div class="setup-icon"><Database/></div><span class="setup-kicker"><ShieldCheck/>本地数据存储</span><h1>{{recovery?'恢复数据库连接':'初始化年糕崽崽 PMS'}}</h1><p>{{recovery?'上次使用的数据库当前不可用。你可以重试,或选择新的 SQLite 数据库位置。':'选择统计数据和项目配置的保存位置。后续启动将自动使用此数据库。'}}</p><div v-if="recovery" class="setup-warning"><TriangleAlert/><div><b>{{messages[status.errorCode]||status.errorCode}}</b><small>{{status.errorDetail}}</small></div></div><label>数据库文件<div class="setup-path"><input v-model="path"/><button title="选择位置" @click="browse"><FolderOpen/></button></div></label><small class="setup-default">默认位置:{{status.defaultPath}}</small><p v-if="error" class="form-error">{{error}}</p><div class="setup-actions"><button v-if="recovery" class="btn secondary" :disabled="busy" @click="initialize(true)"><RefreshCw :class="{spin:busy}"/>重试原位置</button><button class="btn primary" :disabled="busy||!path" @click="initialize(false)"><Database/>{{busy?'正在初始化...':'初始化数据库'}}</button></div></section></div></template>
<style scoped>.setup-screen{background-color:var(--bg);background-image:linear-gradient(rgba(123,115,255,.055) 1px,transparent 1px),linear-gradient(90deg,rgba(123,115,255,.045) 1px,transparent 1px),linear-gradient(135deg,rgba(67,201,150,.08),transparent 36%,rgba(91,86,192,.11) 72%,transparent);background-size:40px 40px,40px 40px,100% 100%}</style> <style scoped>.setup-screen{background-color:var(--bg);background-image:linear-gradient(rgba(123,115,255,.055) 1px,transparent 1px),linear-gradient(90deg,rgba(123,115,255,.045) 1px,transparent 1px),linear-gradient(135deg,rgba(67,201,150,.08),transparent 36%,rgba(91,86,192,.11) 72%,transparent);background-size:40px 40px,40px 40px,100% 100%}</style>

View File

@@ -14,12 +14,13 @@ const matrix = computed(() => {
const start = new Date(end) const start = new Date(end)
start.setDate(start.getDate() - 364) start.setDate(start.getDate() - 364)
start.setDate(start.getDate() - start.getDay()) start.setDate(start.getDate() - start.getDay())
const counts = new Map(props.days.map(x => [x.date, Number(x.count || 0)])) const counts = new Map(props.days.map(x => [x.date, x]))
const cells = [] const cells = []
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const date = d.toISOString().slice(0, 10) const date = d.toISOString().slice(0, 10)
const count = counts.get(date) || 0 const day = counts.get(date)
cells.push({ date, count, week: Math.floor((d - start) / weekMs), dow: d.getDay(), level: Math.min(4, count === 0 ? 0 : count < 2 ? 1 : count < 5 ? 2 : count < 10 ? 3 : 4) }) const count = Number(day?.count || 0)
cells.push({ date, count, added: Number(day?.added || 0), deleted: Number(day?.deleted || 0), week: Math.floor((d - start) / weekMs), dow: d.getDay(), level: Math.min(4, count === 0 ? 0 : count < 2 ? 1 : count < 5 ? 2 : count < 10 ? 3 : 4) })
} }
const labels = [] const labels = []
let lastMonth = -1 let lastMonth = -1
@@ -58,7 +59,7 @@ function pick(cell) {
@click="pick(cell)" @click="pick(cell)"
/> />
</div> </div>
<div v-if="hover" class="heat-tooltip">{{ hover.date }} · {{ hover.count }} {{ locale === 'zh-CN' ? '次提交' : 'commits' }}</div> <div v-if="hover" class="heat-tooltip">{{ hover.date }} · {{ hover.count }} {{ locale === 'zh-CN' ? '次提交' : 'commits' }}<template v-if="hover.count"> · <b class="positive">+{{ hover.added }}</b> <b class="negative">-{{ hover.deleted }}</b></template></div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,71 +0,0 @@
<script setup>
import {reactive} from 'vue'
import {Greet} from '../../wailsjs/go/main/App'
const data = reactive({
name: "",
resultText: "Please enter your name below 👇",
})
function greet() {
Greet(data.name).then(result => {
data.resultText = result
})
}
</script>
<template>
<main>
<div id="result" class="result">{{ data.resultText }}</div>
<div id="input" class="input-box">
<input id="name" v-model="data.name" autocomplete="off" class="input" type="text"/>
<button class="btn" @click="greet">Greet</button>
</div>
</main>
</template>
<style scoped>
.result {
height: 20px;
line-height: 20px;
margin: 1.5rem auto;
}
.input-box .btn {
width: 60px;
height: 30px;
line-height: 30px;
border-radius: 3px;
border: none;
margin: 0 0 0 20px;
padding: 0 8px;
cursor: pointer;
}
.input-box .btn:hover {
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
color: #333333;
}
.input-box .input {
border: none;
border-radius: 3px;
outline: none;
height: 30px;
line-height: 30px;
padding: 0 10px;
background-color: rgba(240, 240, 240, 1);
-webkit-font-smoothing: antialiased;
}
.input-box .input:hover {
border: none;
background-color: rgba(255, 255, 255, 1);
}
.input-box .input:focus {
border: none;
background-color: rgba(255, 255, 255, 1);
}
</style>

View File

@@ -1,4 +1,4 @@
*{scrollbar-width:thin;scrollbar-color:rgba(145,136,255,.55) transparent}::-webkit-scrollbar{width:9px;height:9px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:rgba(145,136,255,.38);border:2px solid transparent;background-clip:padding-box;border-radius:8px}::-webkit-scrollbar-thumb:hover{background:rgba(145,136,255,.7);border:2px solid transparent;background-clip:padding-box} *{scrollbar-width:thin;scrollbar-color:rgba(145,136,255,.55) transparent}::-webkit-scrollbar{width:9px;height:9px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:rgba(145,136,255,.38);border:2px solid transparent;background-clip:padding-box;border-radius:8px}::-webkit-scrollbar-thumb:hover{background:rgba(145,136,255,.7);border:2px solid transparent;background-clip:padding-box}
.wsl-picker{display:flex;gap:8px;margin:12px 24px 0}.wsl-picker select{flex:1;background:var(--glass-soft);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:0 10px}.icon-actions button:disabled{opacity:.5;cursor:not-allowed} .wsl-picker{display:flex;gap:8px;margin:12px 24px 0}.wsl-picker select{flex:1;background:var(--glass-soft);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:0 10px}.icon-actions button:disabled{opacity:.5;cursor:not-allowed}
.page{overflow-x:hidden}.project-title>div:first-child{min-width:0;flex:1}.project-title p{max-width:100%!important}.icon-actions{flex:none}.project-card{min-width:0} .page{overflow-x:clip}.project-title>div:first-child{min-width:0;flex:1}.project-title p{max-width:100%!important}.icon-actions{flex:none}.project-card{min-width:0}
.git-context{display:flex;align-items:center;gap:18px;margin:-8px 0 18px;padding:11px 15px;border:1px solid var(--glass-border);border-radius:7px;background:var(--glass-soft);color:var(--muted)}.git-context span{display:flex;align-items:center;gap:7px}.git-context svg{width:17px;color:#9188ff}.git-context b{color:var(--text)}.heat-panel{height:255px}.heat-panel .chart{height:185px}.branch.interactive{position:relative;padding-right:52px;cursor:pointer;transition:border-color .2s,background .2s}.branch.interactive:hover,.branch.interactive.selected{background:rgba(123,115,255,.12);outline:1px solid rgba(145,136,255,.35)}.checkout-btn{position:absolute;right:12px;top:20px;width:32px;height:32px;border:0;border-radius:6px;background:rgba(123,115,255,.15);color:#9188ff;display:grid;place-items:center;cursor:pointer}.checkout-btn svg{width:16px}.commit{width:100%;border:0;color:var(--text);text-align:left;cursor:pointer}.commit:hover{outline:1px solid rgba(145,136,255,.3)}.git-trend{position:relative}.git-trend>.segments{position:absolute;right:0;top:-38px;z-index:2}.git-trend .chart{height:280px}.drawer-mask{position:fixed;inset:0;z-index:60;background:rgba(0,0,0,.55);backdrop-filter:blur(5px);display:flex;justify-content:flex-end;animation:overlay-in .2s}.commit-drawer{width:min(620px,90vw);height:100%;overflow:auto;background:var(--glass-strong);border-left:1px solid var(--glass-border);box-shadow:-20px 0 50px rgba(0,0,0,.3);padding:26px;animation:drawer-in .3s cubic-bezier(.16,1,.3,1)}.commit-drawer header{display:flex;justify-content:space-between;gap:20px;border-bottom:1px solid var(--border);padding-bottom:18px}.commit-drawer header small{color:var(--muted)}.commit-drawer h2{margin:7px 0 0;font-size:20px}.commit-drawer header button,.commit-meta button{border:0;background:var(--glass-soft);color:var(--text);width:34px;height:34px;border-radius:6px;display:grid;place-items:center;cursor:pointer}.commit-drawer svg{width:17px}.commit-meta{display:grid;grid-template-columns:1fr auto auto auto;gap:10px;align-items:center;margin:20px 0;padding:15px;background:var(--glass-soft);border-radius:7px}.commit-meta code{min-width:0;overflow:hidden;text-overflow:ellipsis}.commit-meta span,.commit-meta time{grid-column:1/3;color:var(--muted)}.change-file{display:grid;grid-template-columns:22px 1fr auto auto auto;gap:10px;align-items:center;padding:11px;border-bottom:1px solid var(--border)}.change-file span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.change-file small{color:var(--muted)}@keyframes drawer-in{from{transform:translateX(100%)}to{transform:none}}@media(prefers-reduced-motion:reduce){.commit-drawer{animation:none}} .git-context{display:flex;align-items:center;gap:18px;margin:-8px 0 18px;padding:11px 15px;border:1px solid var(--glass-border);border-radius:7px;background:var(--glass-soft);color:var(--muted)}.git-context span{display:flex;align-items:center;gap:7px}.git-context svg{width:17px;color:#9188ff}.git-context b{color:var(--text)}.heat-panel{height:255px}.heat-panel .chart{height:185px}.branch.interactive{position:relative;padding-right:52px;cursor:pointer;transition:border-color .2s,background .2s}.branch.interactive:hover,.branch.interactive.selected{background:rgba(123,115,255,.12);outline:1px solid rgba(145,136,255,.35)}.checkout-btn{position:absolute;right:12px;top:20px;width:32px;height:32px;border:0;border-radius:6px;background:rgba(123,115,255,.15);color:#9188ff;display:grid;place-items:center;cursor:pointer}.checkout-btn svg{width:16px}.commit{width:100%;border:0;color:var(--text);text-align:left;cursor:pointer}.commit:hover{outline:1px solid rgba(145,136,255,.3)}.git-trend{position:relative}.git-trend>.segments{position:absolute;right:0;top:-38px;z-index:2}.git-trend .chart{height:280px}.drawer-mask{position:fixed;inset:0;z-index:60;background:rgba(0,0,0,.55);backdrop-filter:blur(5px);display:flex;justify-content:flex-end;animation:overlay-in .2s}.commit-drawer{width:min(620px,90vw);height:100%;overflow:auto;background:var(--glass-strong);border-left:1px solid var(--glass-border);box-shadow:-20px 0 50px rgba(0,0,0,.3);padding:26px;animation:drawer-in .3s cubic-bezier(.16,1,.3,1)}.commit-drawer header{display:flex;justify-content:space-between;gap:20px;border-bottom:1px solid var(--border);padding-bottom:18px}.commit-drawer header small{color:var(--muted)}.commit-drawer h2{margin:7px 0 0;font-size:20px}.commit-drawer header button,.commit-meta button{border:0;background:var(--glass-soft);color:var(--text);width:34px;height:34px;border-radius:6px;display:grid;place-items:center;cursor:pointer}.commit-drawer svg{width:17px}.commit-meta{display:grid;grid-template-columns:1fr auto auto auto;gap:10px;align-items:center;margin:20px 0;padding:15px;background:var(--glass-soft);border-radius:7px}.commit-meta code{min-width:0;overflow:hidden;text-overflow:ellipsis}.commit-meta span,.commit-meta time{grid-column:1/3;color:var(--muted)}.change-file{display:grid;grid-template-columns:22px 1fr auto auto auto;gap:10px;align-items:center;padding:11px;border-bottom:1px solid var(--border)}.change-file span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.change-file small{color:var(--muted)}@keyframes drawer-in{from{transform:translateX(100%)}to{transform:none}}@media(prefers-reduced-motion:reduce){.commit-drawer{animation:none}}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
html{--glass-user-opacity:.55;--glass:rgba(27,35,48,var(--glass-user-opacity));--glass-strong:rgba(32,41,57,calc(var(--glass-user-opacity) + .12));--glass-soft:rgba(40,50,69,calc(var(--glass-user-opacity) - .1));--glass-border:rgba(164,178,211,.18);--glass-highlight:rgba(255,255,255,.07);--glass-shadow:0 18px 42px rgba(0,0,0,.22);--glass-blur:24px} html{--glass-user-opacity:.55;--glass:rgba(27,35,48,var(--glass-user-opacity));--glass-strong:rgba(32,41,57,calc(var(--glass-user-opacity) + .12));--glass-soft:rgba(40,50,69,calc(var(--glass-user-opacity) - .1));--glass-border:rgba(164,178,211,.18);--glass-highlight:rgba(255,255,255,.07);--glass-shadow:0 18px 42px rgba(0,0,0,.22);--glass-blur:24px}
html[data-theme=light]{--glass:rgba(255,255,255,calc(.72 + var(--glass-user-opacity) * .28));--glass-strong:rgba(255,255,255,calc(.8 + var(--glass-user-opacity) * .2));--glass-soft:rgba(244,247,252,calc(.64 + var(--glass-user-opacity) * .3));--glass-border:rgba(56,70,98,.16);--glass-highlight:rgba(255,255,255,.85);--glass-shadow:0 18px 42px rgba(40,53,78,.12)} html[data-theme=light]{--glass:rgba(255,255,255,calc(.72 + var(--glass-user-opacity) * .28));--glass-strong:rgba(255,255,255,calc(.8 + var(--glass-user-opacity) * .2));--glass-soft:rgba(244,247,252,calc(.64 + var(--glass-user-opacity) * .3));--glass-border:rgba(56,70,98,.16);--glass-highlight:rgba(255,255,255,.85);--glass-shadow:0 18px 42px rgba(40,53,78,.12)}
main,.setup-screen{background-color:var(--bg);background-image:linear-gradient(rgba(123,115,255,.055) 1px,transparent 1px),linear-gradient(90deg,rgba(123,115,255,.045) 1px,transparent 1px),linear-gradient(135deg,rgba(67,201,150,.08),transparent 36%,rgba(91,86,192,.11) 72%,transparent);background-size:40px 40px,40px 40px,100% 100%;background-attachment:fixed}main{position:relative;isolation:isolate}.sidebar,.stat-card,.project-card,.panel,.modal,.taskbar,.toast,.tabs,.search,.setup-card{background:linear-gradient(135deg,var(--glass-highlight),transparent 42%),var(--glass);border-color:var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow);backdrop-filter:blur(var(--glass-blur)) saturate(145%);-webkit-backdrop-filter:blur(var(--glass-blur)) saturate(145%)}.sidebar{background:linear-gradient(145deg,var(--glass-highlight),transparent 38%),var(--glass-strong)}.modal,.setup-card{background:linear-gradient(135deg,var(--glass-highlight),transparent 45%),var(--glass-strong)} main,.setup-screen{background-color:var(--bg);background-image:linear-gradient(rgba(123,115,255,.055) 1px,transparent 1px),linear-gradient(90deg,rgba(123,115,255,.045) 1px,transparent 1px),linear-gradient(135deg,rgba(67,201,150,.08),transparent 36%,rgba(91,86,192,.11) 72%,transparent);background-size:40px 40px,40px 40px,100% 100%;background-attachment:fixed}main{position:relative;isolation:isolate}.sidebar,.stat-card,.project-card,.panel,.modal,.taskbar,.toast,.tabs,.search,.setup-card{background:linear-gradient(135deg,var(--glass-highlight),transparent 42%),var(--glass);border-color:var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow);backdrop-filter:blur(var(--glass-blur)) saturate(145%);-webkit-backdrop-filter:blur(var(--glass-blur)) saturate(145%)}.sidebar{background:linear-gradient(145deg,var(--glass-highlight),transparent 38%),var(--glass-strong)}.modal,.setup-card{background:linear-gradient(135deg,var(--glass-highlight),transparent 45%),var(--glass-strong)}
.page{animation:page-enter .48s cubic-bezier(.2,.8,.2,1) both}.page-head,.project-head{animation:fade-rise .42s .04s both}.stats-grid>.stat-card,.project-grid>*{animation:card-enter .5s cubic-bezier(.16,1,.3,1) both}.stats-grid>:nth-child(1),.project-grid>:nth-child(1){animation-delay:.08s}.stats-grid>:nth-child(2),.project-grid>:nth-child(2){animation-delay:.13s}.stats-grid>:nth-child(3),.project-grid>:nth-child(3){animation-delay:.18s}.stats-grid>:nth-child(4),.project-grid>:nth-child(4){animation-delay:.23s}.stats-grid>:nth-child(5),.project-grid>:nth-child(5){animation-delay:.28s}.project-grid>:nth-child(n+6){animation-delay:.32s} .page{animation:page-enter .48s cubic-bezier(.2,.8,.2,1) backwards}.page-head,.project-head{animation:fade-rise .42s .04s both}.stats-grid>.stat-card,.project-grid>*{animation:card-enter .5s cubic-bezier(.16,1,.3,1) both}.stats-grid>:nth-child(1),.project-grid>:nth-child(1){animation-delay:.08s}.stats-grid>:nth-child(2),.project-grid>:nth-child(2){animation-delay:.13s}.stats-grid>:nth-child(3),.project-grid>:nth-child(3){animation-delay:.18s}.stats-grid>:nth-child(4),.project-grid>:nth-child(4){animation-delay:.23s}.stats-grid>:nth-child(5),.project-grid>:nth-child(5){animation-delay:.28s}.project-grid>:nth-child(n+6){animation-delay:.32s}
.stat-card,.panel,.project-card,.add-card{transition:transform .26s cubic-bezier(.2,.8,.2,1),border-color .26s,box-shadow .26s,background-color .26s}.stat-card:hover,.panel:hover{border-color:rgba(130,120,255,.38);box-shadow:inset 0 1px 0 var(--glass-highlight),0 22px 46px rgba(0,0,0,.25);transform:translateY(-2px)}.project-card:hover,.add-card:hover{transform:translateY(-5px);border-color:rgba(130,120,255,.6);box-shadow:inset 0 1px 0 var(--glass-highlight),0 25px 52px rgba(0,0,0,.3);background:var(--glass-strong)} .stat-card,.panel,.project-card,.add-card{transition:transform .26s cubic-bezier(.2,.8,.2,1),border-color .26s,box-shadow .26s,background-color .26s}.stat-card:hover,.panel:hover{border-color:rgba(130,120,255,.38);box-shadow:inset 0 1px 0 var(--glass-highlight),0 22px 46px rgba(0,0,0,.25);transform:translateY(-2px)}.project-card:hover,.add-card:hover{transform:translateY(-5px);border-color:rgba(130,120,255,.6);box-shadow:inset 0 1px 0 var(--glass-highlight),0 25px 52px rgba(0,0,0,.3);background:var(--glass-strong)}
.stat-card strong{animation:number-arrive .55s .2s both}.overlay{animation:overlay-in .24s both}.modal{animation:modal-in .32s cubic-bezier(.16,1,.3,1) both}.toast{animation:toast-in .36s cubic-bezier(.16,1,.3,1) both}.taskbar{animation:task-in .38s cubic-bezier(.16,1,.3,1) both}.taskbar .progress i{transition:width .4s cubic-bezier(.2,.8,.2,1);position:relative;overflow:hidden}.taskbar .progress i:after{content:"";position:absolute;inset:0;background:rgba(255,255,255,.35);animation:progress-sweep 1.2s linear infinite}.heatmap i[class]:not(.l0){animation:heat-in .38s both}.heatmap i:nth-child(5n){animation-delay:.08s}.heatmap i:nth-child(7n){animation-delay:.14s}.spin{animation:spin .9s linear infinite}.btn,.tabs button,.icon-actions button{transition:background-color .2s,border-color .2s,color .2s,box-shadow .2s,transform .2s}.btn:active,.tabs button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid #9188ff;outline-offset:2px} .stat-card strong{animation:number-arrive .55s .2s both}.overlay{animation:overlay-in .24s both}.modal{animation:modal-in .32s cubic-bezier(.16,1,.3,1) both}.toast{animation:toast-in .36s cubic-bezier(.16,1,.3,1) both}.taskbar{animation:task-in .38s cubic-bezier(.16,1,.3,1) both}.taskbar .progress i{transition:width .4s cubic-bezier(.2,.8,.2,1);position:relative;overflow:hidden}.taskbar .progress i:after{content:"";position:absolute;inset:0;background:rgba(255,255,255,.35);animation:progress-sweep 1.2s linear infinite}.heatmap i[class]:not(.l0){animation:heat-in .38s both}.heatmap i:nth-child(5n){animation-delay:.08s}.heatmap i:nth-child(7n){animation-delay:.14s}.spin{animation:spin .9s linear infinite}.btn,.tabs button,.icon-actions button{transition:background-color .2s,border-color .2s,color .2s,box-shadow .2s,transform .2s}.btn:active,.tabs button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid #9188ff;outline-offset:2px}
.boot-loading,.setup-screen{min-height:100vh;display:grid;place-items:center;background:var(--bg)}.boot-loading{gap:12px;color:var(--muted)}.boot-loading svg{width:28px}.setup-screen{padding:32px}.setup-card{width:min(620px,100%);padding:42px;border:1px solid var(--glass-border);border-radius:10px;animation:setup-in .55s cubic-bezier(.16,1,.3,1) both}.setup-icon{width:62px;height:62px;display:grid;place-items:center;background:rgba(115,103,245,.18);color:#9188ff;border:1px solid rgba(145,136,255,.28);border-radius:10px;margin-bottom:24px}.setup-icon svg{width:30px}.setup-kicker{display:flex;align-items:center;gap:7px;color:#9b94ff;font-size:13px;font-weight:bold}.setup-kicker svg{width:16px}.setup-card h1{font-size:30px;margin:10px 0}.setup-card>p{color:var(--muted);line-height:1.65}.setup-card label{display:block;margin-top:26px;font-size:13px;font-weight:bold}.setup-path{display:grid;grid-template-columns:1fr 44px;margin-top:8px}.setup-path input{height:44px;border:1px solid var(--border);border-radius:7px 0 0 7px;background:var(--glass-soft);color:var(--text);padding:0 13px;min-width:0}.setup-path button{border:1px solid var(--border);border-left:0;border-radius:0 7px 7px 0;background:var(--glass-soft);color:var(--text);cursor:pointer}.setup-path svg{width:19px}.setup-default{display:block;color:var(--muted);margin-top:8px;overflow-wrap:anywhere}.setup-warning{display:flex;gap:12px;background:rgba(240,94,104,.1);border:1px solid rgba(240,94,104,.28);padding:14px;border-radius:8px;margin-top:20px;color:var(--red)}.setup-warning svg{width:20px;flex:none}.setup-warning b,.setup-warning small{display:block}.setup-warning small{margin-top:5px;opacity:.8;overflow-wrap:anywhere}.setup-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:28px} .boot-loading,.setup-screen{min-height:100vh;display:grid;place-items:center;background:var(--bg)}.boot-loading{gap:12px;color:var(--muted)}.boot-loading svg{width:28px}.setup-screen{padding:32px}.setup-card{width:min(620px,100%);padding:42px;border:1px solid var(--glass-border);border-radius:10px;animation:setup-in .55s cubic-bezier(.16,1,.3,1) both}.setup-icon{width:62px;height:62px;display:grid;place-items:center;background:rgba(115,103,245,.18);color:#9188ff;border:1px solid rgba(145,136,255,.28);border-radius:10px;margin-bottom:24px}.setup-icon svg{width:30px}.setup-kicker{display:flex;align-items:center;gap:7px;color:#9b94ff;font-size:13px;font-weight:bold}.setup-kicker svg{width:16px}.setup-card h1{font-size:30px;margin:10px 0}.setup-card>p{color:var(--muted);line-height:1.65}.setup-card label{display:block;margin-top:26px;font-size:13px;font-weight:bold}.setup-path{display:grid;grid-template-columns:1fr 44px;margin-top:8px}.setup-path input{height:44px;border:1px solid var(--border);border-radius:7px 0 0 7px;background:var(--glass-soft);color:var(--text);padding:0 13px;min-width:0}.setup-path button{border:1px solid var(--border);border-left:0;border-radius:0 7px 7px 0;background:var(--glass-soft);color:var(--text);cursor:pointer}.setup-path svg{width:19px}.setup-default{display:block;color:var(--muted);margin-top:8px;overflow-wrap:anywhere}.setup-warning{display:flex;gap:12px;background:rgba(240,94,104,.1);border:1px solid rgba(240,94,104,.28);padding:14px;border-radius:8px;margin-top:20px;color:var(--red)}.setup-warning svg{width:20px;flex:none}.setup-warning b,.setup-warning small{display:block}.setup-warning small{margin-top:5px;opacity:.8;overflow-wrap:anywhere}.setup-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:28px}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { call, on } from './api' import { call, on, onTasksChanged } from './api'
export const useAppStore = defineStore('app', { export const useAppStore = defineStore('app', {
state: () => ({ state: () => ({
@@ -8,13 +8,29 @@ export const useAppStore = defineStore('app', {
projectGroups: [], projectGroups: [],
selectedProjectGroupId: Number(localStorage.getItem('cc-project-group-id') || 0), selectedProjectGroupId: Number(localStorage.getItem('cc-project-group-id') || 0),
dashboard: { projects: 0, totalLines: 0, commits: 0 }, dashboard: { projects: 0, totalLines: 0, commits: 0 },
settings: { theme: 'dark', locale: 'zh-CN', glassOpacity: 55, gitScope: 'current', autoRefresh: true, loadingStyle: 'fullscreen-orbit' }, settings: { theme: 'dark', locale: 'zh-CN', glassOpacity: 55, gitScope: 'current', autoRefresh: true, loadingStyle: 'fullscreen-orbit', imageMode: 'base64' },
tasks: {}, tasks: {},
batchTaskIds: [], batchTaskIds: [],
batchSummary: null,
pendingAction: '',
favorites: [],
unreadMessages: 0,
// 左侧导航徽标(消息未读/待办/工单/今日到期/日历红点/团队指派)
badges: { unread: 0, todosOpen: 0, ticketsActive: 0, todayDue: 0, calendarDot: false, teamAssigned: 0 },
toast: null, toast: null,
toastTimer: null, toastTimer: null,
toastLeaveTimer: null, toastLeaveTimer: null,
loading: false loading: false,
syncStatus: { configured: false, loggedIn: false, userId: 0, username: '', online: false, syncing: false },
avatarSrc: '',
loginOpen: false,
paletteOpen: false,
// 日历页"每日心语"入口:设为 'YYYY-MM-DD' 时 DailyCard 打开对应日期的卡片
dailyCardDate: '',
// 节日名 → { mode: 'photo'|'art', image: dataURL }(管理员上传,随同步分发)
festivalImages: {},
// 探测到加载失败的节日图key → true日历格自动回退动态插画
festBroken: {}
}), }),
actions: { actions: {
async boot() { async boot() {
@@ -22,9 +38,22 @@ export const useAppStore = defineStore('app', {
if (this.bootstrap.state === 'ready') { if (this.bootstrap.state === 'ready') {
this.settings = await call('GetSettings') this.settings = await call('GetSettings')
this.applyAppearance(this.settings) this.applyAppearance(this.settings)
try { this.syncStatus = await call('GetSyncStatus') } catch {}
this.loadFestivalImages()
await this.refresh() await this.refresh()
} }
}, },
async loadFestivalImages() {
try { this.festivalImages = (await call('ListFestivalImages')) || {} } catch { return }
// 预探测 photo 模式的图片:解码失败的标记为 broken展示时回退动态插画
for (const [key, it] of Object.entries(this.festivalImages)) {
if (it.mode !== 'photo' || !it.image) continue
const probe = new Image()
probe.onerror = () => { this.festBroken = { ...this.festBroken, [key]: true } }
probe.onload = () => { if (this.festBroken[key]) { const n = { ...this.festBroken }; delete n[key]; this.festBroken = n } }
probe.src = it.image
}
},
applyAppearance(settings) { applyAppearance(settings) {
this.settings = { ...this.settings, ...settings } this.settings = { ...this.settings, ...settings }
let theme = this.settings.theme || 'dark' let theme = this.settings.theme || 'dark'
@@ -32,6 +61,25 @@ export const useAppStore = defineStore('app', {
document.documentElement.dataset.theme = theme document.documentElement.dataset.theme = theme
document.documentElement.style.setProperty('--glass-user-opacity', String((this.settings.glassOpacity || 55) / 100)) document.documentElement.style.setProperty('--glass-user-opacity', String((this.settings.glassOpacity || 55) / 100))
localStorage.setItem('cc-settings', JSON.stringify(this.settings)) localStorage.setItem('cc-settings', JSON.stringify(this.settings))
this.resolveAvatar()
},
// resolveAvatar 把设置中的头像解析为可显示的 <img> 源path 模式需经后端读文件转 dataURL。
async resolveAvatar() {
const { avatarMode, avatarValue } = this.settings
if (!avatarMode || !avatarValue) { this.avatarSrc = ''; return }
if (avatarMode === 'path') {
try { this.avatarSrc = await call('ReadImageAsDataURL', avatarValue) } catch { this.avatarSrc = '' }
return
}
this.avatarSrc = avatarValue
},
async refreshSyncStatus() {
try { this.syncStatus = await call('GetSyncStatus') } catch {}
},
// openAccount 是所有登录入口的统一行为:未登录弹全屏登录层,已登录进个人主页。
openAccount(router) {
if (this.syncStatus.loggedIn) router?.push('/profile')
else this.loginOpen = true
}, },
async saveSettings(patch) { async saveSettings(patch) {
const next = { ...this.settings, ...patch } const next = { ...this.settings, ...patch }
@@ -63,20 +111,35 @@ export const useAppStore = defineStore('app', {
}, },
async refresh() { async refresh() {
this.loading = true this.loading = true
this.refreshSyncStatus()
try { try {
this.projectGroups = await call('ListProjectGroups') this.projectGroups = await call('ListProjectGroups')
if (this.selectedProjectGroupId && !this.projectGroups.some(g => g.id === this.selectedProjectGroupId)) { if (this.selectedProjectGroupId && !this.projectGroups.some(g => g.id === this.selectedProjectGroupId)) {
this.setProjectGroup(0) this.setProjectGroup(0)
} }
const groupId = this.selectedProjectGroupId || 0 const groupId = this.selectedProjectGroupId || 0
;[this.projects, this.dashboard] = await Promise.all([ ;[this.projects, this.dashboard, this.favorites, this.unreadMessages] = await Promise.all([
groupId ? call('ListProjectsByGroup', groupId) : call('ListProjects'), groupId ? call('ListProjectsByGroup', groupId) : call('ListProjects'),
groupId ? call('GetDashboardByGroup', groupId) : call('GetDashboard') groupId ? call('GetDashboardByGroup', groupId) : call('GetDashboard'),
call('ListFavorites').catch(() => []),
call('UnreadMessageCount').catch(() => 0)
]) ])
} finally { } finally {
this.loading = false this.loading = false
} }
}, },
async toggleFavorite(projectId) {
const fav = await call('ToggleFavorite', projectId)
this.favorites = fav ? [projectId, ...this.favorites.filter(x => x !== projectId)] : this.favorites.filter(x => x !== projectId)
return fav
},
async refreshUnread() {
try { this.unreadMessages = await call('UnreadMessageCount') } catch {}
this.refreshBadges()
},
async refreshBadges() {
try { this.badges = await call('GetNavBadges') } catch {}
},
async changeProjectGroup(groupId) { async changeProjectGroup(groupId) {
this.setProjectGroup(groupId) this.setProjectGroup(groupId)
await this.refresh() await this.refresh()
@@ -86,7 +149,7 @@ export const useAppStore = defineStore('app', {
if (persist) localStorage.setItem('cc-project-group-id', String(this.selectedProjectGroupId)) if (persist) localStorage.setItem('cc-project-group-id', String(this.selectedProjectGroupId))
}, },
listen() { listen() {
return on('analysis:progress', e => { const offProgress = on('analysis:progress', e => {
delete this.tasks.__batch_pending__ delete this.tasks.__batch_pending__
this.tasks[e.taskId] = e this.tasks[e.taskId] = e
if (['completed', 'error', 'cancelled'].includes(e.stage)) { if (['completed', 'error', 'cancelled'].includes(e.stage)) {
@@ -100,6 +163,30 @@ export const useAppStore = defineStore('app', {
this.refresh() this.refresh()
} }
}) })
const offBatch = on('batch:done', s => {
this.batchSummary = s
this.batchTaskIds = []
delete this.tasks.__batch_pending__
this.showToast({ type: s.failed ? 'error' : 'success', key: 'batchDoneToast', params: { completed: s.completed, total: s.total } })
this.refresh()
})
const offMessage = on('message:new', () => { this.unreadMessages += 1; this.refreshBadges() })
const offSync = on('sync:done', async st => {
this.syncStatus = st || this.syncStatus
this.refreshBadges()
// 拉取可能更新了设置(头像 / API Key刷新本地副本
try { this.applyAppearance(await call('GetSettings')) } catch {}
// 拉取可能带来新项目/分组/收藏,刷新项目列表
if (st?.pulled > 0) {
this.refresh().catch(() => {})
this.loadFestivalImages()
}
})
// 徽标:启动即取,任务变更事件 + 60s 轮询兜底
this.refreshBadges()
const offTasks = onTasksChanged(() => this.refreshBadges())
const badgeTimer = setInterval(() => this.refreshBadges(), 60000)
return () => { offProgress?.(); offBatch?.(); offMessage?.(); offSync?.(); offTasks?.(); clearInterval(badgeTimer) }
}, },
async analyze(id, kind = 'all') { async analyze(id, kind = 'all') {
const task = await call('StartAnalysis', id, kind) const task = await call('StartAnalysis', id, kind)

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,12 @@
<script setup> <script setup>
import { computed, reactive, ref } from 'vue' import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Folder, Code2, GitCommitHorizontal, Plus, RefreshCw, Search, Trash2, Pencil, FolderOpen } from 'lucide-vue-next' import { Folder, Code2, GitCommitHorizontal, Plus, RefreshCw, Search, Trash2, Pencil, FolderOpen, PieChart, X, CheckCircle2, CircleX, Ban, Star, CloudDownload, ChevronUp, ChevronDown } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue' import StatCard from '../components/StatCard.vue'
import ChartView from '../components/ChartView.vue'
import { useAppStore } from '../store' import { useAppStore } from '../store'
import { call } from '../api' import { call, on } from '../api'
const store = useAppStore() const store = useAppStore()
const router = useRouter() const router = useRouter()
@@ -23,6 +24,8 @@ const wslDistros = ref([])
const wslDistro = ref('') const wslDistro = ref('')
const form = reactive({ name: '', path: '', description: '', groupId: 1 }) const form = reactive({ name: '', path: '', description: '', groupId: 1 })
const groupForm = reactive({ name: '' }) const groupForm = reactive({ name: '' })
const srcMode = ref('local') // 新建项目来源local 本地目录 / git 克隆
const gitForm = reactive({ url: '', parentDir: '' })
const palette = ['#53d6a2', '#5da8ff', '#f7cb4d', '#a78bfa', '#ef6f8f'] const palette = ['#53d6a2', '#5da8ff', '#f7cb4d', '#a78bfa', '#ef6f8f']
const filtered = computed(() => { const filtered = computed(() => {
@@ -36,11 +39,25 @@ const filteredDashboard = computed(() => filtered.value.reduce((acc, p) => {
acc.commits += Number(p.stats?.commitCount || 0) acc.commits += Number(p.stats?.commitCount || 0)
return acc return acc
}, { projects: 0, totalLines: 0, commits: 0 })) }, { projects: 0, totalLines: 0, commits: 0 }))
const langColors = ['#7b73ff', '#4fd1a1', '#4da5ff', '#f4c84a', '#ef6683', '#23b5d3', '#a78bfa', '#fb923c']
const languages = computed(() => store.dashboard.languages || [])
const langTotal = computed(() => languages.value.reduce((s, x) => s + x.code, 0))
const langPie = computed(() => ({
backgroundColor: 'transparent',
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [{ type: 'pie', radius: ['50%', '74%'], label: { show: false }, data: languages.value.slice(0, 12).map((x, i) => ({ name: x.name, value: x.code, itemStyle: { color: langColors[i % langColors.length] } })) }]
}))
const fmt = n => { const fmt = n => {
n = +n || 0 n = +n || 0
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString() return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString()
} }
// 顶部统计(大卡片 + 语言分布)可折叠为一行小卡片,状态跨会话记忆
const statsCollapsed = ref(localStorage.getItem('cc-dash-stats-collapsed') === '1')
function toggleStats() {
statsCollapsed.value = !statsCollapsed.value
localStorage.setItem('cc-dash-stats-collapsed', statsCollapsed.value ? '1' : '0')
}
const total = p => p.languages?.reduce((s, x) => s + x.code, 0) || 0 const total = p => p.languages?.reduce((s, x) => s + x.code, 0) || 0
const projectRunning = id => Object.values(store.tasks).some(x => x.projectId === id && !['completed', 'error', 'cancelled'].includes(x.stage)) const projectRunning = id => Object.values(store.tasks).some(x => x.projectId === id && !['completed', 'error', 'cancelled'].includes(x.stage))
const defaultGroupId = computed(() => store.projectGroups[0]?.id || 1) const defaultGroupId = computed(() => store.projectGroups[0]?.id || 1)
@@ -53,9 +70,21 @@ const errorText = raw => {
function open(p) { function open(p) {
editing.value = p?.id || 0 editing.value = p?.id || 0
Object.assign(form, p ? { name: p.name, path: p.path, description: p.description, groupId: p.groupId || defaultGroupId.value } : { name: '', path: '', description: '', groupId: store.selectedProjectGroupId || defaultGroupId.value }) Object.assign(form, p ? { name: p.name, path: p.path, description: p.description, groupId: p.groupId || defaultGroupId.value } : { name: '', path: '', description: '', groupId: store.selectedProjectGroupId || defaultGroupId.value })
srcMode.value = 'local'
Object.assign(gitForm, { url: '', parentDir: gitForm.parentDir })
error.value = '' error.value = ''
modal.value = true modal.value = true
} }
// Git 地址变化时自动推导项目名(用户未手填的情况下)
function onGitUrl() {
const u = gitForm.url.trim().replace(/[/\\]+$/, '').replace(/\.git$/, '')
const seg = u.split(/[/:\\]/).pop() || ''
if (seg && !form.name) form.name = seg
}
async function browseParent() {
const p = await call('SelectDirectory')
if (p) gitForm.parentDir = p
}
function openGroup(g) { function openGroup(g) {
groupEditing.value = g?.id || 0 groupEditing.value = g?.id || 0
groupForm.name = g?.name || '' groupForm.name = g?.name || ''
@@ -117,6 +146,7 @@ async function browseWSL() {
} }
async function save() { async function save() {
if (saving.value) return if (saving.value) return
if (!editing.value && srcMode.value === 'git') return cloneSave()
error.value = '' error.value = ''
form.path = form.path.trim() form.path = form.path.trim()
if (!form.path) { if (!form.path) {
@@ -137,6 +167,29 @@ async function save() {
saving.value = false saving.value = false
} }
} }
// 从 Git 克隆到指定目录后直接入库;进度经任务事件展示
async function cloneSave() {
error.value = ''
if (!gitForm.url.trim()) { error.value = t('cloneUrlRequired'); return }
if (!gitForm.parentDir.trim()) { error.value = t('cloneDirRequired'); return }
saving.value = true
try {
await call('CloneProject', {
url: gitForm.url.trim(), parentDir: gitForm.parentDir.trim(), name: form.name.trim(),
groupId: Number(form.groupId) || defaultGroupId.value, description: form.description
})
await store.refresh()
modal.value = false
store.showToast({ type: 'success', text: t('cloneDone') })
} catch (e) {
const raw = String(e?.message || e)
const code = ['URL_REQUIRED', 'DIR_NOT_FOUND', 'NAME_REQUIRED', 'TARGET_NOT_EMPTY'].find(x => raw.includes(x))
error.value = code ? t(`errors.${code}`) : raw
store.showToast({ type: 'error', text: error.value })
} finally {
saving.value = false
}
}
async function remove(p) { async function remove(p) {
if (confirm(`${t('delete')} ${p.name}?`)) { if (confirm(`${t('delete')} ${p.name}?`)) {
await call('DeleteProject', p.id) await call('DeleteProject', p.id)
@@ -153,21 +206,136 @@ async function refreshProject(p) {
store.showToast({ type: 'error', text: errorText(e) }) store.showToast({ type: 'error', text: errorText(e) })
} }
} }
function consumePendingAction() {
if (store.pendingAction === 'addProject') {
store.pendingAction = ''
open()
}
}
watch(() => store.pendingAction, consumePendingAction)
// ---- 云端项目待绑定:同账号在其它机器添加的项目,本机需要选目录落地 ----
const cloudPending = ref([])
const bindTarget = ref(null) // { name, description, group } 待绑定项
const bindDir = ref('')
const bindBusy = ref(false)
const bindErr = ref('')
let offSync = null
async function loadPending() {
try { cloudPending.value = (await call('ListCloudPendingProjects')) || [] } catch { cloudPending.value = [] }
}
function openBind(it) {
bindTarget.value = it
bindDir.value = ''
bindErr.value = ''
}
async function browseBind() {
const p = await call('SelectDirectory')
if (p) bindDir.value = p
}
async function bindNow() {
if (bindBusy.value || !bindTarget.value) return
bindErr.value = ''
if (!bindDir.value.trim()) { bindErr.value = t('pathRequired'); return }
bindBusy.value = true
try {
await call('BindCloudProject', bindTarget.value.name, bindDir.value.trim())
bindTarget.value = null
await Promise.all([store.refresh(), loadPending()])
store.showToast({ type: 'success', key: 'cloudBindDone' })
} catch (e) {
bindErr.value = errorText(e)
} finally {
bindBusy.value = false
}
}
onMounted(() => {
consumePendingAction()
loadPending()
offSync = on('sync:done', loadPending)
})
onUnmounted(() => offSync?.())
</script> </script>
<template> <template>
<div class="page dashboard-page"> <div class="page dashboard-page">
<header class="page-head sticky-head"> <header class="page-head sticky-head">
<div><h1>{{ t('dashboard') }}</h1><p>{{ t('dashboardSubtitle') }}</p></div> <div><h1>{{ t('projects') }}</h1><p>{{ t('dashboardSubtitle') }}</p></div>
<div class="actions"> <div class="actions">
<button class="btn secondary" @click="batch"><RefreshCw />{{ t('batch') }}</button> <button class="btn secondary" @click="batch"><RefreshCw />{{ t('batch') }}</button>
<button class="btn primary" @click="open()"><Plus />{{ t('addProject') }}</button> <button class="btn primary" @click="open()"><Plus />{{ t('addProject') }}</button>
</div> </div>
</header> </header>
<div class="stats-grid three"> <section v-if="cloudPending.length" class="panel cloud-pending">
<StatCard :icon="Folder" :value="filteredDashboard.projects" :label="t('totalProjects')" /> <header>
<StatCard :icon="Code2" tone="green" :value="fmt(filteredDashboard.totalLines)" :label="t('totalLines')" /> <b><CloudDownload />{{ t('cloudPendingTitle') }}<em>{{ cloudPending.length }}</em></b>
<StatCard :icon="GitCommitHorizontal" tone="blue" :value="fmt(filteredDashboard.commits)" :label="t('commits')" /> <small>{{ t('cloudPendingDesc') }}</small>
</header>
<div class="cloud-pending-list">
<div v-for="it in cloudPending" :key="it.name" class="cloud-pending-item">
<div class="cp-main">
<b>{{ it.name }}</b>
<small>{{ [it.group, it.description].filter(Boolean).join(' · ') }}</small>
</div>
<button class="btn secondary" @click="openBind(it)"><FolderOpen />{{ t('cloudBindBtn') }}</button>
</div>
</div>
</section>
<!-- 展开三张统计大卡折叠一行小卡片语言分布区一并收起 -->
<template v-if="!statsCollapsed">
<div class="stats-head-bar">
<small>{{ t('statsOverview') }}</small>
<button type="button" class="stats-toggle" @click="toggleStats"><ChevronUp />{{ t('statsCollapseBtn') }}</button>
</div>
<div class="stats-grid three">
<StatCard :icon="Folder" :value="filteredDashboard.projects" :label="t('totalProjects')" />
<StatCard :icon="Code2" tone="green" :value="fmt(filteredDashboard.totalLines)" :label="t('totalLines')" />
<StatCard :icon="GitCommitHorizontal" tone="blue" :value="fmt(filteredDashboard.commits)" :label="t('commits')" />
</div>
</template>
<div v-else class="stats-mini">
<span class="mini-stat"><i class="tone"><Folder /></i><b>{{ filteredDashboard.projects }}</b><small>{{ t('totalProjects') }}</small></span>
<span class="mini-stat"><i class="tone green"><Code2 /></i><b>{{ fmt(filteredDashboard.totalLines) }}</b><small>{{ t('totalLines') }}</small></span>
<span class="mini-stat"><i class="tone blue"><GitCommitHorizontal /></i><b>{{ fmt(filteredDashboard.commits) }}</b><small>{{ t('commits') }}</small></span>
<span v-if="languages.length" class="mini-stat langs"><i class="tone purple"><PieChart /></i>
<em v-for="(x, i) in languages.slice(0, 3)" :key="x.name"><s :style="{ background: langColors[i % langColors.length] }" />{{ x.name }} {{ langTotal ? Math.round(x.code / langTotal * 100) : 0 }}%</em>
</span>
<button type="button" class="stats-toggle" :title="t('statsExpandBtn')" @click="toggleStats"><ChevronDown />{{ t('statsExpandBtn') }}</button>
</div>
<section v-if="store.batchSummary" class="panel batch-summary" :class="{ 'has-failures': store.batchSummary.failed }">
<header>
<h2><CheckCircle2 v-if="!store.batchSummary.failed" class="ok" /><CircleX v-else class="bad" />{{ t('batchSummaryTitle') }}</h2>
<button class="batch-close" :title="t('cancel')" @click="store.batchSummary = null"><X /></button>
</header>
<div class="batch-counts">
<span>{{ t('batchTotal', { n: store.batchSummary.total }) }}</span>
<span class="ok"><CheckCircle2 />{{ store.batchSummary.completed }} {{ t('batchOk') }}</span>
<span class="bad" v-if="store.batchSummary.failed"><CircleX />{{ store.batchSummary.failed }} {{ t('batchFail') }}</span>
<span class="skip" v-if="store.batchSummary.cancelled"><Ban />{{ store.batchSummary.cancelled }} {{ t('batchCancelled') }}</span>
</div>
<ul v-if="store.batchSummary.failures?.length" class="batch-failures">
<li v-for="f in store.batchSummary.failures" :key="f">{{ f }}</li>
</ul>
</section>
<div v-if="!statsCollapsed && languages.length" class="split lang-overview">
<section class="panel shine-card">
<h2><PieChart class="panel-icon" />{{ t('languageDistribution') }}<small>{{ store.selectedProjectGroupId ? groupLabel(store.projectGroups.find(g => g.id === store.selectedProjectGroupId)) : t('allProjectGroups') }}</small></h2>
<ChartView :option="langPie" />
</section>
<section class="panel shine-card">
<h2>{{ t('languageDetails') }}<small>{{ fmt(langTotal) }} {{ t('lines') }}</small></h2>
<div class="lang-rank">
<div v-for="(x, i) in languages.slice(0, 8)" :key="x.name" class="lang-rank-row">
<i :style="{ background: langColors[i % langColors.length] }" />
<b>{{ x.name }}</b>
<span>{{ fmt(x.files) }} {{ t('files') }}</span>
<em><s :style="{ width: (x.code / Math.max(1, languages[0]?.code) * 100) + '%', background: langColors[i % langColors.length] }" /></em>
<strong>{{ fmt(x.code) }}</strong>
<small>{{ langTotal ? Math.round(x.code / langTotal * 100) : 0 }}%</small>
</div>
</div>
</section>
</div> </div>
<div class="section-head"> <div class="section-head">
<h2>{{ t('projects') }}</h2> <h2>{{ t('projects') }}</h2>
@@ -185,10 +353,11 @@ async function refreshProject(p) {
</div> </div>
</div> </div>
<div class="project-grid"> <div class="project-grid">
<article v-for="p in filtered" :key="p.id" class="project-card shine-card" @click="router.push('/project/' + p.id)"> <article v-for="p in filtered" :key="p.id" class="project-card shine-card" :class="{ favorited: store.favorites.includes(p.id) }" @click="router.push('/project/' + p.id)">
<div class="project-title"> <div class="project-title">
<div><h3>{{ p.name }}</h3><span class="group-chip">{{ p.groupId === 1 ? t('myProjectGroup') : (p.groupName || t('projectGroup')) }}</span><p :title="p.path">{{ p.path }}</p></div> <div><h3>{{ p.name }}</h3><span class="group-chip">{{ p.groupId === 1 ? t('myProjectGroup') : (p.groupName || t('projectGroup')) }}</span><p :title="p.path">{{ p.path }}</p></div>
<div class="icon-actions"> <div class="icon-actions">
<button class="wb-star" :class="{ active: store.favorites.includes(p.id) }" :title="store.favorites.includes(p.id) ? t('unfavorite') : t('favorite')" @click.stop="store.toggleFavorite(p.id)"><Star /></button>
<button :title="t('refreshProject')" :disabled="projectRunning(p.id)" @click.stop="refreshProject(p)"><RefreshCw :class="{ spin: projectRunning(p.id) }" /></button> <button :title="t('refreshProject')" :disabled="projectRunning(p.id)" @click.stop="refreshProject(p)"><RefreshCw :class="{ spin: projectRunning(p.id) }" /></button>
<button :title="t('edit')" @click.stop="open(p)"><Pencil /></button> <button :title="t('edit')" @click.stop="open(p)"><Pencil /></button>
<button :title="t('delete')" @click.stop="remove(p)"><Trash2 /></button> <button :title="t('delete')" @click.stop="remove(p)"><Trash2 /></button>
@@ -213,19 +382,27 @@ async function refreshProject(p) {
<div v-if="modal" class="overlay" @click.self="!saving && (modal = false)"> <div v-if="modal" class="overlay" @click.self="!saving && (modal = false)">
<form class="modal" @submit.prevent="save"> <form class="modal" @submit.prevent="save">
<header><h2>{{ editing ? t('editProjectTitle') : t('addProjectTitle') }}</h2><button type="button" :disabled="saving" @click="modal = false">×</button></header> <header><h2>{{ editing ? t('editProjectTitle') : t('addProjectTitle') }}</h2><button type="button" :disabled="saving" @click="modal = false">×</button></header>
<div v-if="!editing" class="src-switch">
<button type="button" :class="{ active: srcMode === 'local' }" :disabled="saving" @click="srcMode = 'local'"><FolderOpen />{{ t('srcLocal') }}</button>
<button type="button" :class="{ active: srcMode === 'git' }" :disabled="saving" @click="srcMode = 'git'"><GitCommitHorizontal />{{ t('srcGit') }}</button>
</div>
<label v-if="!editing && srcMode === 'git'">{{ t('cloneUrl') }}<input v-model="gitForm.url" placeholder="https://github.com/user/repo.git" :disabled="saving" @change="onGitUrl" /></label>
<label>{{ t('projectName') }}<input v-model="form.name" :disabled="saving" /></label> <label>{{ t('projectName') }}<input v-model="form.name" :disabled="saving" /></label>
<label>{{ t('projectGroup') }}<select v-model.number="form.groupId" :disabled="saving"><option v-for="g in store.projectGroups" :key="g.id" :value="g.id">{{ groupLabel(g) }}</option></select></label> <label>{{ t('projectGroup') }}<select v-model.number="form.groupId" :disabled="saving"><option v-for="g in store.projectGroups" :key="g.id" :value="g.id">{{ groupLabel(g) }}</option></select></label>
<label>{{ t('projectPath') }}<div class="browse"><input v-model="form.path" :disabled="saving" required /><button type="button" class="btn secondary" :disabled="saving" @click="browse"><FolderOpen />{{ t('browse') }}</button></div></label> <template v-if="editing || srcMode === 'local'">
<div class="wsl-picker hidden-wsl-picker"> <label>{{ t('projectPath') }}<div class="browse"><input v-model="form.path" :disabled="saving" required /><button type="button" class="btn secondary" :disabled="saving" @click="browse"><FolderOpen />{{ t('browse') }}</button></div></label>
<button v-if="!wslDistros.length" type="button" class="btn secondary" @click="loadWSL">{{ t('selectWSL') }}</button> <div class="wsl-picker hidden-wsl-picker">
<template v-else> <button v-if="!wslDistros.length" type="button" class="btn secondary" @click="loadWSL">{{ t('selectWSL') }}</button>
<select v-model="wslDistro"><option v-for="d in wslDistros" :key="d">{{ d }}</option></select> <template v-else>
<button type="button" class="btn secondary" @click="browseWSL"><FolderOpen />{{ t('selectWSL') }}</button> <select v-model="wslDistro"><option v-for="d in wslDistros" :key="d">{{ d }}</option></select>
</template> <button type="button" class="btn secondary" @click="browseWSL"><FolderOpen />{{ t('selectWSL') }}</button>
</div> </template>
</div>
</template>
<label v-else>{{ t('cloneDir') }}<div class="browse"><input v-model="gitForm.parentDir" :placeholder="t('cloneDirHint')" :disabled="saving" /><button type="button" class="btn secondary" :disabled="saving" @click="browseParent"><FolderOpen />{{ t('browse') }}</button></div></label>
<label>{{ t('description') }}<textarea v-model="form.description" :disabled="saving" /></label> <label>{{ t('description') }}<textarea v-model="form.description" :disabled="saving" /></label>
<p v-if="error" class="form-error">{{ error }}</p> <p v-if="error" class="form-error">{{ error }}</p>
<footer><button type="button" class="btn secondary" :disabled="saving" @click="modal = false">{{ t('cancel') }}</button><button class="btn primary" :disabled="saving"><RefreshCw v-if="saving" class="spin" />{{ saving ? t('saving') : t('saveProject') }}</button></footer> <footer><button type="button" class="btn secondary" :disabled="saving" @click="modal = false">{{ t('cancel') }}</button><button class="btn primary" :disabled="saving"><RefreshCw v-if="saving" class="spin" />{{ saving ? (srcMode === 'git' && !editing ? t('cloning') : t('saving')) : (srcMode === 'git' && !editing ? t('cloneAndAdd') : t('saveProject')) }}</button></footer>
</form> </form>
</div> </div>
<div v-if="groupModal" class="overlay" @click.self="!groupSaving && (groupModal = false)"> <div v-if="groupModal" class="overlay" @click.self="!groupSaving && (groupModal = false)">
@@ -236,5 +413,14 @@ async function refreshProject(p) {
<footer><button type="button" class="btn secondary" :disabled="groupSaving" @click="groupModal = false">{{ t('cancel') }}</button><button class="btn primary" :disabled="groupSaving"><RefreshCw v-if="groupSaving" class="spin" />{{ groupSaving ? t('saving') : t('saveProjectGroup') }}</button></footer> <footer><button type="button" class="btn secondary" :disabled="groupSaving" @click="groupModal = false">{{ t('cancel') }}</button><button class="btn primary" :disabled="groupSaving"><RefreshCw v-if="groupSaving" class="spin" />{{ groupSaving ? t('saving') : t('saveProjectGroup') }}</button></footer>
</form> </form>
</div> </div>
<div v-if="bindTarget" class="overlay" @click.self="!bindBusy && (bindTarget = null)">
<form class="modal compact-modal" @submit.prevent="bindNow">
<header><h2>{{ t('cloudBindTitle') }}</h2><button type="button" :disabled="bindBusy" @click="bindTarget = null">&times;</button></header>
<label>{{ t('projectName') }}<input :value="bindTarget.name" disabled /></label>
<label>{{ t('projectPath') }}<div class="browse"><input v-model="bindDir" :disabled="bindBusy" required /><button type="button" class="btn secondary" :disabled="bindBusy" @click="browseBind"><FolderOpen />{{ t('browse') }}</button></div></label>
<p v-if="bindErr" class="form-error">{{ bindErr }}</p>
<footer><button type="button" class="btn secondary" :disabled="bindBusy" @click="bindTarget = null">{{ t('cancel') }}</button><button class="btn primary" :disabled="bindBusy"><RefreshCw v-if="bindBusy" class="spin" />{{ bindBusy ? t('saving') : t('cloudBindBtn') }}</button></footer>
</form>
</div>
</Teleport> </Teleport>
</template> </template>

View File

@@ -1,47 +1,176 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { RefreshCw, Trash2, ScrollText, Info, TriangleAlert, CircleX } from 'lucide-vue-next' import { RefreshCw, Trash2, ScrollText, Info, TriangleAlert, CircleX, Search, ChevronLeft, ChevronRight, ChevronDown, Copy, Check, Sparkles } from 'lucide-vue-next'
import { call } from '../api' import { call } from '../api'
import StatCard from '../components/StatCard.vue' import StatCard from '../components/StatCard.vue'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
const { t } = useI18n() const { t } = useI18n()
const logs = ref([]) const aiOpen = ref(false)
const filter = ref('all') const page = ref({ items: [], total: 0, info: 0, warning: 0, error: 0 })
const query = ref('')
const level = ref('all')
const category = ref('all')
const categories = ref([])
const pageIndex = ref(0)
const pageSize = 50
const auto = ref(true) const auto = ref(true)
const expanded = ref(new Set())
const loading = ref(false)
const copiedId = ref(0)
let timer let timer
const shown = computed(() => filter.value === 'all' ? logs.value : logs.value.filter(x => filter.value === 'run' ? x.level !== 'error' : x.level === 'error')) let debounce
const counts = computed(() => ({ let copyTimer
all: logs.value.length,
info: logs.value.filter(x => x.level === 'info').length, const pageCount = () => Math.max(1, Math.ceil(page.value.total / pageSize))
warning: logs.value.filter(x => x.level === 'warning').length,
error: logs.value.filter(x => x.level === 'error').length // 按本地日期分组,头部显示 今天/昨天/日期。
})) const localDate = s => {
async function load() { logs.value = await call('GetLogs', 'all') } const d = new Date(s)
return isNaN(d) ? String(s).slice(0, 10) : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const groups = computed(() => {
const out = []
let cur = null
for (const x of page.value.items) {
const d = localDate(x.createdAt)
if (!cur || cur.date !== d) { cur = { date: d, items: [] }; out.push(cur) }
cur.items.push(x)
}
return out
})
function dayLabel(d) {
const now = new Date()
const today = localDate(now)
now.setDate(now.getDate() - 1)
if (d === today) return t('today')
if (d === localDate(now)) return t('yesterday')
return d
}
const fullTime = s => String(s || '').replace('T', ' ').slice(0, 19)
function relTime(s) {
const ms = Date.now() - new Date(s).getTime()
if (!isFinite(ms) || ms < 0) return fullTime(s).slice(11, 16)
const m = Math.floor(ms / 60000)
if (m < 1) return t('justNow')
if (m < 60) return t('minAgo', { n: m })
const h = Math.floor(m / 60)
if (h < 24) return t('hourAgo', { n: h })
const d = Math.floor(h / 24)
if (d < 7) return t('dayAgo', { n: d })
return fullTime(s).slice(5, 16)
}
async function load() {
loading.value = true
try {
page.value = await call('SearchLogs', query.value, level.value, category.value, pageIndex.value * pageSize, pageSize)
categories.value = await call('GetLogCategories')
} finally {
loading.value = false
}
}
function setLevel(v) {
level.value = level.value === v ? 'all' : v
pageIndex.value = 0
load()
}
function go(delta) {
const next = pageIndex.value + delta
if (next < 0 || next >= pageCount()) return
pageIndex.value = next
load()
}
function toggle(id) {
const s = new Set(expanded.value)
s.has(id) ? s.delete(id) : s.add(id)
expanded.value = s
}
async function copyDetail(x) {
try {
await navigator.clipboard.writeText(`[${x.level}] ${x.category} ${fullTime(x.createdAt)}\n${x.message}\n${x.detail}`)
copiedId.value = x.id
clearTimeout(copyTimer)
copyTimer = setTimeout(() => { copiedId.value = 0 }, 1500)
} catch {}
}
async function clear() { async function clear() {
if (confirm(t('clearLogs') + '?')) { if (confirm(t('clearLogs') + '?')) {
await call('ClearLogs') await call('ClearLogs')
pageIndex.value = 0
await load() await load()
} }
} }
watch(query, () => {
clearTimeout(debounce)
debounce = setTimeout(() => { pageIndex.value = 0; load() }, 300)
})
watch(category, () => { pageIndex.value = 0; load() })
onMounted(async () => { onMounted(async () => {
await load() await load()
timer = setInterval(() => auto.value && load(), 5000) timer = setInterval(() => auto.value && !query.value && pageIndex.value === 0 && load(), 5000)
}) })
onUnmounted(() => clearInterval(timer)) onUnmounted(() => { clearInterval(timer); clearTimeout(debounce); clearTimeout(copyTimer) })
</script> </script>
<template> <template>
<div class="page"> <div class="page logs-page">
<header class="page-head sticky-head"> <header class="page-head sticky-head">
<div><h1>{{ t('logs') }}</h1><p>{{ t('logSubtitle') }}</p></div> <div><h1>{{ t('logs') }}</h1><p>{{ t('logSubtitle') }}</p></div>
<div class="actions"><label class="toggle"><input type="checkbox" v-model="auto" />{{ t('autoRefresh') }}</label><button class="btn secondary" @click="load"><RefreshCw />{{ t('refresh') }}</button><button class="btn danger" @click="clear"><Trash2 />{{ t('clearLogs') }}</button></div> <div class="actions">
<label class="toggle"><input type="checkbox" v-model="auto" />{{ t('autoRefresh') }}</label>
<button class="btn secondary" @click="aiOpen = true"><Sparkles />{{ t('aiScopeBtn') }}</button>
<button class="btn secondary" @click="load"><RefreshCw :class="{ spin: loading }" />{{ t('refresh') }}</button>
<button class="btn danger" @click="clear"><Trash2 />{{ t('clearLogs') }}</button>
</div>
</header> </header>
<div class="stats-grid four"><StatCard :icon="ScrollText" :value="counts.all" :label="t('totalLogs')" /><StatCard :icon="Info" :value="counts.info" :label="t('info')" /><StatCard :icon="TriangleAlert" :value="counts.warning" :label="t('warning')" /><StatCard :icon="CircleX" :value="counts.error" :label="t('error')" /></div> <div class="stats-grid four">
<div class="tabs compact"><button :class="{ active: filter === 'all' }" @click="filter = 'all'">{{ t('allLogs') }}</button><button :class="{ active: filter === 'run' }" @click="filter = 'run'">{{ t('runLogs') }}</button><button :class="{ active: filter === 'error' }" @click="filter = 'error'">{{ t('errorLogs') }}</button></div> <StatCard :icon="ScrollText" :value="page.total" :label="t('totalLogs')" />
<StatCard :icon="Info" tone="blue" :value="page.info" :label="t('info')" />
<StatCard :icon="TriangleAlert" :value="page.warning" :label="t('warning')" />
<StatCard :icon="CircleX" tone="red" :value="page.error" :label="t('error')" />
</div>
<div class="log-toolbar">
<label class="search log-search"><Search /><input v-model="query" :placeholder="t('searchLogs')" /></label>
<div class="log-chips" role="group">
<button class="log-chip info" :class="{ active: level === 'info' }" @click="setLevel('info')"><Info />{{ t('info') }}<b>{{ page.info }}</b></button>
<button class="log-chip warning" :class="{ active: level === 'warning' }" @click="setLevel('warning')"><TriangleAlert />{{ t('warning') }}<b>{{ page.warning }}</b></button>
<button class="log-chip error" :class="{ active: level === 'error' }" @click="setLevel('error')"><CircleX />{{ t('error') }}<b>{{ page.error }}</b></button>
</div>
<select v-model="category" class="log-category">
<option value="all">{{ t('allCategories') }}</option>
<option v-for="c in categories" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<section class="panel log-panel"> <section class="panel log-panel">
<article class="log" v-for="x in shown" :key="x.id" :class="x.level"><span><Info v-if="x.level === 'info'" /><TriangleAlert v-else-if="x.level === 'warning'" /><CircleX v-else /></span><div><small>{{ x.category }}</small><b>{{ x.message }}</b><code v-if="x.detail">{{ x.detail }}</code></div><time>{{ x.createdAt?.replace('T', ' ').slice(0, 19) }}</time></article> <div class="log-rows">
<div v-if="!shown.length" class="empty"><ScrollText />{{ t('noLogs') }}</div> <template v-for="g in groups" :key="g.date">
<div class="log-day"><span>{{ dayLabel(g.date) }}</span><i /></div>
<article v-for="x in g.items" :key="x.id" class="log-row" :class="[x.level, { open: expanded.has(x.id), clickable: !!x.detail }]">
<button class="log-row-main" @click="x.detail && toggle(x.id)">
<span class="log-badge"><Info v-if="x.level === 'info'" /><TriangleAlert v-else-if="x.level === 'warning'" /><CircleX v-else /></span>
<b class="log-msg">{{ x.message }}</b>
<small class="log-cat">{{ x.category }}</small>
<time :title="fullTime(x.createdAt)">{{ relTime(x.createdAt) }}</time>
<ChevronDown v-if="x.detail" class="log-caret" />
</button>
<div v-if="x.detail && expanded.has(x.id)" class="log-detail-wrap">
<code class="log-detail">{{ x.detail }}</code>
<button class="log-copy" :class="{ ok: copiedId === x.id }" @click.stop="copyDetail(x)">
<Check v-if="copiedId === x.id" /><Copy v-else />{{ copiedId === x.id ? t('copiedShort') : t('copyBtn') }}
</button>
</div>
</article>
</template>
</div>
<div v-if="!page.items.length" class="empty log-empty"><ScrollText /><b>{{ t('noLogs') }}</b><small>{{ t('noLogsHint') }}</small></div>
<footer v-if="page.total > pageSize" class="log-pager">
<button class="btn secondary" :disabled="pageIndex === 0" @click="go(-1)"><ChevronLeft /></button>
<span>{{ pageIndex + 1 }} / {{ pageCount() }}</span>
<button class="btn secondary" :disabled="pageIndex + 1 >= pageCount()" @click="go(1)"><ChevronRight /></button>
</footer>
</section> </section>
<AIScopeDrawer v-if="aiOpen" kind="logs" :title="t('logs')" @close="aiOpen = false" />
</div> </div>
</template> </template>

View File

@@ -2,12 +2,15 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ArrowLeft, Code2, GitCommitHorizontal, FolderTree, RefreshCw, Files, MessageSquareText, Rows3, Users, Plus, Minus, HardDrive, Folder, FileWarning, GitBranch, ExternalLink, TriangleAlert, ClipboardCheck } from 'lucide-vue-next' import { ArrowLeft, Code2, GitCommitHorizontal, FolderTree, RefreshCw, Files, MessageSquareText, Rows3, Users, Plus, Minus, HardDrive, Folder, FileWarning, GitBranch, ExternalLink, TriangleAlert, ClipboardCheck, Flame, Sparkles, MessageCircleQuestion } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue' import StatCard from '../components/StatCard.vue'
import ChartView from '../components/ChartView.vue' import ChartView from '../components/ChartView.vue'
import GitHeatmap from '../components/GitHeatmap.vue' import GitHeatmap from '../components/GitHeatmap.vue'
import GitTrend from '../components/GitTrend.vue' import GitTrend from '../components/GitTrend.vue'
import CommitDrawer from '../components/CommitDrawer.vue' import CommitDrawer from '../components/CommitDrawer.vue'
import FilePreviewModal from '../components/FilePreviewModal.vue'
import AIBrief from '../components/AIBrief.vue'
import ProjectAIDrawer from '../components/ProjectAIDrawer.vue'
import { call } from '../api' import { call } from '../api'
import { useAppStore } from '../store' import { useAppStore } from '../store'
@@ -29,8 +32,21 @@ const issueSeverity = ref('all')
const issueType = ref('all') const issueType = ref('all')
const detail = ref(null) const detail = ref(null)
const detailLoading = ref(false) const detailLoading = ref(false)
const preview = ref('')
const previewLine = ref(0)
// AI 问答抽屉:详情页内的“二级页面”,不跳转
const aiDrawer = ref(false)
const aiAsk = ref('')
const askText = ref('')
// 这些检查项的 path 是真实源码文件folder_concentration 是目录、large_commit 是提交哈希,不可预览)
const fileIssueTypes = ['large_file', 'long_file', 'todo_marker']
const colors = ['#7b73ff', '#4fd1a1', '#4da5ff', '#f4c84a', '#ef6683', '#23b5d3'] const colors = ['#7b73ff', '#4fd1a1', '#4da5ff', '#f4c84a', '#ef6683', '#23b5d3']
function openPreview(path, line = 0) {
previewLine.value = line
preview.value = path
}
const fmt = n => { const fmt = n => {
n = +n || 0 n = +n || 0
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString() return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString()
@@ -101,6 +117,11 @@ async function showCommit(c) {
detail.value = { ...c, files: [] } detail.value = { ...c, files: [] }
try { detail.value = await call('GetCommitDetails', +route.params.id, c.hash) } finally { detailLoading.value = false } try { detail.value = await call('GetCommitDetails', +route.params.id, c.hash) } finally { detailLoading.value = false }
} }
function openChat(text) {
aiAsk.value = (text || '').trim()
askText.value = ''
aiDrawer.value = true
}
onMounted(load) onMounted(load)
</script> </script>
@@ -108,14 +129,16 @@ onMounted(load)
<div class="page detail-page"> <div class="page detail-page">
<header class="project-head sticky-head detail-sticky-head"> <header class="project-head sticky-head detail-sticky-head">
<div class="detail-head-row"> <div class="detail-head-row">
<button class="back" @click="router.push('/')"><ArrowLeft />{{ t('back') }}</button> <button class="back" @click="router.push('/projects')"><ArrowLeft />{{ t('back') }}</button>
<div><h1>{{ p.name }}</h1><p>{{ p.path }}</p></div> <div><h1>{{ p.name }}</h1><p>{{ p.path }}</p></div>
<button class="btn secondary ai-entry" @click="openChat()"><Sparkles />{{ t('aiAskGo') }}</button>
</div> </div>
<div class="tabs detail-tabs"> <div class="tabs detail-tabs">
<button :class="{ active: tab === 'code' }" @click="tab = 'code'"><Code2 />{{ t('code') }}</button> <button :class="{ active: tab === 'code' }" @click="tab = 'code'"><Code2 />{{ t('code') }}</button>
<button :class="{ active: tab === 'git' }" @click="tab = 'git'"><GitCommitHorizontal />{{ t('git') }}</button> <button :class="{ active: tab === 'git' }" @click="tab = 'git'"><GitCommitHorizontal />{{ t('git') }}</button>
<button :class="{ active: tab === 'structure' }" @click="tab = 'structure'"><FolderTree />{{ t('structure') }}</button> <button :class="{ active: tab === 'structure' }" @click="tab = 'structure'"><FolderTree />{{ t('structure') }}</button>
<button :class="{ active: tab === 'insights' }" @click="tab = 'insights'"><ClipboardCheck />{{ t('insights') }}</button> <button :class="{ active: tab === 'insights' }" @click="tab = 'insights'"><ClipboardCheck />{{ t('insights') }}</button>
<button :class="{ active: tab === 'ai' }" @click="tab = 'ai'"><Sparkles />{{ t('aiAnalysis') }}</button>
</div> </div>
</header> </header>
@@ -156,6 +179,19 @@ onMounted(load)
<section class="panel structure-scroll-panel"><h2>{{ t('recentCommits') }}</h2><button class="commit" v-for="c in visibleCommits.slice(0, 30)" :key="c.hash" @click="showCommit(c)"><span class="avatar">{{ c.author?.[0] }}</span><div><b>{{ c.message }}</b><small>{{ c.author }} · {{ c.hash.slice(0, 7) }} · {{ c.date?.slice(0, 10) }}</small></div><span class="positive">+{{ c.added }}</span><span class="negative">-{{ c.deleted }}</span></button><div v-if="!visibleCommits.length" class="empty">{{ t('empty') }}</div></section> <section class="panel structure-scroll-panel"><h2>{{ t('recentCommits') }}</h2><button class="commit" v-for="c in visibleCommits.slice(0, 30)" :key="c.hash" @click="showCommit(c)"><span class="avatar">{{ c.author?.[0] }}</span><div><b>{{ c.message }}</b><small>{{ c.author }} · {{ c.hash.slice(0, 7) }} · {{ c.date?.slice(0, 10) }}</small></div><span class="positive">+{{ c.added }}</span><span class="negative">-{{ c.deleted }}</span></button><div v-if="!visibleCommits.length" class="empty">{{ t('empty') }}</div></section>
</div> </div>
<section class="panel trend shine-card"><h2>{{ t('commitTrend') }}</h2><GitTrend :commits="git.commits" @select="selectedDate = $event" /></section> <section class="panel trend shine-card"><h2>{{ t('commitTrend') }}</h2><GitTrend :commits="git.commits" @select="selectedDate = $event" /></section>
<section v-if="git.hotspots?.length" class="panel shine-card hotspot-panel">
<h2><Flame class="hotspot-icon" />{{ t('fileHotspots') }}<small>{{ t('fileHotspotsHint') }}</small></h2>
<div class="hotspot-list">
<button v-for="(h, i) in git.hotspots.slice(0, 20)" :key="h.path" class="hotspot-row" :title="h.path" @click="openPreview(h.path)">
<b>#{{ i + 1 }}</b>
<span class="hotspot-path">{{ h.path }}</span>
<i class="hotspot-bar"><em :style="{ width: (h.changes / Math.max(1, git.hotspots[0]?.changes) * 100) + '%' }" /></i>
<strong>{{ h.changes }} {{ t('changesUnit') }}</strong>
<span class="positive">+{{ fmt(h.added) }}</span>
<span class="negative">-{{ fmt(h.deleted) }}</span>
</button>
</div>
</section>
<section class="panel shine-card"><h2>{{ t('contributorRanking') }}</h2><div class="contributor" v-for="(c, i) in git.contributors" :key="c.email"><b>#{{ i + 1 }}</b><span class="avatar">{{ c.name?.[0] }}</span><div><strong>{{ c.name }}</strong><small>{{ c.email }}</small></div><span>{{ c.commits }} {{ t('commitsUnit') }}</span><span class="positive">+{{ fmt(c.added) }}</span><span class="negative">-{{ fmt(c.deleted) }}</span></div><div v-if="!git.contributors?.length" class="empty">{{ t('empty') }}</div></section> <section class="panel shine-card"><h2>{{ t('contributorRanking') }}</h2><div class="contributor" v-for="(c, i) in git.contributors" :key="c.email"><b>#{{ i + 1 }}</b><span class="avatar">{{ c.name?.[0] }}</span><div><strong>{{ c.name }}</strong><small>{{ c.email }}</small></div><span>{{ c.commits }} {{ t('commitsUnit') }}</span><span class="positive">+{{ fmt(c.added) }}</span><span class="negative">-{{ fmt(c.deleted) }}</span></div><div v-if="!git.contributors?.length" class="empty">{{ t('empty') }}</div></section>
<CommitDrawer v-if="detail" :detail="detail" :loading="detailLoading" @close="detail = null" /> <CommitDrawer v-if="detail" :detail="detail" :loading="detailLoading" @close="detail = null" />
</template> </template>
@@ -168,12 +204,12 @@ onMounted(load)
<StatCard :icon="FileWarning" tone="red" :value="structure.largeFiles?.length || 0" :label="t('largeFiles')" /> <StatCard :icon="FileWarning" tone="red" :value="structure.largeFiles?.length || 0" :label="t('largeFiles')" />
</div> </div>
<div class="split structure-split"> <div class="split structure-split">
<section class="panel structure-panel"><h2>{{ t('directoryStructure') }}</h2><div class="file-tree"><div v-for="f in structure.files?.slice(0, 300)" :key="f.path" :style="{ paddingLeft: Math.min((f.path.split('/').length - 1) * 16, 160) + 'px' }"><Folder v-if="f.isDir" /><Files v-else /><span :title="f.path">{{ f.name }}</span><small>{{ f.isDir ? '' : bytes(f.size) }}</small></div></div></section> <section class="panel structure-panel"><h2>{{ t('directoryStructure') }}</h2><div class="file-tree"><div v-for="f in structure.files?.slice(0, 300)" :key="f.path" :class="{ 'file-clickable': !f.isDir }" :style="{ paddingLeft: Math.min((f.path.split('/').length - 1) * 16, 160) + 'px' }" @click="!f.isDir && openPreview(f.path)"><Folder v-if="f.isDir" /><Files v-else /><span :title="f.path">{{ f.name }}</span><small>{{ f.isDir ? '' : bytes(f.size) }}</small></div></div></section>
<section class="panel structure-panel"><h2>{{ t('folderSize') }}</h2><div class="folder-size" v-for="f in structure.folders" :key="f.name"><b :title="f.name">{{ f.name }}</b><span>{{ f.files }} {{ t('files') }}</span><i><em :style="{ width: (f.size / Math.max(1, structure.folders[0]?.size) * 100) + '%' }" /></i><strong>{{ bytes(f.size) }}</strong></div></section> <section class="panel structure-panel"><h2>{{ t('folderSize') }}</h2><div class="folder-size" v-for="f in structure.folders" :key="f.name"><b :title="f.name">{{ f.name }}</b><span>{{ f.files }} {{ t('files') }}</span><i><em :style="{ width: (f.size / Math.max(1, structure.folders[0]?.size) * 100) + '%' }" /></i><strong>{{ bytes(f.size) }}</strong></div></section>
</div> </div>
<section class="panel large-file-panel"><h2>{{ t('largeFileDetection') }}</h2><div class="large-file" v-for="f in structure.largeFiles" :key="f.path"><div><b>{{ f.name }}</b><small>{{ f.path }}</small></div><strong>{{ bytes(f.size) }}</strong></div><div v-if="!structure.largeFiles?.length" class="empty">{{ t('noLargeFiles') }}</div></section> <section class="panel large-file-panel"><h2>{{ t('largeFileDetection') }}</h2><div class="large-file file-clickable" v-for="f in structure.largeFiles" :key="f.path" @click="openPreview(f.path)"><div><b>{{ f.name }}</b><small>{{ f.path }}</small></div><strong>{{ bytes(f.size) }}</strong></div><div v-if="!structure.largeFiles?.length" class="empty">{{ t('noLargeFiles') }}</div></section>
</template> </template>
<template v-else> <template v-else-if="tab === 'insights'">
<section class="panel insights-hero shine-card"> <section class="panel insights-hero shine-card">
<div class="score-ring" :style="{ '--score': insights.healthScore || 0 }"><strong>{{ insights.healthScore || 0 }}</strong><span>{{ t('healthScore') }}</span></div> <div class="score-ring" :style="{ '--score': insights.healthScore || 0 }"><strong>{{ insights.healthScore || 0 }}</strong><span>{{ t('healthScore') }}</span></div>
<div class="insight-summary"> <div class="insight-summary">
@@ -199,12 +235,27 @@ onMounted(load)
<div class="issue-list"> <div class="issue-list">
<article v-for="issue in filteredIssues" :key="issue.type + issue.path + issue.line + issue.title" class="issue-card" :class="issue.severity"> <article v-for="issue in filteredIssues" :key="issue.type + issue.path + issue.line + issue.title" class="issue-card" :class="issue.severity">
<span class="issue-severity">{{ t('severity.' + issue.severity) }}</span> <span class="issue-severity">{{ t('severity.' + issue.severity) }}</span>
<div><h3>{{ issue.title }}</h3><p>{{ issue.detail }}</p><small v-if="issue.path">{{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template></small><code v-if="issue.evidence">{{ issue.evidence }}</code><b>{{ issue.suggestion }}</b></div> <div><h3>{{ issue.title }}</h3><p>{{ issue.detail }}</p><small v-if="issue.path" :class="{ 'issue-file': fileIssueTypes.includes(issue.type) }" :title="fileIssueTypes.includes(issue.type) ? t('clickPreview') : ''" @click="fileIssueTypes.includes(issue.type) && openPreview(issue.path, issue.line || 0)">{{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template></small><code v-if="issue.evidence">{{ issue.evidence }}</code><b>{{ issue.suggestion }}</b></div>
</article> </article>
<div v-if="!filteredIssues.length" class="empty"><ClipboardCheck />{{ t('noIssues') }}</div> <div v-if="!filteredIssues.length" class="empty"><ClipboardCheck />{{ t('noIssues') }}</div>
</div> </div>
</section> </section>
</template> </template>
<template v-else>
<section class="panel ai-ask-bar">
<Sparkles />
<input v-model="askText" :placeholder="t('aiAskThisProject')" @keyup.enter="openChat(askText)" />
<button class="btn primary" @click="openChat(askText)"><MessageCircleQuestion />{{ t('aiAskGo') }}</button>
</section>
<div class="ai-brief-grid">
<AIBrief v-if="p.id" :project-id="p.id" kind="project" />
<AIBrief v-if="p.id" :project-id="p.id" kind="git" />
<AIBrief v-if="p.id" :project-id="p.id" kind="structure" />
<AIBrief v-if="p.id" :project-id="p.id" kind="insights" />
</div>
</template>
<div class="center-action"><button class="btn secondary" @click="analyze()"><RefreshCw />{{ t('analyze') }}</button></div> <div class="center-action"><button class="btn secondary" @click="analyze()"><RefreshCw />{{ t('analyze') }}</button></div>
<FilePreviewModal v-if="preview" :project-id="+route.params.id" :path="preview" :line="previewLine" @close="preview = ''; previewLine = 0" />
<ProjectAIDrawer v-if="aiDrawer" :project-id="p.id" :project-name="p.name" :ask="aiAsk" @close="aiDrawer = false; aiAsk = ''" />
</div> </div>
</template> </template>

View File

@@ -1,19 +1,31 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { FileSliders, Database, Plus, Trash2, Upload, BarChart3, Folder, Languages, SunMoon, CheckCircle2, Info, Copy } from 'lucide-vue-next' import { Database, Plus, Trash2, Upload, BarChart3, Folder, Languages, CheckCircle2, Info, Copy, Timer, Rocket, Sparkles, CloudUpload, Wifi } from 'lucide-vue-next'
import { call, isNative } from '../api' import { call, isNative } from '../api'
import { useAppStore } from '../store' import { useAppStore } from '../store'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
const tab=ref(new URLSearchParams(location.search).get('settingsTab')||'rules'),rules=ref([]),form=reactive({pattern:'',category:'custom'}) const route=useRoute()
const settings=reactive({theme:'dark',locale:'zh-CN',gitScope:'current',databasePath:'',autoRefresh:true,glassOpacity:55,loadingStyle:'fullscreen-orbit'}) // tab 记忆:优先 URL 深链,其次上次停留的 tabsync 已迁往个人主页,做合法性回退)
const store=useAppStore(),{locale}=useI18n(),native=isNative(),dbMessage=ref('') // 分区切换入口在侧边栏“设置”一级导航的二级菜单,页内不再有 tabbar。
const TABS=['rules','appearance','ai','filestorage','database']
const TAB_TITLE={rules:'tabRules',appearance:'tabAppearance',ai:'aiAnalysis',filestorage:'tabFileStorage',database:'tabDatabase'}
const pickTab=v=>TABS.includes(String(v))?String(v):''
const tab=ref(pickTab(route.query.tab)||pickTab(localStorage.getItem('cc-settings-tab'))||'rules'),rules=ref([]),form=reactive({pattern:'',category:'custom'})
const aiOpen=ref(false)
watch(tab,v=>localStorage.setItem('cc-settings-tab',v))
const settings=reactive({theme:'dark',locale:'zh-CN',gitScope:'current',databasePath:'',autoRefresh:true,glassOpacity:55,loadingStyle:'fullscreen-orbit',minimizeToTray:true,autoUpdateEnabled:false,autoUpdateMode:'daily',autoUpdateInterval:1,autoUpdateTime:'09:00',aiProvider:'spark',sparkKey:'',deepSeekKey:'',syncApiKeys:false,avatarMode:'',avatarValue:'',imageMode:'base64'})
const store=useAppStore(),{locale,t}=useI18n(),native=isNative(),dbMessage=ref('')
const autostart=ref(false),autostartBusy=ref(false)
const groups=computed(()=>Object.groupBy?Object.groupBy(rules.value,x=>x.category):rules.value.reduce((a,x)=>((a[x.category]??=[]).push(x),a),{})) const groups=computed(()=>Object.groupBy?Object.groupBy(rules.value,x=>x.category):rules.value.reduce((a,x)=>((a[x.category]??=[]).push(x),a),{}))
const loadingOptions=[ const loadingOptions=[
{value:'fullscreen-orbit',title:'能量轨道',desc:'环形粒子、扫描光束和能量核心'}, {value:'fullscreen-orbit',title:'loadingStyleOrbit',desc:'loadingStyleOrbitDesc'},
{value:'fullscreen-grid',title:'数据矩阵',desc:'流动数据网格和聚合节点'}, {value:'fullscreen-grid',title:'loadingStyleGrid',desc:'loadingStyleGridDesc'},
{value:'fullscreen-warp',title:'光速跃迁',desc:'深空隧道、放射光束和跃迁环'}, {value:'fullscreen-warp',title:'loadingStyleWarp',desc:'loadingStyleWarpDesc'},
{value:'bar',title:'底部进度条',desc:'保留当前页面,只显示底部进度'} {value:'fullscreen-matrix',title:'loadingStyleMatrix',desc:'loadingStyleMatrixDesc'},
{value:'bar',title:'loadingStyleBar',desc:'loadingStyleBarDesc'}
] ]
async function load(){ async function load(){
@@ -21,7 +33,50 @@ async function load(){
const [saved,bootstrap]=await Promise.all([call('GetSettings'),call('GetBootstrapStatus')]) const [saved,bootstrap]=await Promise.all([call('GetSettings'),call('GetBootstrapStatus')])
Object.assign(settings,saved) Object.assign(settings,saved)
settings.databasePath=bootstrap.databasePath||saved.databasePath||bootstrap.defaultPath||'' settings.databasePath=bootstrap.databasePath||saved.databasePath||bootstrap.defaultPath||''
try{autostart.value=await call('GetAutostart')}catch{autostart.value=false}
} }
async function toggleAutostart(){
if(autostartBusy.value)return
autostartBusy.value=true
try{
await call('SetAutostart',!autostart.value)
autostart.value=!autostart.value
store.showToast({type:'success',key:autostart.value?'autostartOnToast':'autostartOffToast'})
}catch(e){store.showToast({type:'error',key:'autostartFail',params:{err:String(e)}})}
finally{autostartBusy.value=false}
}
// ---- 全局文件存储(仅管理员 id=1权威配置存远端 MySQL保存即全员生效 ----
const isAdmin=computed(()=>store.syncStatus.userId===1)
const fsCfg=reactive({mode:'local',baseUrl:'',apiKey:''})
const fsBusy=ref(''),fsLoaded=ref(false)
async function loadFileStorage(){
try{
const c=await call('GetFileStorageConfig')
Object.assign(fsCfg,{mode:c.mode||'local',baseUrl:c.baseUrl||'',apiKey:c.apiKey||''})
}catch{}
fsLoaded.value=true
}
async function saveFileStorage(){
if(fsBusy.value)return
fsBusy.value='save'
try{
await call('SaveFileStorageConfig',{...fsCfg})
store.showToast({type:'success',key:'fsSavedToast'})
}catch(e){store.showToast({type:'error',text:errText(e)})}
finally{fsBusy.value=''}
}
async function testFileStorage(){
if(fsBusy.value)return
fsBusy.value='test'
try{
await call('TestFileStorage',{...fsCfg})
store.showToast({type:'success',key:'fsTestOkToast'})
}catch(e){store.showToast({type:'error',text:errText(e)})}
finally{fsBusy.value=''}
}
const errText=e=>{const code=String(e).split(':')[0].trim();return t('errors.'+code)!=='errors.'+code?t('errors.'+code):String(e)}
watch(tab,v=>{if(v==='filestorage')loadFileStorage()})
async function add(){if(!form.pattern)return;await call('AddRule',form.pattern,form.category);form.pattern='';await load()} async function add(){if(!form.pattern)return;await call('AddRule',form.pattern,form.category);form.pattern='';await load()}
async function remove(r){if(!r.builtin){await call('DeleteRule',r.id);await load()}} async function remove(r){if(!r.builtin){await call('DeleteRule',r.id);await load()}}
async function save(){await call('SaveSettings',{...settings,databasePath:''});store.applyAppearance(settings);apply();localStorage.setItem('cc-settings',JSON.stringify(settings))} async function save(){await call('SaveSettings',{...settings,databasePath:''});store.applyAppearance(settings);apply();localStorage.setItem('cc-settings',JSON.stringify(settings))}
@@ -34,21 +89,68 @@ async function migrate(){
await call('MigrateDatabase',p) await call('MigrateDatabase',p)
const status=await call('GetBootstrapStatus') const status=await call('GetBootstrapStatus')
settings.databasePath=status.databasePath settings.databasePath=status.databasePath
dbMessage.value='数据库已迁移并切换到新位置' dbMessage.value=t('dbMigratedMsg')
store.showToast({type:'success',text:dbMessage.value}) store.showToast({type:'success',text:dbMessage.value})
}catch(e){dbMessage.value=String(e);store.showToast({type:'error',text:'数据库迁移失败'})} }catch(e){dbMessage.value=String(e);store.showToast({type:'error',key:'dbMigrateFail'})}
} }
async function copyPath(){try{await navigator.clipboard.writeText(settings.databasePath);store.showToast({type:'success',text:'数据库路径已复制'})}catch{store.showToast({type:'error',text:'无法复制路径'})}} async function copyPath(){try{await navigator.clipboard.writeText(settings.databasePath);store.showToast({type:'success',key:'dbPathCopied'})}catch{store.showToast({type:'error',key:'dbCopyFail'})}}
async function clear(mode){const id=mode==='project'?Number(prompt('项目 ID')):0;if(mode==='project'&&!id)return;if(confirm('此操作不可恢复,确认继续?')){await call('ClearData',mode,id);await store.refresh()}} async function clear(mode){const id=mode==='project'?Number(prompt(t('projectIdPrompt'))):0;if(mode==='project'&&!id)return;if(confirm(t('confirmIrreversible'))){await call('ClearData',mode,id);await store.refresh()}}
watch(()=>[settings.theme,settings.locale,settings.glassOpacity,settings.loadingStyle],save) watch(()=>[settings.theme,settings.locale,settings.glassOpacity,settings.loadingStyle,settings.gitScope,settings.minimizeToTray,settings.autoUpdateEnabled,settings.autoUpdateMode,settings.autoUpdateInterval,settings.autoUpdateTime,settings.aiProvider,settings.syncApiKeys,settings.avatarMode,settings.avatarValue,settings.imageMode],save)
onMounted(async()=>{await load();apply()}) let aiKeyTimer=null
watch(()=>[settings.sparkKey,settings.deepSeekKey],()=>{clearTimeout(aiKeyTimer);aiKeyTimer=setTimeout(save,600)})
watch(()=>route.query.tab,v=>{const n=pickTab(v);if(n)tab.value=n})
onMounted(async()=>{await load();apply();if(tab.value==='filestorage')loadFileStorage()})
onUnmounted(()=>{clearTimeout(aiKeyTimer)})
</script> </script>
<template><div class="page settings-page"> <template><div class="page settings-page">
<header class="page-head"><div><h1>设置</h1><p>管理排除规则界面和数据库配置</p></div></header> <header class="page-head sticky-head"><div><h1>{{t('settings')}} · {{t(TAB_TITLE[tab])}}</h1><p>{{t('settingsSubtitle')}}</p></div>
<div class="tabs settings-tabs"><button :class="{active:tab==='rules'}" @click="tab='rules'"><FileSliders/>排除规则</button><button :class="{active:tab==='appearance'}" @click="tab='appearance'"><SunMoon/>界面设置</button><button :class="{active:tab==='database'}" @click="tab='database'"><Database/>数据管理</button></div> <div class="actions"><button class="btn secondary" @click="aiOpen=true"><Sparkles/>{{t('aiScopeBtn')}}</button></div>
<template v-if="tab==='rules'"><section class="panel rule-add"><h2><Plus/>添加排除规则</h2><div><input v-model="form.pattern" placeholder="例如 *.log, cache, temp" @keyup.enter="add"/><select v-model="form.category"><option value="general">通用</option><option value="php">PHP</option><option value="go">Go</option><option value="vue">Vue/JS</option><option value="custom">自定义</option></select><button class="btn primary" @click="add"><Plus/>添加</button></div><small>支持 * 通配符;目录名会在任意层级匹配</small></section><section v-for="(items,name) in groups" :key="name" class="panel rule-group"><h2>{{name}} <small>{{items.length}} 条规则</small></h2><div><button v-for="r in items" :key="r.id" :class="{builtin:r.builtin}" @click="remove(r)">{{r.pattern}}<small v-if="r.builtin">默认</small><Trash2 v-else/></button></div></section></template> </header>
<template v-else-if="tab==='appearance'"><section class="panel form-panel"><h2><Languages/>语言与主题</h2><label>界面语言<select v-model="settings.locale"><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label>主题<select v-model="settings.theme"><option value="dark">暗色</option><option value="light">浅色</option><option value="system">跟随系统</option></select></label><label>Git 默认范围<select v-model="settings.gitScope"><option value="current">当前分支</option><option value="all">所有分支</option></select></label><div class="loading-style-setting"><span>统计 Loading 样式</span><div class="loading-style-grid" role="radiogroup" aria-label="统计 Loading 样式"><button v-for="option in loadingOptions" :key="option.value" type="button" role="radio" :aria-checked="settings.loadingStyle===option.value" :class="['loading-style-card',option.value,{active:settings.loadingStyle===option.value}]" @click="settings.loadingStyle=option.value"><span class="loading-style-preview" aria-hidden="true"><i/></span><b>{{option.title}}</b><small>{{option.desc}}</small></button></div></div><label class="opacity-setting"><span>卡片透明度 <b>{{settings.glassOpacity}}%</b></span><input v-model.number="settings.glassOpacity" type="range" min="30" max="75" step="1"/></label></section></template> <template v-if="tab==='rules'"><section class="panel rule-add"><h2><Plus/>{{t('addRule')}}</h2><div><input v-model="form.pattern" :placeholder="t('rulePatternPh')" @keyup.enter="add"/><select v-model="form.category"><option value="general">{{t('catGeneral')}}</option><option value="php">PHP</option><option value="go">Go</option><option value="vue">Vue/JS</option><option value="custom">{{t('catCustom')}}</option></select><button class="btn primary" @click="add"><Plus/>{{t('add')}}</button></div><small>{{t('ruleHint')}}</small></section><section v-for="(items,name) in groups" :key="name" class="panel rule-group"><h2>{{name}} <small>{{t('rulesCount',{n:items.length})}}</small></h2><div><button v-for="r in items" :key="r.id" :class="{builtin:r.builtin}" @click="remove(r)">{{r.pattern}}<small v-if="r.builtin">{{t('builtinTag')}}</small><Trash2 v-else/></button></div></section></template>
<template v-else><section class="panel database-panel"><div class="db-title"><h2><Database/>数据库位置</h2><span class="db-connected"><CheckCircle2/>已连接</span></div><div v-if="!native" class="preview-notice"><Info/><div><b>当前是浏览器预览模式</b><small>浏览器无法访问本地数据库和文件选择器,请运行 code-count.exe 使用数据库功能。</small></div></div><div class="db-path"><span>当前位置</span><code :title="settings.databasePath">{{settings.databasePath||'未获取到数据库路径'}}</code><button title="复制路径" :disabled="!settings.databasePath" @click="copyPath"><Copy/></button></div><p v-if="dbMessage" class="db-message">{{dbMessage}}</p><button class="btn secondary migrate" :disabled="!native" @click="migrate"><Upload/>选择新位置并迁移</button></section> <template v-else-if="tab==='appearance'"><section class="panel form-panel"><h2><Languages/>{{t('langThemeTitle')}}</h2><label>{{t('uiLanguage')}}<select v-model="settings.locale"><option value="zh-CN">简体中文</option><option value="en">English</option></select></label><label>{{t('theme')}}<select v-model="settings.theme"><option value="dark">{{t('themeDark')}}</option><option value="light">{{t('themeLight')}}</option><option value="system">{{t('themeSystem')}}</option></select></label><label>{{t('gitScopeDefault')}}<select v-model="settings.gitScope"><option value="current">{{t('scopeCurrent')}}</option><option value="all">{{t('scopeAll')}}</option></select></label><div class="loading-style-setting"><span>{{t('loadingStyle')}}</span><div class="loading-style-grid" role="radiogroup" :aria-label="t('loadingStyle')"><button v-for="option in loadingOptions" :key="option.value" type="button" role="radio" :aria-checked="settings.loadingStyle===option.value" :class="['loading-style-card',option.value,{active:settings.loadingStyle===option.value}]" @click="settings.loadingStyle=option.value"><span class="loading-style-preview" aria-hidden="true"><i/></span><b>{{t(option.title)}}</b><small>{{t(option.desc)}}</small></button></div></div><label class="opacity-setting"><span>{{t('glassOpacity')}} <b>{{settings.glassOpacity}}%</b></span><input v-model.number="settings.glassOpacity" type="range" min="30" max="75" step="1"/></label></section>
<section class="panel danger-zone"><h2><Trash2/>清空数据</h2><p>选择要清空的数据类型此操作不可恢复</p><div><button @click="clear('stats')"><BarChart3/><span><b>清空统计数据</b><small>删除分析记录保留项目配置</small></span></button><button @click="clear('project')"><Folder/><span><b>清空单个项目</b><small>删除指定项目的统计数据</small></span></button><button class="danger" @click="clear('all')"><Trash2/><span><b>清空所有数据</b><small>删除所有项目和分析记录</small></span></button></div></section></template> <section class="panel form-panel"><h2><Rocket/>{{t('sysIntegration')}}</h2>
<label>{{t('onWindowClose')}}<select v-model="settings.minimizeToTray"><option :value="true">{{t('closeToTray')}}</option><option :value="false">{{t('closeQuit')}}</option></select></label>
<label>{{t('autostartLabel')}}<div class="autostart-row"><button type="button" class="btn secondary" :disabled="autostartBusy||!native" @click="toggleAutostart">{{autostart?t('autostartOnBtn'):t('autostartOffBtn')}}</button><small>{{autostart?t('autostartOnHint'):t('autostartOffHint')}}</small></div></label>
</section>
<section class="panel form-panel"><h2><Timer/>{{t('autoUpdateTitle')}}</h2>
<label>{{t('autoUpdateEnable')}}<select v-model="settings.autoUpdateEnabled"><option :value="false">{{t('optOff')}}</option><option :value="true">{{t('optOn')}}</option></select></label>
<template v-if="settings.autoUpdateEnabled">
<label>{{t('updateFreq')}}<select v-model="settings.autoUpdateMode"><option value="daily">{{t('freqDaily')}}</option><option value="everyNDays">{{t('freqNDays')}}</option><option value="everyNHours">{{t('freqNHours')}}</option></select></label>
<label v-if="settings.autoUpdateMode!=='daily'">{{t('intervalN')}}<input v-model.number="settings.autoUpdateInterval" type="number" min="1" max="720" class="interval-input"/></label>
<label v-if="settings.autoUpdateMode!=='everyNHours'">{{t('triggerTime')}}<input v-model="settings.autoUpdateTime" type="time" class="interval-input"/></label>
<p class="auto-update-hint">{{t('autoUpdateHint')}}</p>
</template>
</section></template>
<template v-else-if="tab==='ai'">
<section class="panel form-panel"><h2><Sparkles/>{{t('aiProviderTitle')}}</h2>
<label>{{t('aiCurrentProvider')}}<select v-model="settings.aiProvider"><option value="spark">{{t('aiSparkOption')}}</option><option value="deepseek">DeepSeek</option></select></label>
<label>{{t('sparkKeyLabel')}}<input v-model="settings.sparkKey" type="password" :placeholder="t('sparkKeyPh')"/></label>
<label>{{t('deepseekKeyLabel')}}<input v-model="settings.deepSeekKey" type="password" :placeholder="t('deepseekKeyPh')"/></label>
<p class="auto-update-hint">{{t('aiKeyHint')}}</p>
<label>{{t('syncApiKeysLabel')}}<select v-model="settings.syncApiKeys"><option :value="false">{{t('syncApiKeysOff')}}</option><option :value="true">{{t('syncApiKeysOn')}}</option></select></label>
<p class="auto-update-hint">{{t('syncApiKeysHint')}}</p>
</section>
</template>
<template v-else-if="tab==='filestorage'">
<section class="panel form-panel">
<h2><CloudUpload/>{{t('fsTitle')}}</h2>
<template v-if="isAdmin">
<label>{{t('fsMode')}}<select v-model="fsCfg.mode"><option value="local">{{t('fsModeLocal')}}</option><option value="server">{{t('fsModeServer')}}</option></select></label>
<template v-if="fsCfg.mode==='server'">
<label>{{t('fsBaseUrl')}}<input v-model.trim="fsCfg.baseUrl" placeholder="http://192.168.1.10:8788"/></label>
<label>{{t('fsApiKey')}}<input v-model.trim="fsCfg.apiKey" type="password" :placeholder="t('fsApiKeyPh')"/></label>
</template>
<p class="auto-update-hint">{{t('fsPageHint')}}</p>
<div class="fs-page-actions">
<button v-if="fsCfg.mode==='server'" class="btn secondary" :disabled="!!fsBusy||!fsLoaded" @click="testFileStorage"><Wifi/>{{fsBusy==='test'?t('fsTesting'):t('fsTestBtn')}}</button>
<button class="btn primary" :disabled="!!fsBusy||!fsLoaded" @click="saveFileStorage"><CheckCircle2/>{{fsBusy==='save'?t('saving'):t('fsSaveBtn')}}</button>
</div>
</template>
<div v-else class="preview-notice"><Info/><div><b>{{t('fsAdminOnlyTitle')}}</b><small>{{t('fsAdminOnlyDesc')}}</small></div></div>
</section>
</template>
<template v-else><section class="panel database-panel"><div class="db-title"><h2><Database/>{{t('dbLocation')}}</h2><span class="db-connected"><CheckCircle2/>{{t('dbConnected')}}</span></div><div v-if="!native" class="preview-notice"><Info/><div><b>{{t('previewModeTitle')}}</b><small>{{t('previewModeDb')}}</small></div></div><div class="db-path"><span>{{t('dbCurrentLoc')}}</span><code :title="settings.databasePath">{{settings.databasePath||t('dbNoPath')}}</code><button :title="t('copyPathTitle')" :disabled="!settings.databasePath" @click="copyPath"><Copy/></button></div><p v-if="dbMessage" class="db-message">{{dbMessage}}</p><button class="btn secondary migrate" :disabled="!native" @click="migrate"><Upload/>{{t('migrateBtn')}}</button></section>
<section class="panel danger-zone"><h2><Trash2/>{{t('dangerZone')}}</h2><p>{{t('dangerDesc')}}</p><div><button @click="clear('stats')"><BarChart3/><span><b>{{t('clearStats')}}</b><small>{{t('clearStatsDesc')}}</small></span></button><button @click="clear('project')"><Folder/><span><b>{{t('clearProject')}}</b><small>{{t('clearProjectDesc')}}</small></span></button><button class="danger" @click="clear('all')"><Trash2/><span><b>{{t('clearAllData')}}</b><small>{{t('clearAllDesc')}}</small></span></button></div></section></template>
<AIScopeDrawer v-if="aiOpen" kind="config" :title="t('settings')" @close="aiOpen=false"/>
</div></template> </div></template>

View File

@@ -1,84 +0,0 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {model} from '../models';
import {main} from '../models';
export function AddRule(arg1:string,arg2:string):Promise<model.ExclusionRule>;
export function CancelAnalysis(arg1:string):Promise<void>;
export function CheckoutBranch(arg1:number,arg2:string):Promise<model.CheckoutResult>;
export function ClearData(arg1:string,arg2:number):Promise<void>;
export function ClearLogs():Promise<void>;
export function DeleteProject(arg1:number):Promise<void>;
export function DeleteProjectGroup(arg1:number):Promise<void>;
export function DeleteRule(arg1:number):Promise<void>;
export function GetBootstrapStatus():Promise<main.BootstrapStatus>;
export function GetCommitDetails(arg1:number,arg2:string):Promise<model.GitCommitDetail>;
export function GetDashboard():Promise<model.Dashboard>;
export function GetDashboardByGroup(arg1:number):Promise<model.Dashboard>;
export function GetGitDiagnostics(arg1:number):Promise<model.GitDiagnostics>;
export function GetGitStats(arg1:number):Promise<model.GitStats>;
export function GetGitStatsForRef(arg1:number,arg2:string):Promise<model.GitStats>;
export function GetLogs(arg1:string):Promise<Array<model.LogEntry>>;
export function GetProject(arg1:number):Promise<model.Project>;
export function GetProjectInsights(arg1:number):Promise<model.ProjectInsights>;
export function GetRules():Promise<Array<model.ExclusionRule>>;
export function GetSettings():Promise<model.AppSettings>;
export function GetStructure(arg1:number):Promise<model.StructureStats>;
export function InitializeDatabase(arg1:string):Promise<main.BootstrapStatus>;
export function ListProjectGroups():Promise<Array<model.ProjectGroup>>;
export function ListProjects():Promise<Array<model.Project>>;
export function ListProjectsByGroup(arg1:number):Promise<Array<model.Project>>;
export function ListWSLDistros():Promise<Array<string>>;
export function MigrateDatabase(arg1:string):Promise<void>;
export function RefreshProjectInsights(arg1:number):Promise<model.ProjectInsights>;
export function ReportClientError(arg1:string,arg2:string,arg3:string):Promise<void>;
export function RetryDatabase():Promise<main.BootstrapStatus>;
export function SaveProject(arg1:number,arg2:model.ProjectInput):Promise<model.Project>;
export function SaveProjectGroup(arg1:number,arg2:string):Promise<model.ProjectGroup>;
export function SaveSettings(arg1:model.AppSettings):Promise<void>;
export function SelectDatabaseFile():Promise<string>;
export function SelectDirectory():Promise<string>;
export function SelectInitialDatabaseFile(arg1:string):Promise<string>;
export function SelectWSLDirectory(arg1:string):Promise<string>;
export function StartAnalysis(arg1:number,arg2:string):Promise<string>;
export function StartBatchAnalysis():Promise<Array<string>>;
export function StartBatchAnalysisByGroup(arg1:number):Promise<Array<string>>;

View File

@@ -1,163 +0,0 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AddRule(arg1, arg2) {
return window['go']['main']['App']['AddRule'](arg1, arg2);
}
export function CancelAnalysis(arg1) {
return window['go']['main']['App']['CancelAnalysis'](arg1);
}
export function CheckoutBranch(arg1, arg2) {
return window['go']['main']['App']['CheckoutBranch'](arg1, arg2);
}
export function ClearData(arg1, arg2) {
return window['go']['main']['App']['ClearData'](arg1, arg2);
}
export function ClearLogs() {
return window['go']['main']['App']['ClearLogs']();
}
export function DeleteProject(arg1) {
return window['go']['main']['App']['DeleteProject'](arg1);
}
export function DeleteProjectGroup(arg1) {
return window['go']['main']['App']['DeleteProjectGroup'](arg1);
}
export function DeleteRule(arg1) {
return window['go']['main']['App']['DeleteRule'](arg1);
}
export function GetBootstrapStatus() {
return window['go']['main']['App']['GetBootstrapStatus']();
}
export function GetCommitDetails(arg1, arg2) {
return window['go']['main']['App']['GetCommitDetails'](arg1, arg2);
}
export function GetDashboard() {
return window['go']['main']['App']['GetDashboard']();
}
export function GetDashboardByGroup(arg1) {
return window['go']['main']['App']['GetDashboardByGroup'](arg1);
}
export function GetGitDiagnostics(arg1) {
return window['go']['main']['App']['GetGitDiagnostics'](arg1);
}
export function GetGitStats(arg1) {
return window['go']['main']['App']['GetGitStats'](arg1);
}
export function GetGitStatsForRef(arg1, arg2) {
return window['go']['main']['App']['GetGitStatsForRef'](arg1, arg2);
}
export function GetLogs(arg1) {
return window['go']['main']['App']['GetLogs'](arg1);
}
export function GetProject(arg1) {
return window['go']['main']['App']['GetProject'](arg1);
}
export function GetProjectInsights(arg1) {
return window['go']['main']['App']['GetProjectInsights'](arg1);
}
export function GetRules() {
return window['go']['main']['App']['GetRules']();
}
export function GetSettings() {
return window['go']['main']['App']['GetSettings']();
}
export function GetStructure(arg1) {
return window['go']['main']['App']['GetStructure'](arg1);
}
export function InitializeDatabase(arg1) {
return window['go']['main']['App']['InitializeDatabase'](arg1);
}
export function ListProjectGroups() {
return window['go']['main']['App']['ListProjectGroups']();
}
export function ListProjects() {
return window['go']['main']['App']['ListProjects']();
}
export function ListProjectsByGroup(arg1) {
return window['go']['main']['App']['ListProjectsByGroup'](arg1);
}
export function ListWSLDistros() {
return window['go']['main']['App']['ListWSLDistros']();
}
export function MigrateDatabase(arg1) {
return window['go']['main']['App']['MigrateDatabase'](arg1);
}
export function RefreshProjectInsights(arg1) {
return window['go']['main']['App']['RefreshProjectInsights'](arg1);
}
export function ReportClientError(arg1, arg2, arg3) {
return window['go']['main']['App']['ReportClientError'](arg1, arg2, arg3);
}
export function RetryDatabase() {
return window['go']['main']['App']['RetryDatabase']();
}
export function SaveProject(arg1, arg2) {
return window['go']['main']['App']['SaveProject'](arg1, arg2);
}
export function SaveProjectGroup(arg1, arg2) {
return window['go']['main']['App']['SaveProjectGroup'](arg1, arg2);
}
export function SaveSettings(arg1) {
return window['go']['main']['App']['SaveSettings'](arg1);
}
export function SelectDatabaseFile() {
return window['go']['main']['App']['SelectDatabaseFile']();
}
export function SelectDirectory() {
return window['go']['main']['App']['SelectDirectory']();
}
export function SelectInitialDatabaseFile(arg1) {
return window['go']['main']['App']['SelectInitialDatabaseFile'](arg1);
}
export function SelectWSLDirectory(arg1) {
return window['go']['main']['App']['SelectWSLDirectory'](arg1);
}
export function StartAnalysis(arg1, arg2) {
return window['go']['main']['App']['StartAnalysis'](arg1, arg2);
}
export function StartBatchAnalysis() {
return window['go']['main']['App']['StartBatchAnalysis']();
}
export function StartBatchAnalysisByGroup(arg1) {
return window['go']['main']['App']['StartBatchAnalysisByGroup'](arg1);
}

View File

@@ -1,663 +0,0 @@
export namespace main {
export class BootstrapStatus {
state: string;
databasePath: string;
defaultPath: string;
errorCode?: string;
errorDetail?: string;
static createFrom(source: any = {}) {
return new BootstrapStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.state = source["state"];
this.databasePath = source["databasePath"];
this.defaultPath = source["defaultPath"];
this.errorCode = source["errorCode"];
this.errorDetail = source["errorDetail"];
}
}
}
export namespace model {
export class AppSettings {
theme: string;
locale: string;
gitScope: string;
databasePath: string;
autoRefresh: boolean;
glassOpacity: number;
loadingStyle: string;
static createFrom(source: any = {}) {
return new AppSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.theme = source["theme"];
this.locale = source["locale"];
this.gitScope = source["gitScope"];
this.databasePath = source["databasePath"];
this.autoRefresh = source["autoRefresh"];
this.glassOpacity = source["glassOpacity"];
this.loadingStyle = source["loadingStyle"];
}
}
export class CheckoutResult {
branch: string;
message: string;
static createFrom(source: any = {}) {
return new CheckoutResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.branch = source["branch"];
this.message = source["message"];
}
}
export class Contributor {
name: string;
email: string;
commits: number;
added: number;
deleted: number;
static createFrom(source: any = {}) {
return new Contributor(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.email = source["email"];
this.commits = source["commits"];
this.added = source["added"];
this.deleted = source["deleted"];
}
}
export class Dashboard {
projects: number;
totalLines: number;
commits: number;
static createFrom(source: any = {}) {
return new Dashboard(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.projects = source["projects"];
this.totalLines = source["totalLines"];
this.commits = source["commits"];
}
}
export class ExclusionRule {
id: number;
pattern: string;
category: string;
builtin: boolean;
static createFrom(source: any = {}) {
return new ExclusionRule(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.pattern = source["pattern"];
this.category = source["category"];
this.builtin = source["builtin"];
}
}
export class ExtensionStat {
extension: string;
files: number;
size: number;
static createFrom(source: any = {}) {
return new ExtensionStat(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.extension = source["extension"];
this.files = source["files"];
this.size = source["size"];
}
}
export class FileEntry {
path: string;
name: string;
extension: string;
size: number;
isDir: boolean;
parent: string;
static createFrom(source: any = {}) {
return new FileEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
this.name = source["name"];
this.extension = source["extension"];
this.size = source["size"];
this.isDir = source["isDir"];
this.parent = source["parent"];
}
}
export class FolderStat {
name: string;
files: number;
size: number;
static createFrom(source: any = {}) {
return new FolderStat(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.files = source["files"];
this.size = source["size"];
}
}
export class GitCommit {
hash: string;
author: string;
email: string;
message: string;
date: string;
added: number;
deleted: number;
static createFrom(source: any = {}) {
return new GitCommit(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.hash = source["hash"];
this.author = source["author"];
this.email = source["email"];
this.message = source["message"];
this.date = source["date"];
this.added = source["added"];
this.deleted = source["deleted"];
}
}
export class GitFileChange {
path: string;
status: string;
added: number;
deleted: number;
static createFrom(source: any = {}) {
return new GitFileChange(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
this.status = source["status"];
this.added = source["added"];
this.deleted = source["deleted"];
}
}
export class GitCommitDetail {
hash: string;
author: string;
email: string;
message: string;
date: string;
added: number;
deleted: number;
files: GitFileChange[];
static createFrom(source: any = {}) {
return new GitCommitDetail(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.hash = source["hash"];
this.author = source["author"];
this.email = source["email"];
this.message = source["message"];
this.date = source["date"];
this.added = source["added"];
this.deleted = source["deleted"];
this.files = this.convertValues(source["files"], GitFileChange);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class GitDiagnostics {
available: boolean;
errorCode?: string;
detail?: string;
isWsl: boolean;
distro?: string;
linuxPath?: string;
workspaceBranch?: string;
viewRef?: string;
refCount: number;
static createFrom(source: any = {}) {
return new GitDiagnostics(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.available = source["available"];
this.errorCode = source["errorCode"];
this.detail = source["detail"];
this.isWsl = source["isWsl"];
this.distro = source["distro"];
this.linuxPath = source["linuxPath"];
this.workspaceBranch = source["workspaceBranch"];
this.viewRef = source["viewRef"];
this.refCount = source["refCount"];
}
}
export class GitRef {
name: string;
hash: string;
kind: string;
current: boolean;
static createFrom(source: any = {}) {
return new GitRef(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.hash = source["hash"];
this.kind = source["kind"];
this.current = source["current"];
}
}
export class HeatDay {
date: string;
count: number;
static createFrom(source: any = {}) {
return new HeatDay(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.date = source["date"];
this.count = source["count"];
}
}
export class GitStats {
available: boolean;
error?: string;
currentBranch: string;
workspaceBranch: string;
viewRef: string;
commitCount: number;
added: number;
deleted: number;
contributorCount: number;
commits: GitCommit[];
refs: GitRef[];
contributors: Contributor[];
heatmap: HeatDay[];
static createFrom(source: any = {}) {
return new GitStats(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.available = source["available"];
this.error = source["error"];
this.currentBranch = source["currentBranch"];
this.workspaceBranch = source["workspaceBranch"];
this.viewRef = source["viewRef"];
this.commitCount = source["commitCount"];
this.added = source["added"];
this.deleted = source["deleted"];
this.contributorCount = source["contributorCount"];
this.commits = this.convertValues(source["commits"], GitCommit);
this.refs = this.convertValues(source["refs"], GitRef);
this.contributors = this.convertValues(source["contributors"], Contributor);
this.heatmap = this.convertValues(source["heatmap"], HeatDay);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class InsightIssue {
severity: string;
type: string;
title: string;
detail: string;
path?: string;
line?: number;
suggestion: string;
evidence?: string;
static createFrom(source: any = {}) {
return new InsightIssue(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.severity = source["severity"];
this.type = source["type"];
this.title = source["title"];
this.detail = source["detail"];
this.path = source["path"];
this.line = source["line"];
this.suggestion = source["suggestion"];
this.evidence = source["evidence"];
}
}
export class InsightSummary {
high: number;
medium: number;
low: number;
todoCount: number;
longFiles: number;
largeFiles: number;
static createFrom(source: any = {}) {
return new InsightSummary(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.high = source["high"];
this.medium = source["medium"];
this.low = source["low"];
this.todoCount = source["todoCount"];
this.longFiles = source["longFiles"];
this.largeFiles = source["largeFiles"];
}
}
export class LanguageStat {
name: string;
files: number;
code: number;
comments: number;
blanks: number;
static createFrom(source: any = {}) {
return new LanguageStat(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.files = source["files"];
this.code = source["code"];
this.comments = source["comments"];
this.blanks = source["blanks"];
}
}
export class LogEntry {
id: number;
level: string;
category: string;
message: string;
detail: string;
createdAt: string;
static createFrom(source: any = {}) {
return new LogEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.level = source["level"];
this.category = source["category"];
this.message = source["message"];
this.detail = source["detail"];
this.createdAt = source["createdAt"];
}
}
export class ProjectStats {
totalLines: number;
codeLines: number;
commentLines: number;
blankLines: number;
fileCount: number;
commitCount: number;
addedLines: number;
deletedLines: number;
contributorCount: number;
lastAnalyzed: string;
static createFrom(source: any = {}) {
return new ProjectStats(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.totalLines = source["totalLines"];
this.codeLines = source["codeLines"];
this.commentLines = source["commentLines"];
this.blankLines = source["blankLines"];
this.fileCount = source["fileCount"];
this.commitCount = source["commitCount"];
this.addedLines = source["addedLines"];
this.deletedLines = source["deletedLines"];
this.contributorCount = source["contributorCount"];
this.lastAnalyzed = source["lastAnalyzed"];
}
}
export class Project {
id: number;
name: string;
path: string;
description: string;
groupId: number;
groupName: string;
createdAt: string;
updatedAt: string;
stats: ProjectStats;
languages: LanguageStat[];
static createFrom(source: any = {}) {
return new Project(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.path = source["path"];
this.description = source["description"];
this.groupId = source["groupId"];
this.groupName = source["groupName"];
this.createdAt = source["createdAt"];
this.updatedAt = source["updatedAt"];
this.stats = this.convertValues(source["stats"], ProjectStats);
this.languages = this.convertValues(source["languages"], LanguageStat);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ProjectGroup {
id: number;
name: string;
createdAt: string;
updatedAt: string;
static createFrom(source: any = {}) {
return new ProjectGroup(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.createdAt = source["createdAt"];
this.updatedAt = source["updatedAt"];
}
}
export class ProjectInput {
name: string;
path: string;
description: string;
groupId: number;
static createFrom(source: any = {}) {
return new ProjectInput(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.path = source["path"];
this.description = source["description"];
this.groupId = source["groupId"];
}
}
export class ProjectInsights {
projectId: number;
healthScore: number;
generatedAt: string;
summary: InsightSummary;
issues: InsightIssue[];
static createFrom(source: any = {}) {
return new ProjectInsights(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.projectId = source["projectId"];
this.healthScore = source["healthScore"];
this.generatedAt = source["generatedAt"];
this.summary = this.convertValues(source["summary"], InsightSummary);
this.issues = this.convertValues(source["issues"], InsightIssue);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class StructureStats {
files: FileEntry[];
totalFiles: number;
totalDirs: number;
totalSize: number;
largeFiles: FileEntry[];
folders: FolderStat[];
extensions: ExtensionStat[];
static createFrom(source: any = {}) {
return new StructureStats(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.files = this.convertValues(source["files"], FileEntry);
this.totalFiles = source["totalFiles"];
this.totalDirs = source["totalDirs"];
this.totalSize = source["totalSize"];
this.largeFiles = this.convertValues(source["largeFiles"], FileEntry);
this.folders = this.convertValues(source["folders"], FolderStat);
this.extensions = this.convertValues(source["extensions"], ExtensionStat);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}

View File

@@ -1,24 +0,0 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}

View File

@@ -1,330 +0,0 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View File

@@ -1,298 +0,0 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}

45
go.mod
View File

@@ -3,49 +3,40 @@ module view
go 1.25.0 go 1.25.0
require ( require (
github.com/go-sql-driver/mysql v1.10.0
github.com/hhatto/gocloc v0.7.0 github.com/hhatto/gocloc v0.7.0
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/wailsapp/wails/v2 v2.12.0 github.com/shirou/gopsutil/v4 v4.26.7
github.com/wailsapp/wails/v3 v3.0.0-beta.6
golang.org/x/crypto v0.54.0
golang.org/x/image v0.44.0
golang.org/x/sys v0.47.0
modernc.org/sqlite v1.53.0 modernc.org/sqlite v1.53.0
) )
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect github.com/adrg/xdg v0.5.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/go-enry/go-enry/v2 v2.8.0 // indirect github.com/go-enry/go-enry/v2 v2.8.0 // indirect
github.com/go-enry/go-oniguruma v1.2.1 // indirect github.com/go-enry/go-oniguruma v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/samber/lo v1.49.1 // indirect github.com/tklauser/numcpus v0.11.0 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.22.0 // indirect
modernc.org/libc v1.73.4 // indirect modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
) )
// replace github.com/wailsapp/wails/v2 v2.12.0 => C:\Users\admin\go\pkg\mod

125
go.sum
View File

@@ -1,114 +1,99 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/go-enry/go-enry/v2 v2.8.0 h1:KMW4mSG+8uUF6FaD3iPkFqyfC5tF8gRrsYImq6yhHzo= github.com/go-enry/go-enry/v2 v2.8.0 h1:KMW4mSG+8uUF6FaD3iPkFqyfC5tF8gRrsYImq6yhHzo=
github.com/go-enry/go-enry/v2 v2.8.0/go.mod h1:GVzIiAytiS5uT/QiuakK7TF1u4xDab87Y8V5EJRpsIQ= github.com/go-enry/go-enry/v2 v2.8.0/go.mod h1:GVzIiAytiS5uT/QiuakK7TF1u4xDab87Y8V5EJRpsIQ=
github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo= github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4= github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hhatto/gocloc v0.7.0 h1:PS+C3H7To0kr8dwNDz+ahKRt05pYkUdhR3YAhr/27RA= github.com/hhatto/gocloc v0.7.0 h1:PS+C3H7To0kr8dwNDz+ahKRt05pYkUdhR3YAhr/27RA=
github.com/hhatto/gocloc v0.7.0/go.mod h1:H2qL5xyLUYpiUY8JSLHaXYhACYhRuM/j5HWEOR29hus= github.com/hhatto/gocloc v0.7.0/go.mod h1:H2qL5xyLUYpiUY8JSLHaXYhACYhRuM/j5HWEOR29hus=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/wailsapp/wails/v3 v3.0.0-beta.6 h1:k9FHF/T39EyTZNCHweRrLt1c6dwV3R3A+r1oCNiPB8I=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/wailsapp/wails/v3 v3.0.0-beta.6/go.mod h1:A/OaL1mXOnwWynTJv4rZU89Wbk5q3rtq3C3qkx4rRN0=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

63
main.go
View File

@@ -2,38 +2,59 @@ package main
import ( import (
"embed" "embed"
"log/slog"
"os"
"github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v3/pkg/services/notifications"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
) )
//go:embed all:frontend/dist //go:embed all:frontend/dist
var assets embed.FS var assets embed.FS
func main() { func devtoolsArgs() []string {
// Create an instance of the app structure if p := os.Getenv("CC_DEVTOOLS_PORT"); p != "" {
app := NewApp() return []string{"--remote-debugging-port=" + p}
}
return nil
}
// Create application with options func main() {
err := wails.Run(&options.App{ app := NewApp()
Title: "Code Count", notifier := notifications.New()
Width: 1280, app.notifier = notifier
Height: 820,
MinWidth: 960, wapp := application.New(application.Options{
MinHeight: 680, Name: "年糕崽崽项目管理PMS",
AssetServer: &assetserver.Options{ Description: "本地代码统计与项目工作台",
Assets: assets, Services: []application.Service{
application.NewService(app),
application.NewService(notifier),
}, },
BackgroundColour: &options.RGBA{R: 13, G: 18, B: 28, A: 1}, Assets: application.AssetOptions{
OnStartup: app.startup, Handler: application.AssetFileServerFS(assets),
OnShutdown: app.shutdown, },
Bind: []interface{}{ LogLevel: slog.LevelError,
app, Windows: application.WindowsOptions{
// Opt-in DevTools protocol endpoint for automated UI testing; inert unless the env var is set.
AdditionalBrowserArgs: devtoolsArgs(),
}, },
}) })
if err != nil { win := wapp.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "main",
Title: "年糕崽崽项目管理PMS",
Width: 1280,
Height: 820,
MinWidth: 960,
MinHeight: 680,
URL: "/",
BackgroundColour: application.NewRGBA(13, 18, 28, 255),
})
app.SetupShell(wapp, win)
if err := wapp.Run(); err != nil {
println("Error:", err.Error()) println("Error:", err.Error())
} }
} }

View File

@@ -96,23 +96,34 @@ type Contributor struct {
Deleted int64 `json:"deleted"` Deleted int64 `json:"deleted"`
} }
type HeatDay struct { type HeatDay struct {
Date string `json:"date"` Date string `json:"date"`
Count int64 `json:"count"` Count int64 `json:"count"`
Added int64 `json:"added"`
Deleted int64 `json:"deleted"`
}
// GitFileHotspot 记录变更最频繁的文件,用于识别高风险热点。
type GitFileHotspot struct {
Path string `json:"path"`
Changes int64 `json:"changes"`
Added int64 `json:"added"`
Deleted int64 `json:"deleted"`
} }
type GitStats struct { type GitStats struct {
Available bool `json:"available"` Available bool `json:"available"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
CurrentBranch string `json:"currentBranch"` CurrentBranch string `json:"currentBranch"`
WorkspaceBranch string `json:"workspaceBranch"` WorkspaceBranch string `json:"workspaceBranch"`
ViewRef string `json:"viewRef"` ViewRef string `json:"viewRef"`
CommitCount int64 `json:"commitCount"` CommitCount int64 `json:"commitCount"`
Added int64 `json:"added"` Added int64 `json:"added"`
Deleted int64 `json:"deleted"` Deleted int64 `json:"deleted"`
ContributorCount int64 `json:"contributorCount"` ContributorCount int64 `json:"contributorCount"`
Commits []GitCommit `json:"commits"` Commits []GitCommit `json:"commits"`
Refs []GitRef `json:"refs"` Refs []GitRef `json:"refs"`
Contributors []Contributor `json:"contributors"` Contributors []Contributor `json:"contributors"`
Heatmap []HeatDay `json:"heatmap"` Heatmap []HeatDay `json:"heatmap"`
Hotspots []GitFileHotspot `json:"hotspots"`
} }
type GitDiagnostics struct { type GitDiagnostics struct {
Available bool `json:"available"` Available bool `json:"available"`
@@ -179,6 +190,54 @@ type LogEntry struct {
Detail string `json:"detail"` Detail string `json:"detail"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
} }
type Todo struct {
ID int64 `json:"id"`
UUID string `json:"uuid"`
Title string `json:"title"`
Content string `json:"content"`
ProjectID int64 `json:"projectId"`
ProjectName string `json:"projectName"`
DueAt string `json:"dueAt"`
Priority string `json:"priority"` // low | medium | high
Status string `json:"status"` // open | doing | done
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
History string `json:"history"` // JSON 数组:[{"status","at"}],记录每次进入某状态的时间
TeamID int64 `json:"teamId"` // >0 表示已共享给该团队
}
type Ticket struct {
ID int64 `json:"id"`
UUID string `json:"uuid"`
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"` // feature | bug | task | improvement
ProjectID int64 `json:"projectId"`
ProjectName string `json:"projectName"`
StartAt string `json:"startAt"`
DueAt string `json:"dueAt"`
Status string `json:"status"` // open | in_progress | resolved | closed
Priority string `json:"priority"` // low | medium | high
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
History string `json:"history"` // JSON 数组:[{"status","at"}],记录每次进入某状态的时间
TeamID int64 `json:"teamId"` // >0 表示已共享给该团队
}
type Note struct {
ID int64 `json:"id"`
UUID string `json:"uuid"`
Content string `json:"content"`
UpdatedAt string `json:"updatedAt"`
}
type Message struct {
ID int64 `json:"id"`
Kind string `json:"kind"` // todo_due | ticket_due | analysis | sync | system
Title string `json:"title"`
Body string `json:"body"`
SourceType string `json:"sourceType"` // todo | ticket | project | ""
SourceID int64 `json:"sourceId"`
Read bool `json:"read"`
CreatedAt string `json:"createdAt"`
}
type AppSettings struct { type AppSettings struct {
Theme string `json:"theme"` Theme string `json:"theme"`
Locale string `json:"locale"` Locale string `json:"locale"`
@@ -187,11 +246,174 @@ type AppSettings struct {
AutoRefresh bool `json:"autoRefresh"` AutoRefresh bool `json:"autoRefresh"`
GlassOpacity int `json:"glassOpacity"` GlassOpacity int `json:"glassOpacity"`
LoadingStyle string `json:"loadingStyle"` LoadingStyle string `json:"loadingStyle"`
// 托盘与定时更新Phase 2
MinimizeToTray bool `json:"minimizeToTray"`
AutoUpdateEnabled bool `json:"autoUpdateEnabled"`
AutoUpdateMode string `json:"autoUpdateMode"` // daily | everyNDays | everyNHours
AutoUpdateInterval int `json:"autoUpdateInterval"` // everyNDays/everyNHours 的 N
AutoUpdateTime string `json:"autoUpdateTime"` // daily/everyNDays 的触发时刻 HH:MM
// AI 分析Phase 5
AIProvider string `json:"aiProvider"` // spark | deepseek
SparkKey string `json:"sparkKey"`
DeepSeekKey string `json:"deepSeekKey"`
// SyncAPIKeys 开启后 API Key 会加密同步到 MySQL密钥由登录密码派生服务器不可解密
SyncAPIKeys bool `json:"syncApiKeys"`
// 用户头像AvatarMode 为 ""(未设置) | base64 | url | pathAvatarValue 依模式存 dataURL / 图片 URL / 本地文件路径。
AvatarMode string `json:"avatarMode"`
AvatarValue string `json:"avatarValue"`
// 内容图片存储:待办/工单 Markdown 里粘贴或插入的图片如何保存。
// base64内嵌 dataURL随内容同步到云端| path写入数据目录仅本机可见
ImageMode string `json:"imageMode"`
}
// AvatarPick 是选择头像图片的结果Value 为要保存的值Preview 为可直接显示的图片源。
// SearchHit 全局搜索Ctrl+K 命令面板)的一条结果。
type SearchHit struct {
Kind string `json:"kind"` // project | todo | ticket | conversation
ID int64 `json:"id"`
Title string `json:"title"`
Sub string `json:"sub"` // 路径 / 状态 / 供应商等辅助信息
Extra string `json:"extra"` // 次级状态(如优先级)
}
type AvatarPick struct {
// Mode 是选图后实际采用的存储模式base64 | urlauto 选图时由全局配置决定。
Mode string `json:"mode"`
Value string `json:"value"`
Preview string `json:"preview"`
}
// AIConversation 是一次 AI 分析会话(可绑定项目)。
type AIConversation struct {
ID int64 `json:"id"`
ProjectID int64 `json:"projectId"`
ProjectName string `json:"projectName"`
Provider string `json:"provider"`
Title string `json:"title"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// AIMessage 是会话中的一条消息。
type AIMessage struct {
ID int64 `json:"id"`
ConversationID int64 `json:"conversationId"`
Role string `json:"role"` // user | assistant
Content string `json:"content"`
CreatedAt string `json:"createdAt"`
}
// AISummary 是分析完成后 AI 生成的模块介绍(按项目+模块各存最新一份)。
type AISummary struct {
ProjectID int64 `json:"projectId"`
Kind string `json:"kind"` // project | git
Content string `json:"content"`
Provider string `json:"provider"`
GeneratedAt string `json:"generatedAt"`
} }
type Dashboard struct { type Dashboard struct {
Projects int64 `json:"projects"` Projects int64 `json:"projects"`
TotalLines int64 `json:"totalLines"` TotalLines int64 `json:"totalLines"`
Commits int64 `json:"commits"` CodeLines int64 `json:"codeLines"`
CommentLines int64 `json:"commentLines"`
BlankLines int64 `json:"blankLines"`
FileCount int64 `json:"fileCount"`
Commits int64 `json:"commits"`
Contributors int64 `json:"contributors"`
Languages []LanguageStat `json:"languages"`
}
// FileStorageConfig 是全局「文件存储方式」配置:由管理员(云端账号 id=1设置
// 云端存 sync_settings 的 file_storage 行(挂在 id=1 名下),随同步分发给所有账号。
// mode=local 时图片维持本地行为mode=server 时内容图imageMode=server
// 头像选图上传到 nl-pms-api 换取 http URL远程/跨设备场景直接引用。
type FileStorageConfig struct {
Mode string `json:"mode"` // local | server
BaseURL string `json:"baseUrl"` // nl-pms-api 地址,如 http://192.168.1.10:8788
APIKey string `json:"apiKey"` // 上传密钥Authorization: Bearer <apiKey>
}
// ServerFile 是 nl-pms-api 素材库中的一条文件记录(含上传者用户名与公开访问 URL
type ServerFile struct {
ID int64 `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Mime string `json:"mime"`
Size int64 `json:"size"`
UserID int64 `json:"userId"`
TeamID int64 `json:"teamId"`
Kind string `json:"kind"` // avatar | content
Username string `json:"username"`
CreatedAt string `json:"createdAt"`
}
// ServerFileList 是素材库的一页结果。
type ServerFileList struct {
Total int64 `json:"total"`
Items []ServerFile `json:"items"`
}
// SyncConfig 是 MySQL 同步服务器的连接配置(保存在本地 SQLite
type SyncConfig struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
}
// SyncStatus 描述当前登录与同步状态,供设置页展示。
type SyncStatus struct {
Configured bool `json:"configured"`
LoggedIn bool `json:"loggedIn"`
// UserID 为云端账号 idid=1 视为管理员(可配置节日背景图等全局资源)。
UserID int64 `json:"userId"`
Username string `json:"username"`
LastSyncAt string `json:"lastSyncAt"`
Syncing bool `json:"syncing"`
Online bool `json:"online"`
LastError string `json:"lastError"`
Pushed int `json:"pushed"`
Pulled int `json:"pulled"`
// Pending 为本地待推送dirty行数用于界面徽标。
Pending int `json:"pending"`
}
// FestivalImage 是一个节日的背景配置mode 决定日历格显示自定义照片还是动态插画。
// image 在 mode=art 时仍保留,方便管理员切回图片模式无需重新上传。
type FestivalImage struct {
Mode string `json:"mode"` // photo | art
Image string `json:"image"`
}
// LogPage 是日志分页搜索的结果counts 基于当前搜索条件(忽略级别筛选)。
type LogPage struct {
Items []LogEntry `json:"items"`
Total int64 `json:"total"`
Info int64 `json:"info"`
Warning int64 `json:"warning"`
Error int64 `json:"error"`
}
// BatchSummary 是批量统计完成后的汇总事件载荷。
type BatchSummary struct {
Total int `json:"total"`
Completed int `json:"completed"`
Failed int `json:"failed"`
Cancelled int `json:"cancelled"`
Failures []string `json:"failures"`
}
// FilePreview 是文件预览接口的返回载荷。
type FilePreview struct {
Path string `json:"path"`
Name string `json:"name"`
Extension string `json:"extension"`
Size int64 `json:"size"`
Content string `json:"content"`
Lines int `json:"lines"`
Truncated bool `json:"truncated"`
Binary bool `json:"binary"`
} }
// TaskEvent 只传递稳定消息键,具体中文或英文由前端根据当前语言即时翻译。 // TaskEvent 只传递稳定消息键,具体中文或英文由前端根据当前语言即时翻译。
@@ -203,3 +425,51 @@ type TaskEvent struct {
MessageKey string `json:"messageKey"` MessageKey string `json:"messageKey"`
Params map[string]any `json:"params,omitempty"` Params map[string]any `json:"params,omitempty"`
} }
// CloneInput 是「从 Git 克隆并添加项目」的入参。
type CloneInput struct {
URL string `json:"url"`
ParentDir string `json:"parentDir"`
Name string `json:"name"`
GroupID int64 `json:"groupId"`
Description string `json:"description"`
}
// LaunchApp 是启动台保存的应用(含启停命令)。
type LaunchApp struct {
ID int64 `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Port int `json:"port"`
Dir string `json:"dir"`
StartCmd string `json:"startCmd"`
StopCmd string `json:"stopCmd"`
LastPID int64 `json:"lastPid"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// LaunchEntry 是启动台卡片条目:保存的应用与实时扫描到的监听进程合并后的视图。
type LaunchEntry struct {
ID int64 `json:"id"` // launch_apps id0 表示未保存的扫描条目
Name string `json:"name"`
Kind string `json:"kind"`
Port int `json:"port"`
Dir string `json:"dir"`
StartCmd string `json:"startCmd"`
StopCmd string `json:"stopCmd"`
Running bool `json:"running"`
PID int32 `json:"pid"`
Exe string `json:"exe"`
Cmdline string `json:"cmdline"`
Ports []int `json:"ports"`
CPU float64 `json:"cpu"` // 占全机 CPU 百分比
MemMB float64 `json:"memMB"` // 常驻内存
IOKBs float64 `json:"ioKBs"` // 磁盘读写吞吐
}
// LaunchSuggest 是按项目种类推荐的启停命令。
type LaunchSuggest struct {
Start []string `json:"start"`
Stop []string `json:"stop"`
}

View File

@@ -29,3 +29,26 @@ type LogEntry = model.LogEntry
type AppSettings = model.AppSettings type AppSettings = model.AppSettings
type Dashboard = model.Dashboard type Dashboard = model.Dashboard
type TaskEvent = model.TaskEvent type TaskEvent = model.TaskEvent
type GitFileHotspot = model.GitFileHotspot
type LogPage = model.LogPage
type BatchSummary = model.BatchSummary
type FilePreview = model.FilePreview
type Todo = model.Todo
type Ticket = model.Ticket
type Note = model.Note
type Message = model.Message
type SyncConfig = model.SyncConfig
type FileStorageConfig = model.FileStorageConfig
type ServerFile = model.ServerFile
type ServerFileList = model.ServerFileList
type SyncStatus = model.SyncStatus
type AIConversation = model.AIConversation
type AIMessage = model.AIMessage
type AISummary = model.AISummary
type AvatarPick = model.AvatarPick
type SearchHit = model.SearchHit
type FestivalImage = model.FestivalImage
type LaunchApp = model.LaunchApp
type LaunchEntry = model.LaunchEntry
type LaunchSuggest = model.LaunchSuggest
type CloneInput = model.CloneInput

View File

@@ -59,6 +59,9 @@ func RunHidden(ctx context.Context, name string, args ...string) (string, error)
return out, nil return out, nil
} }
// ConfigureHidden 供需要自建 exec.Cmd 流式读取输出的调用方隐藏控制台窗口。
func ConfigureHidden(cmd *exec.Cmd) { configureHidden(cmd) }
// DecodeWSLList 兼容部分 Windows 版本返回的 UTF-16LE 发行版列表。 // DecodeWSLList 兼容部分 Windows 版本返回的 UTF-16LE 发行版列表。
func DecodeWSLList(b []byte) string { return decodeOutput(b) } func DecodeWSLList(b []byte) string { return decodeOutput(b) }

View File

@@ -2,6 +2,14 @@
package platform package platform
import "os/exec" import (
"os/exec"
"syscall"
)
func configureHidden(_ *exec.Cmd) {} func configureHidden(_ *exec.Cmd) {}
// ConfigureDetached 供启动台启动长驻服务:放入独立进程组,便于整组停止。
func ConfigureDetached(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}

View File

@@ -11,3 +11,9 @@ import (
func configureHidden(cmd *exec.Cmd) { func configureHidden(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
} }
// ConfigureDetached 供启动台启动长驻服务:隐藏窗口并放入新进程组,
// 使子进程独立于主程序运行0x00000200 = CREATE_NEW_PROCESS_GROUP
func ConfigureDetached(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000 | 0x00000200}
}

View File

@@ -129,27 +129,46 @@ func (g GitService) Diagnostics(ctx context.Context, dir string, ref string) mod
return d return d
} }
// gitLogMaxCommits 限制单次 git log 解析的提交数量,避免超大仓库拖垮分析。
const gitLogMaxCommits = 20000
// GitAnalyzeOptions 控制 Git 分析的范围与增量起点。
type GitAnalyzeOptions struct {
Ref string // 统计视图引用;为空表示当前分支
AllBranches bool // true 时统计所有分支gitScope=all
SinceHash string // 已入库的最新提交哈希;非空且仍在历史内时执行增量分析
}
// Analyze 分析工作区当前分支。 // Analyze 分析工作区当前分支。
func (g GitService) Analyze(ctx context.Context, dir string) (model.GitStats, error) { func (g GitService) Analyze(ctx context.Context, dir string) (model.GitStats, error) {
return g.AnalyzeRef(ctx, dir, "") stats, _, e := g.AnalyzeWithOptions(ctx, dir, GitAnalyzeOptions{})
return stats, e
} }
// AnalyzeRef 只切换统计视图,不修改用户工作区。 // AnalyzeRef 只切换统计视图,不修改用户工作区。
func (g GitService) AnalyzeRef(ctx context.Context, dir, ref string) (model.GitStats, error) { func (g GitService) AnalyzeRef(ctx context.Context, dir, ref string) (model.GitStats, error) {
stats, _, e := g.AnalyzeWithOptions(ctx, dir, GitAnalyzeOptions{Ref: ref})
return stats, e
}
// AnalyzeWithOptions 返回统计结果和 incremental 标记:
// incremental=true 时结果只包含 SinceHash 之后的新提交,调用方应执行合并而非整表替换。
func (g GitService) AnalyzeWithOptions(ctx context.Context, dir string, opt GitAnalyzeOptions) (model.GitStats, bool, error) {
if _, e := g.run(ctx, dir, "rev-parse", "--git-dir"); e != nil { if _, e := g.run(ctx, dir, "rev-parse", "--git-dir"); e != nil {
return model.GitStats{Available: false, Error: e.Error()}, e return model.GitStats{Available: false, Error: e.Error()}, false, e
} }
branch, _ := g.run(ctx, dir, "branch", "--show-current") branch, _ := g.run(ctx, dir, "branch", "--show-current")
branch = strings.TrimSpace(branch) branch = strings.TrimSpace(branch)
ref := opt.Ref
if ref == "" { if ref == "" {
ref = branch ref = branch
} }
out := model.GitStats{Available: true, CurrentBranch: branch, WorkspaceBranch: branch, ViewRef: ref, Commits: []model.GitCommit{}, Refs: []model.GitRef{}, Contributors: []model.Contributor{}, Heatmap: []model.HeatDay{}} out := model.GitStats{Available: true, CurrentBranch: branch, WorkspaceBranch: branch, ViewRef: ref, Commits: []model.GitCommit{}, Refs: []model.GitRef{}, Contributors: []model.Contributor{}, Heatmap: []model.HeatDay{}, Hotspots: []model.GitFileHotspot{}}
refs, e := g.run(ctx, dir, "for-each-ref", "--format=%(refname:short)%x1f%(objectname)%x1f%(refname)", "refs/heads", "refs/remotes") refs, e := g.run(ctx, dir, "for-each-ref", "--format=%(refname:short)%x1f%(objectname)%x1f%(refname)", "refs/heads", "refs/remotes", "refs/tags")
if e != nil { if e != nil {
out.Available = false out.Available = false
out.Error = e.Error() out.Error = e.Error()
return out, e return out, false, e
} }
for _, line := range strings.Split(refs, "\n") { for _, line := range strings.Split(refs, "\n") {
p := strings.Split(line, "\x1f") p := strings.Split(line, "\x1f")
@@ -162,21 +181,43 @@ func (g GitService) AnalyzeRef(ctx context.Context, dir, ref string) (model.GitS
kind := "local" kind := "local"
if strings.HasPrefix(p[2], "refs/remotes/") { if strings.HasPrefix(p[2], "refs/remotes/") {
kind = "remote" kind = "remote"
} else if strings.HasPrefix(p[2], "refs/tags/") {
kind = "tag"
} }
out.Refs = append(out.Refs, model.GitRef{Name: p[0], Hash: ShortHash(p[1]), Kind: kind, Current: p[0] == branch}) out.Refs = append(out.Refs, model.GitRef{Name: p[0], Hash: ShortHash(p[1]), Kind: kind, Current: p[0] == branch})
} }
args := []string{"log", "--use-mailmap", "--date=iso-strict", "--pretty=format:@@CC@@%H%x1f%aN%x1f%aE%x1f%aI%x1f%s", "--numstat"} incremental := opt.SinceHash != "" && g.canIncrement(ctx, dir, opt.SinceHash, ref, opt.AllBranches)
if strings.TrimSpace(ref) != "" { args := []string{"log", "--use-mailmap", "--date=iso-strict", "--pretty=format:@@CC@@%H%x1f%aN%x1f%aE%x1f%aI%x1f%s", "--numstat", "--max-count", strconv.Itoa(gitLogMaxCommits)}
if opt.AllBranches {
args = append(args, "--all")
}
if incremental {
args = append(args, "^"+opt.SinceHash)
}
if !opt.AllBranches && strings.TrimSpace(ref) != "" {
args = append(args, ref) args = append(args, ref)
} }
log, e := g.run(ctx, dir, args...) log, e := g.run(ctx, dir, args...)
if e != nil { if e != nil {
out.Available = false out.Available = false
out.Error = e.Error() out.Error = e.Error()
return out, e return out, false, e
} }
parseLog(log, &out) parseLog(log, &out)
return out, nil return out, incremental, nil
}
// canIncrement 校验增量起点仍然存在且属于目标历史,否则回退全量分析。
func (g GitService) canIncrement(ctx context.Context, dir, since, ref string, all bool) bool {
if _, e := g.run(ctx, dir, "rev-parse", "--verify", "--quiet", since+"^{commit}"); e != nil {
return false
}
target := strings.TrimSpace(ref)
if all || target == "" {
target = "HEAD"
}
_, e := g.run(ctx, dir, "merge-base", "--is-ancestor", since, target)
return e == nil
} }
// CommitDetail 按需读取单次提交和逐文件增删行,避免 Git 首页一次传输过多数据。 // CommitDetail 按需读取单次提交和逐文件增删行,避免 Git 首页一次传输过多数据。
@@ -253,11 +294,15 @@ func (g GitService) CheckoutBranch(ctx context.Context, dir, ref string) (model.
func parseLog(log string, out *model.GitStats) { func parseLog(log string, out *model.GitStats) {
var cur *model.GitCommit var cur *model.GitCommit
fm := map[string]*model.GitFileHotspot{}
flush := func() {
if cur != nil {
out.Commits = append(out.Commits, *cur)
}
}
for _, line := range strings.Split(log, "\n") { for _, line := range strings.Split(log, "\n") {
if strings.HasPrefix(line, "@@CC@@") { if strings.HasPrefix(line, "@@CC@@") {
if cur != nil { flush()
out.Commits = append(out.Commits, *cur)
}
p := strings.Split(strings.TrimPrefix(line, "@@CC@@"), "\x1f") p := strings.Split(strings.TrimPrefix(line, "@@CC@@"), "\x1f")
if len(p) == 5 { if len(p) == 5 {
cur = &model.GitCommit{Hash: p[0], Author: p[1], Email: strings.ToLower(p[2]), Date: p[3], Message: p[4]} cur = &model.GitCommit{Hash: p[0], Author: p[1], Email: strings.ToLower(p[2]), Date: p[3], Message: p[4]}
@@ -268,23 +313,34 @@ func parseLog(log string, out *model.GitStats) {
} }
if cur != nil { if cur != nil {
p := strings.Split(line, "\t") p := strings.Split(line, "\t")
if len(p) >= 2 { if len(p) >= 3 {
a, e1 := strconv.ParseInt(p[0], 10, 64) a, e1 := strconv.ParseInt(p[0], 10, 64)
d, e2 := strconv.ParseInt(p[1], 10, 64) d, e2 := strconv.ParseInt(p[1], 10, 64)
if e1 == nil { if e1 != nil {
cur.Added += a a = 0
} }
if e2 == nil { if e2 != nil {
cur.Deleted += d d = 0
}
cur.Added += a
cur.Deleted += d
path := strings.TrimSpace(p[2])
if path != "" {
h := fm[path]
if h == nil {
h = &model.GitFileHotspot{Path: path}
fm[path] = h
}
h.Changes++
h.Added += a
h.Deleted += d
} }
} }
} }
} }
if cur != nil { flush()
out.Commits = append(out.Commits, *cur)
}
cm := map[string]*model.Contributor{} cm := map[string]*model.Contributor{}
hm := map[string]int64{} hm := map[string]*model.HeatDay{}
cut := time.Now().AddDate(-1, 0, 0) cut := time.Now().AddDate(-1, 0, 0)
for _, c := range out.Commits { for _, c := range out.Commits {
out.Added += c.Added out.Added += c.Added
@@ -298,7 +354,15 @@ func parseLog(log string, out *model.GitStats) {
x.Added += c.Added x.Added += c.Added
x.Deleted += c.Deleted x.Deleted += c.Deleted
if t, e := time.Parse(time.RFC3339, c.Date); e == nil && t.After(cut) { 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 = &model.HeatDay{Date: key}
hm[key] = d
}
d.Count++
d.Added += c.Added
d.Deleted += c.Deleted
} }
} }
out.CommitCount = int64(len(out.Commits)) out.CommitCount = int64(len(out.Commits))
@@ -307,10 +371,22 @@ func parseLog(log string, out *model.GitStats) {
} }
out.ContributorCount = int64(len(out.Contributors)) out.ContributorCount = int64(len(out.Contributors))
sort.Slice(out.Contributors, func(i, j int) bool { return out.Contributors[i].Commits > out.Contributors[j].Commits }) sort.Slice(out.Contributors, func(i, j int) bool { return out.Contributors[i].Commits > out.Contributors[j].Commits })
for d, c := range hm { for _, d := range hm {
out.Heatmap = append(out.Heatmap, model.HeatDay{Date: d, Count: c}) out.Heatmap = append(out.Heatmap, *d)
} }
sort.Slice(out.Heatmap, func(i, j int) bool { return out.Heatmap[i].Date < out.Heatmap[j].Date }) sort.Slice(out.Heatmap, func(i, j int) bool { return out.Heatmap[i].Date < out.Heatmap[j].Date })
for _, h := range fm {
out.Hotspots = append(out.Hotspots, *h)
}
sort.Slice(out.Hotspots, func(i, j int) bool {
if out.Hotspots[i].Changes != out.Hotspots[j].Changes {
return out.Hotspots[i].Changes > out.Hotspots[j].Changes
}
return out.Hotspots[i].Added+out.Hotspots[i].Deleted > out.Hotspots[j].Added+out.Hotspots[j].Deleted
})
if len(out.Hotspots) > 50 {
out.Hotspots = out.Hotspots[:50]
}
} }
func ShortHash(s string) string { func ShortHash(s string) string {