Files
code-utils/database.go

1173 lines
48 KiB
Go
Raw Normal View History

2026-08-11 19:07:05 +08:00
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
}
2026-08-15 17:18:00 +08:00
// 每次打开库:把上次异常退出留下的 running 任务标为失败(表不存在时忽略)。
s.markInterruptedPackTasks()
2026-08-11 19:07:05 +08:00
return s, nil
}
2026-08-14 07:51:46 +08:00
// schemaVersion 是当前本地库结构版本;仅当已记录版本低于它时才执行迁移语句,
// 避免应用每次启动都重复执行建表/回填(版本一致时启动零迁移)。
// v3新增 festival_images节日背景图本地缓存
// v4todos/tickets 新增 history 生命周期轨迹列。
// v5新增 ai_summaries分析完成后 AI 生成的模块介绍)。
// v6新增 day_briefs工作台 AI 今日规划 / 下班日报)。
// v7新增 launch_apps启动台保存的应用与启停命令
// v8todos/tickets 新增 team_id 团队共享列。
2026-08-15 17:18:00 +08:00
// v9新增 launch_logs启动台进程输出仅本地不同步
// v10launch_apps 新增 icon本地抓取的 favicon/logo dataURL
// v11launch_apps 新增 category我的应用分类筛选
// v12launch_categories 一级分类launch_apps.category_idprojects.iconkind_icons。
// v13pack_tasks / pack_task_logs打包任务与控制台输出仅本地不同步
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
// v14exclusion_rules 新增 project_id0=全局),唯一约束改为 (project_id,pattern)。
const schemaVersion = 14
2026-08-14 07:51:46 +08:00
2026-08-11 19:07:05 +08:00
func (s *Store) migrate() error {
2026-08-14 07:51:46 +08:00
// PRAGMA 是连接级/文件级配置,不属于迁移,每次打开都需执行。
for _, q := range []string{`PRAGMA journal_mode=WAL`, `PRAGMA foreign_keys=ON`} {
if _, err := s.db.Exec(q); err != nil {
return fmt.Errorf("database migration: %w", err)
}
}
// 历史数据修正:早期同步日志把警告级别写成了 "warn",统一为 "warning"。
// 全新数据库此时表还不存在,忽略错误即可。
_, _ = s.db.Exec(`UPDATE app_logs SET level='warning' WHERE level='warn'`)
var applied int
_ = s.db.QueryRow(`SELECT COALESCE(MAX(version),0) FROM schema_migrations`).Scan(&applied)
if applied >= schemaVersion {
return nil
}
2026-08-11 19:07:05 +08:00
stmts := []string{
`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)`,
2026-08-14 07:51:46 +08:00
`DROP TABLE IF EXISTS commit_refs`,
2026-08-11 19:07:05 +08:00
`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)`,
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
`CREATE TABLE IF NOT EXISTS exclusion_rules(id INTEGER PRIMARY KEY AUTOINCREMENT, pattern TEXT NOT NULL, category TEXT NOT NULL, builtin INTEGER NOT NULL DEFAULT 0, project_id INTEGER NOT NULL DEFAULT 0, UNIQUE(project_id,pattern))`,
2026-08-11 19:07:05 +08:00
`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)`,
2026-08-14 07:51:46 +08:00
`CREATE TABLE IF NOT EXISTS git_hotspots(project_id INTEGER NOT NULL, path TEXT NOT NULL, changes INTEGER NOT NULL, added INTEGER NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(project_id,path), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS todos(id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', project_id INTEGER NOT NULL DEFAULT 0, due_at TEXT NOT NULL DEFAULT '', priority TEXT NOT NULL DEFAULT 'medium', status TEXT NOT NULL DEFAULT 'open', reminded INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 1, history TEXT NOT NULL DEFAULT '', team_id INTEGER NOT NULL DEFAULT 0)`,
`CREATE TABLE IF NOT EXISTS tickets(id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', type TEXT NOT NULL DEFAULT 'task', project_id INTEGER NOT NULL, start_at TEXT NOT NULL, due_at TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', priority TEXT NOT NULL DEFAULT 'medium', reminded INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 1, history TEXT NOT NULL DEFAULT '', team_id INTEGER NOT NULL DEFAULT 0)`,
`CREATE TABLE IF NOT EXISTS notes(id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, content TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 1)`,
`CREATE TABLE IF NOT EXISTS favorites(project_id INTEGER PRIMARY KEY, created_at TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS messages(id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', source_type TEXT NOT NULL DEFAULT '', source_id INTEGER NOT NULL DEFAULT 0, is_read INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS ai_conversations(id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL DEFAULT 0, provider TEXT NOT NULL DEFAULT '', title TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS ai_messages(id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS festival_images(fest_key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '', dirty INTEGER NOT NULL DEFAULT 0)`,
`CREATE TABLE IF NOT EXISTS ai_summaries(project_id INTEGER NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', generated_at TEXT NOT NULL, PRIMARY KEY(project_id,kind), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS day_briefs(kind TEXT PRIMARY KEY, content TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', generated_at TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS launch_apps(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'other', port INTEGER NOT NULL DEFAULT 0, dir TEXT NOT NULL DEFAULT '', start_cmd TEXT NOT NULL DEFAULT '', stop_cmd TEXT NOT NULL DEFAULT '', last_pid INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
2026-08-15 17:18:00 +08:00
`CREATE TABLE IF NOT EXISTS launch_logs(id INTEGER PRIMARY KEY AUTOINCREMENT, app_id INTEGER NOT NULL, level TEXT NOT NULL DEFAULT 'info', line TEXT NOT NULL, created_at TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS launch_categories(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, sort INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS kind_icons(kind TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE IF NOT EXISTS pack_tasks(id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', cmd TEXT NOT NULL DEFAULT '', dir TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'running', pid INTEGER NOT NULL DEFAULT 0, started_at TEXT NOT NULL DEFAULT '', ended_at TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE IF NOT EXISTS pack_task_logs(id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, line TEXT NOT NULL, created_at TEXT NOT NULL)`,
`CREATE INDEX IF NOT EXISTS idx_launch_logs_app ON launch_logs(app_id, id DESC)`,
`CREATE INDEX IF NOT EXISTS idx_pack_tasks_started ON pack_tasks(started_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_pack_task_logs_task ON pack_task_logs(task_id, id)`,
2026-08-14 07:51:46 +08:00
`CREATE INDEX IF NOT EXISTS idx_ai_messages_conv ON ai_messages(conversation_id)`,
`CREATE INDEX IF NOT EXISTS idx_messages_read ON messages(is_read)`,
`CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(deleted,status)`,
`CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(deleted,status)`,
2026-08-11 19:07:05 +08:00
}
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)
}
}
2026-08-14 07:51:46 +08:00
// 待办/工单生命周期history 存 JSON 状态轨迹(进入某状态的时间点)。
for _, table := range []string{"todos", "tickets"} {
if ok, err := s.columnExists(table, "history"); err != nil {
return err
} else if !ok {
if _, err = s.db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN history TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("database migration: %w", err)
}
// 存量数据补一个创建节点,保证时间线至少有起点。
_, _ = s.db.Exec(`UPDATE ` + table + ` SET history='[{"status":"open","at":"'||created_at||'"}]' WHERE history=''`)
}
}
// 团队共享team_id>0 表示该条对团队管理员可见0 私密)。
for _, table := range []string{"todos", "tickets"} {
if ok, err := s.columnExists(table, "team_id"); err != nil {
return err
} else if !ok {
if _, err = s.db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN team_id INTEGER NOT NULL DEFAULT 0`); err != nil {
return fmt.Errorf("database migration: %w", err)
}
}
}
2026-08-15 17:18:00 +08:00
if ok, err := s.columnExists("launch_apps", "icon"); err != nil {
return err
} else if !ok {
if _, err = s.db.Exec(`ALTER TABLE launch_apps ADD COLUMN icon TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("database migration: %w", err)
}
}
if ok, err := s.columnExists("launch_apps", "category"); err != nil {
return err
} else if !ok {
if _, err = s.db.Exec(`ALTER TABLE launch_apps ADD COLUMN category TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("database migration: %w", err)
}
}
if ok, err := s.columnExists("launch_apps", "category_id"); err != nil {
return err
} else if !ok {
if _, err = s.db.Exec(`ALTER TABLE launch_apps ADD COLUMN category_id INTEGER NOT NULL DEFAULT 0`); err != nil {
return fmt.Errorf("database migration: %w", err)
}
}
if ok, err := s.columnExists("projects", "icon"); err != nil {
return err
} else if !ok {
if _, err = s.db.Exec(`ALTER TABLE projects ADD COLUMN icon TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("database migration: %w", err)
}
}
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
// 排除规则支持项目级project_id>0旧表 pattern 全局唯一,需重建为 (project_id,pattern) 唯一。
if ok, err := s.columnExists("exclusion_rules", "project_id"); err != nil {
return err
} else if !ok {
for _, q := range []string{
`CREATE TABLE exclusion_rules_new(id INTEGER PRIMARY KEY AUTOINCREMENT, pattern TEXT NOT NULL, category TEXT NOT NULL, builtin INTEGER NOT NULL DEFAULT 0, project_id INTEGER NOT NULL DEFAULT 0, UNIQUE(project_id,pattern))`,
`INSERT INTO exclusion_rules_new(id,pattern,category,builtin,project_id) SELECT id,pattern,category,builtin,0 FROM exclusion_rules`,
`DROP TABLE exclusion_rules`,
`ALTER TABLE exclusion_rules_new RENAME TO exclusion_rules`,
} {
if _, err = s.db.Exec(q); err != nil {
return fmt.Errorf("database migration: %w", err)
}
}
}
2026-08-11 19:07:05 +08:00
_, _ = 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')`)
2026-08-14 07:51:46 +08:00
_, _ = s.db.Exec(`INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(?,?)`, schemaVersion, time.Now().Format(time.RFC3339))
2026-08-11 19:07:05 +08:00
// 旧版本曾写入英文日志迁移时转换已知固定文案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, &notNull, &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) {
2026-08-15 17:18:00 +08:00
q := `SELECT p.id,p.name,p.path,p.description,p.group_id,COALESCE(g.name,''),COALESCE(p.icon,''),p.created_at,p.updated_at FROM projects p LEFT JOIN project_groups g ON g.id=p.group_id`
2026-08-11 19:07:05 +08:00
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
2026-08-15 17:18:00 +08:00
if e = rows.Scan(&p.ID, &p.Name, &p.Path, &p.Description, &p.GroupID, &p.GroupName, &p.Icon, &p.CreatedAt, &p.UpdatedAt); e != nil {
2026-08-11 19:07:05 +08:00
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
2026-08-15 17:18:00 +08:00
e := s.db.QueryRow(`SELECT p.id,p.name,p.path,p.description,p.group_id,COALESCE(g.name,''),COALESCE(p.icon,''),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.Icon, &p.CreatedAt, &p.UpdatedAt)
2026-08-11 19:07:05 +08:00
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
2026-08-14 07:51:46 +08:00
var e error
2026-08-11 19:07:05 +08:00
if groupID > 0 {
2026-08-14 07:51:46 +08:00
e = s.db.QueryRow(`SELECT COUNT(*),
COALESCE((SELECT SUM(ls.code+ls.comments+ls.blanks) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0),
COALESCE((SELECT SUM(ls.code) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0),
COALESCE((SELECT SUM(ls.comments) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0),
COALESCE((SELECT SUM(ls.blanks) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0),
COALESCE((SELECT SUM(ls.files) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0),
COALESCE((SELECT COUNT(*) FROM git_commits gc JOIN projects p ON p.id=gc.project_id WHERE p.group_id=?),0),
COALESCE((SELECT COUNT(DISTINCT gc.email) FROM git_commits gc JOIN projects p ON p.id=gc.project_id WHERE p.group_id=?),0)
FROM projects WHERE group_id=?`, groupID, groupID, groupID, groupID, groupID, groupID, groupID, groupID).
Scan(&d.Projects, &d.TotalLines, &d.CodeLines, &d.CommentLines, &d.BlankLines, &d.FileCount, &d.Commits, &d.Contributors)
} else {
e = s.db.QueryRow(`SELECT COUNT(*),
COALESCE((SELECT SUM(code+comments+blanks) FROM language_stats),0),
COALESCE((SELECT SUM(code) FROM language_stats),0),
COALESCE((SELECT SUM(comments) FROM language_stats),0),
COALESCE((SELECT SUM(blanks) FROM language_stats),0),
COALESCE((SELECT SUM(files) FROM language_stats),0),
COALESCE((SELECT COUNT(*) FROM git_commits),0),
COALESCE((SELECT COUNT(DISTINCT email) FROM git_commits),0)
FROM projects`).
Scan(&d.Projects, &d.TotalLines, &d.CodeLines, &d.CommentLines, &d.BlankLines, &d.FileCount, &d.Commits, &d.Contributors)
}
if e != nil {
2026-08-11 19:07:05 +08:00
return d, e
}
2026-08-14 07:51:46 +08:00
d.Languages, e = s.AggregateLanguages(groupID)
2026-08-11 19:07:05 +08:00
return d, e
}
2026-08-14 07:51:46 +08:00
// AggregateLanguages 跨项目聚合语言分布groupID>0 时仅统计该项目组。
func (s *Store) AggregateLanguages(groupID int64) ([]LanguageStat, error) {
q := `SELECT ls.name,SUM(ls.files),SUM(ls.code),SUM(ls.comments),SUM(ls.blanks) FROM language_stats ls`
args := []any{}
if groupID > 0 {
q += ` JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?`
args = append(args, groupID)
}
q += ` GROUP BY ls.name ORDER BY SUM(ls.code) DESC`
r, e := s.db.Query(q, args...)
if e != nil {
return nil, e
}
defer r.Close()
out := []LanguageStat{}
for r.Next() {
var x LanguageStat
if e = r.Scan(&x.Name, &x.Files, &x.Code, &x.Comments, &x.Blanks); e != nil {
return nil, e
}
out = append(out, x)
}
return out, r.Err()
}
2026-08-11 19:07:05 +08:00
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()
}
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
// Rules 全局排除规则project_id=0含内置
2026-08-11 19:07:05 +08:00
func (s *Store) Rules() ([]ExclusionRule, error) {
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
return s.queryRules(`SELECT id,pattern,category,builtin,project_id FROM exclusion_rules WHERE project_id=0 ORDER BY category,builtin DESC,pattern`)
}
// ProjectRules 某项目专属规则。
func (s *Store) ProjectRules(projectID int64) ([]ExclusionRule, error) {
return s.queryRules(`SELECT id,pattern,category,builtin,project_id FROM exclusion_rules WHERE project_id=? ORDER BY category,pattern`, projectID)
}
// RulesForProject 扫描用:全局规则 + 项目专属规则叠加。
func (s *Store) RulesForProject(projectID int64) ([]ExclusionRule, error) {
return s.queryRules(`SELECT id,pattern,category,builtin,project_id FROM exclusion_rules WHERE project_id IN (0,?) ORDER BY project_id,category,builtin DESC,pattern`, projectID)
}
func (s *Store) queryRules(q string, args ...any) ([]ExclusionRule, error) {
r, e := s.db.Query(q, args...)
2026-08-11 19:07:05 +08:00
if e != nil {
return nil, e
}
defer r.Close()
o := []ExclusionRule{}
for r.Next() {
var x ExclusionRule
var b int
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
if e = r.Scan(&x.ID, &x.Pattern, &x.Category, &b, &x.ProjectID); e != nil {
2026-08-11 19:07:05 +08:00
return nil, e
}
x.Builtin = b == 1
o = append(o, x)
}
return o, r.Err()
}
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
2026-08-11 19:07:05 +08:00
func (s *Store) AddRule(pattern, category string) (ExclusionRule, error) {
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
return s.AddProjectRule(0, pattern, category)
}
// AddProjectRule projectID=0 为全局规则,>0 为项目专属。
func (s *Store) AddProjectRule(projectID int64, pattern, category string) (ExclusionRule, error) {
2026-08-11 19:07:05 +08:00
pattern = strings.TrimSpace(pattern)
if pattern == "" {
return ExclusionRule{}, errors.New("PATTERN_REQUIRED")
}
if category == "" {
category = "custom"
}
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
r, e := s.db.Exec(`INSERT INTO exclusion_rules(pattern,category,builtin,project_id) VALUES(?,?,0,?)`, pattern, category, projectID)
2026-08-11 19:07:05 +08:00
if e != nil {
return ExclusionRule{}, e
}
id, _ := r.LastInsertId()
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
return ExclusionRule{ID: id, Pattern: pattern, Category: category, ProjectID: projectID}, nil
2026-08-11 19:07:05 +08:00
}
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) {
2026-08-14 07:51:46 +08:00
if level == "warn" {
level = "warning" // UI 与统计只认 info/warning/error
}
2026-08-11 19:07:05 +08:00
_, _ = 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 }
2026-08-14 07:51:46 +08:00
// SearchLogs 支持关键词(消息/详情/分类)、级别、分类过滤和分页。
// 计数按当前关键词+分类统计(不含级别),供前端标签页展示。
func (s *Store) SearchLogs(query, level, category string, offset, limit int64) (LogPage, error) {
page := LogPage{Items: []LogEntry{}}
where, args := []string{}, []any{}
if q := strings.TrimSpace(query); q != "" {
where = append(where, `(message LIKE ? OR detail LIKE ? OR category LIKE ?)`)
like := "%" + q + "%"
args = append(args, like, like, like)
}
if category != "" && category != "all" {
where = append(where, `category=?`)
args = append(args, category)
}
base := ""
if len(where) > 0 {
base = " WHERE " + strings.Join(where, " AND ")
}
e := s.db.QueryRow(`SELECT COUNT(*),COALESCE(SUM(level='info'),0),COALESCE(SUM(level='warning'),0),COALESCE(SUM(level='error'),0) FROM app_logs`+base, args...).
Scan(&page.Total, &page.Info, &page.Warning, &page.Error)
if e != nil {
return page, e
}
itemWhere, itemArgs := where, args
if level != "" && level != "all" {
itemWhere = append(append([]string{}, where...), `level=?`)
itemArgs = append(append([]any{}, args...), level)
switch level {
case "info":
page.Total = page.Info
case "warning":
page.Total = page.Warning
case "error":
page.Total = page.Error
}
}
q := `SELECT id,level,category,message,detail,created_at FROM app_logs`
if len(itemWhere) > 0 {
q += ` WHERE ` + strings.Join(itemWhere, " AND ")
}
if limit <= 0 || limit > 200 {
limit = 50
}
if offset < 0 {
offset = 0
}
q += ` ORDER BY id DESC LIMIT ? OFFSET ?`
itemArgs = append(itemArgs, limit, offset)
r, e := s.db.Query(q, itemArgs...)
if e != nil {
return page, e
}
defer r.Close()
for r.Next() {
var x LogEntry
if e = r.Scan(&x.ID, &x.Level, &x.Category, &x.Message, &x.Detail, &x.CreatedAt); e != nil {
return page, e
}
page.Items = append(page.Items, x)
}
return page, r.Err()
}
// LogCategories 返回日志中出现过的分类,供筛选下拉框使用。
func (s *Store) LogCategories() ([]string, error) {
r, e := s.db.Query(`SELECT DISTINCT category FROM app_logs ORDER BY category`)
if e != nil {
return nil, e
}
defer r.Close()
out := []string{}
for r.Next() {
var c string
if e = r.Scan(&c); e != nil {
return nil, e
}
out = append(out, c)
}
return out, r.Err()
}
2026-08-11 19:07:05 +08:00
func (s *Store) Settings() (AppSettings, error) {
2026-08-14 07:51:46 +08:00
x := AppSettings{DatabasePath: s.path, Theme: "dark", Locale: "zh-CN", GitScope: "current", AutoRefresh: true, GlassOpacity: 55, LoadingStyle: "fullscreen-orbit",
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
MinimizeToTray: true, AutoUpdateMode: "daily", AutoUpdateInterval: 1, AutoUpdateTime: "09:00", AIProvider: "spark", ImageMode: "base64", ProjectSyncMode: "auto"}
2026-08-11 19:07:05 +08:00
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
}
2026-08-14 07:51:46 +08:00
case "minimizeToTray":
x.MinimizeToTray = v == "true"
case "autoUpdateEnabled":
x.AutoUpdateEnabled = v == "true"
case "autoUpdateMode":
if v == "daily" || v == "everyNDays" || v == "everyNHours" {
x.AutoUpdateMode = v
}
case "autoUpdateInterval":
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 720 {
x.AutoUpdateInterval = n
}
case "aiProvider":
if v == "spark" || v == "deepseek" {
x.AIProvider = v
}
case "sparkKey":
x.SparkKey = v
case "deepSeekKey":
x.DeepSeekKey = v
case "syncApiKeys":
x.SyncAPIKeys = v == "true"
case "avatarMode":
if v == "base64" || v == "url" || v == "path" {
x.AvatarMode = v
}
case "avatarValue":
x.AvatarValue = v
case "imageMode":
if v == "base64" || v == "path" || v == "server" {
x.ImageMode = v
}
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
case "projectSyncMode":
if v == "auto" || v == "manual" {
x.ProjectSyncMode = v
}
2026-08-14 07:51:46 +08:00
case "autoUpdateTime":
if len(v) == 5 && v[2] == ':' {
x.AutoUpdateTime = v
}
2026-08-11 19:07:05 +08:00
}
}
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"
}
2026-08-14 07:51:46 +08:00
if x.AutoUpdateInterval < 1 || x.AutoUpdateInterval > 720 {
x.AutoUpdateInterval = 1
}
if x.AutoUpdateMode != "daily" && x.AutoUpdateMode != "everyNDays" && x.AutoUpdateMode != "everyNHours" {
x.AutoUpdateMode = "daily"
}
if len(x.AutoUpdateTime) != 5 || x.AutoUpdateTime[2] != ':' {
x.AutoUpdateTime = "09:00"
}
if x.AIProvider != "spark" && x.AIProvider != "deepseek" {
x.AIProvider = "spark"
}
if x.ImageMode != "base64" && x.ImageMode != "path" && x.ImageMode != "server" {
x.ImageMode = "base64"
}
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
if x.ProjectSyncMode != "auto" && x.ProjectSyncMode != "manual" {
x.ProjectSyncMode = "auto"
}
2026-08-14 07:51:46 +08:00
vals := map[string]string{"theme": x.Theme, "locale": x.Locale, "gitScope": x.GitScope, "autoRefresh": fmt.Sprint(x.AutoRefresh), "glassOpacity": strconv.Itoa(x.GlassOpacity), "loadingStyle": x.LoadingStyle, "loadingStyleSet": "true",
"minimizeToTray": fmt.Sprint(x.MinimizeToTray), "autoUpdateEnabled": fmt.Sprint(x.AutoUpdateEnabled), "autoUpdateMode": x.AutoUpdateMode, "autoUpdateInterval": strconv.Itoa(x.AutoUpdateInterval), "autoUpdateTime": x.AutoUpdateTime,
"aiProvider": x.AIProvider, "sparkKey": x.SparkKey, "deepSeekKey": x.DeepSeekKey, "syncApiKeys": fmt.Sprint(x.SyncAPIKeys),
1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。 2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。 3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。 4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。 5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。 6. 项目云同步与认领 全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。 统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。 个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。 途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00
"avatarMode": x.AvatarMode, "avatarValue": x.AvatarValue, "imageMode": x.ImageMode, "projectSyncMode": x.ProjectSyncMode}
2026-08-14 07:51:46 +08:00
if x.AvatarMode != "base64" && x.AvatarMode != "url" && x.AvatarMode != "path" {
vals["avatarMode"], vals["avatarValue"] = "", ""
}
// API Key / 头像变化时记录修改时间,作为同步的 LWW 依据。
if prev, e := s.Settings(); e == nil {
if prev.SparkKey != x.SparkKey || prev.DeepSeekKey != x.DeepSeekKey {
vals["api_keys_updated_at"] = nowRFC()
}
if prev.AvatarMode != vals["avatarMode"] || prev.AvatarValue != vals["avatarValue"] {
vals["avatar_updated_at"] = nowRFC()
}
}
2026-08-11 19:07:05 +08:00
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()
}
2026-08-14 07:51:46 +08:00
// Meta 读取 settings 表中的任意键(用于内部状态,如上次自动更新时间)。
func (s *Store) Meta(key string) string {
var v string
_ = s.db.QueryRow(`SELECT value FROM settings WHERE key=?`, key).Scan(&v)
return v
}
// SetMeta 写入 settings 表中的任意键。
func (s *Store) SetMeta(key, value string) error {
_, e := s.db.Exec(`INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value)
return e
}
2026-08-11 19:07:05 +08:00
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()
2026-08-14 07:51:46 +08:00
tables := []string{"language_stats", "file_entries", "git_commits", "git_refs", "contributors", "git_hotspots", "project_insights", "insight_issues", "analysis_runs"}
2026-08-11 19:07:05 +08:00
if mode == "all" {
2026-08-14 07:51:46 +08:00
tables = append(tables, "projects", "todos", "tickets", "notes", "favorites", "messages", "ai_conversations", "ai_messages")
2026-08-11 19:07:05 +08:00
}
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()
2026-08-14 07:51:46 +08:00
for _, t := range []string{"git_commits", "git_refs", "contributors", "git_hotspots"} {
2026-08-11 19:07:05 +08:00
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
}
}
2026-08-14 07:51:46 +08:00
for _, h := range g.Hotspots {
if _, e = tx.Exec(`INSERT INTO git_hotspots VALUES(?,?,?,?,?)`, id, h.Path, h.Changes, h.Added, h.Deleted); e != nil {
return e
}
}
2026-08-11 19:07:05 +08:00
_, 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()
}
2026-08-14 07:51:46 +08:00
// MergeGit 增量合并新提交:追加提交与热点,替换引用,再从提交表重算贡献者。
func (s *Store) MergeGit(id int64, g GitStats) error {
tx, e := s.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
for _, c := range g.Commits {
if _, e = tx.Exec(`INSERT INTO git_commits VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(project_id,hash) DO UPDATE SET author=excluded.author,email=excluded.email,message=excluded.message,committed_at=excluded.committed_at,added=excluded.added,deleted=excluded.deleted`,
id, c.Hash, c.Author, c.Email, c.Message, c.Date, c.Added, c.Deleted); e != nil {
return e
}
}
if _, e = tx.Exec(`DELETE FROM git_refs WHERE project_id=?`, id); e != nil {
return e
}
for _, r := range g.Refs {
if _, e = tx.Exec(`INSERT INTO git_refs VALUES(?,?,?,?,?)`, id, r.Name, r.Hash, r.Kind, boolInt(r.Current)); e != nil {
return e
}
}
for _, h := range g.Hotspots {
if _, e = tx.Exec(`INSERT INTO git_hotspots VALUES(?,?,?,?,?)
ON CONFLICT(project_id,path) DO UPDATE SET changes=changes+excluded.changes,added=added+excluded.added,deleted=deleted+excluded.deleted`,
id, h.Path, h.Changes, h.Added, h.Deleted); e != nil {
return e
}
}
if _, e = tx.Exec(`DELETE FROM contributors WHERE project_id=?`, id); e != nil {
return e
}
if _, e = tx.Exec(`INSERT INTO contributors(project_id,email,name,commits,added,deleted)
SELECT project_id,email,MAX(author),COUNT(*),SUM(added),SUM(deleted) FROM git_commits WHERE project_id=? GROUP BY email`, id); e != nil {
return e
}
_, e = tx.Exec(`INSERT INTO analysis_runs(project_id,kind,status,started_at,completed_at) VALUES(?,'git','completed',?,?)`, id, now(), now())
if e != nil {
return e
}
return tx.Commit()
}
// LatestCommitHash 返回项目已入库的最新提交哈希,用于增量分析起点。
func (s *Store) LatestCommitHash(id int64) string {
var h string
_ = s.db.QueryRow(`SELECT hash FROM git_commits WHERE project_id=? ORDER BY committed_at DESC LIMIT 1`, id).Scan(&h)
return h
}
2026-08-11 19:07:05 +08:00
func (s *Store) GitStats(id int64) (GitStats, error) {
2026-08-14 07:51:46 +08:00
g := GitStats{Available: true, Commits: []GitCommit{}, Refs: []GitRef{}, Contributors: []Contributor{}, Heatmap: []HeatDay{}, Hotspots: []GitFileHotspot{}}
2026-08-11 19:07:05 +08:00
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)
2026-08-14 07:51:46 +08:00
hm := map[string]*HeatDay{}
2026-08-11 19:07:05 +08:00
for _, c := range g.Commits {
if t, e := time.Parse(time.RFC3339, c.Date); e == nil && t.After(cut) {
2026-08-14 07:51:46 +08:00
key := t.Local().Format("2006-01-02")
d := hm[key]
if d == nil {
d = &HeatDay{Date: key}
hm[key] = d
}
d.Count++
d.Added += c.Added
d.Deleted += c.Deleted
2026-08-11 19:07:05 +08:00
}
}
2026-08-14 07:51:46 +08:00
for _, d := range hm {
g.Heatmap = append(g.Heatmap, *d)
2026-08-11 19:07:05 +08:00
}
sort.Slice(g.Heatmap, func(i, j int) bool { return g.Heatmap[i].Date < g.Heatmap[j].Date })
2026-08-14 07:51:46 +08:00
hr, e := s.db.Query(`SELECT path,changes,added,deleted FROM git_hotspots WHERE project_id=? ORDER BY changes DESC,added+deleted DESC LIMIT 20`, id)
if e != nil {
return g, e
}
for hr.Next() {
var h GitFileHotspot
_ = hr.Scan(&h.Path, &h.Changes, &h.Added, &h.Deleted)
g.Hotspots = append(g.Hotspots, h)
}
hr.Close()
2026-08-11 19:07:05 +08:00
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()
}