commit d2aeb13a0902beaf66ec48e9efd29b4d681b0e0e Author: 李琦 Date: Tue Aug 11 19:07:05 2026 +0800 初始化 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..129d522 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/bin +node_modules +frontend/dist diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b6b1ecf --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..d7202f0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..efd6cfd --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/view.iml b/.idea/view.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/view.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..22762a1 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Code Count + +Code Count is a Wails v2 desktop application for local source-code and Git analytics. The Go backend scans source files with gocloc, reads Git history through the installed `git` executable, and persists analysis snapshots in SQLite. The Vue 3 frontend provides Chinese/English and light/dark themes. + +## Requirements + +- Go 1.25+ +- Node.js 20+ +- Wails CLI 2.12+ +- Git (optional; required only for Git analytics) + +## Development + +```powershell +cd frontend +npm install +cd .. +wails dev +``` + +The database is created under the operating system's user configuration directory in `CodeCount/code-count.db`. Its location can be changed from Settings. + +## Tests and builds + +```powershell +go test ./... +cd frontend +npm run build +cd .. +wails build +``` + +Windows builds produce `build/bin/code-count.exe`. Run `wails build -platform darwin/universal` on macOS to create the macOS application bundle. + +## About + +This is the official Wails Vue template. + +You can configure the project by editing `wails.json`. More information about the project settings can be found +here: https://wails.io/docs/reference/project-config + +## Live Development + +To run in live development mode, run `wails dev` in the project directory. This will run a Vite development +server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser +and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect +to this in your browser, and you can call your Go code from devtools. + +## Building + +To build a redistributable, production mode package, use `wails build`. diff --git a/app.go b/app.go new file mode 100644 index 0000000..2f40642 --- /dev/null +++ b/app.go @@ -0,0 +1,579 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + "view/platform" + "view/service" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +type App struct { + ctx context.Context + store *Store + bootstrapFile string + bootstrap BootstrapStatus + runtimeReady bool + mu sync.Mutex + tasks map[string]context.CancelFunc +} + +func NewApp() *App { return &App{tasks: map[string]context.CancelFunc{}} } +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + a.runtimeReady = true + defaultPath, e := defaultDBPath() + if e != nil { + a.bootstrap = BootstrapStatus{State: BootstrapRecovery, ErrorCode: "DB_DEFAULT_PATH_FAILED", ErrorDetail: e.Error()} + return + } + a.bootstrapFile, e = bootstrapPath() + if e != nil { + a.bootstrap = BootstrapStatus{State: BootstrapRecovery, DefaultPath: defaultPath, ErrorCode: "BOOTSTRAP_PATH_FAILED", ErrorDetail: e.Error()} + return + } + c, e := readBootstrap(a.bootstrapFile) + if e != nil { + if os.IsNotExist(e) { + if _, se := os.Stat(defaultPath); se == nil { + c = BootstrapConfig{DatabasePath: defaultPath, Initialized: true} + _ = writeBootstrap(a.bootstrapFile, c) + } else { + a.bootstrap = BootstrapStatus{State: BootstrapSetup, DefaultPath: defaultPath, DatabasePath: defaultPath} + return + } + } else { + a.bootstrap = BootstrapStatus{State: BootstrapRecovery, DefaultPath: defaultPath, DatabasePath: defaultPath, ErrorCode: "BOOTSTRAP_INVALID", ErrorDetail: e.Error()} + return + } + } + if _, e = os.Stat(c.DatabasePath); e != nil { + code := "DB_FILE_UNREADABLE" + if os.IsNotExist(e) { + code = "DB_FILE_MISSING" + } + a.bootstrap = BootstrapStatus{State: BootstrapRecovery, DefaultPath: defaultPath, DatabasePath: c.DatabasePath, ErrorCode: code, ErrorDetail: e.Error()} + return + } + s, e := OpenStore(c.DatabasePath) + if e != nil { + a.bootstrap = bootstrapFailure(defaultPath, c.DatabasePath, e) + runtime.LogError(ctx, e.Error()) + return + } + a.store = s + a.bootstrap = BootstrapStatus{State: BootstrapReady, DefaultPath: defaultPath, DatabasePath: c.DatabasePath} + a.store.Log("info", "系统", "应用程序启动", "") + a.store.Log("info", "数据库", "桌面运行时已连接", s.path) +} + +func (a *App) GetBootstrapStatus() BootstrapStatus { return a.bootstrap } +func (a *App) SelectInitialDatabaseFile(defaultPath string) (string, error) { + if strings.TrimSpace(defaultPath) == "" { + defaultPath = a.bootstrap.DefaultPath + } + return 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"}}}) +} +func (a *App) InitializeDatabase(path string) (BootstrapStatus, error) { + a.mu.Lock() + defer a.mu.Unlock() + s, e := createValidatedStore(path) + if e != nil { + a.bootstrap = bootstrapFailure(a.bootstrap.DefaultPath, path, e) + return a.bootstrap, e + } + if a.store != nil { + _ = a.store.db.Close() + } + a.store = s + if e = writeBootstrap(a.bootstrapFile, BootstrapConfig{DatabasePath: s.path, Initialized: true}); e != nil { + _ = s.db.Close() + a.store = nil + a.bootstrap = bootstrapFailure(a.bootstrap.DefaultPath, path, coded("BOOTSTRAP_WRITE_FAILED", e)) + return a.bootstrap, e + } + a.bootstrap = BootstrapStatus{State: BootstrapReady, DefaultPath: a.bootstrap.DefaultPath, DatabasePath: s.path} + a.store.Log("info", "数据库", "数据库初始化成功", s.path) + return a.bootstrap, nil +} +func (a *App) RetryDatabase() (BootstrapStatus, error) { + return a.InitializeDatabase(a.bootstrap.DatabasePath) +} +func (a *App) shutdown(context.Context) { + if a.store != nil && a.store.db != nil { + _ = a.store.db.Close() + } +} +func (a *App) ready() error { + if a.store == nil { + return errors.New("DATABASE_NOT_READY") + } + return nil +} +func (a *App) SelectDirectory() (string, error) { + if e := a.ready(); e != nil { + return "", e + } + return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{Title: a.localized("选择代码目录", "Select code directory")}) +} + +// ListWSLDistros 返回本机可用的 WSL 发行版,供前端创建 UNC 项目路径。 +func (a *App) ListWSLDistros() ([]string, error) { return platform.ListWSLDistros(a.ctx) } + +// SelectWSLDirectory 从指定发行版根目录打开原生目录选择器。 +func (a *App) SelectWSLDirectory(distro string) (string, error) { + if strings.TrimSpace(distro) == "" { + return "", errors.New("WSL_DISTRO_REQUIRED") + } + root := "\\\\wsl.localhost\\" + distro + "\\" + return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{Title: a.localized("选择 WSL 代码目录", "Select WSL code directory"), DefaultDirectory: root}) +} + +func (a *App) localized(zh, en string) string { + if a.store != nil { + if s, e := a.store.Settings(); e == nil && s.Locale == "en" { + return en + } + } + return zh +} +func (a *App) SelectDatabaseFile() (string, error) { + return runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{Title: "Select database location", DefaultFilename: "code-count.db", Filters: []runtime.FileFilter{{DisplayName: "SQLite Database", Pattern: "*.db"}}}) +} +func (a *App) ListProjects() ([]Project, error) { + if e := a.ready(); e != nil { + return nil, e + } + return a.store.ListProjects(0) +} +func (a *App) ListProjectsByGroup(groupID int64) ([]Project, error) { + if e := a.ready(); e != nil { + return nil, e + } + return a.store.ListProjects(groupID) +} +func (a *App) ListProjectGroups() ([]ProjectGroup, error) { + if e := a.ready(); e != nil { + return nil, e + } + return a.store.ListProjectGroups() +} +func (a *App) SaveProjectGroup(id int64, name string) (ProjectGroup, error) { + if e := a.ready(); e != nil { + return ProjectGroup{}, e + } + g, e := a.store.SaveProjectGroup(id, name) + if e == nil { + a.store.Log("info", "project", "Project group saved", g.Name) + } + return g, e +} +func (a *App) DeleteProjectGroup(id int64) error { + if e := a.ready(); e != nil { + return e + } + e := a.store.DeleteProjectGroup(id) + if e == nil { + a.store.Log("info", "project", "Project group deleted", fmt.Sprint(id)) + } + return e +} +func (a *App) GetProject(id int64) (Project, error) { + if e := a.ready(); e != nil { + return Project{}, e + } + return a.store.GetProject(id) +} +func (a *App) SaveProject(id int64, in ProjectInput) (Project, error) { + if e := a.ready(); e != nil { + if a.runtimeReady { + runtime.LogError(a.ctx, "SaveProject: "+e.Error()) + } + return Project{}, e + } + p, e := a.store.SaveProject(id, in) + if e == nil { + a.store.Log("info", "项目", "项目保存成功", p.Path) + if a.runtimeReady { + runtime.LogInfo(a.ctx, "Project saved: "+p.Path) + } + } else { + a.store.Log("error", "项目", "项目保存失败", in.Path+" | "+e.Error()) + if a.runtimeReady { + runtime.LogError(a.ctx, "Project save failed: "+in.Path+" | "+e.Error()) + } + } + return p, e +} + +func (a *App) ReportClientError(category, message, detail string) { + if a.store != nil { + a.store.Log("error", category, message, detail) + } + if a.runtimeReady { + runtime.LogError(a.ctx, category+": "+message+" | "+detail) + } +} +func (a *App) DeleteProject(id int64) error { + if e := a.ready(); e != nil { + return e + } + e := a.store.DeleteProject(id) + if e == nil { + a.store.Log("info", "项目", "项目删除成功", fmt.Sprint(id)) + } + return e +} +func (a *App) GetDashboard() (Dashboard, error) { + if e := a.ready(); e != nil { + return Dashboard{}, e + } + return a.store.Dashboard(0) +} +func (a *App) GetDashboardByGroup(groupID int64) (Dashboard, error) { + if e := a.ready(); e != nil { + return Dashboard{}, e + } + return a.store.Dashboard(groupID) +} +func (a *App) GetStructure(id int64) (StructureStats, error) { + if e := a.ready(); e != nil { + return StructureStats{}, e + } + return a.store.Structure(id) +} + +func (a *App) GetProjectInsights(id int64) (ProjectInsights, error) { + if e := a.ready(); e != nil { + return ProjectInsights{}, e + } + x, e := a.store.Insights(id) + if e == nil { + return x, nil + } + if errors.Is(e, sql.ErrNoRows) { + return a.RefreshProjectInsights(id) + } + return ProjectInsights{}, e +} + +func (a *App) RefreshProjectInsights(id int64) (ProjectInsights, error) { + if e := a.ready(); e != nil { + return ProjectInsights{}, e + } + p, e := a.store.GetProject(id) + if e != nil { + return ProjectInsights{}, e + } + structure, e := a.store.Structure(id) + if e != nil { + return ProjectInsights{}, e + } + git, _ := a.store.GitStats(id) + x, e := (service.InsightService{}).Analyze(a.ctx, p, structure, git) + if e != nil { + a.store.Log("error", "项目检查", "深度检查失败", p.Name+" | "+e.Error()) + return x, e + } + if e = a.store.ReplaceInsights(id, x); e != nil { + a.store.Log("error", "项目检查", "保存检查结果失败", p.Name+" | "+e.Error()) + return x, e + } + a.store.Log("info", "项目检查", "深度检查完成", fmt.Sprintf("%s | %d 分 | %d 个问题", p.Name, x.HealthScore, len(x.Issues))) + return x, nil +} + +func (a *App) GetGitStats(id int64) (GitStats, error) { + if e := a.ready(); e != nil { + return GitStats{}, e + } + return a.store.GitStats(id) +} + +// GetGitDiagnostics 分步检查 Git/WSL 状态,用于解释 Git 统计为空或失败的原因。 +func (a *App) GetGitDiagnostics(id int64) (GitDiagnostics, error) { + if e := a.ready(); e != nil { + return GitDiagnostics{}, e + } + p, e := a.store.GetProject(id) + if e != nil { + return GitDiagnostics{}, e + } + d := (GitAnalyzer{}).Diagnostics(a.ctx, p.Path, "") + if d.Available { + a.store.Log("info", "Git分析", "Git 诊断完成", p.Name+" | "+d.WorkspaceBranch) + } else { + a.store.Log("warning", "Git分析", "Git 诊断失败", p.Name+" | "+d.ErrorCode+" | "+d.Detail) + } + return d, nil +} + +// GetGitStatsForRef 只改变统计视图,不执行 checkout。 +func (a *App) GetGitStatsForRef(id int64, ref string) (GitStats, error) { + p, e := a.store.GetProject(id) + if e != nil { + return GitStats{}, e + } + a.store.Log("info", "Git分析", "查询分支统计", p.Name+" | "+ref) + g, e := (GitAnalyzer{}).AnalyzeRef(a.ctx, p.Path, ref) + if e != nil { + a.store.Log("error", "Git分析", "查询分支统计失败", e.Error()) + } + return g, e +} + +// GetCommitDetails 按需查询提交涉及的文件,避免 Git 首页一次加载所有明细。 +func (a *App) GetCommitDetails(id int64, hash string) (GitCommitDetail, error) { + p, e := a.store.GetProject(id) + if e != nil { + return GitCommitDetail{}, e + } + return (GitAnalyzer{}).CommitDetail(a.ctx, p.Path, hash) +} + +// CheckoutBranch 安全切换工作区分支;服务层会拒绝任何脏工作区。 +func (a *App) CheckoutBranch(id int64, ref string) (CheckoutResult, error) { + p, e := a.store.GetProject(id) + if e != nil { + return CheckoutResult{}, e + } + result, e := (GitAnalyzer{}).CheckoutBranch(a.ctx, p.Path, ref) + if e != nil { + a.store.Log("error", "Git分析", "切换分支失败", p.Name+" | "+ref+" | "+e.Error()) + return result, e + } + a.store.Log("info", "Git分析", "切换分支成功", p.Name+" | "+result.Branch) + return result, nil +} +func (a *App) GetRules() ([]ExclusionRule, error) { + if e := a.ready(); e != nil { + return nil, e + } + return a.store.Rules() +} +func (a *App) AddRule(pattern, category string) (ExclusionRule, error) { + if e := a.ready(); e != nil { + return ExclusionRule{}, e + } + return a.store.AddRule(pattern, category) +} +func (a *App) DeleteRule(id int64) error { + if e := a.ready(); e != nil { + return e + } + return a.store.DeleteRule(id) +} +func (a *App) GetLogs(level string) ([]LogEntry, error) { + if e := a.ready(); e != nil { + return nil, e + } + return a.store.Logs(level) +} +func (a *App) ClearLogs() error { + if e := a.ready(); e != nil { + return e + } + return a.store.ClearLogs() +} +func (a *App) GetSettings() (AppSettings, error) { + if e := a.ready(); e != nil { + return AppSettings{}, e + } + return a.store.Settings() +} +func (a *App) SaveSettings(x AppSettings) error { + if e := a.ready(); e != nil { + return e + } + return a.store.SaveSettings(x) +} +func (a *App) ClearData(mode string, projectID int64) error { + if e := a.ready(); e != nil { + return e + } + return a.store.ClearData(mode, projectID) +} + +func (a *App) MigrateDatabase(target string) error { + if e := a.ready(); e != nil { + return e + } + if target == "" { + return errors.New("PATH_REQUIRED") + } + target = filepath.Clean(target) + if target == a.store.path { + return nil + } + if e := os.MkdirAll(filepath.Dir(target), 0755); e != nil { + return e + } + tmp := target + ".tmp" + _ = os.Remove(tmp) + _, _ = a.store.db.Exec(`PRAGMA wal_checkpoint(FULL)`) + src, e := os.Open(a.store.path) + if e != nil { + return e + } + defer src.Close() + dst, e := os.Create(tmp) + if e != nil { + return e + } + if _, e = dst.ReadFrom(src); e != nil { + dst.Close() + os.Remove(tmp) + return e + } + _ = dst.Sync() + dst.Close() + check, e := OpenStore(tmp) + if e != nil { + os.Remove(tmp) + return e + } + var result string + e = check.db.QueryRow(`PRAGMA integrity_check`).Scan(&result) + check.db.Close() + if e != nil || result != "ok" { + os.Remove(tmp) + return errors.New("DATABASE_INTEGRITY_FAILED") + } + if e = os.Rename(tmp, target); e != nil { + return e + } + next, e := OpenStore(target) + if e != nil { + return e + } + old := a.store + a.store = next + _ = old.db.Close() + if e = writeBootstrap(a.bootstrapFile, BootstrapConfig{DatabasePath: target, Initialized: true}); e != nil { + return coded("BOOTSTRAP_WRITE_FAILED", e) + } + a.bootstrap = BootstrapStatus{State: BootstrapReady, DefaultPath: a.bootstrap.DefaultPath, DatabasePath: target} + a.store.Log("info", "数据库", "数据库迁移成功", target) + return nil +} + +func (a *App) StartAnalysis(projectID int64, kind string) (string, error) { + if e := a.ready(); e != nil { + return "", e + } + p, e := a.store.GetProject(projectID) + if e != nil { + return "", e + } + taskID := fmt.Sprintf("%d-%s", projectID, kind) + a.mu.Lock() + if _, ok := a.tasks[taskID]; ok { + a.mu.Unlock() + return "", errors.New("TASK_ALREADY_RUNNING") + } + ctx, cancel := context.WithCancel(a.ctx) + a.tasks[taskID] = cancel + a.mu.Unlock() + go func() { + defer func() { + if recovered := recover(); recovered != nil { + detail := fmt.Sprintf("%v", recovered) + 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.mu.Lock() + delete(a.tasks, taskID) + a.mu.Unlock() + }() + emit := func(stage string, progress int, messageKey string) { + runtime.EventsEmit(a.ctx, "analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: stage, Progress: progress, MessageKey: messageKey, Params: map[string]any{"project": p.Name}}) + } + emit("start", 2, "task.start") + a.store.Log("info", "代码分析", "开始分析项目", p.Name) + var err error + if kind == "all" || kind == "code" { + rules, _ := a.store.Rules() + var ls []LanguageStat + var fs []FileEntry + ls, fs, err = (Scanner{}).Analyze(ctx, p.Path, rules, func(n int, s string) { emit(s, n, s) }) + if err == nil { + err = a.store.ReplaceScan(projectID, ls, fs) + } + } + if err == nil && (kind == "all" || kind == "git") { + emit("git", 80, "task.git") + var g GitStats + g, err = (GitAnalyzer{}).Analyze(ctx, p.Path) + if err == nil { + err = a.store.ReplaceGit(projectID, g) + } else if kind == "all" && (err.Error() == "NOT_GIT_REPOSITORY" || err.Error() == "GIT_NOT_INSTALLED") { + a.store.Log("warning", "Git分析", "已跳过 Git 分析", err.Error()) + err = nil + } + } + if err != nil { + level, stage := "error", "error" + if errors.Is(err, context.Canceled) { + level, stage = "warning", "cancelled" + } + a.store.Log(level, "代码分析", "项目分析失败", p.Name+" | "+err.Error()) + emit(stage, 100, "task.failed") + return + } + a.store.Log("info", "代码分析", "项目分析完成", p.Name) + emit("completed", 100, "task.completed") + }() + return taskID, nil +} +func (a *App) CancelAnalysis(taskID string) error { + a.mu.Lock() + defer a.mu.Unlock() + if c, ok := a.tasks[taskID]; ok { + c() + return nil + } + return errors.New("TASK_NOT_FOUND") +} +func (a *App) StartBatchAnalysis() ([]string, error) { + return a.StartBatchAnalysisByGroup(0) +} +func (a *App) StartBatchAnalysisByGroup(groupID int64) ([]string, error) { + ps, e := a.store.ListProjects(groupID) + if e != nil { + return nil, e + } + ids := make([]string, 0, len(ps)) + for _, p := range ps { + ids = append(ids, fmt.Sprintf("%d-all", p.ID)) + } + go func() { + for _, p := range ps { + tid, e := a.StartAnalysis(p.ID, "all") + if e != nil { + continue + } + for { + a.mu.Lock() + _, running := a.tasks[tid] + a.mu.Unlock() + if !running { + break + } + select { + case <-a.ctx.Done(): + return + case <-time.After(100 * time.Millisecond): + } + } + } + }() + return ids, nil +} diff --git a/app_test.go b/app_test.go new file mode 100644 index 0000000..2f4a63a --- /dev/null +++ b/app_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "path/filepath" + "strings" + "testing" +) + +func TestAppSaveProjectWritesProjectAndLog(t *testing.T) { + s, e := OpenStore(filepath.Join(t.TempDir(), "app.db")) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + a := &App{ctx: context.Background(), store: s, tasks: map[string]context.CancelFunc{}} + dir := t.TempDir() + p, e := a.SaveProject(0, ProjectInput{Name: "saved", Path: dir}) + if e != nil { + t.Fatal(e) + } + if p.Path != dir { + t.Fatalf("path=%q", p.Path) + } + projects, e := s.ListProjects(0) + if e != nil { + t.Fatal(e) + } + if len(projects) != 1 { + t.Fatalf("projects=%d", len(projects)) + } + logs, e := s.Logs("all") + if e != nil { + t.Fatal(e) + } + if len(logs) == 0 || logs[0].Message != "项目保存成功" { + t.Fatalf("missing save log: %#v", logs) + } +} + +func TestAppSaveProjectFailureIsLogged(t *testing.T) { + s, e := OpenStore(filepath.Join(t.TempDir(), "app.db")) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + a := &App{ctx: context.Background(), store: s, tasks: map[string]context.CancelFunc{}} + _, e = a.SaveProject(0, ProjectInput{Name: "missing", Path: filepath.Join(t.TempDir(), "missing")}) + if e == nil { + t.Fatal("invalid project saved") + } + logs, e := s.Logs("error") + if e != nil { + t.Fatal(e) + } + if len(logs) == 0 || !strings.Contains(logs[0].Detail, "PROJECT_PATH_NOT_FOUND") { + t.Fatalf("missing failure log: %#v", logs) + } +} diff --git a/bootstrap.go b/bootstrap.go new file mode 100644 index 0000000..532667f --- /dev/null +++ b/bootstrap.go @@ -0,0 +1,138 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + BootstrapReady = "ready" + BootstrapSetup = "setup_required" + BootstrapRecovery = "recovery_required" +) + +type BootstrapConfig struct { + DatabasePath string `json:"databasePath"` + Initialized bool `json:"initialized"` +} + +type BootstrapStatus struct { + State string `json:"state"` + DatabasePath string `json:"databasePath"` + DefaultPath string `json:"defaultPath"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorDetail string `json:"errorDetail,omitempty"` +} + +type CodedError struct{ Code, Detail string } + +func (e *CodedError) Error() string { + if e.Detail == "" { + return e.Code + } + return e.Code + ": " + e.Detail +} +func coded(code string, err error) error { + if err == nil { + return &CodedError{Code: code} + } + return &CodedError{Code: code, Detail: err.Error()} +} + +func bootstrapPath() (string, error) { + d, e := os.UserConfigDir() + if e != nil { + return "", e + } + return filepath.Join(d, "CodeCount", "bootstrap.json"), nil +} +func readBootstrap(path string) (BootstrapConfig, error) { + b, e := os.ReadFile(path) + if e != nil { + return BootstrapConfig{}, e + } + var c BootstrapConfig + if e = json.Unmarshal(b, &c); e != nil { + return c, e + } + if !c.Initialized || strings.TrimSpace(c.DatabasePath) == "" { + return c, errors.New("invalid bootstrap configuration") + } + return c, nil +} +func writeBootstrap(path string, c BootstrapConfig) error { + if e := os.MkdirAll(filepath.Dir(path), 0755); e != nil { + return e + } + b, e := json.MarshalIndent(c, "", " ") + if e != nil { + return e + } + tmp := path + ".tmp" + if e = os.WriteFile(tmp, b, 0600); e != nil { + return e + } + if e = os.Rename(tmp, path); e != nil { + _ = os.Remove(tmp) + return e + } + return nil +} + +func createValidatedStore(target string) (*Store, error) { + target = filepath.Clean(strings.TrimSpace(target)) + if target == "" { + return nil, coded("DB_PATH_REQUIRED", nil) + } + dir := filepath.Dir(target) + if e := os.MkdirAll(dir, 0755); e != nil { + return nil, coded("DB_DIRECTORY_UNWRITABLE", e) + } + probe := filepath.Join(dir, ".code-count-write-test") + if e := os.WriteFile(probe, []byte("ok"), 0600); e != nil { + return nil, coded("DB_DIRECTORY_UNWRITABLE", e) + } + _ = os.Remove(probe) + if _, e := os.Stat(target); e == nil { + return OpenStore(target) + } else if !os.IsNotExist(e) { + return nil, coded("DB_FILE_UNREADABLE", e) + } + tmp := target + ".initializing" + _ = os.Remove(tmp) + _ = os.Remove(tmp + "-wal") + _ = os.Remove(tmp + "-shm") + s, e := OpenStore(tmp) + if e != nil { + return nil, coded("DB_INITIALIZE_FAILED", e) + } + var result string + e = s.db.QueryRow(`PRAGMA integrity_check`).Scan(&result) + _ = s.db.Close() + if e != nil || result != "ok" { + _ = os.Remove(tmp) + return nil, coded("DB_INTEGRITY_FAILED", e) + } + if e = os.Rename(tmp, target); e != nil { + _ = os.Remove(tmp) + return nil, coded("DB_INITIALIZE_FAILED", e) + } + s, e = OpenStore(target) + if e != nil { + return nil, coded("DB_OPEN_FAILED", e) + } + return s, nil +} + +func bootstrapFailure(defaultPath, path string, e error) BootstrapStatus { + code := "DB_OPEN_FAILED" + var ce *CodedError + if errors.As(e, &ce) { + code = ce.Code + } + return BootstrapStatus{State: BootstrapRecovery, DatabasePath: path, DefaultPath: defaultPath, ErrorCode: code, ErrorDetail: fmt.Sprint(e)} +} diff --git a/bootstrap_test.go b/bootstrap_test.go new file mode 100644 index 0000000..72d8347 --- /dev/null +++ b/bootstrap_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestBootstrapRoundTrip(t *testing.T) { + p := filepath.Join(t.TempDir(), "bootstrap.json") + want := BootstrapConfig{DatabasePath: filepath.Join(t.TempDir(), "data.db"), Initialized: true} + if e := writeBootstrap(p, want); e != nil { + t.Fatal(e) + } + got, e := readBootstrap(p) + if e != nil { + t.Fatal(e) + } + if got != want { + t.Fatalf("got %#v want %#v", got, want) + } +} +func TestInvalidBootstrap(t *testing.T) { + p := filepath.Join(t.TempDir(), "bootstrap.json") + if e := os.WriteFile(p, []byte("{"), 0600); e != nil { + t.Fatal(e) + } + if _, e := readBootstrap(p); e == nil { + t.Fatal("invalid bootstrap accepted") + } +} +func TestCreateValidatedStore(t *testing.T) { + p := filepath.Join(t.TempDir(), "nested", "code-count.db") + s, e := createValidatedStore(p) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + if _, e = os.Stat(p); e != nil { + t.Fatal(e) + } + var result string + if e = s.db.QueryRow(`PRAGMA integrity_check`).Scan(&result); e != nil || result != "ok" { + t.Fatalf("integrity: %q %v", result, e) + } + if _, e = os.Stat(p + ".initializing"); !os.IsNotExist(e) { + t.Fatal("temporary database remains") + } +} +func TestCreateValidatedStoreRejectsEmptyPath(t *testing.T) { + if _, e := createValidatedStore(""); e == nil { + t.Fatal("empty path accepted") + } +} diff --git a/browser-blocked.png b/browser-blocked.png new file mode 100644 index 0000000..b54d9a7 Binary files /dev/null and b/browser-blocked.png differ diff --git a/build/README.md b/build/README.md new file mode 100644 index 0000000..1ae2f67 --- /dev/null +++ b/build/README.md @@ -0,0 +1,35 @@ +# Build Directory + +The build directory is used to house all the build files and assets for your application. + +The structure is: + +* bin - Output directory +* darwin - macOS specific files +* windows - Windows specific files + +## Mac + +The `darwin` directory holds files specific to Mac builds. +These may be customised and used as part of the build. To return these files to the default state, simply delete them +and +build with `wails build`. + +The directory contains the following files: + +- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`. +- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`. + +## Windows + +The `windows` directory contains the manifest and rc files used when building with `wails build`. +These may be customised for your application. To return these files to the default state, simply delete them and +build with `wails build`. + +- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to + use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file + will be created using the `appicon.png` file in the build directory. +- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`. +- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer, + as well as the application itself (right click the exe -> properties -> details) +- `wails.exe.manifest` - The main application manifest file. \ No newline at end of file diff --git a/build/appicon.png b/build/appicon.png new file mode 100644 index 0000000..63617fe Binary files /dev/null and b/build/appicon.png differ diff --git a/build/bin.rar b/build/bin.rar new file mode 100644 index 0000000..5d8f067 Binary files /dev/null and b/build/bin.rar differ diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist new file mode 100644 index 0000000..14121ef --- /dev/null +++ b/build/darwin/Info.dev.plist @@ -0,0 +1,68 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + com.wails.{{.Name}} + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist new file mode 100644 index 0000000..d17a747 --- /dev/null +++ b/build/darwin/Info.plist @@ -0,0 +1,63 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + com.wails.{{.Name}} + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + + diff --git a/build/windows/icon.ico b/build/windows/icon.ico new file mode 100644 index 0000000..f334798 Binary files /dev/null and b/build/windows/icon.ico differ diff --git a/build/windows/info.json b/build/windows/info.json new file mode 100644 index 0000000..9727946 --- /dev/null +++ b/build/windows/info.json @@ -0,0 +1,15 @@ +{ + "fixed": { + "file_version": "{{.Info.ProductVersion}}" + }, + "info": { + "0000": { + "ProductVersion": "{{.Info.ProductVersion}}", + "CompanyName": "{{.Info.CompanyName}}", + "FileDescription": "{{.Info.ProductName}}", + "LegalCopyright": "{{.Info.Copyright}}", + "ProductName": "{{.Info.ProductName}}", + "Comments": "{{.Info.Comments}}" + } + } +} \ No newline at end of file diff --git a/build/windows/installer/project.nsi b/build/windows/installer/project.nsi new file mode 100644 index 0000000..654ae2e --- /dev/null +++ b/build/windows/installer/project.nsi @@ -0,0 +1,114 @@ +Unicode true + +#### +## Please note: Template replacements don't work in this file. They are provided with default defines like +## mentioned underneath. +## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo. +## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually +## from outside of Wails for debugging and development of the installer. +## +## For development first make a wails nsis build to populate the "wails_tools.nsh": +## > wails build --target windows/amd64 --nsis +## Then you can call makensis on this file with specifying the path to your binary: +## For a AMD64 only installer: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe +## For a ARM64 only installer: +## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe +## For a installer with both architectures: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe +#### +## The following information is taken from the ProjectInfo file, but they can be overwritten here. +#### +## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}" +## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}" +## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}" +## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}" +## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}" +### +## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe" +## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +#### +## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html +#### +## Include the wails tools +#### +!include "wails_tools.nsh" + +# The version information for this two must consist of 4 parts +VIProductVersion "${INFO_PRODUCTVERSION}.0" +VIFileVersion "${INFO_PRODUCTVERSION}.0" + +VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}" +VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer" +VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}" +VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}" + +# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware +ManifestDPIAware true + +!include "MUI.nsh" + +!define MUI_ICON "..\icon.ico" +!define MUI_UNICON "..\icon.ico" +# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314 +!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps +!define MUI_ABORTWARNING # This will warn the user if they exit from the installer. + +!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page. +# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer +!insertmacro MUI_PAGE_DIRECTORY # In which folder install page. +!insertmacro MUI_PAGE_INSTFILES # Installing page. +!insertmacro MUI_PAGE_FINISH # Finished installation page. + +!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page + +!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer + +## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1 +#!uninstfinalize 'signtool --file "%1"' +#!finalize 'signtool --file "%1"' + +Name "${INFO_PRODUCTNAME}" +OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file. +InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder). +ShowInstDetails show # This will always show the installation details. + +Function .onInit + !insertmacro wails.checkArchitecture +FunctionEnd + +Section + !insertmacro wails.setShellContext + + !insertmacro wails.webview2runtime + + SetOutPath $INSTDIR + + !insertmacro wails.files + + CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + + !insertmacro wails.associateFiles + !insertmacro wails.associateCustomProtocols + + !insertmacro wails.writeUninstaller +SectionEnd + +Section "uninstall" + !insertmacro wails.setShellContext + + RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath + + RMDir /r $INSTDIR + + Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" + Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" + + !insertmacro wails.unassociateFiles + !insertmacro wails.unassociateCustomProtocols + + !insertmacro wails.deleteUninstaller +SectionEnd diff --git a/build/windows/installer/wails_tools.nsh b/build/windows/installer/wails_tools.nsh new file mode 100644 index 0000000..2f6d321 --- /dev/null +++ b/build/windows/installer/wails_tools.nsh @@ -0,0 +1,249 @@ +# DO NOT EDIT - Generated automatically by `wails build` + +!include "x64.nsh" +!include "WinVer.nsh" +!include "FileFunc.nsh" + +!ifndef INFO_PROJECTNAME + !define INFO_PROJECTNAME "{{.Name}}" +!endif +!ifndef INFO_COMPANYNAME + !define INFO_COMPANYNAME "{{.Info.CompanyName}}" +!endif +!ifndef INFO_PRODUCTNAME + !define INFO_PRODUCTNAME "{{.Info.ProductName}}" +!endif +!ifndef INFO_PRODUCTVERSION + !define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}" +!endif +!ifndef INFO_COPYRIGHT + !define INFO_COPYRIGHT "{{.Info.Copyright}}" +!endif +!ifndef PRODUCT_EXECUTABLE + !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" +!endif +!ifndef UNINST_KEY_NAME + !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +!endif +!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}" + +!ifndef REQUEST_EXECUTION_LEVEL + !define REQUEST_EXECUTION_LEVEL "admin" +!endif + +RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" + +!ifdef ARG_WAILS_AMD64_BINARY + !define SUPPORTS_AMD64 +!endif + +!ifdef ARG_WAILS_ARM64_BINARY + !define SUPPORTS_ARM64 +!endif + +!ifdef SUPPORTS_AMD64 + !ifdef SUPPORTS_ARM64 + !define ARCH "amd64_arm64" + !else + !define ARCH "amd64" + !endif +!else + !ifdef SUPPORTS_ARM64 + !define ARCH "arm64" + !else + !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY" + !endif +!endif + +!macro wails.checkArchitecture + !ifndef WAILS_WIN10_REQUIRED + !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later." + !endif + + !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED + !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}" + !endif + + ${If} ${AtLeastWin10} + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + Goto ok + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + Goto ok + ${EndIf} + !endif + + IfSilent silentArch notSilentArch + silentArch: + SetErrorLevel 65 + Abort + notSilentArch: + MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}" + Quit + ${else} + IfSilent silentWin notSilentWin + silentWin: + SetErrorLevel 64 + Abort + notSilentWin: + MessageBox MB_OK "${WAILS_WIN10_REQUIRED}" + Quit + ${EndIf} + + ok: +!macroend + +!macro wails.files + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}" + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}" + ${EndIf} + !endif +!macroend + +!macro wails.writeUninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + SetRegView 64 + WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" + WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" + + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" +!macroend + +!macro wails.deleteUninstaller + Delete "$INSTDIR\uninstall.exe" + + SetRegView 64 + DeleteRegKey HKLM "${UNINST_KEY}" +!macroend + +!macro wails.setShellContext + ${If} ${REQUEST_EXECUTION_LEVEL} == "admin" + SetShellVarContext all + ${else} + SetShellVarContext current + ${EndIf} +!macroend + +# Install webview2 by launching the bootstrapper +# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment +!macro wails.webview2runtime + !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT + !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime" + !endif + + SetRegView 64 + # If the admin key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + + ${If} ${REQUEST_EXECUTION_LEVEL} == "user" + # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + ${EndIf} + + SetDetailsPrint both + DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}" + SetDetailsPrint listonly + + InitPluginsDir + CreateDirectory "$pluginsdir\webview2bootstrapper" + SetOutPath "$pluginsdir\webview2bootstrapper" + File "tmp\MicrosoftEdgeWebview2Setup.exe" + ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' + + SetDetailsPrint both + ok: +!macroend + +# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b +!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0" + + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}" + + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open" + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}` +!macroend + +!macro APP_UNASSOCIATE EXT FILECLASS + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup` + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0" + + DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}` +!macroend + +!macro wails.associateFiles + ; Create file associations + {{range .Info.FileAssociations}} + !insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" + + File "..\{{.IconName}}.ico" + {{end}} +!macroend + +!macro wails.unassociateFiles + ; Delete app associations + {{range .Info.FileAssociations}} + !insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}" + + Delete "$INSTDIR\{{.IconName}}.ico" + {{end}} +!macroend + +!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}" +!macroend + +!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" +!macroend + +!macro wails.associateCustomProtocols + ; Create custom protocols associations + {{range .Info.Protocols}} + !insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" + + {{end}} +!macroend + +!macro wails.unassociateCustomProtocols + ; Delete app custom protocol associations + {{range .Info.Protocols}} + !insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}" + {{end}} +!macroend diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest new file mode 100644 index 0000000..17e1a23 --- /dev/null +++ b/build/windows/wails.exe.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + \ No newline at end of file diff --git a/dashboard-1024.png b/dashboard-1024.png new file mode 100644 index 0000000..7db7a14 Binary files /dev/null and b/dashboard-1024.png differ diff --git a/dashboard-glass.png b/dashboard-glass.png new file mode 100644 index 0000000..c704f85 Binary files /dev/null and b/dashboard-glass.png differ diff --git a/dashboard-v2.png b/dashboard-v2.png new file mode 100644 index 0000000..d5a289e Binary files /dev/null and b/dashboard-v2.png differ diff --git a/dashboard.png b/dashboard.png new file mode 100644 index 0000000..c0325a8 Binary files /dev/null and b/dashboard.png differ diff --git a/database-settings.png b/database-settings.png new file mode 100644 index 0000000..1f15f9c Binary files /dev/null and b/database-settings.png differ diff --git a/database-setup.png b/database-setup.png new file mode 100644 index 0000000..c0a58e4 Binary files /dev/null and b/database-setup.png differ diff --git a/database.go b/database.go new file mode 100644 index 0000000..4299a5b --- /dev/null +++ b/database.go @@ -0,0 +1,739 @@ +package main + +import ( + "database/sql" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" + + _ "modernc.org/sqlite" +) + +type Store struct { + mu sync.RWMutex + db *sql.DB + path string +} + +func defaultDBPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + dir = filepath.Join(dir, "CodeCount") + if err = os.MkdirAll(dir, 0755); err != nil { + return "", err + } + return filepath.Join(dir, "code-count.db"), nil +} + +func OpenStore(path string) (*Store, error) { + if path == "" { + var err error + path, err = defaultDBPath() + if err != nil { + return nil, err + } + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + s := &Store{db: db, path: path} + if err = s.migrate(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) migrate() error { + stmts := []string{ + `PRAGMA journal_mode=WAL`, `PRAGMA foreign_keys=ON`, + `CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS project_groups(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS projects(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, path TEXT NOT NULL UNIQUE, description TEXT NOT NULL DEFAULT '', group_id INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS analysis_runs(id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL, kind TEXT NOT NULL, status TEXT NOT NULL, started_at TEXT NOT NULL, completed_at TEXT, error TEXT, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, + `CREATE TABLE IF NOT EXISTS language_stats(project_id INTEGER NOT NULL, name TEXT NOT NULL, files INTEGER NOT NULL, code INTEGER NOT NULL, comments INTEGER NOT NULL, blanks INTEGER NOT NULL, PRIMARY KEY(project_id,name), 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_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)`, + `CREATE TABLE IF NOT EXISTS contributors(project_id INTEGER NOT NULL, email TEXT NOT NULL, name TEXT NOT NULL, commits INTEGER NOT NULL, added INTEGER NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(project_id,email), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, + `CREATE TABLE IF NOT EXISTS project_insights(project_id INTEGER PRIMARY KEY, health_score INTEGER NOT NULL, generated_at TEXT NOT NULL, high INTEGER NOT NULL, medium INTEGER NOT NULL, low INTEGER NOT NULL, todo_count INTEGER NOT NULL, long_files INTEGER NOT NULL, large_files INTEGER NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, + `CREATE TABLE IF NOT EXISTS insight_issues(project_id INTEGER NOT NULL, idx INTEGER NOT NULL, severity TEXT NOT NULL, type TEXT NOT NULL, title TEXT NOT NULL, detail TEXT NOT NULL, path TEXT NOT NULL DEFAULT '', line INTEGER NOT NULL DEFAULT 0, suggestion TEXT NOT NULL DEFAULT '', evidence TEXT NOT NULL DEFAULT '', PRIMARY KEY(project_id,idx), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, + `CREATE TABLE IF NOT EXISTS exclusion_rules(id INTEGER PRIMARY KEY AUTOINCREMENT, pattern TEXT NOT NULL UNIQUE, category TEXT NOT NULL, builtin INTEGER NOT NULL DEFAULT 0)`, + `CREATE TABLE IF NOT EXISTS app_logs(id INTEGER PRIMARY KEY AUTOINCREMENT, level TEXT NOT NULL, category TEXT NOT NULL, message TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL)`, + } + for _, q := range stmts { + if _, err := s.db.Exec(q); err != nil { + return fmt.Errorf("database migration: %w", err) + } + } + if ok, err := s.columnExists("projects", "group_id"); err != nil { + return err + } else if !ok { + if _, err = s.db.Exec(`ALTER TABLE projects ADD COLUMN group_id INTEGER NOT NULL DEFAULT 1`); err != nil { + return fmt.Errorf("database migration: %w", err) + } + } + _, _ = s.db.Exec(`INSERT OR IGNORE INTO project_groups(id,name,created_at,updated_at) VALUES(1,'My Projects',?,?)`, time.Now().Format(time.RFC3339), time.Now().Format(time.RFC3339)) + _, _ = s.db.Exec(`UPDATE projects SET group_id=1 WHERE group_id IS NULL OR group_id=0`) + defaults := map[string][]string{"general": {".git", ".idea", ".vscode", "node_modules", "dist", "coverage", "*.min.js", "*.min.css", ".DS_Store"}, "php": {"vendor", "storage/framework", "bootstrap/cache", ".phpunit.cache"}, "go": {"go.sum"}, "vue": {".nuxt", ".next", ".output", "unpackage"}} + for category, patterns := range defaults { + for _, p := range patterns { + _, _ = s.db.Exec(`INSERT OR IGNORE INTO exclusion_rules(pattern,category,builtin) VALUES(?,?,1)`, p, category) + } + } + _, _ = s.db.Exec(`INSERT OR IGNORE INTO settings(key,value) VALUES('theme','dark'),('locale','zh-CN'),('gitScope','current'),('autoRefresh','true'),('glassOpacity','55'),('loadingStyle','fullscreen-orbit')`) + _, _ = s.db.Exec(`UPDATE settings SET value='fullscreen-orbit' WHERE key='loadingStyle' AND value IN ('bar','fullscreen') AND NOT EXISTS(SELECT 1 FROM settings WHERE key='loadingStyleSet' AND value='true')`) + _, _ = s.db.Exec(`INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(1,?)`, time.Now().Format(time.RFC3339)) + // 旧版本曾写入英文日志;迁移时转换已知固定文案,Git 提交消息等用户内容不做修改。 + translations := map[string]string{"Application started": "应用程序启动", "Desktop runtime connected": "桌面运行时已连接", "Project saved": "项目保存成功", "Project save failed": "项目保存失败", "Project deleted": "项目删除成功", "Analysis completed": "项目分析完成", "Analysis failed": "项目分析失败", "Database initialized": "数据库初始化成功", "Database migrated": "数据库迁移成功"} + for en, zh := range translations { + _, _ = s.db.Exec(`UPDATE app_logs SET message=? WHERE message=?`, zh, en) + } + _, _ = s.db.Exec(`UPDATE app_logs SET category='系统' WHERE category='system'`) + _, _ = s.db.Exec(`UPDATE app_logs SET category='数据库' WHERE category='database'`) + _, _ = s.db.Exec(`UPDATE app_logs SET category='项目' WHERE category='project'`) + _, _ = s.db.Exec(`UPDATE app_logs SET category='代码分析' WHERE category='analysis'`) + _, _ = s.db.Exec(`UPDATE app_logs SET category='Git分析' WHERE category='git'`) + return nil +} + +func (s *Store) columnExists(table, column string) (bool, error) { + rows, err := s.db.Query(`PRAGMA table_info(` + table + `)`) + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var cid int + var name, typ string + var notNull, pk int + var dflt sql.NullString + if err = rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil { + return false, err + } + if name == column { + return true, nil + } + } + return false, rows.Err() +} + +func normalizePath(p string) (string, error) { + p = strings.TrimSpace(p) + if p == "" { + return "", errors.New("PATH_REQUIRED") + } + a, e := filepath.Abs(p) + if e != nil { + return "", e + } + a = filepath.Clean(a) + if st, e := os.Stat(a); e != nil { + if os.IsNotExist(e) { + return "", coded("PROJECT_PATH_NOT_FOUND", e) + } + return "", coded("PROJECT_PATH_UNREADABLE", e) + } else if !st.IsDir() { + return "", coded("PROJECT_PATH_NOT_DIRECTORY", nil) + } + d, e := os.Open(a) + if e != nil { + return "", coded("PROJECT_PATH_UNREADABLE", e) + } + _, e = d.Readdirnames(1) + _ = d.Close() + if e != nil && !errors.Is(e, io.EOF) { + return "", coded("PROJECT_PATH_UNREADABLE", e) + } + return a, nil +} +func now() string { return time.Now().Format(time.RFC3339) } + +func (s *Store) ListProjectGroups() ([]ProjectGroup, error) { + rows, e := s.db.Query(`SELECT id,name,created_at,updated_at FROM project_groups ORDER BY id=1 DESC, name COLLATE NOCASE`) + if e != nil { + return nil, e + } + defer rows.Close() + out := []ProjectGroup{} + for rows.Next() { + var g ProjectGroup + if e = rows.Scan(&g.ID, &g.Name, &g.CreatedAt, &g.UpdatedAt); e != nil { + return nil, e + } + out = append(out, g) + } + return out, rows.Err() +} +func (s *Store) SaveProjectGroup(id int64, name string) (ProjectGroup, error) { + name = strings.TrimSpace(name) + if name == "" { + return ProjectGroup{}, errors.New("PROJECT_GROUP_NAME_REQUIRED") + } + t := now() + if id == 0 { + r, e := s.db.Exec(`INSERT INTO project_groups(name,created_at,updated_at) VALUES(?,?,?)`, name, t, t) + if e != nil { + if strings.Contains(strings.ToLower(e.Error()), "unique") { + return ProjectGroup{}, errors.New("PROJECT_GROUP_DUPLICATE") + } + return ProjectGroup{}, e + } + id, _ = r.LastInsertId() + } else { + if id == 1 { + return ProjectGroup{}, errors.New("PROJECT_GROUP_DEFAULT_READONLY") + } + _, e := s.db.Exec(`UPDATE project_groups SET name=?,updated_at=? WHERE id=?`, name, t, id) + if e != nil { + if strings.Contains(strings.ToLower(e.Error()), "unique") { + return ProjectGroup{}, errors.New("PROJECT_GROUP_DUPLICATE") + } + return ProjectGroup{}, e + } + } + var g ProjectGroup + e := s.db.QueryRow(`SELECT id,name,created_at,updated_at FROM project_groups WHERE id=?`, id).Scan(&g.ID, &g.Name, &g.CreatedAt, &g.UpdatedAt) + return g, e +} +func (s *Store) DeleteProjectGroup(id int64) error { + if id == 1 { + return errors.New("PROJECT_GROUP_DEFAULT_READONLY") + } + tx, e := s.db.Begin() + if e != nil { + return e + } + defer tx.Rollback() + if _, e = tx.Exec(`UPDATE projects SET group_id=1,updated_at=? WHERE group_id=?`, now(), id); e != nil { + return e + } + if _, e = tx.Exec(`DELETE FROM project_groups WHERE id=?`, id); e != nil { + return e + } + return tx.Commit() +} +func (s *Store) ListProjects(groupID int64) ([]Project, error) { + q := `SELECT p.id,p.name,p.path,p.description,p.group_id,COALESCE(g.name,''),p.created_at,p.updated_at FROM projects p LEFT JOIN project_groups g ON g.id=p.group_id` + args := []any{} + if groupID > 0 { + q += ` WHERE p.group_id=?` + args = append(args, groupID) + } + q += ` ORDER BY p.updated_at DESC` + rows, e := s.db.Query(q, args...) + if e != nil { + return nil, e + } + out := []Project{} + for rows.Next() { + var p Project + if e = rows.Scan(&p.ID, &p.Name, &p.Path, &p.Description, &p.GroupID, &p.GroupName, &p.CreatedAt, &p.UpdatedAt); e != nil { + rows.Close() + return nil, e + } + out = append(out, p) + } + if e = rows.Err(); e != nil { + rows.Close() + return nil, e + } + if e = rows.Close(); e != nil { + return nil, e + } + for i := range out { + out[i].Stats, _ = s.projectStats(out[i].ID) + out[i].Languages, _ = s.languages(out[i].ID) + } + return out, nil +} +func (s *Store) GetProject(id int64) (Project, error) { + var p Project + e := s.db.QueryRow(`SELECT p.id,p.name,p.path,p.description,p.group_id,COALESCE(g.name,''),p.created_at,p.updated_at FROM projects p LEFT JOIN project_groups g ON g.id=p.group_id WHERE p.id=?`, id).Scan(&p.ID, &p.Name, &p.Path, &p.Description, &p.GroupID, &p.GroupName, &p.CreatedAt, &p.UpdatedAt) + if e != nil { + return p, e + } + p.Stats, _ = s.projectStats(id) + p.Languages, _ = s.languages(id) + return p, nil +} +func (s *Store) SaveProject(id int64, in ProjectInput) (Project, error) { + p, e := normalizePath(in.Path) + if e != nil { + return Project{}, e + } + name := strings.TrimSpace(in.Name) + if name == "" { + name = filepath.Base(p) + } + groupID := in.GroupID + if groupID <= 0 { + groupID = 1 + } + var exists int + if e = s.db.QueryRow(`SELECT COUNT(*) FROM project_groups WHERE id=?`, groupID).Scan(&exists); e != nil { + return Project{}, e + } + if exists == 0 { + return Project{}, errors.New("PROJECT_GROUP_NOT_FOUND") + } + t := now() + if id == 0 { + r, e := s.db.Exec(`INSERT INTO projects(name,path,description,group_id,created_at,updated_at) VALUES(?,?,?,?,?,?)`, name, p, in.Description, groupID, t, t) + if e != nil { + if strings.Contains(strings.ToLower(e.Error()), "unique") { + return Project{}, coded("PROJECT_PATH_DUPLICATE", e) + } + return Project{}, coded("PROJECT_SAVE_FAILED", e) + } + id, _ = r.LastInsertId() + } else { + _, e = s.db.Exec(`UPDATE projects SET name=?,path=?,description=?,group_id=?,updated_at=? WHERE id=?`, name, p, in.Description, groupID, t, id) + if e != nil { + return Project{}, e + } + } + return s.GetProject(id) +} +func (s *Store) DeleteProject(id int64) error { + _, e := s.db.Exec(`DELETE FROM projects WHERE id=?`, id) + return e +} +func (s *Store) projectStats(id int64) (ProjectStats, error) { + var x ProjectStats + e := s.db.QueryRow(`SELECT COALESCE(SUM(code+comments+blanks),0),COALESCE(SUM(code),0),COALESCE(SUM(comments),0),COALESCE(SUM(blanks),0),COALESCE(SUM(files),0) FROM language_stats WHERE project_id=?`, id).Scan(&x.TotalLines, &x.CodeLines, &x.CommentLines, &x.BlankLines, &x.FileCount) + if e != nil { + return x, e + } + _ = s.db.QueryRow(`SELECT COUNT(*),COALESCE(SUM(added),0),COALESCE(SUM(deleted),0),COUNT(DISTINCT email) FROM git_commits WHERE project_id=?`, id).Scan(&x.CommitCount, &x.AddedLines, &x.DeletedLines, &x.ContributorCount) + _ = s.db.QueryRow(`SELECT COALESCE(MAX(completed_at),'') FROM analysis_runs WHERE project_id=? AND status='completed'`, id).Scan(&x.LastAnalyzed) + return x, nil +} +func (s *Store) languages(id int64) ([]LanguageStat, error) { + r, e := s.db.Query(`SELECT name,files,code,comments,blanks FROM language_stats WHERE project_id=? ORDER BY code DESC`, id) + if e != nil { + return nil, e + } + defer r.Close() + o := []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 + } + o = append(o, x) + } + return o, r.Err() +} +func (s *Store) Dashboard(groupID int64) (Dashboard, error) { + var d Dashboard + 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) + 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) + return d, e +} + +func (s *Store) ReplaceScan(id int64, langs []LanguageStat, files []FileEntry) error { + tx, e := s.db.Begin() + if e != nil { + return e + } + defer tx.Rollback() + if _, e = tx.Exec(`DELETE FROM language_stats WHERE project_id=?`, id); e != nil { + return e + } + if _, e = tx.Exec(`DELETE FROM file_entries WHERE project_id=?`, id); e != nil { + return e + } + for _, x := range langs { + if _, e = tx.Exec(`INSERT INTO language_stats VALUES(?,?,?,?,?,?)`, id, x.Name, x.Files, x.Code, x.Comments, x.Blanks); e != nil { + return e + } + } + for _, x := range files { + if _, e = tx.Exec(`INSERT INTO file_entries VALUES(?,?,?,?,?,?,?)`, id, x.Path, x.Name, x.Extension, x.Size, boolInt(x.IsDir), x.Parent); e != nil { + return e + } + } + _, e = tx.Exec(`INSERT INTO analysis_runs(project_id,kind,status,started_at,completed_at) VALUES(?,'scan','completed',?,?)`, id, now(), now()) + if e != nil { + return e + } + return tx.Commit() +} +func boolInt(v bool) int { + if v { + return 1 + } + return 0 +} +func (s *Store) Structure(id int64) (StructureStats, error) { + r, e := s.db.Query(`SELECT path,name,extension,size,is_dir,parent FROM file_entries WHERE project_id=? ORDER BY path`, id) + if e != nil { + return StructureStats{}, e + } + defer r.Close() + x := StructureStats{Files: []FileEntry{}, LargeFiles: []FileEntry{}} + fm := map[string]*FolderStat{} + em := map[string]*ExtensionStat{} + for r.Next() { + var f FileEntry + var d int + if e = r.Scan(&f.Path, &f.Name, &f.Extension, &f.Size, &d, &f.Parent); e != nil { + return x, e + } + f.IsDir = d == 1 + x.Files = append(x.Files, f) + if f.IsDir { + x.TotalDirs++ + continue + } + x.TotalFiles++ + x.TotalSize += f.Size + if f.Size >= 1024*1024 { + x.LargeFiles = append(x.LargeFiles, f) + } + top := strings.Split(filepath.ToSlash(f.Path), "/")[0] + if top == f.Name { + top = "/" + } + if fm[top] == nil { + fm[top] = &FolderStat{Name: top} + } + fm[top].Files++ + fm[top].Size += f.Size + ext := f.Extension + if ext == "" { + ext = "(no extension)" + } + if em[ext] == nil { + em[ext] = &ExtensionStat{Extension: ext} + } + em[ext].Files++ + em[ext].Size += f.Size + } + for _, v := range fm { + x.Folders = append(x.Folders, *v) + } + for _, v := range em { + x.Extensions = append(x.Extensions, *v) + } + sort.Slice(x.Folders, func(i, j int) bool { return x.Folders[i].Size > x.Folders[j].Size }) + sort.Slice(x.Extensions, func(i, j int) bool { return x.Extensions[i].Size > x.Extensions[j].Size }) + sort.Slice(x.LargeFiles, func(i, j int) bool { return x.LargeFiles[i].Size > x.LargeFiles[j].Size }) + return x, r.Err() +} + +func (s *Store) Rules() ([]ExclusionRule, error) { + r, e := s.db.Query(`SELECT id,pattern,category,builtin FROM exclusion_rules ORDER BY category,builtin DESC,pattern`) + if e != nil { + return nil, e + } + defer r.Close() + o := []ExclusionRule{} + for r.Next() { + var x ExclusionRule + var b int + if e = r.Scan(&x.ID, &x.Pattern, &x.Category, &b); e != nil { + return nil, e + } + x.Builtin = b == 1 + o = append(o, x) + } + return o, r.Err() +} +func (s *Store) AddRule(pattern, category string) (ExclusionRule, error) { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + return ExclusionRule{}, errors.New("PATTERN_REQUIRED") + } + if category == "" { + category = "custom" + } + r, e := s.db.Exec(`INSERT INTO exclusion_rules(pattern,category,builtin) VALUES(?,?,0)`, pattern, category) + if e != nil { + return ExclusionRule{}, e + } + id, _ := r.LastInsertId() + return ExclusionRule{ID: id, Pattern: pattern, Category: category}, nil +} +func (s *Store) DeleteRule(id int64) error { + r, e := s.db.Exec(`DELETE FROM exclusion_rules WHERE id=? AND builtin=0`, id) + if e != nil { + return e + } + n, _ := r.RowsAffected() + if n == 0 { + return errors.New("BUILTIN_RULE") + } + return nil +} +func (s *Store) Log(level, category, message, detail string) { + _, _ = 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) { + q := `SELECT id,level,category,message,detail,created_at FROM app_logs` + args := []any{} + if level != "" && level != "all" { + q += ` WHERE level=?` + args = append(args, level) + } + q += ` ORDER BY id DESC LIMIT 500` + r, e := s.db.Query(q, args...) + if e != nil { + return nil, e + } + defer r.Close() + o := []LogEntry{} + 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 nil, e + } + o = append(o, x) + } + return o, r.Err() +} +func (s *Store) ClearLogs() error { _, e := s.db.Exec(`DELETE FROM app_logs`); return e } +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"} + r, e := s.db.Query(`SELECT key,value FROM settings`) + if e != nil { + return x, e + } + defer r.Close() + for r.Next() { + var k, v string + _ = r.Scan(&k, &v) + switch k { + case "theme": + x.Theme = v + case "locale": + x.Locale = v + case "gitScope": + x.GitScope = v + case "autoRefresh": + x.AutoRefresh = v == "true" + case "glassOpacity": + if n, err := strconv.Atoi(v); err == nil && n >= 30 && n <= 75 { + x.GlassOpacity = n + } + case "loadingStyle": + if validLoadingStyle(v) { + x.LoadingStyle = v + } + } + } + return x, nil +} +func (s *Store) SaveSettings(x AppSettings) error { + if x.GlassOpacity < 30 || x.GlassOpacity > 75 { + x.GlassOpacity = 55 + } + if !validLoadingStyle(x.LoadingStyle) { + x.LoadingStyle = "fullscreen-orbit" + } + vals := map[string]string{"theme": x.Theme, "locale": x.Locale, "gitScope": x.GitScope, "autoRefresh": fmt.Sprint(x.AutoRefresh), "glassOpacity": strconv.Itoa(x.GlassOpacity), "loadingStyle": x.LoadingStyle, "loadingStyleSet": "true"} + tx, e := s.db.Begin() + if e != nil { + return e + } + defer tx.Rollback() + for k, v := range vals { + if _, e = tx.Exec(`INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, k, v); e != nil { + return e + } + } + return tx.Commit() +} + +func validLoadingStyle(v string) bool { + switch v { + case "bar", "fullscreen", "fullscreen-orbit", "fullscreen-grid", "fullscreen-warp": + return true + default: + return false + } +} +func (s *Store) ClearData(mode string, projectID int64) error { + tx, e := s.db.Begin() + if e != nil { + return e + } + defer tx.Rollback() + tables := []string{"language_stats", "file_entries", "git_commits", "git_refs", "commit_refs", "contributors", "project_insights", "insight_issues", "analysis_runs"} + if mode == "all" { + tables = append(tables, "projects") + } + for _, t := range tables { + q := "DELETE FROM " + t + args := []any{} + if mode == "project" && t != "projects" { + q += " WHERE project_id=?" + args = append(args, projectID) + } + if _, e = tx.Exec(q, args...); e != nil { + return e + } + } + if mode == "all" { + if _, e = tx.Exec(`DELETE FROM project_groups WHERE id<>1`); e != nil { + return e + } + } + return tx.Commit() +} + +func (s *Store) ReplaceGit(id int64, g GitStats) error { + tx, e := s.db.Begin() + if e != nil { + return e + } + defer tx.Rollback() + for _, t := range []string{"git_commits", "git_refs", "commit_refs", "contributors"} { + if _, e = tx.Exec("DELETE FROM "+t+" WHERE project_id=?", id); e != nil { + return e + } + } + for _, c := range g.Commits { + if _, e = tx.Exec(`INSERT INTO git_commits VALUES(?,?,?,?,?,?,?,?)`, id, c.Hash, c.Author, c.Email, c.Message, c.Date, c.Added, c.Deleted); 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 _, c := range g.Contributors { + if _, e = tx.Exec(`INSERT INTO contributors VALUES(?,?,?,?,?,?)`, id, c.Email, c.Name, c.Commits, c.Added, c.Deleted); e != nil { + return e + } + } + _, e = tx.Exec(`INSERT INTO analysis_runs(project_id,kind,status,started_at,completed_at) VALUES(?,'git','completed',?,?)`, id, now(), now()) + if e != nil { + return e + } + return tx.Commit() +} +func (s *Store) GitStats(id int64) (GitStats, error) { + g := GitStats{Available: true, Commits: []GitCommit{}, Refs: []GitRef{}, Contributors: []Contributor{}, Heatmap: []HeatDay{}} + r, e := s.db.Query(`SELECT hash,author,email,message,committed_at,added,deleted FROM git_commits WHERE project_id=? ORDER BY committed_at DESC`, id) + if e != nil { + return g, e + } + for r.Next() { + var c GitCommit + if e = r.Scan(&c.Hash, &c.Author, &c.Email, &c.Message, &c.Date, &c.Added, &c.Deleted); e != nil { + r.Close() + return g, e + } + g.Commits = append(g.Commits, c) + g.Added += c.Added + g.Deleted += c.Deleted + } + r.Close() + rr, e := s.db.Query(`SELECT name,hash,kind,is_current FROM git_refs WHERE project_id=? ORDER BY is_current DESC,kind,name`, id) + if e != nil { + return g, e + } + for rr.Next() { + var x GitRef + var c int + _ = rr.Scan(&x.Name, &x.Hash, &x.Kind, &c) + x.Current = c == 1 + if x.Current { + g.CurrentBranch = x.Name + } + g.Refs = append(g.Refs, x) + } + rr.Close() + g.WorkspaceBranch = g.CurrentBranch + g.ViewRef = g.CurrentBranch + cr, e := s.db.Query(`SELECT name,email,commits,added,deleted FROM contributors WHERE project_id=? ORDER BY commits DESC`, id) + if e != nil { + return g, e + } + for cr.Next() { + var x Contributor + _ = cr.Scan(&x.Name, &x.Email, &x.Commits, &x.Added, &x.Deleted) + g.Contributors = append(g.Contributors, x) + } + cr.Close() + g.CommitCount = int64(len(g.Commits)) + g.ContributorCount = int64(len(g.Contributors)) + cut := time.Now().AddDate(-1, 0, 0) + hm := map[string]int64{} + for _, c := range g.Commits { + if t, e := time.Parse(time.RFC3339, c.Date); e == nil && t.After(cut) { + hm[t.Local().Format("2006-01-02")]++ + } + } + for d, c := range hm { + g.Heatmap = append(g.Heatmap, HeatDay{Date: d, Count: c}) + } + sort.Slice(g.Heatmap, func(i, j int) bool { return g.Heatmap[i].Date < g.Heatmap[j].Date }) + return g, nil +} + +func (s *Store) Insights(id int64) (ProjectInsights, error) { + x := ProjectInsights{ProjectID: id, Issues: []InsightIssue{}} + e := s.db.QueryRow(`SELECT health_score,generated_at,high,medium,low,todo_count,long_files,large_files FROM project_insights WHERE project_id=?`, id).Scan(&x.HealthScore, &x.GeneratedAt, &x.Summary.High, &x.Summary.Medium, &x.Summary.Low, &x.Summary.TodoCount, &x.Summary.LongFiles, &x.Summary.LargeFiles) + if e != nil { + return x, e + } + r, e := s.db.Query(`SELECT severity,type,title,detail,path,line,suggestion,evidence FROM insight_issues WHERE project_id=? ORDER BY idx`, id) + if e != nil { + return x, e + } + defer r.Close() + for r.Next() { + var issue InsightIssue + if e = r.Scan(&issue.Severity, &issue.Type, &issue.Title, &issue.Detail, &issue.Path, &issue.Line, &issue.Suggestion, &issue.Evidence); e != nil { + return x, e + } + x.Issues = append(x.Issues, issue) + } + return x, nil +} + +func (s *Store) ReplaceInsights(id int64, x ProjectInsights) error { + tx, e := s.db.Begin() + if e != nil { + return e + } + defer tx.Rollback() + if _, e = tx.Exec(`DELETE FROM insight_issues WHERE project_id=?`, id); e != nil { + return e + } + if _, e = tx.Exec(`DELETE FROM project_insights WHERE project_id=?`, id); e != nil { + return e + } + if _, e = tx.Exec(`INSERT INTO project_insights VALUES(?,?,?,?,?,?,?,?,?)`, id, x.HealthScore, x.GeneratedAt, x.Summary.High, x.Summary.Medium, x.Summary.Low, x.Summary.TodoCount, x.Summary.LongFiles, x.Summary.LargeFiles); e != nil { + return e + } + for i, issue := range x.Issues { + if _, e = tx.Exec(`INSERT INTO insight_issues VALUES(?,?,?,?,?,?,?,?,?,?)`, id, i, issue.Severity, issue.Type, issue.Title, issue.Detail, issue.Path, issue.Line, issue.Suggestion, issue.Evidence); e != nil { + return e + } + } + return tx.Commit() +} diff --git a/database_test.go b/database_test.go new file mode 100644 index 0000000..44a98f8 --- /dev/null +++ b/database_test.go @@ -0,0 +1,239 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestStoreProjectAndSnapshot(t *testing.T) { + s, e := OpenStore(filepath.Join(t.TempDir(), "test.db")) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + dir := t.TempDir() + p, e := s.SaveProject(0, ProjectInput{Name: "demo", Path: dir}) + if e != nil { + t.Fatal(e) + } + if _, e = s.SaveProject(0, ProjectInput{Name: "duplicate", Path: dir}); e == nil { + t.Fatal("duplicate path accepted") + } + e = s.ReplaceScan(p.ID, []LanguageStat{{Name: "Go", Files: 1, Code: 2, Comments: 1, Blanks: 1}}, []FileEntry{{Path: "main.go", Name: "main.go", Extension: ".go", Size: 20}}) + if e != nil { + t.Fatal(e) + } + got, e := s.GetProject(p.ID) + if e != nil { + t.Fatal(e) + } + if got.Stats.TotalLines != 4 || got.Stats.FileCount != 1 { + t.Fatalf("unexpected stats: %#v", got.Stats) + } +} + +func TestNormalizePath(t *testing.T) { + if _, e := normalizePath(filepath.Join(t.TempDir(), "missing")); e == nil { + t.Fatal("missing directory accepted") + } + d := t.TempDir() + p, e := normalizePath(d) + if e != nil { + t.Fatal(e) + } + if _, e = os.Stat(p); e != nil { + t.Fatal(e) + } +} + +func TestDuplicateProjectReturnsStableCode(t *testing.T) { + s, e := OpenStore(filepath.Join(t.TempDir(), "test.db")) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + dir := t.TempDir() + if _, e = s.SaveProject(0, ProjectInput{Name: "one", Path: dir}); e != nil { + t.Fatal(e) + } + if _, e = s.SaveProject(0, ProjectInput{Name: "two", Path: dir}); e == nil || !strings.HasPrefix(e.Error(), "PROJECT_PATH_DUPLICATE") { + t.Fatalf("unexpected error: %v", e) + } +} + +func TestProjectGroupsFilterProjectsAndDashboard(t *testing.T) { + s, e := OpenStore(filepath.Join(t.TempDir(), "groups.db")) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + g, e := s.SaveProjectGroup(0, "Backend") + if e != nil { + t.Fatal(e) + } + dir1 := t.TempDir() + dir2 := t.TempDir() + p1, e := s.SaveProject(0, ProjectInput{Name: "api", Path: dir1, GroupID: g.ID}) + if e != nil { + t.Fatal(e) + } + if _, e = s.SaveProject(0, ProjectInput{Name: "web", Path: dir2}); e != nil { + t.Fatal(e) + } + if e = s.ReplaceScan(p1.ID, []LanguageStat{{Name: "Go", Files: 2, Code: 10, Comments: 1, Blanks: 1}}, nil); e != nil { + t.Fatal(e) + } + projects, e := s.ListProjects(g.ID) + if e != nil { + t.Fatal(e) + } + if len(projects) != 1 || projects[0].GroupID != g.ID || projects[0].GroupName != "Backend" { + t.Fatalf("unexpected grouped projects: %#v", projects) + } + d, e := s.Dashboard(g.ID) + if e != nil { + t.Fatal(e) + } + if d.Projects != 1 || d.TotalLines != 12 { + t.Fatalf("unexpected dashboard: %#v", d) + } +} + +func TestDeleteProjectGroupMovesProjectsToDefault(t *testing.T) { + s, e := OpenStore(filepath.Join(t.TempDir(), "groups.db")) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + g, e := s.SaveProjectGroup(0, "Temp") + if e != nil { + t.Fatal(e) + } + p, e := s.SaveProject(0, ProjectInput{Name: "demo", Path: t.TempDir(), GroupID: g.ID}) + if e != nil { + t.Fatal(e) + } + if e = s.DeleteProjectGroup(g.ID); e != nil { + t.Fatal(e) + } + got, e := s.GetProject(p.ID) + if e != nil { + t.Fatal(e) + } + if got.GroupID != 1 { + t.Fatalf("groupId=%d want 1", got.GroupID) + } +} + +func TestSettingsReturnsActiveDatabasePath(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "active.db") + s, e := OpenStore(dbPath) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + settings, e := s.Settings() + if e != nil { + t.Fatal(e) + } + if settings.DatabasePath != dbPath { + t.Fatalf("databasePath=%q want %q", settings.DatabasePath, dbPath) + } +} + +func TestGlassOpacityPersistsAndValidates(t *testing.T) { + s, e := OpenStore(filepath.Join(t.TempDir(), "settings.db")) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + if e = s.SaveSettings(AppSettings{Theme: "dark", Locale: "zh-CN", GitScope: "current", AutoRefresh: true, GlassOpacity: 30}); e != nil { + t.Fatal(e) + } + got, e := s.Settings() + if e != nil { + t.Fatal(e) + } + if got.GlassOpacity != 30 { + t.Fatalf("glassOpacity=%d", got.GlassOpacity) + } + if e = s.SaveSettings(AppSettings{GlassOpacity: 99}); e != nil { + t.Fatal(e) + } + got, e = s.Settings() + if e != nil { + t.Fatal(e) + } + if got.GlassOpacity != 55 { + t.Fatalf("invalid opacity did not reset: %d", got.GlassOpacity) + } +} + +func TestLoadingStylePersistsAndValidates(t *testing.T) { + s, e := OpenStore(filepath.Join(t.TempDir(), "settings.db")) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + if e = s.SaveSettings(AppSettings{Theme: "dark", Locale: "zh-CN", GitScope: "current", AutoRefresh: true, GlassOpacity: 55, LoadingStyle: "fullscreen-grid"}); e != nil { + t.Fatal(e) + } + got, e := s.Settings() + if e != nil { + t.Fatal(e) + } + if got.LoadingStyle != "fullscreen-grid" { + t.Fatalf("loadingStyle=%q", got.LoadingStyle) + } + if e = s.SaveSettings(AppSettings{GlassOpacity: 55, LoadingStyle: "floating"}); e != nil { + t.Fatal(e) + } + got, e = s.Settings() + if e != nil { + t.Fatal(e) + } + if got.LoadingStyle != "fullscreen-orbit" { + t.Fatalf("invalid loadingStyle did not reset: %q", got.LoadingStyle) + } +} + +func TestLegacyLoadingStyleDefaultMigratesOnce(t *testing.T) { + db := filepath.Join(t.TempDir(), "settings.db") + s, e := OpenStore(db) + if e != nil { + t.Fatal(e) + } + if _, e = s.db.Exec(`UPDATE settings SET value='bar' WHERE key='loadingStyle'`); e != nil { + t.Fatal(e) + } + s.db.Close() + s, e = OpenStore(db) + if e != nil { + t.Fatal(e) + } + got, e := s.Settings() + if e != nil { + t.Fatal(e) + } + if got.LoadingStyle != "fullscreen-orbit" { + t.Fatalf("legacy loadingStyle=%q", got.LoadingStyle) + } + if e = s.SaveSettings(AppSettings{GlassOpacity: 55, LoadingStyle: "bar"}); e != nil { + t.Fatal(e) + } + s.db.Close() + s, e = OpenStore(db) + if e != nil { + t.Fatal(e) + } + defer s.db.Close() + got, e = s.Settings() + if e != nil { + t.Fatal(e) + } + if got.LoadingStyle != "bar" { + t.Fatalf("explicit loadingStyle=%q", got.LoadingStyle) + } +} diff --git a/desktop-final.png b/desktop-final.png new file mode 100644 index 0000000..4de289b Binary files /dev/null and b/desktop-final.png differ diff --git a/final-dashboard-v3.png b/final-dashboard-v3.png new file mode 100644 index 0000000..1093af9 Binary files /dev/null and b/final-dashboard-v3.png differ diff --git a/final-git-v3.png b/final-git-v3.png new file mode 100644 index 0000000..98929e9 Binary files /dev/null and b/final-git-v3.png differ diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..b4719be --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,8 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d866e0e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1025 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "echarts": "^6.1.0", + "lucide-vue-next": "^1.0.0", + "pinia": "^4.0.1", + "vue": "^3.2.37", + "vue-i18n": "^9.14.5", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^3.0.3", + "vite": "^3.0.7" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", + "integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", + "integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@intlify/core-base": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz", + "integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==", + "dependencies": { + "@intlify/message-compiler": "9.14.5", + "@intlify/shared": "9.14.5" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz", + "integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==", + "dependencies": { + "@intlify/shared": "9.14.5", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz", + "integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-3.2.0.tgz", + "integrity": "sha512-E0tnaL4fr+qkdCNxJ+Xd0yM31UwMkQje76fsDVBBUCoGOUPexu2VDUYHL8P4CwV+zMvWw6nlRw19OnRKmYAJpw==", + "dev": true, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^3.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.5.tgz", + "integrity": "sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==", + "peer": true, + "dependencies": { + "@vue/devtools-kit": "^8.1.5" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.5.tgz", + "integrity": "sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==", + "peer": true, + "dependencies": { + "@vue/devtools-shared": "^8.1.5", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.5.tgz", + "integrity": "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==", + "peer": true + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==" + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz", + "integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.15.18", + "@esbuild/linux-loong64": "0.15.18", + "esbuild-android-64": "0.15.18", + "esbuild-android-arm64": "0.15.18", + "esbuild-darwin-64": "0.15.18", + "esbuild-darwin-arm64": "0.15.18", + "esbuild-freebsd-64": "0.15.18", + "esbuild-freebsd-arm64": "0.15.18", + "esbuild-linux-32": "0.15.18", + "esbuild-linux-64": "0.15.18", + "esbuild-linux-arm": "0.15.18", + "esbuild-linux-arm64": "0.15.18", + "esbuild-linux-mips64le": "0.15.18", + "esbuild-linux-ppc64le": "0.15.18", + "esbuild-linux-riscv64": "0.15.18", + "esbuild-linux-s390x": "0.15.18", + "esbuild-netbsd-64": "0.15.18", + "esbuild-openbsd-64": "0.15.18", + "esbuild-sunos-64": "0.15.18", + "esbuild-windows-32": "0.15.18", + "esbuild-windows-64": "0.15.18", + "esbuild-windows-arm64": "0.15.18" + } + }, + "node_modules/esbuild-android-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz", + "integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-android-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz", + "integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz", + "integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz", + "integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz", + "integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz", + "integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz", + "integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz", + "integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz", + "integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz", + "integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz", + "integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz", + "integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-riscv64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz", + "integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-s390x": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz", + "integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz", + "integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz", + "integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-sunos-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz", + "integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz", + "integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz", + "integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz", + "integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "peer": true + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/lucide-vue-next": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz", + "integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==", + "deprecated": "Package deprecated. Please use @lucide/vue instead.", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nostics": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/nostics/-/nostics-1.1.4.tgz", + "integrity": "sha512-U4FApICSLCQ0dYiN59pxUCEZC053palQXi57yLaNdMaL6TBY4C4q3qZrJKUGcor2dGv8EhEwt8y5KvU2bo2kFg==" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "peer": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/pinia": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-4.0.1.tgz", + "integrity": "sha512-0T9mU4aDENt40cnJLxYLkqr1Fwqao2CHCL5H65jCSjnL0kOsvARc+PYydwEw2YfQ0VObzwjMNFwzDr+q7n/nnQ==", + "dependencies": { + "nostics": "^1.1.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/devtools-api": "^8.1.5", + "typescript": ">=5.6.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "@vue/devtools-api": { + "optional": false + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + }, + "node_modules/vite": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz", + "integrity": "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==", + "dev": true, + "dependencies": { + "esbuild": "^0.15.9", + "postcss": "^8.4.18", + "resolve": "^1.22.1", + "rollup": "^2.79.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-i18n": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz", + "integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==", + "deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html", + "dependencies": { + "@intlify/core-base": "9.14.5", + "@intlify/shared": "9.14.5", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-i18n/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..44c1032 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "echarts": "^6.1.0", + "lucide-vue-next": "^1.0.0", + "pinia": "^4.0.1", + "vue": "^3.2.37", + "vue-i18n": "^9.14.5", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^3.0.3", + "vite": "^3.0.7" + } +} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 new file mode 100644 index 0000000..17add89 --- /dev/null +++ b/frontend/package.json.md5 @@ -0,0 +1 @@ +737ba6354d6500f88d51c323cdf11056 \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..c95a3c2 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,142 @@ + + + diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..80335b3 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,12 @@ +export const isNative=()=>Boolean(window.go?.main?.App) + +export async function call(name,...args){ + const fn=window.go?.main?.App?.[name] + if(fn)return fn(...args) + throw new Error(`NATIVE_RUNTIME_REQUIRED: ${name}`) +} + +export function on(name,cb){ + if(window.runtime?.EventsOn)return window.runtime.EventsOn(name,cb) + return()=>{} +} diff --git a/frontend/src/assets/fonts/OFL.txt b/frontend/src/assets/fonts/OFL.txt new file mode 100644 index 0000000..9cac04c --- /dev/null +++ b/frontend/src/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com), + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 b/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 new file mode 100644 index 0000000..2f9cc59 Binary files /dev/null and b/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 differ diff --git a/frontend/src/assets/images/logo-universal.png b/frontend/src/assets/images/logo-universal.png new file mode 100644 index 0000000..e9913c1 Binary files /dev/null and b/frontend/src/assets/images/logo-universal.png differ diff --git a/frontend/src/components/AnalysisCanvas.vue b/frontend/src/components/AnalysisCanvas.vue new file mode 100644 index 0000000..c148e99 --- /dev/null +++ b/frontend/src/components/AnalysisCanvas.vue @@ -0,0 +1,355 @@ + + + diff --git a/frontend/src/components/BrowserBlocked.vue b/frontend/src/components/BrowserBlocked.vue new file mode 100644 index 0000000..05f70ce --- /dev/null +++ b/frontend/src/components/BrowserBlocked.vue @@ -0,0 +1,2 @@ + + diff --git a/frontend/src/components/ChartView.vue b/frontend/src/components/ChartView.vue new file mode 100644 index 0000000..cab7dec --- /dev/null +++ b/frontend/src/components/ChartView.vue @@ -0,0 +1,6 @@ + diff --git a/frontend/src/components/CommitDrawer.vue b/frontend/src/components/CommitDrawer.vue new file mode 100644 index 0000000..839d449 --- /dev/null +++ b/frontend/src/components/CommitDrawer.vue @@ -0,0 +1,4 @@ + diff --git a/frontend/src/components/DatabaseSetup.vue b/frontend/src/components/DatabaseSetup.vue new file mode 100644 index 0000000..df3b3b7 --- /dev/null +++ b/frontend/src/components/DatabaseSetup.vue @@ -0,0 +1,10 @@ + + + diff --git a/frontend/src/components/GitHeatmap.vue b/frontend/src/components/GitHeatmap.vue new file mode 100644 index 0000000..904509f --- /dev/null +++ b/frontend/src/components/GitHeatmap.vue @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/components/GitTrend.vue b/frontend/src/components/GitTrend.vue new file mode 100644 index 0000000..9b60992 --- /dev/null +++ b/frontend/src/components/GitTrend.vue @@ -0,0 +1,7 @@ + + diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue new file mode 100644 index 0000000..29c023f --- /dev/null +++ b/frontend/src/components/HelloWorld.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/frontend/src/components/StatCard.vue b/frontend/src/components/StatCard.vue new file mode 100644 index 0000000..8f16e5f --- /dev/null +++ b/frontend/src/components/StatCard.vue @@ -0,0 +1,10 @@ + + + diff --git a/frontend/src/database.css b/frontend/src/database.css new file mode 100644 index 0000000..01b7ec2 --- /dev/null +++ b/frontend/src/database.css @@ -0,0 +1 @@ +.db-title{display:flex;align-items:center;justify-content:space-between}.db-title h2{display:flex;align-items:center;gap:9px}.db-title h2 svg{width:20px;color:#9188ff}.db-connected{display:flex;align-items:center;gap:6px;color:var(--green);font-size:13px}.db-connected svg{width:16px}.preview-notice{display:flex;gap:12px;margin-top:18px;padding:14px;border:1px solid rgba(79,157,245,.3);background:rgba(79,157,245,.09);border-radius:7px;color:var(--blue)}.preview-notice>svg{width:20px;flex:none}.preview-notice b,.preview-notice small{display:block}.preview-notice small{color:var(--muted);margin-top:4px}.database-panel .db-path{display:grid;grid-template-columns:90px minmax(0,1fr) 38px;align-items:center;gap:10px;border:1px solid var(--glass-border);border-radius:7px;background:var(--glass-soft);padding:12px 12px 12px 16px}.db-path>span{font-weight:bold}.db-path code{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#9188ff}.db-path button{height:34px;border:0;border-radius:6px;background:rgba(123,115,255,.14);color:#9188ff;display:grid;place-items:center;cursor:pointer}.db-path button:disabled{opacity:.4;cursor:not-allowed}.db-path button svg{width:16px}.db-message{color:var(--green);font-size:13px}.migrate:disabled{opacity:.45;cursor:not-allowed}.database-panel{animation:card-enter .45s both} diff --git a/frontend/src/git.css b/frontend/src/git.css new file mode 100644 index 0000000..dc6bcdb --- /dev/null +++ b/frontend/src/git.css @@ -0,0 +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} +.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} +.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}} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..1c322ca --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,354 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import { createRouter, createWebHashHistory } from 'vue-router' +import { createI18n } from 'vue-i18n' +import App from './App.vue' +import Dashboard from './views/Dashboard.vue' +import ProjectDetail from './views/ProjectDetail.vue' +import Logs from './views/Logs.vue' +import Settings from './views/Settings.vue' +import './style.css' +import './motion.css' +import './database.css' +import './runtime.css' +import './git.css' +import './polish.css' + +const zh = { + app: 'Code Count', + dashboard: '仪表盘', + logs: '运行日志', + settings: '设置', + projects: '我的项目', + addProject: '添加项目', + batch: '批量统计', + analyzingProject: '正在统计项目', + search: '搜索项目...', + totalProjects: '项目总数', + totalLines: '代码总行数', + commits: 'Git 提交数', + code: '代码统计', + git: 'Git 分析', + structure: '项目结构', + insights: '检查', + analyze: '重新分析', + analyzeGit: '重新分析 Git', + empty: '暂无数据', + back: '返回', + refreshProject: '更新此项目', + edit: '编辑', + delete: '删除', + add: '新增', + save: '保存', + cancel: '取消', + browse: '浏览', + selectWSL: '选择 WSL 目录', + noWSL: '未发现可用的 WSL 发行版', + dashboardSubtitle: '管理和分析你的代码项目', + projectName: '项目名称', + projectPath: '项目路径', + description: '描述(可选)', + addProjectTitle: '添加项目', + editProjectTitle: '编辑项目', + projectGroup: '项目组', + allProjectGroups: '全部项目组', + myProjectGroup: '我的项目组', + addProjectGroup: '新增项目组', + editProjectGroup: '编辑项目组', + deleteProjectGroup: '删除项目组', + projectGroupName: '项目组名称', + saveProjectGroup: '保存项目组', + saveProject: '保存项目', + saving: '正在保存...', + pathRequired: '请选择项目目录', + unanalyzed: '尚未分析', + totalLineCount: '总行数', + codeLines: '代码行', + commentLines: '注释行', + blankLines: '空行', + fileCount: '文件数', + languageDistribution: '语言分布', + languageDetails: '语言详情', + files: '文件', + lines: '行', + workspaceBranch: '工作区分支', + viewRef: '统计视图', + switchView: '查看统计', + checkout: '切换工作区', + checkoutConfirm: '确认将工作区切换到分支 {ref}?未提交或未跟踪改动会导致操作被拒绝。', + dirty: '工作区存在未提交或未跟踪文件,已拒绝切换', + branchChanged: '工作区分支已切换', + commitDetail: '提交详情', + copyHash: '复制哈希', + filesChanged: '变更文件', + clearFilter: '清除筛选', + selectedDate: '已筛选 {date}', + day: '日', + week: '周', + month: '月', + commitCount: '总提交数', + addedLines: '新增行数', + deletedLines: '删除行数', + contributors: '贡献者数', + activityHeatmap: '活跃度热力图', + branches: '分支信息', + recentCommits: '最近提交', + commitTrend: '提交趋势', + contributorRanking: '贡献者排行', + commitsUnit: '提交', + gitUnavailable: 'Git 数据不可用', + gitUnavailableHint: '当前项目的 Git 信息读取失败,代码统计不会受影响。', + gitDetail: '诊断详情', + totalFiles: '总文件数', + folderCount: '文件夹数', + totalSize: '总大小', + largeFiles: '大文件数', + directoryStructure: '目录结构', + folderSize: '文件夹大小', + largeFileDetection: '大文件检测(≥ 1 MB)', + noLargeFiles: '未发现大文件', + deepInsights: '深度检查', + deepInsightsHint: '基于代码结构、源码标记和 Git 活跃度识别维护风险。', + healthScore: '健康分', + highRisk: '高风险', + mediumRisk: '中风险', + lowRisk: '低风险', + issueList: '问题列表', + allSeverity: '全部级别', + allTypes: '全部类型', + noIssues: '暂无风险项', + insightDone: '深度检查完成', + severity: { high: '高风险', medium: '中风险', low: '低风险' }, + quickSettings: '快捷设置', + language: '语言', + theme: '主题', + themeDark: '暗色', + themeLight: '浅色', + themeSystem: '跟随系统', + glassOpacity: '卡片透明度', + loadingStyle: '统计 Loading 样式', + openSettings: '打开设置页', + logSubtitle: '查看应用运行状态和错误信息', + autoRefresh: '自动刷新', + refresh: '刷新', + clearLogs: '清空日志', + allLogs: '全部日志', + runLogs: '运行日志', + errorLogs: '错误日志', + totalLogs: '总日志', + info: '信息', + warning: '警告', + error: '错误', + noLogs: '暂无日志记录', + errors: { + PROJECT_PATH_NOT_FOUND: '项目目录不存在', + PROJECT_PATH_UNREADABLE: '项目目录不可读取', + PROJECT_PATH_NOT_DIRECTORY: '选择的路径不是目录', + PROJECT_PATH_DUPLICATE: '该项目目录已经添加', + PROJECT_GROUP_NOT_FOUND: '项目组不存在', + PROJECT_GROUP_NAME_REQUIRED: '请输入项目组名称', + PROJECT_GROUP_DEFAULT_READONLY: '默认项目组不能修改或删除', + PROJECT_GROUP_DUPLICATE: '项目组名称已存在', + DATABASE_NOT_READY: '数据库尚未初始化', + PROJECT_SAVE_FAILED: '项目保存失败', + NOT_GIT_REPOSITORY: '该目录不是 Git 仓库', + GIT_NOT_INSTALLED: '系统未安装 Git', + WSL_NOT_INSTALLED: '系统未安装 WSL', + WSL_DISTRO_NOT_FOUND: '未找到对应 WSL 发行版', + WSL_GIT_NOT_INSTALLED: 'WSL 发行版内未安装 Git', + WSL_PATH_UNREADABLE: 'WSL 路径不可访问', + GIT_PERMISSION_DENIED: 'Git 没有权限读取该仓库', + GIT_SAFE_DIRECTORY: 'Git 拒绝读取该仓库,请检查 safe.directory 配置', + GIT_REF_NOT_FOUND: '未找到该分支或引用', + GIT_COMMAND_FAILED: 'Git 命令执行失败' + }, + task: { + start: '开始分析 {project}', + scan: '正在扫描文件', + count: '正在统计代码', + save: '正在保存统计结果', + git: '正在读取 Git 历史', + completed: '分析完成', + failed: '分析失败', + cancelled: '分析已取消' + } +} + +const en = { + app: 'Code Count', + dashboard: 'Dashboard', + logs: 'Activity Log', + settings: 'Settings', + projects: 'Projects', + addProject: 'Add project', + batch: 'Analyze all', + analyzingProject: 'Analyzing project', + search: 'Search projects...', + totalProjects: 'Projects', + totalLines: 'Total lines', + commits: 'Git commits', + code: 'Code stats', + git: 'Git analysis', + structure: 'Structure', + insights: 'Check', + analyze: 'Analyze again', + analyzeGit: 'Analyze Git again', + empty: 'No data', + back: 'Back', + refreshProject: 'Update this project', + edit: 'Edit', + delete: 'Delete', + add: 'Add', + save: 'Save', + cancel: 'Cancel', + browse: 'Browse', + selectWSL: 'Select WSL directory', + noWSL: 'No WSL distribution found', + dashboardSubtitle: 'Manage and analyze your code projects', + projectName: 'Project name', + projectPath: 'Project path', + description: 'Description (optional)', + addProjectTitle: 'Add project', + editProjectTitle: 'Edit project', + projectGroup: 'Project group', + allProjectGroups: 'All project groups', + myProjectGroup: 'My project group', + addProjectGroup: 'Add project group', + editProjectGroup: 'Edit project group', + deleteProjectGroup: 'Delete project group', + projectGroupName: 'Project group name', + saveProjectGroup: 'Save project group', + saveProject: 'Save project', + saving: 'Saving...', + pathRequired: 'Please select a project directory', + unanalyzed: 'Not analyzed yet', + totalLineCount: 'Total lines', + codeLines: 'Code lines', + commentLines: 'Comment lines', + blankLines: 'Blank lines', + fileCount: 'Files', + languageDistribution: 'Language distribution', + languageDetails: 'Language details', + files: 'files', + lines: 'lines', + workspaceBranch: 'Workspace branch', + viewRef: 'Statistics view', + switchView: 'View statistics', + checkout: 'Switch workspace', + checkoutConfirm: 'Switch the workspace to {ref}? Uncommitted or untracked changes will be rejected.', + dirty: 'The worktree has uncommitted or untracked files', + branchChanged: 'Workspace branch changed', + commitDetail: 'Commit details', + copyHash: 'Copy hash', + filesChanged: 'Changed files', + clearFilter: 'Clear filter', + selectedDate: 'Filtered by {date}', + day: 'Day', + week: 'Week', + month: 'Month', + commitCount: 'Commits', + addedLines: 'Added lines', + deletedLines: 'Deleted lines', + contributors: 'Contributors', + activityHeatmap: 'Activity heatmap', + branches: 'Branches', + recentCommits: 'Recent commits', + commitTrend: 'Commit trend', + contributorRanking: 'Contributor ranking', + commitsUnit: 'commits', + gitUnavailable: 'Git data unavailable', + gitUnavailableHint: 'Git history could not be read for this project. Code statistics are unaffected.', + gitDetail: 'Diagnostic detail', + totalFiles: 'Total files', + folderCount: 'Folders', + totalSize: 'Total size', + largeFiles: 'Large files', + directoryStructure: 'Directory structure', + folderSize: 'Folder size', + largeFileDetection: 'Large files (≥ 1 MB)', + noLargeFiles: 'No large files found', + deepInsights: 'Deep inspection', + deepInsightsHint: 'Find maintainability risks from structure, source markers, and Git activity.', + healthScore: 'Health', + highRisk: 'high', + mediumRisk: 'medium', + lowRisk: 'low', + issueList: 'Issues', + allSeverity: 'All severity', + allTypes: 'All types', + noIssues: 'No issues found', + insightDone: 'Inspection completed', + severity: { high: 'High', medium: 'Medium', low: 'Low' }, + quickSettings: 'Quick settings', + language: 'Language', + theme: 'Theme', + themeDark: 'Dark', + themeLight: 'Light', + themeSystem: 'System', + glassOpacity: 'Card opacity', + loadingStyle: 'Analysis loading style', + openSettings: 'Open settings', + logSubtitle: 'View application status and errors', + autoRefresh: 'Auto refresh', + refresh: 'Refresh', + clearLogs: 'Clear logs', + allLogs: 'All logs', + runLogs: 'Run logs', + errorLogs: 'Error logs', + totalLogs: 'Total logs', + info: 'Info', + warning: 'Warning', + error: 'Error', + noLogs: 'No logs yet', + errors: { + PROJECT_PATH_NOT_FOUND: 'Project directory does not exist', + PROJECT_PATH_UNREADABLE: 'Project directory is not readable', + PROJECT_PATH_NOT_DIRECTORY: 'Selected path is not a directory', + PROJECT_PATH_DUPLICATE: 'This project path already exists', + PROJECT_GROUP_NOT_FOUND: 'Project group does not exist', + PROJECT_GROUP_NAME_REQUIRED: 'Enter a project group name', + PROJECT_GROUP_DEFAULT_READONLY: 'The default project group cannot be changed or deleted', + PROJECT_GROUP_DUPLICATE: 'Project group name already exists', + DATABASE_NOT_READY: 'Database is not initialized', + PROJECT_SAVE_FAILED: 'Failed to save project', + NOT_GIT_REPOSITORY: 'This directory is not a Git repository', + GIT_NOT_INSTALLED: 'Git is not installed', + WSL_NOT_INSTALLED: 'WSL is not installed', + WSL_DISTRO_NOT_FOUND: 'WSL distribution was not found', + WSL_GIT_NOT_INSTALLED: 'Git is not installed inside the WSL distribution', + WSL_PATH_UNREADABLE: 'WSL path is not accessible', + GIT_PERMISSION_DENIED: 'Git does not have permission to read this repository', + GIT_SAFE_DIRECTORY: 'Git refused this repository; check safe.directory', + GIT_REF_NOT_FOUND: 'Branch or ref was not found', + GIT_COMMAND_FAILED: 'Git command failed' + }, + task: { + start: 'Analyzing {project}', + scan: 'Scanning files', + count: 'Counting code', + save: 'Saving statistics', + git: 'Reading Git history', + completed: 'Analysis completed', + failed: 'Analysis failed', + cancelled: 'Analysis cancelled' + } +} + +const saved = JSON.parse(localStorage.getItem('cc-settings') || '{}') +let theme = new URLSearchParams(location.search).get('theme') || saved.theme || 'dark' +if (theme === 'system') theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +document.documentElement.dataset.theme = theme +document.documentElement.style.setProperty('--glass-user-opacity', String((saved.glassOpacity || 55) / 100)) + +const i18n = createI18n({ legacy: false, locale: saved.locale || 'zh-CN', fallbackLocale: 'en', messages: { 'zh-CN': zh, en } }) +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { path: '/', component: Dashboard }, + { path: '/project/:id', component: ProjectDetail }, + { path: '/logs', component: Logs }, + { path: '/settings', component: Settings } + ] +}) + +createApp(App).use(createPinia()).use(router).use(i18n).mount('#app') diff --git a/frontend/src/motion.css b/frontend/src/motion.css new file mode 100644 index 0000000..3886933 --- /dev/null +++ b/frontend/src/motion.css @@ -0,0 +1,9 @@ +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)} +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} +.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} +.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} +@keyframes page-enter{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}@keyframes fade-rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}@keyframes card-enter{from{opacity:0;transform:translateY(18px)}to{opacity:1;transform:none}}@keyframes number-arrive{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:none}}@keyframes overlay-in{from{opacity:0}to{opacity:1}}@keyframes modal-in{from{opacity:0;transform:translateY(14px) scale(.97)}to{opacity:1;transform:none}}@keyframes toast-in{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:none}}@keyframes task-in{from{opacity:0;transform:translate(-50%,18px)}to{opacity:1;transform:translate(-50%,0)}}@keyframes progress-sweep{from{transform:translateX(-100%)}to{transform:translateX(100%)}}@keyframes heat-in{from{opacity:0;transform:scale(.35)}to{opacity:1;transform:none}}@keyframes setup-in{from{opacity:0;transform:translateY(22px) scale(.98)}to{opacity:1;transform:none}}@keyframes spin{to{transform:rotate(360deg)}} +@media(prefers-reduced-motion:reduce){.page,.page-head,.project-head,.stats-grid>*,.project-grid>*,.modal,.overlay,.toast,.taskbar,.setup-card,.stat-card strong,.heatmap i{animation:none!important}.stat-card:hover,.panel:hover,.project-card:hover,.add-card:hover{transform:none}.taskbar .progress i:after{display:none}} diff --git a/frontend/src/polish.css b/frontend/src/polish.css new file mode 100644 index 0000000..0b325a7 --- /dev/null +++ b/frontend/src/polish.css @@ -0,0 +1,183 @@ +main{position:relative;overflow:visible} +main::before{content:"";position:fixed;inset:0 0 0 232px;pointer-events:none;background:linear-gradient(90deg,rgba(255,255,255,.035) 1px,transparent 1px),linear-gradient(180deg,rgba(255,255,255,.03) 1px,transparent 1px);background-size:80px 80px;mask-image:linear-gradient(90deg,transparent,black 14%,black 86%,transparent);animation:gridDrift 18s linear infinite;opacity:.55} +main::after{content:"";position:fixed;inset:0 0 0 232px;pointer-events:none;background:linear-gradient(115deg,transparent 0 35%,rgba(124,115,255,.1) 45%,transparent 56%);transform:translateX(-35%);animation:ambientSweep 12s ease-in-out infinite} +.page{position:relative;z-index:1} +.sticky-head{position:sticky;top:0;z-index:8;margin:-12px -10px 24px;padding:18px 20px;border:1px solid rgba(255,255,255,.08);border-radius:8px;background:color-mix(in srgb,var(--surface) calc(var(--glass-user-opacity, .55) * 100%),transparent);backdrop-filter:blur(24px) saturate(140%);box-shadow:0 16px 38px rgba(0,0,0,.22)} +.project-head.sticky-head{margin:-12px -10px 16px} +.animated-logo{background:linear-gradient(135deg,rgba(115,103,245,.95),rgba(52,211,153,.82));box-shadow:0 10px 28px rgba(115,103,245,.34)} +.animated-logo svg{width:40px;height:40px;overflow:visible} +.logo-frame{fill:rgba(255,255,255,.08);stroke:url(#logoGlow);stroke-width:1.5} +.logo-track{fill:none;stroke:#fff;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:68;animation:logoTrace 3.2s ease-in-out infinite} +.logo-spark{fill:none;stroke:url(#logoGlow);stroke-width:2.5;stroke-linecap:round;stroke-dasharray:8 12;animation:logoSpark 2.2s linear infinite} +.sidebar-bottom{position:relative;margin-top:auto;border-top:1px solid var(--border);padding:18px 8px 0;display:flex;align-items:center;gap:8px} +.sidebar-bottom .version{margin:0;border:0;padding:0;flex:1} +.quick-settings-btn{width:34px;height:34px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--muted);display:grid;place-items:center;cursor:pointer;transition:color .2s,border-color .2s,background .2s} +.quick-settings-btn:hover{color:var(--text);border-color:var(--primary)} +.quick-settings-btn svg{width:17px} +.quick-settings{position:absolute;left:0;bottom:48px;width:260px;padding:14px;border:1px solid var(--border);border-radius:8px;background:color-mix(in srgb,var(--surface-2) 82%,transparent);box-shadow:0 18px 42px rgba(0,0,0,.38);backdrop-filter:blur(24px) saturate(150%);display:grid;gap:12px;animation:popoverIn .18s ease-out} +.quick-settings header{display:flex;justify-content:space-between;align-items:center} +.quick-settings header button{border:0;background:transparent;color:var(--muted);cursor:pointer} +.quick-settings header svg{width:16px} +.quick-settings label{display:grid;gap:7px;color:var(--muted);font-size:12px} +.quick-settings select,.quick-settings input[type=range]{width:100%;accent-color:var(--primary)} +.quick-settings select{height:34px;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--text);padding:0 10px} +.quick-settings .full{width:100%;justify-content:center} +.shine-card{position:relative;overflow:hidden;isolation:isolate;transition:transform .22s ease,border-color .22s ease,box-shadow .22s ease,background .22s ease} +.shine-card>*{position:relative;z-index:1} +.shine-card::after{content:"";position:absolute;inset:-45%;z-index:0;pointer-events:none;background:linear-gradient(115deg,transparent 35%,rgba(255,255,255,.24) 49%,rgba(126,115,255,.18) 52%,transparent 64%);transform:translateX(-65%) rotate(8deg);opacity:0;transition:opacity .2s ease} +.shine-card:hover{transform:translateY(-3px);border-color:color-mix(in srgb,var(--primary) 55%,var(--border));box-shadow:0 20px 44px rgba(0,0,0,.28),0 0 0 1px rgba(123,115,255,.14)} +.shine-card:hover::after{opacity:1;animation:shineSweep .82s ease-out} +.stat-card{min-width:0;padding:20px;gap:14px} +.stat-copy{min-width:0;display:grid;gap:4px} +.stat-card strong,.stat-value{font-size:clamp(22px,2.2vw,29px);line-height:1.05;word-break:keep-all;white-space:normal} +.stat-card small{line-height:1.25} +.metric-row{gap:12px} +.metric-row>div{min-width:0} +.metric-row b{font-size:clamp(19px,2vw,25px);line-height:1.05;white-space:nowrap} +.metric-row span{display:block;white-space:nowrap} +.project-metrics b{font-variant-numeric:tabular-nums;font-weight:900;letter-spacing:.2px;text-shadow:0 0 18px currentColor} +.project-metrics .metric-total b{color:#72b7ff} +.project-metrics .metric-code b{color:#55d6a2} +.project-metrics .metric-files b{color:#b39cff} +.project-metrics>div{padding:8px 6px;border-radius:8px;background:linear-gradient(180deg,rgba(255,255,255,.045),transparent)} +.project-tools{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end} +.group-filter{height:38px;display:flex;align-items:center;gap:8px} +.group-filter select{height:38px;min-width:180px;border:1px solid var(--border);border-radius:7px;background:var(--surface-2);color:var(--text);padding:0 11px;outline:none} +.group-filter button{width:34px;height:34px;border:1px solid var(--border);border-radius:7px;background:var(--surface-2);color:var(--muted);display:grid;place-items:center;cursor:pointer;transition:color .2s,border-color .2s,background .2s} +.group-filter button:hover{color:var(--text);border-color:var(--primary)} +.group-filter svg{width:15px} +.icon-actions{gap:6px} +.icon-actions button{width:30px;height:30px;display:grid;place-items:center;border-radius:7px;transition:background .2s,color .2s} +.icon-actions button:hover{background:var(--surface-3);color:var(--text)} +.group-chip{display:inline-flex;align-items:center;width:max-content;max-width:180px;height:22px;margin:0 0 7px;padding:0 8px;border-radius:999px;background:rgba(115,103,245,.14);color:#a9a2ff;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.compact-modal{width:420px} +.modal{display:flex;flex-direction:column;overflow:auto} +.modal header{align-items:center;gap:18px;padding:24px 26px 20px} +.modal header h2{font-size:22px;line-height:1.2} +.modal header button{width:36px;height:36px;display:grid;place-items:center;border-radius:8px;line-height:1;flex:none;transition:background .2s,color .2s} +.modal header button:hover{background:var(--surface-2);color:var(--text)} +.modal>label{margin:0;padding:18px 26px 0;font-weight:800;color:var(--text)} +.modal>label:first-of-type{padding-top:24px} +.modal input,.modal textarea,.modal>label select{height:44px;margin-top:10px;border-color:rgba(145,136,255,.26);background:rgba(32,40,56,.92);box-shadow:inset 0 1px 0 rgba(255,255,255,.035);transition:border-color .2s,box-shadow .2s,background .2s} +.modal input:focus,.modal textarea:focus,.modal>label select:focus{border-color:rgba(145,136,255,.72);box-shadow:0 0 0 3px rgba(115,103,245,.18),inset 0 1px 0 rgba(255,255,255,.05)} +.modal textarea{min-height:92px;height:92px} +.modal footer{gap:12px;padding:22px 26px;align-items:center} +.modal footer .btn{min-width:88px;justify-content:center} +.browse{gap:12px;align-items:flex-start} +.browse .btn{min-width:96px;justify-content:center} +.hidden-wsl-picker{display:none!important} +.modal>label select{width:100%;border-radius:7px;color:var(--text);padding:0 12px;outline:none} +.wsl-picker select{height:40px;min-width:180px;background:var(--surface-2);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:0 10px;outline:none} +.overlay{position:fixed;inset:0;width:100vw;min-height:100vh;min-height:100dvh;display:grid;place-items:center;padding:32px;z-index:80;overflow:auto} +.modal{max-width:calc(100vw - 64px);max-height:calc(100vh - 64px);max-height:calc(100dvh - 64px)} +.analysis-loading{position:fixed;inset:0;z-index:70;display:grid;place-items:center;padding:24px;background-color:#03070c;background-image:radial-gradient(circle at 50% 42%,rgba(83,214,162,.14),transparent 30%),linear-gradient(135deg,rgba(6,9,18,.96),rgba(3,7,12,.94));backdrop-filter:blur(16px) saturate(145%);-webkit-backdrop-filter:blur(16px) saturate(145%);overflow:hidden;isolation:isolate;transition:background-color .3s ease,background-image .3s ease} +.analysis-loading.holding section{opacity:.88;transform:translateY(0)} +.analysis-loading.fullscreen-grid{background-image:radial-gradient(circle at 50% 48%,rgba(110,231,255,.16),transparent 32%),radial-gradient(circle at 22% 22%,rgba(83,214,162,.1),transparent 28%),linear-gradient(135deg,rgba(7,12,24,.97),rgba(3,15,16,.95))} +.analysis-loading.fullscreen-warp{background-image:radial-gradient(circle at 50% 50%,rgba(124,108,255,.24),transparent 34%),radial-gradient(circle at 72% 34%,rgba(110,231,255,.12),transparent 26%),linear-gradient(135deg,rgba(5,6,16,.98),rgba(3,7,12,.95))} +.analysis-canvas{position:absolute;inset:0;width:100%;height:100%;opacity:.98} +.analysis-loading section{position:relative;z-index:1;width:min(420px,calc(100vw - 48px));padding:0;display:grid;gap:13px;text-align:center;justify-items:center;overflow:visible;background:transparent;border:0;box-shadow:none;backdrop-filter:none;-webkit-backdrop-filter:none;transition:opacity .28s ease,transform .28s ease} +.analysis-loading section::before{content:"";position:absolute;left:50%;top:50%;width:360px;height:240px;transform:translate(-50%,-50%);border-radius:50%;background:radial-gradient(circle,rgba(8,14,24,.58) 0 24%,rgba(8,14,24,.34) 45%,transparent 72%);filter:blur(12px);pointer-events:none;z-index:-1} +.analysis-loading b{font-size:21px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-shadow:0 0 18px rgba(110,231,255,.38),0 2px 14px rgba(0,0,0,.72)} +.analysis-loading span{color:#b4bdcf;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-shadow:0 2px 12px rgba(0,0,0,.8)} +.analysis-loading strong{color:var(--text);text-shadow:0 0 14px rgba(255,255,255,.28),0 2px 12px rgba(0,0,0,.74)} +.analysis-loading .progress{position:relative;width:min(310px,100%);height:7px;border-radius:999px;background:linear-gradient(180deg,rgba(4,9,18,.78),rgba(18,26,42,.66));overflow:hidden;margin-top:8px;box-shadow:0 0 0 1px rgba(255,255,255,.06),0 0 28px rgba(115,103,245,.24),inset 0 1px 5px rgba(0,0,0,.5)} +.analysis-loading .progress::before{content:"";position:absolute;inset:1px;border-radius:inherit;background:linear-gradient(90deg,rgba(110,231,255,.06),rgba(255,255,255,.16),rgba(83,214,162,.06));transform:translateX(-62%);animation:loadingTrackSweep 1.8s ease-in-out infinite} +.analysis-loading .progress i{position:relative;display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#6ee7ff,#7c6cff 42%,#53d6a2 72%,#6ee7ff);background-size:240% 100%;box-shadow:0 0 20px rgba(110,231,255,.76),0 0 34px rgba(83,214,162,.36);transition:width .48s cubic-bezier(.22,1,.36,1);animation:loadingBarFlow 1.2s linear infinite} +.analysis-loading .progress i::before{content:"";position:absolute;right:-8px;top:50%;width:18px;height:18px;border-radius:50%;background:radial-gradient(circle,#fff 0 18%,#6ee7ff 34%,rgba(110,231,255,0) 72%);transform:translateY(-50%);filter:blur(.2px);box-shadow:0 0 18px rgba(110,231,255,.86)} +.analysis-loading .progress i::after{content:"";position:absolute;inset:-3px;width:58px;background:linear-gradient(90deg,transparent,rgba(255,255,255,.86),transparent);filter:blur(1px);animation:loadingBarSweep 1.05s ease-in-out infinite} +.loading-style-setting{display:grid;gap:12px;margin-top:4px;color:var(--text);font-weight:800} +.loading-style-setting>span{display:flex;align-items:center;gap:8px} +.loading-style-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px} +.loading-style-card{position:relative;min-height:118px;border:1px solid rgba(145,136,255,.2);border-radius:8px;background:linear-gradient(180deg,rgba(255,255,255,.055),rgba(255,255,255,.018)),var(--surface-2);color:var(--text);padding:14px 14px 13px;display:grid;grid-template-columns:46px 1fr;grid-template-rows:auto 1fr;column-gap:12px;row-gap:5px;text-align:left;cursor:pointer;outline:none;overflow:hidden;transition:border-color .2s ease,background .2s ease,box-shadow .2s ease,transform .2s ease} +.loading-style-card:hover{border-color:rgba(110,231,255,.5);box-shadow:0 14px 30px rgba(0,0,0,.22)} +.loading-style-card:focus-visible{box-shadow:0 0 0 3px rgba(110,231,255,.18),0 14px 30px rgba(0,0,0,.22)} +.loading-style-card.active{border-color:rgba(110,231,255,.72);background:linear-gradient(180deg,rgba(110,231,255,.11),rgba(83,214,162,.045)),var(--surface-2);box-shadow:0 0 0 1px rgba(110,231,255,.16),0 18px 36px rgba(0,0,0,.24),0 0 28px rgba(110,231,255,.12)} +.loading-style-card.active::after{content:"";position:absolute;right:12px;top:12px;width:9px;height:9px;border-radius:50%;background:#6ee7ff;box-shadow:0 0 14px rgba(110,231,255,.9)} +.loading-style-preview{grid-row:1/3;width:46px;height:46px;border-radius:8px;border:1px solid rgba(255,255,255,.08);background:rgba(4,9,18,.52);display:grid;place-items:center;overflow:hidden;box-shadow:inset 0 0 18px rgba(0,0,0,.26)} +.loading-style-preview i{position:relative;display:block;width:30px;height:30px;border-radius:50%} +.loading-style-card b{align-self:end;font-size:14px;line-height:1.25;letter-spacing:0} +.loading-style-card small{color:var(--muted);font-weight:600;line-height:1.35;word-break:break-word} +.loading-style-card.fullscreen-orbit .loading-style-preview i{border:2px solid rgba(110,231,255,.72);box-shadow:0 0 0 7px rgba(124,108,255,.14),0 0 18px rgba(110,231,255,.48)} +.loading-style-card.fullscreen-orbit .loading-style-preview i::before{content:"";position:absolute;inset:8px;border-radius:50%;background:#53d6a2;box-shadow:0 0 16px rgba(83,214,162,.78)} +.loading-style-card.fullscreen-orbit .loading-style-preview i::after{content:"";position:absolute;width:7px;height:7px;border-radius:50%;background:#fff;right:-2px;top:9px;box-shadow:0 0 12px rgba(255,255,255,.9)} +.loading-style-card.fullscreen-grid .loading-style-preview i{width:34px;height:34px;border-radius:4px;background:linear-gradient(90deg,rgba(110,231,255,.34) 1px,transparent 1px),linear-gradient(180deg,rgba(83,214,162,.3) 1px,transparent 1px);background-size:9px 9px} +.loading-style-card.fullscreen-grid .loading-style-preview i::before{content:"";position:absolute;left:4px;right:4px;top:15px;height:4px;background:rgba(110,231,255,.75);box-shadow:0 0 12px rgba(110,231,255,.75)} +.loading-style-card.fullscreen-grid .loading-style-preview i::after{content:"";position:absolute;width:7px;height:7px;right:6px;bottom:5px;border-radius:50%;background:#53d6a2;box-shadow:-17px -14px 0 rgba(124,108,255,.82),0 0 12px rgba(83,214,162,.8)} +.loading-style-card.fullscreen-warp .loading-style-preview i{width:36px;height:36px;background:radial-gradient(circle,#fff 0 8%,#6ee7ff 11%,rgba(124,108,255,.32) 34%,transparent 62%);box-shadow:0 0 20px rgba(124,108,255,.62)} +.loading-style-card.fullscreen-warp .loading-style-preview i::before,.loading-style-card.fullscreen-warp .loading-style-preview i::after{content:"";position:absolute;left:50%;top:50%;width:36px;height:2px;border-radius:999px;background:linear-gradient(90deg,transparent,#6ee7ff,transparent);transform:translate(-50%,-50%) rotate(32deg);box-shadow:0 0 10px rgba(110,231,255,.7)} +.loading-style-card.fullscreen-warp .loading-style-preview i::after{transform:translate(-50%,-50%) rotate(-28deg);background:linear-gradient(90deg,transparent,#53d6a2,transparent)} +.loading-style-card.bar .loading-style-preview i{width:34px;height:8px;border-radius:999px;background:rgba(255,255,255,.1);overflow:hidden} +.loading-style-card.bar .loading-style-preview i::before{content:"";position:absolute;inset:0 38% 0 0;border-radius:inherit;background:linear-gradient(90deg,#6ee7ff,#53d6a2);box-shadow:0 0 12px rgba(110,231,255,.65)} +.toast{transition:opacity .32s ease,transform .36s ease,filter .32s ease} +.toast.muted{opacity:.72;filter:saturate(.82)} +.toast.leaving{opacity:0;transform:translate(18px,-12px);pointer-events:none} +.detail-sticky-head{display:grid;gap:16px;align-items:stretch} +.detail-page{--detail-sticky-offset:16px;max-width:none;margin:0;padding:var(--detail-sticky-offset) 30px 60px} +.detail-page .detail-sticky-head{position:sticky;top:var(--detail-sticky-offset);width:100%;z-index:24;margin:0 0 24px;border-radius:8px;border-top:1px solid rgba(255,255,255,.08);background:color-mix(in srgb,var(--surface) 88%,transparent);box-shadow:0 18px 42px rgba(0,0,0,.34),0 1px 0 rgba(255,255,255,.06)} +.detail-head-row{display:flex;align-items:center;gap:28px;min-width:0} +.detail-head-row>div{min-width:0} +.detail-head-row p{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.detail-tabs{margin:0;background:rgba(255,255,255,.045)} +.heat-panel{height:auto;min-height:250px} +.heat-grid-wrap{--cell:13px;--gap:4px;position:relative;margin-top:12px;overflow-x:auto;padding-bottom:8px} +.heat-months{display:grid;grid-template-columns:repeat(var(--weeks),var(--cell));gap:var(--gap);margin-left:34px;margin-bottom:8px;min-width:calc(var(--weeks) * (var(--cell) + var(--gap)))} +.heat-months span{font-size:12px;color:var(--muted);white-space:nowrap} +.heat-body{position:relative;display:flex;gap:10px;align-items:flex-start} +.heat-weekdays{display:grid;grid-template-rows:repeat(7,var(--cell));gap:var(--gap);width:24px;color:var(--muted);font-size:12px;line-height:var(--cell);text-align:right} +.heat-grid{display:grid;grid-template-columns:repeat(var(--weeks),var(--cell));grid-template-rows:repeat(7,var(--cell));gap:var(--gap);min-width:calc(var(--weeks) * (var(--cell) + var(--gap)))} +.heat-cell{width:var(--cell);height:var(--cell);border:1px solid rgba(255,255,255,.035);border-radius:3px;background:#26303d;cursor:pointer;padding:0;transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease} +.heat-cell:hover,.heat-cell.selected{transform:translateY(-1px);border-color:rgba(255,255,255,.45);box-shadow:0 0 0 2px rgba(123,115,255,.22)} +.heat-cell.l1{background:#245140}.heat-cell.l2{background:#2e765b}.heat-cell.l3{background:#3cab7d}.heat-cell.l4{background:#55dfa1} +html[data-theme=light] .heat-cell{background:#e7ecf3;border-color:#d9e0ea} +html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .heat-cell.l2{background:#81dfb4}html[data-theme=light] .heat-cell.l3{background:#42c990}html[data-theme=light] .heat-cell.l4{background:#159c68} +.heat-tooltip{position:absolute;left:44px;bottom:-30px;z-index:3;border:1px solid var(--border);border-radius:7px;background:var(--surface-3);padding:6px 9px;color:var(--text);font-size:12px;box-shadow:0 10px 26px rgba(0,0,0,.28);pointer-events:none} +.insights-hero{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:22px} +.score-ring{width:116px;height:116px;border-radius:50%;display:grid;place-items:center;background:radial-gradient(circle at center,var(--surface) 58%,transparent 59%),conic-gradient(var(--green) calc(var(--score,75)*1%),rgba(255,255,255,.08) 0);border:1px solid var(--border)} +.score-ring strong{font-size:34px;line-height:1} +.score-ring span{font-size:12px;color:var(--muted);margin-top:-22px} +.insight-summary p{color:var(--muted);margin:8px 0 14px} +.insight-counts{display:flex;gap:10px;flex-wrap:wrap} +.insight-counts span,.issue-severity{border-radius:999px;padding:5px 9px;background:var(--surface-2);color:var(--muted);font-size:12px} +.insight-counts .high,.issue-card.high .issue-severity{color:#ff8a95;background:rgba(240,94,104,.12)} +.insight-counts .medium,.issue-card.medium .issue-severity{color:#ffd166;background:rgba(231,189,53,.12)} +.insight-counts .low,.issue-card.low .issue-severity{color:#72b7ff;background:rgba(79,157,245,.12)} +.insight-filter select{height:36px;border:1px solid var(--border);border-radius:7px;background:var(--surface-2);color:var(--text);padding:0 10px} +.issue-list{display:grid;gap:10px;margin-top:16px;max-height:620px;overflow:auto;padding-right:6px} +.issue-card{position:relative;display:grid;grid-template-columns:1fr;gap:10px;padding:16px 18px;border:1px solid var(--border);border-radius:8px;background:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.015)),var(--surface-2);overflow:hidden} +.issue-card::before{display:none} +.issue-card.high{border-color:rgba(240,94,104,.34);background:linear-gradient(180deg,rgba(240,94,104,.055),rgba(255,255,255,.015)),var(--surface-2)} +.issue-card.medium{border-color:rgba(231,189,53,.32);background:linear-gradient(180deg,rgba(231,189,53,.05),rgba(255,255,255,.015)),var(--surface-2)} +.issue-card.low{border-color:rgba(79,157,245,.3);background:linear-gradient(180deg,rgba(79,157,245,.045),rgba(255,255,255,.015)),var(--surface-2)} +.issue-severity{display:inline-flex;align-items:center;justify-content:center;width:max-content;align-self:start;justify-self:start;line-height:1;font-weight:800;text-transform:none} +.issue-card h3{margin:0 0 6px;font-size:15px} +.issue-card p{margin:0;color:var(--muted)} +.issue-card small,.issue-card b,.issue-card code{display:block;margin-top:7px} +.issue-card small{color:#8fb2ff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%} +.issue-card code{white-space:pre-wrap;word-break:break-word;background:rgba(0,0,0,.2);padding:8px;border-radius:6px;color:var(--muted);max-width:100%;overflow:hidden} +.issue-card b{color:var(--text);font-weight:700} +.git-error-panel{display:grid;grid-template-columns:42px 1fr auto;align-items:center;gap:14px;border-color:rgba(240,94,104,.42)} +.git-error-panel>svg{width:30px;color:var(--red)} +.git-error-panel h2{margin:0 0 6px} +.git-error-panel p{margin:0;color:var(--muted)} +.git-error-panel code{display:block;margin-top:8px;white-space:normal;word-break:break-word;color:#ffb4bc} +.structure-panel,.structure-scroll-panel{max-height:560px;overflow:auto} +.structure-panel .file-tree{height:auto;max-height:490px} +.file-tree>div span,.folder-size b,.large-file small{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.file-tree>div{min-width:0} +.large-file-panel{max-height:360px;overflow:auto} +.large-file{border-radius:7px;gap:16px} +.large-file>div{min-width:0} +@keyframes shineSweep{from{transform:translateX(-65%) rotate(8deg)}to{transform:translateX(65%) rotate(8deg)}} +@keyframes logoTrace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:68}} +@keyframes logoSpark{to{stroke-dashoffset:-40}} +@keyframes popoverIn{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}} +@keyframes gridDrift{to{background-position:80px 80px}} +@keyframes ambientSweep{0%,100%{transform:translateX(-40%);opacity:.18}50%{transform:translateX(22%);opacity:.34}} +@keyframes loadingBarFlow{to{background-position:220% 0}} +@keyframes loadingBarSweep{from{transform:translateX(-44px)}to{transform:translateX(290px)}} +@keyframes loadingTrackSweep{0%,100%{transform:translateX(-62%);opacity:.35}50%{transform:translateX(62%);opacity:.9}} +@media(max-width:1150px){main::before,main::after{inset-left:72px}.sidebar-bottom{padding-left:0;padding-right:0;justify-content:center}.quick-settings{left:0}.quick-settings-btn{width:40px;height:40px}.stats-grid.three,.stats-grid.four{grid-template-columns:repeat(2,minmax(0,1fr))}.project-card{padding:20px}} +@media(max-width:980px){body{min-width:0}.page{padding:24px 24px 56px}.stats-grid.three,.stats-grid.four,.stats-grid.five{grid-template-columns:repeat(2,minmax(0,1fr))}.project-grid{grid-template-columns:1fr}.section-head{align-items:flex-start;gap:12px;flex-direction:column}.project-tools{width:100%;justify-content:flex-start}.group-filter{width:100%;flex-wrap:wrap;height:auto}.group-filter select{flex:1;min-width:160px}.search{width:100%}.git-error-panel{grid-template-columns:36px 1fr}.git-error-panel .btn{grid-column:1/3;width:max-content}} +@media(max-width:720px){.loading-style-grid{grid-template-columns:1fr}.loading-style-card{min-height:104px}} +@media(max-width:980px){.detail-page{--detail-sticky-offset:12px;padding:var(--detail-sticky-offset) 20px 56px}.insights-hero{grid-template-columns:1fr}.detail-head-row{align-items:flex-start;flex-direction:column;gap:10px}.detail-tabs{width:100%;overflow-x:auto}} +@media(prefers-reduced-motion:reduce){main::before,main::after,.logo-track,.logo-spark,.shine-card:hover::after{animation:none!important}.shine-card:hover,.heat-cell:hover{transform:none}.toast{transition:opacity .2s ease}} diff --git a/frontend/src/runtime.css b/frontend/src/runtime.css new file mode 100644 index 0000000..6fa1e8c --- /dev/null +++ b/frontend/src/runtime.css @@ -0,0 +1,3 @@ +.opacity-setting{align-items:center}.opacity-setting>span{display:flex;justify-content:space-between;gap:14px}.opacity-setting b{color:#9188ff}.opacity-setting input[type=range]{width:100%;accent-color:var(--primary);cursor:pointer} +.browser-blocked{min-height:100vh;display:grid;place-items:center;padding:32px;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);background-size:40px 40px}.blocked-card{width:min(590px,100%);padding:42px;border:1px solid var(--glass-border);border-radius:10px;background:var(--glass-strong);box-shadow:var(--glass-shadow);backdrop-filter:blur(24px);text-align:left}.blocked-card>span{width:58px;height:58px;display:grid;place-items:center;border-radius:9px;background:rgba(240,94,104,.12);color:var(--red)}.blocked-card>span svg{width:28px}.blocked-card>small{display:block;color:var(--red);font-weight:bold;margin-top:24px}.blocked-card h1{font-size:28px;margin:10px 0}.blocked-card p{color:var(--muted);line-height:1.7}.blocked-card code{color:#9188ff}.blocked-card>div{display:flex;gap:9px;align-items:center;padding:13px;background:var(--glass-soft);border-radius:7px;color:var(--muted)}.blocked-card>div svg{width:18px;color:var(--green)} +.btn:disabled{opacity:.55;cursor:not-allowed} diff --git a/frontend/src/store.js b/frontend/src/store.js new file mode 100644 index 0000000..d61815b --- /dev/null +++ b/frontend/src/store.js @@ -0,0 +1,118 @@ +import { defineStore } from 'pinia' +import { call, on } from './api' + +export const useAppStore = defineStore('app', { + state: () => ({ + bootstrap: { state: 'loading' }, + projects: [], + projectGroups: [], + selectedProjectGroupId: Number(localStorage.getItem('cc-project-group-id') || 0), + dashboard: { projects: 0, totalLines: 0, commits: 0 }, + settings: { theme: 'dark', locale: 'zh-CN', glassOpacity: 55, gitScope: 'current', autoRefresh: true, loadingStyle: 'fullscreen-orbit' }, + tasks: {}, + batchTaskIds: [], + toast: null, + toastTimer: null, + toastLeaveTimer: null, + loading: false + }), + actions: { + async boot() { + this.bootstrap = await call('GetBootstrapStatus') + if (this.bootstrap.state === 'ready') { + this.settings = await call('GetSettings') + this.applyAppearance(this.settings) + await this.refresh() + } + }, + applyAppearance(settings) { + this.settings = { ...this.settings, ...settings } + let theme = this.settings.theme || 'dark' + if (theme === 'system') theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + document.documentElement.dataset.theme = theme + document.documentElement.style.setProperty('--glass-user-opacity', String((this.settings.glassOpacity || 55) / 100)) + localStorage.setItem('cc-settings', JSON.stringify(this.settings)) + }, + async saveSettings(patch) { + const next = { ...this.settings, ...patch } + await call('SaveSettings', next) + this.applyAppearance(next) + return next + }, + showToast(payload) { + clearTimeout(this.toastTimer) + clearTimeout(this.toastLeaveTimer) + this.toast = { ...payload, leaving: false, muted: false, id: Date.now() } + this.toastTimer = setTimeout(() => { + if (this.toast) this.toast.muted = true + }, 3000) + this.toastLeaveTimer = setTimeout(() => { + if (this.toast) this.toast.leaving = true + setTimeout(() => { + this.toast = null + }, 360) + }, 5000) + }, + closeToast() { + clearTimeout(this.toastTimer) + clearTimeout(this.toastLeaveTimer) + if (this.toast) this.toast.leaving = true + setTimeout(() => { + this.toast = null + }, 220) + }, + async refresh() { + this.loading = true + try { + this.projectGroups = await call('ListProjectGroups') + if (this.selectedProjectGroupId && !this.projectGroups.some(g => g.id === this.selectedProjectGroupId)) { + this.setProjectGroup(0) + } + const groupId = this.selectedProjectGroupId || 0 + ;[this.projects, this.dashboard] = await Promise.all([ + groupId ? call('ListProjectsByGroup', groupId) : call('ListProjects'), + groupId ? call('GetDashboardByGroup', groupId) : call('GetDashboard') + ]) + } finally { + this.loading = false + } + }, + async changeProjectGroup(groupId) { + this.setProjectGroup(groupId) + await this.refresh() + }, + setProjectGroup(groupId, persist = true) { + this.selectedProjectGroupId = Number(groupId) || 0 + if (persist) localStorage.setItem('cc-project-group-id', String(this.selectedProjectGroupId)) + }, + listen() { + return on('analysis:progress', e => { + delete this.tasks.__batch_pending__ + this.tasks[e.taskId] = e + if (['completed', 'error', 'cancelled'].includes(e.stage)) { + if (this.batchTaskIds.includes(e.taskId)) { + this.batchTaskIds = this.batchTaskIds.filter(id => id !== e.taskId) + if (this.batchTaskIds.length) { + this.tasks.__batch_pending__ = { taskId: '__batch_pending__', projectId: 0, stage: 'queued', progress: 100, messageKey: e.messageKey, params: e.params } + } + } + this.showToast({ type: e.stage === 'completed' ? 'success' : 'error', key: e.messageKey, params: e.params }) + this.refresh() + } + }) + }, + async analyze(id, kind = 'all') { + const task = await call('StartAnalysis', id, kind) + this.tasks[task] = { taskId: task, projectId: id, stage: 'start', progress: 1, messageKey: 'task.start' } + return task + }, + async batchAnalyze(groupId = 0) { + const ids = groupId ? await call('StartBatchAnalysisByGroup', groupId) : await call('StartBatchAnalysis') + this.batchTaskIds = ids || [] + if (this.batchTaskIds.length && !Object.values(this.tasks).some(x => !['completed', 'error', 'cancelled'].includes(x.stage))) { + this.tasks.__batch_pending__ = { taskId: '__batch_pending__', projectId: 0, stage: 'queued', progress: 1, messageKey: 'task.start' } + } + return this.batchTaskIds + } + } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..1a6667b --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1 @@ +@font-face{font-family:Nunito;src:url('./assets/fonts/nunito-v16-latin-regular.woff2')}*{box-sizing:border-box}html{--bg:#0e141d;--side:#151b24;--surface:#19212b;--surface-2:#202838;--surface-3:#252e40;--border:#303949;--text:#f4f5f8;--muted:#929bac;--primary:#7367f5;--green:#43c996;--blue:#4f9df5;--red:#f05e68;--yellow:#e7bd35;background:var(--bg);color:var(--text);font-family:Nunito,"Segoe UI",sans-serif;letter-spacing:0}html[data-theme=light]{--bg:#f4f6fa;--side:#fff;--surface:#fff;--surface-2:#f6f7fa;--surface-3:#edf0f6;--border:#dce1ea;--text:#111827;--muted:#526071;--primary:#6557e8}body{margin:0;min-width:960px}button,input,textarea,select{font:inherit;letter-spacing:0}.shell{min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:232px;background:var(--side);border-right:1px solid var(--border);padding:24px 16px;display:flex;flex-direction:column;z-index:10}.brand{height:52px;display:flex;align-items:center;gap:14px;font-size:18px;padding:0 8px}.brand-mark{display:grid;place-items:center;width:40px;height:40px;background:var(--primary);border-radius:8px;color:white}.brand-mark svg{width:23px}.sidebar nav{display:grid;gap:8px;margin-top:35px}.sidebar nav a{height:54px;display:flex;align-items:center;gap:16px;padding:0 18px;color:var(--muted);text-decoration:none;border-radius:7px;transition:background .2s,color .2s}.sidebar nav a:hover,.sidebar nav a.active{background:var(--surface-3);color:var(--text)}.sidebar nav a.active{color:#867eff}.sidebar svg{width:21px}.version{margin-top:auto;border-top:1px solid var(--border);padding:24px 8px 0;color:var(--muted);font-size:13px}.version i{display:inline-block;width:8px;height:8px;border-radius:50%;background:#26c795;margin-right:8px}main{margin-left:232px;min-height:100vh;background:radial-gradient(circle at 100% 0,rgba(91,86,192,.12),transparent 34%),var(--bg)}.page{padding:34px 42px 60px;max-width:1540px;margin:auto}.page-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:30px}.page h1{font-size:30px;margin:0 0 6px}.page-head p,.project-head p{margin:0;color:var(--muted)}.actions{display:flex;align-items:center;gap:12px}.btn{height:40px;border:1px solid var(--border);border-radius:7px;padding:0 16px;display:inline-flex;align-items:center;gap:9px;color:var(--text);background:var(--surface-2);cursor:pointer;transition:border-color .2s,background .2s}.btn svg{width:17px}.btn:hover{border-color:var(--primary)}.btn.primary{background:var(--primary);border-color:var(--primary);color:white;box-shadow:0 8px 18px rgba(115,103,245,.25)}.btn.danger{background:#ef4444;border-color:#ef4444;color:white}.stats-grid{display:grid;gap:16px;margin-bottom:30px}.stats-grid.three{grid-template-columns:repeat(3,1fr)}.stats-grid.four{grid-template-columns:repeat(4,1fr)}.stats-grid.five{grid-template-columns:repeat(5,1fr)}.stat-card{height:124px;border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:23px 24px;display:flex;align-items:center;gap:16px}.stat-icon{width:48px;height:48px;border-radius:8px;display:grid;place-items:center;background:rgba(115,103,245,.18);color:#8178ff}.stat-icon.green{background:rgba(67,201,150,.16);color:var(--green)}.stat-icon.blue{background:rgba(79,157,245,.16);color:var(--blue)}.stat-icon.red{background:rgba(240,94,104,.16);color:var(--red)}.stat-icon svg{width:22px}.stat-card strong{font-size:29px;display:block}.stat-card small{display:block;color:var(--muted);margin-top:5px}.section-head{display:flex;align-items:center;justify-content:space-between}.section-head h2,.panel h2{font-size:18px;margin:0}.search{height:38px;width:280px;border:1px solid var(--border);background:var(--surface-2);border-radius:7px;display:flex;align-items:center;padding:0 13px;color:var(--muted)}.search svg{width:17px}.search input{border:0;outline:0;background:transparent;color:var(--text);width:100%;padding-left:9px}.project-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px;margin-top:20px}.project-card{min-height:280px;border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:24px;cursor:pointer;transition:border-color .2s,background .2s}.project-card:hover{border-color:#555f77;background:var(--surface-2)}.project-title{display:flex;justify-content:space-between}.project-title h3{margin:0 0 5px}.project-title p{color:var(--muted);font-size:12px;margin:0;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;max-width:280px}.icon-actions{display:flex}.icon-actions button,.toast button{border:0;background:transparent;color:var(--muted);cursor:pointer;padding:5px}.icon-actions svg{width:16px}.metric-row{display:grid;grid-template-columns:repeat(3,1fr);margin:27px 0 17px}.metric-row b{display:block;font-size:25px}.metric-row span{font-size:12px;color:var(--muted)}.language-bar{height:6px;border-radius:4px;overflow:hidden;display:flex;background:var(--surface-3)}.legend{display:flex;gap:14px;flex-wrap:wrap;margin-top:15px;color:var(--muted);font-size:11px;min-height:34px}.legend i{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:5px}.project-card footer{border-top:1px solid var(--border);margin-top:13px;padding-top:13px;display:flex;gap:14px;color:var(--muted);font-size:12px}.positive{color:var(--green)!important}.negative{color:var(--red)!important}.add-card{min-height:280px;border:1px dashed #3a4558;background:transparent;border-radius:8px;color:var(--muted);display:grid;place-content:center;gap:15px;cursor:pointer}.add-card span{width:48px;height:48px;background:var(--surface-2);display:grid;place-items:center;border-radius:50%;margin:auto}.overlay{position:fixed;inset:0;background:rgba(0,0,0,.66);backdrop-filter:blur(5px);z-index:30;display:grid;place-items:center}.modal{width:500px;background:var(--surface);border:1px solid var(--border);border-radius:8px}.modal header,.modal footer{padding:20px 24px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between}.modal footer{border:0;border-top:1px solid var(--border);justify-content:flex-end}.modal header h2{margin:0}.modal header button{border:0;background:transparent;color:var(--muted);font-size:25px;cursor:pointer}.modal>label{display:block;margin:20px 24px 0;font-size:13px}.modal input,.modal textarea,.rule-add input,.rule-add select,.form-panel select{width:100%;background:var(--surface-2);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:10px 12px;margin-top:7px;outline:none}.modal textarea{height:80px;resize:vertical}.browse{display:flex;gap:8px}.browse input{margin-top:7px}.browse .btn{margin-top:7px;flex:none}.form-error{color:var(--red);padding:0 24px}.project-head{display:flex;align-items:center;gap:30px;background:rgba(8,12,19,.65);padding:24px;margin:-10px 0 12px}.project-head h1{font-size:25px}.back{border:0;background:transparent;color:var(--text);display:flex;align-items:center;gap:7px;cursor:pointer}.back svg{width:18px}.tabs{display:flex;gap:3px;background:var(--surface);width:max-content;padding:4px;border-radius:8px;margin:10px 0 25px}.tabs button{height:37px;padding:0 14px;border:0;background:transparent;color:var(--muted);border-radius:7px;display:flex;align-items:center;gap:8px;cursor:pointer}.tabs svg{width:18px}.tabs button.active{background:var(--primary);color:white}.split{display:grid;grid-template-columns:1fr 1fr;gap:22px;margin-bottom:22px}.panel{border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:24px;margin-bottom:22px}.panel>.chart{height:310px}.language-list{height:310px;overflow:auto;margin-top:14px}.language-list>div{display:grid;grid-template-columns:1fr auto auto;gap:20px;background:var(--surface-2);padding:13px;margin-bottom:8px;border-radius:7px}.language-list i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px}.language-list span{color:var(--muted)}.center-action{text-align:center}.heat-panel{height:220px}.heatmap{display:grid;grid-template-rows:repeat(7,12px);grid-auto-flow:column;grid-auto-columns:12px;gap:4px;overflow:hidden;margin-top:25px}.heatmap i{background:#2b3441;border-radius:2px}.heatmap i.l1{background:#28594b}.heatmap i.l2{background:#318266}.heatmap i.l3{background:#3cab7d}.heatmap i.l4{background:#4dd69b}.branch{height:76px;background:var(--surface-2);border-radius:7px;margin-top:10px;padding:12px 16px;display:grid;grid-template-columns:1fr auto;align-items:center}.branch span{display:flex;gap:9px;align-items:center;font-weight:bold}.branch svg{width:17px}.branch .current{color:var(--green)}.branch small{grid-column:1;color:var(--muted)}.branch code{grid-row:1/3;grid-column:2}.commit{min-height:76px;background:var(--surface-2);border-radius:7px;margin-top:10px;padding:12px;display:grid;grid-template-columns:42px 1fr auto auto;gap:9px;align-items:center}.avatar{width:36px;height:36px;border-radius:8px;background:var(--primary);color:#fff;display:grid;place-items:center;font-weight:bold}.commit div{min-width:0}.commit div b,.commit small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.commit small,.contributor small{color:var(--muted);margin-top:5px}.trend .chart{height:250px}.segments{display:flex;background:var(--surface-2);padding:3px;border-radius:7px}.segments button{border:0;background:transparent;color:var(--muted);height:31px;padding:0 13px;border-radius:5px;cursor:pointer}.segments button.active{background:var(--primary);color:white}.contributor{height:65px;background:var(--surface-2);display:grid;grid-template-columns:35px 45px 1fr auto auto auto;gap:12px;align-items:center;padding:0 15px;margin-top:8px;border-radius:7px}.contributor div strong,.contributor div small{display:block}.file-tree{height:620px;overflow:auto;margin-top:18px}.file-tree>div{height:32px;display:flex;align-items:center;gap:7px}.file-tree svg{width:15px;color:var(--yellow)}.file-tree small{margin-left:auto;color:var(--muted)}.folder-size{display:grid;grid-template-columns:1fr auto;gap:8px 20px;background:var(--surface-2);padding:14px;margin-top:10px;border-radius:7px}.folder-size span{color:var(--muted);font-size:12px}.folder-size i{height:5px;background:#3b4353;border-radius:3px}.folder-size em{display:block;height:100%;background:var(--primary);border-radius:3px}.folder-size strong{font-size:13px}.large-file{display:flex;justify-content:space-between;background:var(--surface-2);padding:14px;margin-top:10px}.large-file b,.large-file small{display:block}.large-file small{color:var(--muted);margin-top:5px}.large-file>strong{color:var(--red)}.extension-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:15px}.extension-grid>div{background:var(--surface-2);padding:16px;display:flex;gap:10px;justify-content:space-between}.extension-grid code{color:#8178ff}.extension-grid span{color:var(--muted)}.empty{height:100%;min-height:130px;display:grid;place-content:center;text-align:center;color:var(--muted);gap:10px}.empty svg{margin:auto;width:38px}.taskbar{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:25;width:460px;background:var(--surface-3);border:1px solid var(--border);box-shadow:0 12px 30px rgba(0,0,0,.3);padding:12px 15px;border-radius:8px;display:grid;grid-template-columns:1fr 60px;gap:7px}.taskbar div:first-child{display:flex;gap:10px}.taskbar span{color:var(--muted)}.taskbar .progress{grid-column:1/3;height:4px;background:#3b4353}.taskbar .progress i{display:block;height:100%;background:var(--primary)}.toast{position:fixed;right:24px;top:22px;z-index:40;background:var(--surface-3);border:1px solid var(--border);padding:13px 15px;border-radius:7px}.toast.success{border-color:var(--green)}.toast.error{border-color:var(--red)}.toggle{display:flex;align-items:center;gap:7px;color:var(--primary)}.log-panel{min-height:400px}.log{min-height:96px;border-left:3px solid var(--blue);background:var(--surface-2);padding:15px 16px;margin-bottom:8px;display:grid;grid-template-columns:34px 1fr auto;gap:14px}.log.warning{border-color:var(--yellow)}.log.error{border-color:var(--red)}.log>span{width:30px;height:30px;background:rgba(79,157,245,.13);display:grid;place-items:center;border-radius:6px;color:var(--blue)}.log svg{width:15px}.log small,.log b,.log code{display:block}.log small{color:var(--muted)}.log code{background:rgba(0,0,0,.22);padding:8px;margin-top:7px}.log time{color:var(--muted);font-size:12px}.settings-page{max-width:1000px}.settings-tabs{width:100%;display:grid;grid-template-columns:repeat(3,1fr)}.settings-tabs button{justify-content:center}.rule-add h2,.form-panel h2{display:flex;gap:10px;align-items:center}.rule-add h2 svg,.form-panel h2 svg{width:20px;color:var(--primary)}.rule-add>div{display:grid;grid-template-columns:1fr 240px auto;gap:15px;margin-top:20px}.rule-add input,.rule-add select{margin:0}.rule-add>small{color:var(--muted);display:block;margin-top:12px}.rule-group h2 small{color:var(--muted);font-weight:normal}.rule-group>div{display:flex;flex-wrap:wrap;gap:8px;border-top:1px solid var(--border);padding-top:15px;margin-top:15px}.rule-group button{border:0;background:var(--surface-3);color:var(--text);border-radius:7px;padding:8px 10px;display:flex;align-items:center;gap:7px;cursor:pointer}.rule-group button.builtin{cursor:default}.rule-group button small{color:#8178ff}.rule-group svg{width:13px}.form-panel label{display:grid;grid-template-columns:180px 1fr;align-items:center;margin-top:18px}.db-path{background:var(--surface-2);padding:18px;margin:20px 0}.db-path code{color:#8178ff}.migrate{height:78px}.danger-zone h2{color:var(--red);display:flex;gap:9px}.danger-zone h2 svg{width:20px}.danger-zone p{color:var(--muted)}.danger-zone>div{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.danger-zone button{border:1px solid var(--border);background:var(--surface-2);color:var(--text);padding:20px;text-align:left;display:flex;gap:14px;align-items:center;border-radius:8px;cursor:pointer}.danger-zone button.danger{border-color:#713947}.danger-zone svg{width:25px}.danger-zone b,.danger-zone small{display:block}.danger-zone small{color:var(--muted);margin-top:5px}@media(max-width:1150px){.sidebar{width:72px}.brand b,.sidebar nav span{display:none}.brand{padding:0}.sidebar nav a{justify-content:center;padding:0}.version{font-size:0}.version i{margin:0}main{margin-left:72px}.project-grid{grid-template-columns:repeat(2,1fr)}.stats-grid.five{grid-template-columns:repeat(3,1fr)}.split{grid-template-columns:1fr}.extension-grid{grid-template-columns:repeat(3,1fr)}}@media(prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important}} diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..ab85430 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,240 @@ + + +