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 键。
This commit is contained in:
60
database.go
60
database.go
@@ -73,7 +73,8 @@ func OpenStore(path string) (*Store, error) {
|
||||
// v11:launch_apps 新增 category(我的应用分类筛选)。
|
||||
// v12:launch_categories 一级分类;launch_apps.category_id;projects.icon;kind_icons。
|
||||
// v13:pack_tasks / pack_task_logs(打包任务与控制台输出,仅本地,不同步)。
|
||||
const schemaVersion = 13
|
||||
// v14:exclusion_rules 新增 project_id(0=全局),唯一约束改为 (project_id,pattern)。
|
||||
const schemaVersion = 14
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
// PRAGMA 是连接级/文件级配置,不属于迁移,每次打开都需执行。
|
||||
@@ -104,7 +105,7 @@ func (s *Store) migrate() error {
|
||||
`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 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))`,
|
||||
`CREATE TABLE IF NOT EXISTS app_logs(id INTEGER PRIMARY KEY AUTOINCREMENT, level TEXT NOT NULL, category TEXT NOT NULL, message TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS git_hotspots(project_id INTEGER NOT NULL, path TEXT NOT NULL, changes INTEGER NOT NULL, added INTEGER NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(project_id,path), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
|
||||
`CREATE TABLE IF NOT EXISTS todos(id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', project_id INTEGER NOT NULL DEFAULT 0, due_at TEXT NOT NULL DEFAULT '', priority TEXT NOT NULL DEFAULT 'medium', status TEXT NOT NULL DEFAULT 'open', reminded INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 1, history TEXT NOT NULL DEFAULT '', team_id INTEGER NOT NULL DEFAULT 0)`,
|
||||
@@ -193,6 +194,21 @@ func (s *Store) migrate() error {
|
||||
return fmt.Errorf("database migration: %w", err)
|
||||
}
|
||||
}
|
||||
// 排除规则支持项目级(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
_, _ = 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"}}
|
||||
@@ -594,8 +610,23 @@ func (s *Store) Structure(id int64) (StructureStats, error) {
|
||||
return x, r.Err()
|
||||
}
|
||||
|
||||
// Rules 全局排除规则(project_id=0,含内置)。
|
||||
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`)
|
||||
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...)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
@@ -604,7 +635,7 @@ func (s *Store) Rules() ([]ExclusionRule, error) {
|
||||
for r.Next() {
|
||||
var x ExclusionRule
|
||||
var b int
|
||||
if e = r.Scan(&x.ID, &x.Pattern, &x.Category, &b); e != nil {
|
||||
if e = r.Scan(&x.ID, &x.Pattern, &x.Category, &b, &x.ProjectID); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
x.Builtin = b == 1
|
||||
@@ -612,7 +643,13 @@ func (s *Store) Rules() ([]ExclusionRule, error) {
|
||||
}
|
||||
return o, r.Err()
|
||||
}
|
||||
|
||||
func (s *Store) AddRule(pattern, category string) (ExclusionRule, error) {
|
||||
return s.AddProjectRule(0, pattern, category)
|
||||
}
|
||||
|
||||
// AddProjectRule projectID=0 为全局规则,>0 为项目专属。
|
||||
func (s *Store) AddProjectRule(projectID int64, pattern, category string) (ExclusionRule, error) {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
return ExclusionRule{}, errors.New("PATTERN_REQUIRED")
|
||||
@@ -620,12 +657,12 @@ func (s *Store) AddRule(pattern, category string) (ExclusionRule, error) {
|
||||
if category == "" {
|
||||
category = "custom"
|
||||
}
|
||||
r, e := s.db.Exec(`INSERT INTO exclusion_rules(pattern,category,builtin) VALUES(?,?,0)`, pattern, category)
|
||||
r, e := s.db.Exec(`INSERT INTO exclusion_rules(pattern,category,builtin,project_id) VALUES(?,?,0,?)`, pattern, category, projectID)
|
||||
if e != nil {
|
||||
return ExclusionRule{}, e
|
||||
}
|
||||
id, _ := r.LastInsertId()
|
||||
return ExclusionRule{ID: id, Pattern: pattern, Category: category}, nil
|
||||
return ExclusionRule{ID: id, Pattern: pattern, Category: category, ProjectID: projectID}, nil
|
||||
}
|
||||
func (s *Store) DeleteRule(id int64) error {
|
||||
r, e := s.db.Exec(`DELETE FROM exclusion_rules WHERE id=? AND builtin=0`, id)
|
||||
@@ -751,7 +788,7 @@ func (s *Store) LogCategories() ([]string, error) {
|
||||
}
|
||||
func (s *Store) Settings() (AppSettings, error) {
|
||||
x := AppSettings{DatabasePath: s.path, Theme: "dark", Locale: "zh-CN", GitScope: "current", AutoRefresh: true, GlassOpacity: 55, LoadingStyle: "fullscreen-orbit",
|
||||
MinimizeToTray: true, AutoUpdateMode: "daily", AutoUpdateInterval: 1, AutoUpdateTime: "09:00", AIProvider: "spark", ImageMode: "base64"}
|
||||
MinimizeToTray: true, AutoUpdateMode: "daily", AutoUpdateInterval: 1, AutoUpdateTime: "09:00", AIProvider: "spark", ImageMode: "base64", ProjectSyncMode: "auto"}
|
||||
r, e := s.db.Query(`SELECT key,value FROM settings`)
|
||||
if e != nil {
|
||||
return x, e
|
||||
@@ -809,6 +846,10 @@ func (s *Store) Settings() (AppSettings, error) {
|
||||
if v == "base64" || v == "path" || v == "server" {
|
||||
x.ImageMode = v
|
||||
}
|
||||
case "projectSyncMode":
|
||||
if v == "auto" || v == "manual" {
|
||||
x.ProjectSyncMode = v
|
||||
}
|
||||
case "autoUpdateTime":
|
||||
if len(v) == 5 && v[2] == ':' {
|
||||
x.AutoUpdateTime = v
|
||||
@@ -839,10 +880,13 @@ func (s *Store) SaveSettings(x AppSettings) error {
|
||||
if x.ImageMode != "base64" && x.ImageMode != "path" && x.ImageMode != "server" {
|
||||
x.ImageMode = "base64"
|
||||
}
|
||||
if x.ProjectSyncMode != "auto" && x.ProjectSyncMode != "manual" {
|
||||
x.ProjectSyncMode = "auto"
|
||||
}
|
||||
vals := map[string]string{"theme": x.Theme, "locale": x.Locale, "gitScope": x.GitScope, "autoRefresh": fmt.Sprint(x.AutoRefresh), "glassOpacity": strconv.Itoa(x.GlassOpacity), "loadingStyle": x.LoadingStyle, "loadingStyleSet": "true",
|
||||
"minimizeToTray": fmt.Sprint(x.MinimizeToTray), "autoUpdateEnabled": fmt.Sprint(x.AutoUpdateEnabled), "autoUpdateMode": x.AutoUpdateMode, "autoUpdateInterval": strconv.Itoa(x.AutoUpdateInterval), "autoUpdateTime": x.AutoUpdateTime,
|
||||
"aiProvider": x.AIProvider, "sparkKey": x.SparkKey, "deepSeekKey": x.DeepSeekKey, "syncApiKeys": fmt.Sprint(x.SyncAPIKeys),
|
||||
"avatarMode": x.AvatarMode, "avatarValue": x.AvatarValue, "imageMode": x.ImageMode}
|
||||
"avatarMode": x.AvatarMode, "avatarValue": x.AvatarValue, "imageMode": x.ImageMode, "projectSyncMode": x.ProjectSyncMode}
|
||||
if x.AvatarMode != "base64" && x.AvatarMode != "url" && x.AvatarMode != "path" {
|
||||
vals["avatarMode"], vals["avatarValue"] = "", ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user