diff --git a/.task/checksum/build-frontend--DEV--RUNNER-npm- b/.task/checksum/build-frontend--DEV--RUNNER-npm- index 1d3fc0d..d6b3dc8 100644 --- a/.task/checksum/build-frontend--DEV--RUNNER-npm- +++ b/.task/checksum/build-frontend--DEV--RUNNER-npm- @@ -1 +1 @@ -cab2c324f232a555dd35c31202652cfc +8ef156c82c13dcfb55a861fc1e1b9483 diff --git a/.task/checksum/windows-common-generate-bindings b/.task/checksum/windows-common-generate-bindings index 1d3f1d3..fdd825b 100644 --- a/.task/checksum/windows-common-generate-bindings +++ b/.task/checksum/windows-common-generate-bindings @@ -1 +1 @@ -48639c2c47b996725d8955fb73394c29 +a59624793eae992b65534b38e0fef064 diff --git a/admin.go b/admin.go index 3b358f3..bf0906c 100644 --- a/admin.go +++ b/admin.go @@ -163,6 +163,15 @@ func (a *App) AdminListUsers() ([]map[string]any, error) { return resp.Items, nil } +// AdminGetUser 用户详情(含待办/工单/笔记/团队数量)。 +func (a *App) AdminGetUser(id int64) (map[string]any, error) { + var out map[string]any + if e := a.adminDecode(http.MethodGet, "/api/v1/admin/users/"+strconv.FormatInt(id, 10), nil, &out, false); e != nil { + return nil, e + } + return out, nil +} + // AdminPatchUser 更新用户禁 AI / 禁用。field: aiBanned | disabled。 func (a *App) AdminPatchUser(id int64, field string, value int) error { body := map[string]any{} diff --git a/ai_tasks.go b/ai_tasks.go new file mode 100644 index 0000000..9007e76 --- /dev/null +++ b/ai_tasks.go @@ -0,0 +1,173 @@ +package main + +// ai_tasks.go 一句话生成多条待办/工单:调用 AI 把自然语言拆解成结构化草稿, +// 前端预览确认后再逐条走 SaveTodo / SaveTicket 正常落库。 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "view/service/ai" +) + +// AITaskDraft 是 AI 生成的一条草稿(todo 与 ticket 共用,前端按 kind 取字段)。 +type AITaskDraft struct { + Kind string `json:"kind"` // todo | ticket + Title string `json:"title"` + Content string `json:"content"` // todo 备注 + Description string `json:"description"` // ticket 描述 + Type string `json:"type"` // ticket: feature | bug | task | improvement + Priority string `json:"priority"` // low | medium | high + StartAt string `json:"startAt"` // ticket: YYYY-MM-DD + DueAt string `json:"dueAt"` // todo: YYYY-MM-DDTHH:MM;ticket: YYYY-MM-DD +} + +// AIGenerateTasks 把一句话拆解成多条待办或工单草稿(不落库,由前端确认后保存)。 +func (a *App) AIGenerateTasks(kind, text string) ([]AITaskDraft, error) { + if e := a.ready(); e != nil { + return nil, e + } + if kind != "todo" && kind != "ticket" { + return nil, errors.New("BAD_REQUEST") + } + text = strings.TrimSpace(text) + if text == "" { + return nil, errors.New("AI_EMPTY_MESSAGE") + } + provider, e := a.aiProvider() + if e != nil { + return nil, e + } + if e := a.checkAIPolicy(); e != nil { + return nil, e + } + msgs := []ai.Message{ + {Role: "system", Content: aiTaskPrompt(kind)}, + {Role: "user", Content: text}, + } + ctx, cancel := context.WithTimeout(a.ctx, 2*time.Minute) + defer cancel() + stream, e := provider.ChatStream(ctx, msgs) + if e != nil { + a.store.Log("error", "AI", "AI 任务生成请求失败", e.Error()) + return nil, e + } + var sb strings.Builder + var usage *ai.Usage + for chunk := range stream { + if chunk.Usage != nil { + usage = chunk.Usage + } + if chunk.Err != nil { + a.store.Log("error", "AI", "AI 任务生成失败", chunk.Err.Error()) + return nil, chunk.Err + } + sb.WriteString(chunk.Content) + } + if usage != nil { + go a.reportAIUsage(provider.Name(), usage.PromptTokens, usage.CompletionTokens, usage.Estimated) + } + drafts, pe := parseAITaskDrafts(kind, sb.String()) + if pe != nil { + a.store.Log("warning", "AI", "AI 任务生成解析失败", sb.String()) + return nil, pe + } + a.store.Log("info", "AI", "AI 任务生成完成", fmt.Sprintf("kind=%s count=%d", kind, len(drafts))) + return drafts, nil +} + +func aiTaskPrompt(kind string) string { + now := time.Now() + weekdays := []string{"日", "一", "二", "三", "四", "五", "六"} + head := fmt.Sprintf("今天是 %s(星期%s),当前时间 %s。\n", now.Format("2006-01-02"), weekdays[int(now.Weekday())], now.Format("15:04")) + if kind == "ticket" { + return head + `你是研发工单拆解助手。把用户的一句话拆成 1~10 条工单。 +只输出 JSON 数组,禁止任何解释、markdown 围栏或多余文字。每个元素: +{"title":"简短标题(<=40字)","description":"补充细节,可为空","type":"feature|bug|task|improvement","priority":"low|medium|high","startAt":"YYYY-MM-DD","dueAt":"YYYY-MM-DD"} +规则:标题用与用户输入相同的语言;startAt 默认今天;dueAt 不得早于 startAt,用户未提及工期时按任务量合理估算(1~7 天);能从用户话中推断出的日期(如“周五前”“下周”)必须转换为具体日期。` + } + return head + `你是待办事项拆解助手。把用户的一句话拆成 1~10 条待办。 +只输出 JSON 数组,禁止任何解释、markdown 围栏或多余文字。每个元素: +{"title":"简短标题(<=40字)","content":"补充细节,可为空","priority":"low|medium|high","dueAt":"YYYY-MM-DDTHH:MM 或空字符串"} +规则:标题用与用户输入相同的语言;只有用户明确或可合理推断截止时间时才填 dueAt(如“明天下午”→ 明天 18:00),否则留空字符串。` +} + +// parseAITaskDrafts 从模型输出中提取 JSON 数组并规范化字段。 +func parseAITaskDrafts(kind, raw string) ([]AITaskDraft, error) { + raw = stripFence(strings.TrimSpace(raw)) + start, end := strings.Index(raw, "["), strings.LastIndex(raw, "]") + if start < 0 || end <= start { + return nil, errors.New("AI_BAD_RESPONSE") + } + var list []AITaskDraft + if json.Unmarshal([]byte(raw[start:end+1]), &list) != nil { + return nil, errors.New("AI_BAD_RESPONSE") + } + today := time.Now().Format("2006-01-02") + out := make([]AITaskDraft, 0, len(list)) + for _, d := range list { + d.Kind = kind + d.Title = strings.TrimSpace(d.Title) + if d.Title == "" { + continue + } + if r := []rune(d.Title); len(r) > 120 { + d.Title = string(r[:120]) + } + if d.Priority != "low" && d.Priority != "medium" && d.Priority != "high" { + d.Priority = "medium" + } + if kind == "ticket" { + switch d.Type { + case "feature", "bug", "task", "improvement": + default: + d.Type = "task" + } + d.Content = "" + if !validDateStr(d.StartAt) { + d.StartAt = today + } + if !validDateStr(d.DueAt) { + d.DueAt = time.Now().AddDate(0, 0, 3).Format("2006-01-02") + } + if d.DueAt < d.StartAt { + d.DueAt = d.StartAt + } + } else { + d.Type, d.Description, d.StartAt = "", "", "" + d.DueAt = normalizeTodoDue(d.DueAt) + } + out = append(out, d) + if len(out) >= 20 { + break + } + } + if len(out) == 0 { + return nil, errors.New("AI_EMPTY_RESULT") + } + return out, nil +} + +func validDateStr(s string) bool { + _, e := time.Parse("2006-01-02", strings.TrimSpace(s)) + return e == nil +} + +// normalizeTodoDue 接受 YYYY-MM-DDTHH:MM 或纯日期(补 18:00),其余返回空。 +func normalizeTodoDue(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + if _, e := time.Parse("2006-01-02T15:04", s); e == nil { + return s + } + if validDateStr(s) { + return s + "T18:00" + } + return "" +} diff --git a/app.go b/app.go index bfbfa59..1bd324a 100644 --- a/app.go +++ b/app.go @@ -437,6 +437,25 @@ func (a *App) DeleteRule(id int64) error { } return a.store.DeleteRule(id) } + +// GetProjectRules 项目专属排除规则(不含全局)。 +func (a *App) GetProjectRules(projectID int64) ([]ExclusionRule, error) { + if e := a.ready(); e != nil { + return nil, e + } + return a.store.ProjectRules(projectID) +} + +// AddProjectRule 给某项目添加专属排除规则。 +func (a *App) AddProjectRule(projectID int64, pattern, category string) (ExclusionRule, error) { + if e := a.ready(); e != nil { + return ExclusionRule{}, e + } + if projectID <= 0 { + return ExclusionRule{}, errors.New("PROJECT_REQUIRED") + } + return a.store.AddProjectRule(projectID, pattern, category) +} func (a *App) GetLogs(level string) ([]LogEntry, error) { if e := a.ready(); e != nil { return nil, e @@ -657,7 +676,8 @@ func (a *App) StartAnalysis(projectID int64, kind string) (string, error) { a.store.Log("info", "代码分析", "开始分析项目", p.Name) var err error if kind == "all" || kind == "code" { - rules, _ := a.store.Rules() + // 全局规则 + 项目专属规则叠加 + rules, _ := a.store.RulesForProject(projectID) var ls []LanguageStat var fs []FileEntry ls, fs, err = (Scanner{}).Analyze(ctx, p.Path, rules, func(n int, s string) { emit(s, n, s) }) diff --git a/cloudbind.go b/cloudbind.go index 75478e0..fcffb50 100644 --- a/cloudbind.go +++ b/cloudbind.go @@ -111,7 +111,75 @@ func (a *App) rebuildCloudPending(entries []projectDocEntry) { _ = a.store.SetMeta("cloud_projects_pending", string(b)) } -// ListCloudPendingProjects 返回云端存在但本机尚未绑定路径的项目。 +// cloudIgnoredSet 已被用户忽略的云端项目名集合(认领弹窗里点了「忽略」)。 +func (a *App) cloudIgnoredSet() map[string]bool { + out := map[string]bool{} + raw := a.store.Meta("cloud_projects_ignored") + if raw == "" { + return out + } + var names []string + if json.Unmarshal([]byte(raw), &names) != nil { + return out + } + for _, n := range names { + if n = strings.TrimSpace(n); n != "" { + out[n] = true + } + } + return out +} + +func (a *App) saveCloudIgnored(set map[string]bool) { + names := make([]string, 0, len(set)) + for n, on := range set { + if on { + names = append(names, n) + } + } + b, _ := json.Marshal(names) + _ = a.store.SetMeta("cloud_projects_ignored", string(b)) +} + +// IgnoreCloudProject 忽略某个云端待认领项目(仅本机记忆,可随时恢复)。 +func (a *App) IgnoreCloudProject(name string) error { + if e := a.ready(); e != nil { + return e + } + name = strings.TrimSpace(name) + if name == "" { + return errors.New("NAME_REQUIRED") + } + set := a.cloudIgnoredSet() + set[name] = true + a.saveCloudIgnored(set) + return nil +} + +// UnignoreCloudProject 恢复被忽略的云端项目,使其重新出现在待认领清单。 +func (a *App) UnignoreCloudProject(name string) error { + if e := a.ready(); e != nil { + return e + } + set := a.cloudIgnoredSet() + delete(set, strings.TrimSpace(name)) + a.saveCloudIgnored(set) + return nil +} + +// ListCloudIgnoredProjects 返回被忽略的云端项目名(供认领弹窗「已忽略」区展示)。 +func (a *App) ListCloudIgnoredProjects() ([]string, error) { + if e := a.ready(); e != nil { + return nil, e + } + out := []string{} + for n := range a.cloudIgnoredSet() { + out = append(out, n) + } + return out, nil +} + +// ListCloudPendingProjects 返回云端存在但本机尚未绑定路径、且未被忽略的项目。 func (a *App) ListCloudPendingProjects() ([]projectDocEntry, error) { if e := a.ready(); e != nil { return nil, e @@ -125,7 +193,11 @@ func (a *App) ListCloudPendingProjects() ([]projectDocEntry, error) { if json.Unmarshal([]byte(raw), &list) != nil { return out, nil } + ignored := a.cloudIgnoredSet() for _, it := range list { + if ignored[strings.TrimSpace(it.Name)] { + continue + } // 本地可能在同步间隙手动建了同名项目,再过滤一遍 var pid int64 if a.store.db.QueryRow(`SELECT id FROM projects WHERE name=? LIMIT 1`, it.Name).Scan(&pid) == sql.ErrNoRows { @@ -189,6 +261,17 @@ func (a *App) BindCloudProject(name, path string) (Project, error) { if entry.Favorite { _, _ = a.store.db.Exec(`INSERT OR IGNORE INTO favorites(project_id,created_at) VALUES(?,?)`, pid, now) } + // 云端身份携带的项目级排除规则一并落地 + if len(entry.Rules) > 0 { + if e := a.applyProjectRules(pid, entry.Rules); e != nil { + return Project{}, e + } + } + // 认领后如仍在忽略清单里,顺手移除 + if set := a.cloudIgnoredSet(); set[name] { + delete(set, name) + a.saveCloudIgnored(set) + } a.store.Log("info", "同步", "云端项目已绑定本机目录", name+" → "+path) if a.syncUserID() > 0 { go a.syncOnce(true) diff --git a/database.go b/database.go index ef9ad65..af94358 100644 --- a/database.go +++ b/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"] = "", "" } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 065c0f2..2ce32bb 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -17,6 +17,7 @@ import NoteCenter from './components/NoteCenter.vue' import LocalPackCenter from './components/LocalPackCenter.vue' import TeamSwitcher from './components/TeamSwitcher.vue' import TitleBar from './components/TitleBar.vue' +import CloudClaimModal from './components/CloudClaimModal.vue' import { call, isNative, on } from './api' const route = useRoute() @@ -369,6 +370,7 @@ watch(activeTask, task => { +
正在检查数据库...
diff --git a/frontend/src/components/AITaskGenerator.vue b/frontend/src/components/AITaskGenerator.vue new file mode 100644 index 0000000..b034494 --- /dev/null +++ b/frontend/src/components/AITaskGenerator.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/frontend/src/components/CloudClaimModal.vue b/frontend/src/components/CloudClaimModal.vue new file mode 100644 index 0000000..129a340 --- /dev/null +++ b/frontend/src/components/CloudClaimModal.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/frontend/src/components/LaunchAppFormModal.vue b/frontend/src/components/LaunchAppFormModal.vue new file mode 100644 index 0000000..1a5bf6f --- /dev/null +++ b/frontend/src/components/LaunchAppFormModal.vue @@ -0,0 +1,188 @@ + + + diff --git a/frontend/src/components/RefreshIntervalPicker.vue b/frontend/src/components/RefreshIntervalPicker.vue new file mode 100644 index 0000000..8b661c2 --- /dev/null +++ b/frontend/src/components/RefreshIntervalPicker.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js index aeeb0b8..991f268 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -51,6 +51,14 @@ const zh = { pmNetDown: '下行', pmNetUp: '上行', pmGpuNA: '未检测到 GPU 数据', + pmSavedTitle: '已存为应用', + pmSavedToast: '已存为启动台应用', + pmSavedAsk: '应用已保存到启动台,现在前往启动台管理吗?', + pmStayHere: '留在本页', + pmGoLaunchpad: '前往启动台', + autoRefreshEvery: '自动刷新间隔', + refreshManualOnly: '仅手动刷新', + refreshEveryN: '每 {n} 秒', packRun: '执行打包', packCmdsTitle: '配置打包命令', packCmdsTitleNamed: '配置打包命令 · {name}', @@ -188,6 +196,18 @@ const zh = { ticketStatus: { open: '待处理', in_progress: '处理中', resolved: '已解决', closed: '已关闭' }, ticketFlow: { start: '开始处理', resolve: '标记解决', close: '关闭', reopen: '重新打开' }, noTickets: '暂无工单', + aiGenPhTodo: '一句话描述,AI 帮你拆成多条待办,回车生成...', + aiGenPhTicket: '一句话描述需求,AI 帮你拆成多条工单,回车生成...', + aiGenBtn: 'AI 生成', + aiGenBusy: '生成中...', + aiGenPreviewTitle: 'AI 生成预览', + aiGenSelectAll: '全选', + aiGenSelectNone: '全不选', + aiGenProject: '归属项目', + aiGenPickProject: '请选择项目', + aiGenNeedProject: '工单必须关联项目,请先选择归属项目', + aiGenSaveN: '保存所选({n})', + aiGenSavedToast: '已保存 {n} 条', today: '今天', selectDate: '选择日期', noSchedule: '当日无排期', @@ -232,11 +252,36 @@ const zh = { aiScopeEmpty: '还没有生成过总结,点击下方按钮让 AI 帮你分析。', aiScopeGen: '生成总结', aiScopeRegen: '重新生成', - cloudPendingTitle: '云端项目待绑定', + cloudPendingTitle: '云端项目待认领', cloudPendingDesc: '这些项目来自你在其它电脑的同步数据,选择本机目录后即可继续统计', cloudBindBtn: '绑定目录', cloudBindTitle: '绑定云端项目到本机', cloudBindDone: '云端项目已绑定,可以开始统计了', + cloudClaimDesc: '逐个选择本机目录认领,或忽略暂不需要的项目(忽略后可在 个人主页 · 云同步 恢复)', + cloudClaimGo: '去认领', + cloudClaimLater: '稍后再说', + cloudClaimPick: '选目录认领', + cloudClaimIgnore: '忽略', + cloudClaimIgnoreHint: '本机不再提示该项目,可在个人主页恢复', + cloudClaimIgnoredToast: '已忽略「{name}」', + cloudClaimRules: '{n} 条专属排除规则', + cloudClaimEmpty: '没有待认领的项目', + projSyncTitle: '项目云同步', + projSyncHint: '项目名称/分组/规则随账号上云;统计数据按机器码分开存储,互不覆盖', + projSyncModeLabel: '同步方式', + projSyncModeAuto: '自动(随每轮同步)', + projSyncModeManual: '手动(仅点击时同步)', + projSyncAutoToast: '已切换为自动同步项目', + projSyncManualToast: '已切换为手动同步,项目清单仅在点击「立即同步项目」时推拉', + projSyncNowBtn: '立即同步项目', + projClaimBtn: '认领项目({n})', + projIgnoredTitle: '已忽略的云端项目', + projUnignoreBtn: '恢复', + projUnignoredToast: '已恢复「{name}」,将重新出现在待认领清单', + projRulesTitle: '项目专属排除规则', + projRulesHint: '仅对本项目生效,与全局规则叠加;随项目上云,其它机器认领后自动带回。改动后重新分析生效', + projRulesEmpty: '暂无专属规则,本项目仅套用全局排除规则', + projRuleAddedToast: '专属规则已添加,重新分析后生效', navTeam: '团队', teamHome: '团队概览', teamHomeSubtitle: '成员、角色与团队设置', @@ -725,6 +770,15 @@ const zh = { adminStatTokens: '今日 Token', adminDAUSeries: '近 14 日日活', adminTokenSeries: '近 14 日 Token', + adminCalls: '调用次数', + adminProviderPie: 'AI 提供商用量分布', + adminDataPie: '云端数据构成', + adminViewDetail: '查看详情', + adminUserDetail: '用户详情', + adminCntTeams: '团队', + adminJoinedAt: '注册时间', + adminLastSeen: '最近活跃', + adminOwnedTeams: '拥有团队', adminColAI: 'AI', adminColDisabled: '账号', adminColMembers: '成员', @@ -1057,6 +1111,14 @@ const en = { pmNetDown: 'Download', pmNetUp: 'Upload', pmGpuNA: 'No GPU metrics', + pmSavedTitle: 'Saved as app', + pmSavedToast: 'Saved to Launchpad', + pmSavedAsk: 'The app was saved to Launchpad. Go there to manage it now?', + pmStayHere: 'Stay here', + pmGoLaunchpad: 'Go to Launchpad', + autoRefreshEvery: 'Auto-refresh interval', + refreshManualOnly: 'Manual only', + refreshEveryN: 'Every {n}s', packRun: 'Run package', packCmdsTitle: 'Configure package commands', packCmdsTitleNamed: 'Package commands · {name}', @@ -1194,6 +1256,18 @@ const en = { ticketStatus: { open: 'Open', in_progress: 'In progress', resolved: 'Resolved', closed: 'Closed' }, ticketFlow: { start: 'Start', resolve: 'Resolve', close: 'Close', reopen: 'Reopen' }, noTickets: 'No tickets yet', + aiGenPhTodo: 'Describe in one sentence and AI splits it into todos. Enter to generate...', + aiGenPhTicket: 'Describe in one sentence and AI splits it into tickets. Enter to generate...', + aiGenBtn: 'AI generate', + aiGenBusy: 'Generating...', + aiGenPreviewTitle: 'AI generated preview', + aiGenSelectAll: 'Select all', + aiGenSelectNone: 'Select none', + aiGenProject: 'Project', + aiGenPickProject: 'Pick a project', + aiGenNeedProject: 'Tickets must belong to a project. Pick one first.', + aiGenSaveN: 'Save selected ({n})', + aiGenSavedToast: 'Saved {n} item(s)', today: 'Today', selectDate: 'Select a date', noSchedule: 'Nothing scheduled', @@ -1238,11 +1312,36 @@ const en = { aiScopeEmpty: 'No summary yet — let AI analyze this page below.', aiScopeGen: 'Generate', aiScopeRegen: 'Regenerate', - cloudPendingTitle: 'Cloud projects to bind', + cloudPendingTitle: 'Cloud projects to claim', cloudPendingDesc: 'These projects were synced from your other computers. Pick a local folder to continue.', cloudBindBtn: 'Bind folder', cloudBindTitle: 'Bind cloud project locally', cloudBindDone: 'Cloud project bound, ready to analyze', + cloudClaimDesc: 'Claim each project by picking a local folder, or ignore ones you do not need here (restore later in Profile · Sync).', + cloudClaimGo: 'Claim now', + cloudClaimLater: 'Later', + cloudClaimPick: 'Pick folder & claim', + cloudClaimIgnore: 'Ignore', + cloudClaimIgnoreHint: 'Hide on this machine; restore in Profile', + cloudClaimIgnoredToast: 'Ignored "{name}"', + cloudClaimRules: '{n} project rule(s)', + cloudClaimEmpty: 'Nothing to claim', + projSyncTitle: 'Project cloud sync', + projSyncHint: 'Project identity/groups/rules sync with your account; stats are stored per machine and never overwrite each other.', + projSyncModeLabel: 'Sync mode', + projSyncModeAuto: 'Auto (every sync round)', + projSyncModeManual: 'Manual (only on demand)', + projSyncAutoToast: 'Project sync set to automatic', + projSyncManualToast: 'Manual mode: project docs sync only when you click "Sync projects now"', + projSyncNowBtn: 'Sync projects now', + projClaimBtn: 'Claim projects ({n})', + projIgnoredTitle: 'Ignored cloud projects', + projUnignoreBtn: 'Restore', + projUnignoredToast: 'Restored "{name}" to the claim list', + projRulesTitle: 'Project exclusion rules', + projRulesHint: 'Apply to this project only, on top of global rules; synced with the project and restored on claim. Re-analyze to take effect.', + projRulesEmpty: 'No project rules yet — only global rules apply', + projRuleAddedToast: 'Rule added. Re-analyze to apply.', navTeam: 'Team', teamHome: 'Team overview', teamHomeSubtitle: 'Members, roles and team settings', @@ -1731,6 +1830,15 @@ const en = { adminStatTokens: 'Tokens today', adminDAUSeries: 'DAU (14d)', adminTokenSeries: 'Tokens (14d)', + adminCalls: 'Calls', + adminProviderPie: 'AI provider usage', + adminDataPie: 'Cloud data breakdown', + adminViewDetail: 'View detail', + adminUserDetail: 'User detail', + adminCntTeams: 'Teams', + adminJoinedAt: 'Joined', + adminLastSeen: 'Last seen', + adminOwnedTeams: 'Owned teams', adminColAI: 'AI', adminColDisabled: 'Account', adminColMembers: 'Members', diff --git a/frontend/src/polish.css b/frontend/src/polish.css index c0e1d19..30b0d51 100644 --- a/frontend/src/polish.css +++ b/frontend/src/polish.css @@ -691,6 +691,11 @@ html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] . .wb-note-item time{font-size:10.5px;color:var(--muted)} .wb-note-list-empty{padding:16px 8px;text-align:center;color:var(--muted);font-size:12px} .wb-note-body .wb-note{min-height:180px;height:100%;resize:vertical} +/* 编辑/预览切换 + Markdown 预览框 */ +.wb-note-tabs{margin-right:2px} +.wb-note-tabs button{display:inline-flex;align-items:center;gap:5px;height:30px;padding:0 10px;font-size:12px} +.wb-note-tabs button svg{width:13px;height:13px} +.wb-note-body .wb-note-preview{min-height:180px;max-height:none;height:100%;overflow:auto} @media (max-width:980px){ .wb-note-body{grid-template-columns:1fr} .wb-note-list{max-height:120px;flex-direction:row;flex-wrap:wrap} @@ -1277,6 +1282,22 @@ html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)} .pc-scope-tags{display:flex;flex-wrap:wrap;gap:8px} .pc-tag{display:inline-flex;align-items:center;gap:7px;height:30px;padding:0 13px;border-radius:999px;font-size:12.5px;font-weight:600;color:var(--text);background:rgba(115,103,245,.09);border:1px solid rgba(115,103,245,.25)} .pc-tag svg{width:14px;height:14px;color:#a9a2ff} +/* 项目云同步(自动/手动模式、手动拉取、忽略恢复) */ +.proj-sync{margin-top:20px;padding:14px 16px;border:1px solid var(--border);border-radius:12px;background:var(--surface-2)} +.proj-sync-head{display:flex;align-items:baseline;gap:10px;margin-bottom:12px;flex-wrap:wrap} +.proj-sync-head b{display:flex;align-items:center;gap:7px;font-size:13.5px} +.proj-sync-head b svg{width:15px;height:15px;color:var(--blue)} +.proj-sync-head small{color:var(--muted);font-size:12px} +.proj-sync-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.proj-sync-mode{display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--muted)} +.proj-sync-mode select{height:32px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface);color:var(--text);font:inherit;font-size:12.5px} +.proj-ignored{margin-top:12px} +.proj-ignored>small{display:block;font-size:11.5px;color:var(--muted);margin-bottom:7px} +.proj-ignored-list{display:flex;flex-wrap:wrap;gap:7px} +.proj-ignored-item{display:inline-flex;align-items:center;gap:6px;padding:4px 8px 4px 11px;border-radius:999px;font-size:12px;border:1px solid var(--border);background:var(--surface);color:var(--muted)} +.proj-ignored-item button{display:grid;place-items:center;width:16px;height:16px;border:0;border-radius:50%;background:transparent;color:var(--muted);cursor:pointer;padding:0} +.proj-ignored-item button:hover{color:var(--red);background:rgba(240,84,84,.12)} +.proj-ignored-item button svg{width:11px;height:11px} /* ============ 项目页统计折叠 ============ */ .stats-head-bar{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px} .stats-head-bar small{color:var(--muted);font-size:12px;letter-spacing:.4px} @@ -1721,6 +1742,18 @@ html[data-theme=light] .lp-log-line{color:#1f2937} .cp-main b{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .cp-main small{color:var(--muted);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .cloud-pending-item .btn{height:30px;padding:0 11px;font-size:12px} +/* 项目详情页:项目专属排除规则 */ +.proj-rules-panel h2{display:flex;align-items:center;gap:8px} +.proj-rules-panel h2 small{color:var(--muted);font-weight:400;font-size:12px} +.proj-rules-add{display:flex;gap:9px;margin:4px 0 12px} +.proj-rules-add input{flex:1;max-width:420px;height:34px;padding:0 12px;border-radius:8px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);font:inherit;font-size:12.5px} +.proj-rules-add input:focus{outline:none;border-color:var(--primary)} +.proj-rules-chips{display:flex;flex-wrap:wrap;gap:8px} +.proj-rule-chip{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:999px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);font:inherit;font-size:12.5px;cursor:pointer;transition:border-color .15s,color .15s} +.proj-rule-chip svg{width:12px;height:12px;color:var(--muted)} +.proj-rule-chip:hover{border-color:var(--red);color:var(--red)} +.proj-rule-chip:hover svg{color:var(--red)} +.proj-rules-empty{color:var(--muted);font-size:12.5px;padding:4px 0} /* ============ 日历页头紧凑化:单行标题 + 32px 按钮 ============ */ .calendar-page .calendar-head{padding:9px 16px;margin-bottom:14px} diff --git a/frontend/src/store.js b/frontend/src/store.js index 552f5eb..68130f1 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -25,6 +25,8 @@ export const useAppStore = defineStore('app', { avatarSrc: '', loginOpen: false, paletteOpen: false, + // 云端项目认领弹窗(CloudClaimModal 全局挂载,工作台横幅/个人主页可手动打开) + cloudClaimOpen: false, // 日历页"每日心语"入口:设为 'YYYY-MM-DD' 时 DailyCard 打开对应日期的卡片 dailyCardDate: '', // 节日名 → { mode: 'photo'|'art', image: dataURL }(管理员上传,随同步分发) diff --git a/frontend/src/views/Admin.vue b/frontend/src/views/Admin.vue index 3ca9a52..6edd16c 100644 --- a/frontend/src/views/Admin.vue +++ b/frontend/src/views/Admin.vue @@ -1,9 +1,11 @@