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:
李琦
2026-08-17 09:04:53 +08:00
parent c65cedf9fa
commit c430a0f6da
25 changed files with 1666 additions and 240 deletions

View File

@@ -1 +1 @@
cab2c324f232a555dd35c31202652cfc
8ef156c82c13dcfb55a861fc1e1b9483

View File

@@ -1 +1 @@
48639c2c47b996725d8955fb73394c29
a59624793eae992b65534b38e0fef064

View File

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

173
ai_tasks.go Normal file
View File

@@ -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:MMticket: 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 ""
}

22
app.go
View File

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

View File

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

View File

@@ -73,7 +73,8 @@ func OpenStore(path string) (*Store, error) {
// v11launch_apps 新增 category我的应用分类筛选
// v12launch_categories 一级分类launch_apps.category_idprojects.iconkind_icons。
// v13pack_tasks / pack_task_logs打包任务与控制台输出仅本地不同步
const schemaVersion = 13
// v14exclusion_rules 新增 project_id0=全局),唯一约束改为 (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"] = "", ""
}

View File

@@ -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 => {
<LoginModal v-if="store.loginOpen" />
<CommandPalette v-if="store.paletteOpen" />
<AboutModal v-if="aboutOpen" @close="aboutOpen = false" />
<CloudClaimModal />
<DailyCard />
</div>
<div v-else class="boot-loading with-titlebar"><Database class="spin" />正在检查数据库...</div>

View File

@@ -0,0 +1,200 @@
<script setup>
// 一句话 AI 生成多条待办/工单:输入 → AIGenerateTasks 拆解 → 预览勾选/微调 → 批量保存。
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Sparkles, X, LoaderCircle, Check } from 'lucide-vue-next'
import { call } from '../api'
import { useAppStore } from '../store'
const props = defineProps({
kind: { type: String, required: true }, // todo | ticket
defaultProjectId: { type: Number, default: 0 }
})
const emit = defineEmits(['saved'])
const { t } = useI18n()
const store = useAppStore()
const text = ref('')
const busy = ref(false)
const saving = ref(false)
const err = ref('')
const drafts = ref(null) // null=未打开预览
const projectId = ref(0)
const errText = e => {
const code = String(e?.message || e).split(':')[0].trim()
const k = 'errors.' + code
return t(k) !== k ? t(k) : String(e?.message || e)
}
const selectedCount = computed(() => (drafts.value || []).filter(d => d._on).length)
const needProject = computed(() => props.kind === 'ticket' && !projectId.value)
async function generate() {
const q = text.value.trim()
if (!q || busy.value) return
busy.value = true
err.value = ''
try {
const list = await call('AIGenerateTasks', props.kind, q)
drafts.value = (list || []).map(d => ({ ...d, _on: true }))
projectId.value = props.defaultProjectId || 0
} catch (e) {
err.value = errText(e)
store.showToast({ type: 'error', text: err.value })
} finally {
busy.value = false
}
}
async function saveSelected() {
if (saving.value || !selectedCount.value || needProject.value) return
saving.value = true
err.value = ''
let ok = 0
try {
for (const d of drafts.value) {
if (!d._on || !d.title.trim()) continue
if (props.kind === 'todo') {
await call('SaveTodo', { id: 0, title: d.title.trim(), content: d.content || '', projectId: Number(projectId.value) || 0, dueAt: d.dueAt || '', priority: d.priority, status: 'open' })
} else {
await call('SaveTicket', { id: 0, title: d.title.trim(), description: d.description || '', type: d.type || 'task', projectId: Number(projectId.value), startAt: d.startAt, dueAt: d.dueAt, priority: d.priority, status: 'open' })
}
ok++
}
drafts.value = null
text.value = ''
store.showToast({ type: 'success', text: t('aiGenSavedToast', { n: ok }) })
emit('saved')
} catch (e) {
err.value = errText(e)
} finally {
saving.value = false
}
}
function toggleAll() {
const on = selectedCount.value < drafts.value.length
drafts.value.forEach(d => { d._on = on })
}
</script>
<template>
<div class="ai-gen">
<label class="ai-gen-bar" :class="{ busy }">
<Sparkles />
<input
v-model="text"
:placeholder="t(kind === 'todo' ? 'aiGenPhTodo' : 'aiGenPhTicket')"
:disabled="busy"
@keyup.enter="generate"
/>
<button type="button" class="btn primary sm" :disabled="busy || !text.trim()" @click="generate">
<LoaderCircle v-if="busy" class="spin" /><Sparkles v-else />{{ busy ? t('aiGenBusy') : t('aiGenBtn') }}
</button>
</label>
<Teleport to="body">
<div v-if="drafts" class="overlay" @click.self="drafts = null">
<section class="modal ai-gen-modal" @click.stop>
<header class="ai-gen-head">
<h2><Sparkles />{{ t('aiGenPreviewTitle') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="drafts = null"><X /></button>
</header>
<div class="ai-gen-tools">
<button type="button" class="btn secondary sm" @click="toggleAll">{{ selectedCount < drafts.length ? t('aiGenSelectAll') : t('aiGenSelectNone') }}</button>
<label class="ai-gen-proj">
<span>{{ t('aiGenProject') }}{{ kind === 'ticket' ? ' *' : '' }}</span>
<select v-model.number="projectId">
<option :value="0">{{ kind === 'ticket' ? t('aiGenPickProject') : t('noProject') }}</option>
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</label>
</div>
<div class="ai-gen-list">
<article v-for="(d, i) in drafts" :key="i" class="ai-gen-item" :class="{ off: !d._on }">
<label class="ai-gen-check"><input v-model="d._on" type="checkbox" /></label>
<div class="ai-gen-fields">
<input v-model="d.title" class="ai-gen-title" :placeholder="t('ticketTitle')" />
<div class="ai-gen-row">
<select v-if="kind === 'ticket'" v-model="d.type">
<option value="feature">{{ t('ticketType.feature') }}</option>
<option value="bug">{{ t('ticketType.bug') }}</option>
<option value="task">{{ t('ticketType.task') }}</option>
<option value="improvement">{{ t('ticketType.improvement') }}</option>
</select>
<select v-model="d.priority">
<option value="low">{{ t('priority.low') }}</option>
<option value="medium">{{ t('priority.medium') }}</option>
<option value="high">{{ t('priority.high') }}</option>
</select>
<template v-if="kind === 'ticket'">
<input v-model="d.startAt" type="date" :title="t('startDate')" />
<input v-model="d.dueAt" type="date" :title="t('dueDate')" />
</template>
<input v-else v-model="d.dueAt" type="datetime-local" :title="t('dueDate')" />
</div>
<p v-if="kind === 'todo' ? d.content : d.description" class="ai-gen-desc">{{ kind === 'todo' ? d.content : d.description }}</p>
</div>
</article>
</div>
<p v-if="err" class="ai-gen-err">{{ err }}</p>
<p v-if="needProject" class="ai-gen-hint">{{ t('aiGenNeedProject') }}</p>
<footer class="ai-gen-foot">
<button type="button" class="btn secondary" @click="drafts = null">{{ t('cancel') }}</button>
<button type="button" class="btn primary" :disabled="saving || !selectedCount || needProject" @click="saveSelected">
<LoaderCircle v-if="saving" class="spin" /><Check v-else />{{ t('aiGenSaveN', { n: selectedCount }) }}
</button>
</footer>
</section>
</div>
</Teleport>
</div>
</template>
<style scoped>
.ai-gen { display: flex; flex: 1; min-width: 220px; }
.ai-gen-bar {
flex: 1;
display: flex;
align-items: center;
gap: .5rem;
padding: .2rem .3rem .2rem .7rem;
border: 1px solid color-mix(in srgb, #8b5cf6 45%, var(--border));
border-radius: 12px;
background: color-mix(in srgb, #8b5cf6 7%, transparent);
}
.ai-gen-bar > svg { width: 16px; height: 16px; color: #a78bfa; flex: none; }
.ai-gen-bar input {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
color: var(--text);
outline: none;
padding: .45rem 0;
font-size: .88rem;
}
.btn.sm { padding: .38rem .7rem; font-size: .82rem; }
.ai-gen-modal { width: min(680px, 94vw); max-height: 84vh; display: flex; flex-direction: column; }
.ai-gen-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .6rem; }
.ai-gen-head h2 { display: flex; align-items: center; gap: .45rem; font-size: 1.05rem; margin: 0; }
.ai-gen-head svg { width: 18px; height: 18px; color: #a78bfa; }
.ai-gen-tools { display: flex; align-items: center; gap: .75rem; margin-bottom: .6rem; flex-wrap: wrap; }
.ai-gen-proj { display: inline-flex; align-items: center; gap: .45rem; font-size: .85rem; color: var(--muted); }
.ai-gen-proj select { padding: .35rem .5rem; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text); }
.ai-gen-list { flex: 1; overflow: auto; display: flex; flex-direction: column; gap: .5rem; min-height: 120px; }
.ai-gen-item { display: flex; gap: .6rem; border: 1px solid var(--border); border-radius: 10px; padding: .6rem .7rem; }
.ai-gen-item.off { opacity: .45; }
.ai-gen-check { padding-top: .35rem; }
.ai-gen-fields { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .4rem; }
.ai-gen-title { width: 100%; padding: .4rem .55rem; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text); font-weight: 600; }
.ai-gen-row { display: flex; gap: .4rem; flex-wrap: wrap; }
.ai-gen-row select, .ai-gen-row input { padding: .3rem .45rem; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text); font-size: .82rem; }
.ai-gen-desc { margin: 0; font-size: .8rem; color: var(--muted); white-space: pre-wrap; }
.ai-gen-err { color: #ef4444; font-size: .85rem; margin: .5rem 0 0; }
.ai-gen-hint { color: #f59e0b; font-size: .85rem; margin: .5rem 0 0; }
.ai-gen-foot { display: flex; justify-content: flex-end; gap: .5rem; margin-top: .85rem; }
.spin { animation: aigen-spin 1s linear infinite; }
@keyframes aigen-spin { to { transform: rotate(360deg); } }
</style>

View File

@@ -0,0 +1,149 @@
<script setup>
// 云端项目认领弹窗:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只弹一次),
// 每个项目可「选目录认领」或「忽略」;忽略后可在 个人主页-云同步 里恢复。
// 手动打开入口store.cloudClaimOpen = true工作台横幅 / 个人主页)。
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { CloudDownload, FolderOpen, X, EyeOff, RefreshCw, FolderGit2, ListFilter } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
const store = useAppStore()
const { t } = useI18n()
const items = ref([])
const busy = ref('') // 正在绑定/忽略的项目名
const errs = ref({})
let offSync = null
const dismissKey = () => 'cc-cloud-claim-seen:' + (store.syncStatus.userId || 0)
const signature = list => list.map(x => x.name).sort().join('\n')
const errText = e => {
const code = String(e?.message || e).split(':')[0].trim()
const k = 'errors.' + code
return t(k) !== k ? t(k) : String(e?.message || e)
}
async function refresh(auto) {
if (!store.syncStatus.loggedIn) { items.value = []; return }
try { items.value = (await call('ListCloudPendingProjects')) || [] } catch { items.value = [] }
if (!items.value.length) {
if (store.cloudClaimOpen) store.cloudClaimOpen = false
return
}
// 自动弹出只针对“没见过的组合”:认领/忽略过或点过稍后,同一批不再打扰
if (auto && localStorage.getItem(dismissKey()) !== signature(items.value)) store.cloudClaimOpen = true
}
function later() {
localStorage.setItem(dismissKey(), signature(items.value))
store.cloudClaimOpen = false
}
async function claim(it) {
if (busy.value) return
errs.value = { ...errs.value, [it.name]: '' }
let dir = ''
try { dir = await call('SelectDirectory') } catch { return }
if (!dir) return
busy.value = it.name
try {
await call('BindCloudProject', it.name, dir)
items.value = items.value.filter(x => x.name !== it.name)
await store.refresh()
store.showToast({ type: 'success', key: 'cloudBindDone' })
if (!items.value.length) store.cloudClaimOpen = false
} catch (e) {
errs.value = { ...errs.value, [it.name]: errText(e) }
} finally {
busy.value = ''
}
}
async function ignore(it) {
if (busy.value) return
busy.value = it.name
try {
await call('IgnoreCloudProject', it.name)
items.value = items.value.filter(x => x.name !== it.name)
store.showToast({ type: 'success', key: 'cloudClaimIgnoredToast', params: { name: it.name } })
if (!items.value.length) store.cloudClaimOpen = false
} catch (e) {
errs.value = { ...errs.value, [it.name]: errText(e) }
} finally {
busy.value = ''
}
}
// 手动打开(横幅/个人主页入口)时拉一次最新清单
watch(() => store.cloudClaimOpen, v => { if (v) refresh(false) })
watch(() => store.syncStatus.loggedIn, v => { if (!v) { items.value = []; store.cloudClaimOpen = false } })
onMounted(() => {
refresh(true)
offSync = on('sync:done', () => refresh(true))
})
onUnmounted(() => { offSync?.() })
</script>
<template>
<Teleport to="body">
<div v-if="store.cloudClaimOpen" class="overlay" @click.self="later">
<section class="modal claim-modal" @click.stop>
<header class="claim-head">
<h2><CloudDownload />{{ t('cloudPendingTitle') }}<em v-if="items.length">{{ items.length }}</em></h2>
<button type="button" class="nm-close" :title="t('close')" @click="later"><X /></button>
</header>
<p class="claim-desc">{{ t('cloudClaimDesc') }}</p>
<div class="claim-list">
<article v-for="it in items" :key="it.name" class="claim-item">
<span class="claim-ico"><FolderGit2 /></span>
<div class="claim-main">
<b>{{ it.name }}</b>
<small>{{ [it.group, it.description].filter(Boolean).join(' · ') || '—' }}</small>
<small v-if="it.rules?.length" class="claim-rules"><ListFilter />{{ t('cloudClaimRules', { n: it.rules.length }) }}</small>
<p v-if="errs[it.name]" class="claim-err">{{ errs[it.name] }}</p>
</div>
<div class="claim-ops">
<button type="button" class="btn primary sm" :disabled="!!busy" @click="claim(it)">
<RefreshCw v-if="busy === it.name" class="spin" /><FolderOpen v-else />{{ t('cloudClaimPick') }}
</button>
<button type="button" class="btn secondary sm" :disabled="!!busy" :title="t('cloudClaimIgnoreHint')" @click="ignore(it)">
<EyeOff />{{ t('cloudClaimIgnore') }}
</button>
</div>
</article>
<p v-if="!items.length" class="claim-empty">{{ t('cloudClaimEmpty') }}</p>
</div>
<footer class="claim-foot">
<button type="button" class="btn secondary" @click="later">{{ t('cloudClaimLater') }}</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.claim-modal { width: min(620px, 94vw); max-height: 80vh; display: flex; flex-direction: column; }
.claim-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .35rem; }
.claim-head h2 { display: flex; align-items: center; gap: .5rem; font-size: 1.05rem; margin: 0; }
.claim-head h2 svg { width: 18px; height: 18px; color: #38bdf8; }
.claim-head h2 em { font-style: normal; font-size: .75rem; padding: .1rem .5rem; border-radius: 999px; background: color-mix(in srgb, #38bdf8 18%, transparent); color: #38bdf8; }
.claim-desc { margin: 0 0 .75rem; font-size: .85rem; color: var(--muted); }
.claim-list { flex: 1; overflow: auto; display: flex; flex-direction: column; gap: .5rem; min-height: 100px; }
.claim-item { display: flex; align-items: center; gap: .7rem; border: 1px solid var(--border); border-radius: 10px; padding: .65rem .75rem; }
.claim-ico { flex: none; width: 36px; height: 36px; display: grid; place-items: center; border-radius: 9px; background: color-mix(in srgb, #38bdf8 12%, transparent); }
.claim-ico svg { width: 18px; height: 18px; color: #38bdf8; }
.claim-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .1rem; }
.claim-main b { font-size: .92rem; }
.claim-main small { color: var(--muted); font-size: .78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.claim-rules { display: inline-flex; align-items: center; gap: .25rem; }
.claim-rules svg { width: 12px; height: 12px; }
.claim-err { margin: .15rem 0 0; color: #ef4444; font-size: .78rem; }
.claim-ops { display: flex; flex-direction: column; gap: .35rem; flex: none; }
.btn.sm { padding: .34rem .65rem; font-size: .8rem; }
.claim-empty { text-align: center; color: var(--muted); padding: 1.5rem 0; }
.claim-foot { display: flex; justify-content: flex-end; margin-top: .85rem; }
.spin { animation: claim-spin 1s linear infinite; }
@keyframes claim-spin { to { transform: rotate(360deg); } }
</style>

View File

@@ -0,0 +1,188 @@
<script setup>
// 启动台「保存为应用」表单弹窗:从 Launchpad 抽出,供启动台与端口监控复用。
// props.initial 非空即打开;带 _suggest 时用其作为启停命令建议(来自项目草稿)。
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Rocket, X, FolderOpen, Image, ImageUp, Hexagon, Zap, FileCode2, Coffee, Database, Globe, Boxes, Box } from 'lucide-vue-next'
import { call } from '../api'
const props = defineProps({
initial: { type: Object, default: null },
initialError: { type: String, default: '' }
})
const emit = defineEmits(['close', 'saved'])
const { t } = useI18n()
const KINDS = ['node', 'go', 'python', 'java', 'php', 'dotnet', 'exe', 'mysql', 'redis', 'nginx', 'web', 'other']
const CAT_PRESETS = ['前端', '后端', '数据库', '工具', '桌面', '其他']
const KIND_UI = {
node: { icon: Hexagon, color: '#8cc84b' },
go: { icon: Zap, color: '#00add8' },
python: { icon: FileCode2, color: '#ffd343' },
java: { icon: Coffee, color: '#f89820' },
php: { icon: FileCode2, color: '#a78bfa' },
dotnet: { icon: Boxes, color: '#8b5cf6' },
exe: { icon: Box, color: '#60a5fa' },
mysql: { icon: Database, color: '#4f9df5' },
redis: { icon: Database, color: '#f16a5b' },
nginx: { icon: Globe, color: '#26c795' },
web: { icon: Globe, color: '#4f9df5' },
other: { icon: Box, color: 'var(--muted)' }
}
const kindUI = k => KIND_UI[k] || KIND_UI.other
const form = ref(null)
const suggest = ref({ start: [], stop: [] })
const err = ref('')
const primaryCats = ref([])
const kindIcons = ref({})
let metaLoaded = false
async function loadMeta() {
if (metaLoaded) return
metaLoaded = true
try { primaryCats.value = await call('ListLaunchCategories') || [] } catch { primaryCats.value = [] }
try { kindIcons.value = await call('ListKindIcons') || {} } catch { kindIcons.value = {} }
}
watch(() => props.initial, async x => {
if (!x) { form.value = null; return }
err.value = ''
form.value = {
id: x.id || 0,
name: x.name || '',
kind: x.kind || 'other',
port: x.port || 0,
dir: x.dir || '',
startCmd: x.startCmd || '',
stopCmd: x.stopCmd || '',
icon: x.icon || '',
categoryId: x.categoryId || 0,
category: x.category || ''
}
loadMeta()
if (x._suggest) {
suggest.value = { start: x._suggest.start || [], stop: x._suggest.stop || [] }
peekIcon()
} else if (form.value.dir && !form.value.startCmd) {
await detectDir(false)
} else {
await loadSuggest()
}
}, { immediate: true })
async function loadSuggest() {
try { suggest.value = await call('LaunchCmdSuggest', form.value.kind) } catch { suggest.value = { start: [], stop: [] } }
}
async function detectDir(overwrite = true) {
if (!form.value?.dir) return
try {
const p = await call('DetectLaunchProfile', form.value.dir)
if (!p) return
if (overwrite || !form.value.name) form.value.name = p.name || form.value.name
if (overwrite || !form.value.kind || form.value.kind === 'other') form.value.kind = p.kind || form.value.kind
if (overwrite || !form.value.port) form.value.port = p.port || form.value.port
if (overwrite || !form.value.startCmd) form.value.startCmd = p.startCmd || form.value.startCmd
suggest.value = { start: p.start || [], stop: p.stop || [] }
if (!suggest.value.start?.length) await loadSuggest()
} catch { await loadSuggest() }
}
async function pickDir() {
try {
const d = await call('SelectDirectory')
if (d) {
form.value.dir = d
await detectDir(true)
}
} catch { /* 用户取消 */ }
}
async function peekIcon() {
if (!form.value) return
try {
form.value.icon = await call('PeekLaunchIcon', Number(form.value.port) || 0, form.value.dir || '')
} catch { /* ignore */ }
}
async function pickLocalIcon() {
if (!form.value) return
try {
const u = await call('PickLaunchIconImage')
if (u) form.value.icon = u
} catch (e) { err.value = String(e?.message || e) }
}
async function save() {
err.value = ''
try {
const saved = await call('SaveLaunchApp', {
...form.value,
port: Number(form.value.port) || 0,
categoryId: Number(form.value.categoryId) || 0
})
emit('saved', saved)
} catch (e) {
err.value = String(e?.message || e)
}
}
</script>
<template>
<Teleport to="body">
<div v-if="form" class="overlay" @click.self="emit('close')">
<section class="modal lp-modal">
<header class="lp-modal-head">
<h2><Rocket />{{ form.id ? t('lpEditApp') : t('lpAddApp') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="emit('close')"><X /></button>
</header>
<div class="lp-form">
<div class="lp-icon-edit">
<span class="lp-icon lg" :style="{ color: kindUI(form.kind).color, background: `color-mix(in srgb, ${kindUI(form.kind).color} 14%, transparent)` }">
<img v-if="form.icon || kindIcons[form.kind]" :src="form.icon || kindIcons[form.kind]" alt="" />
<component v-else :is="kindUI(form.kind).icon" />
</span>
<div class="lp-icon-btns">
<button type="button" class="btn secondary" @click="pickLocalIcon"><ImageUp />{{ t('lpPickIcon') }}</button>
<button type="button" class="btn secondary" @click="peekIcon"><Image />{{ t('lpFetchIcon') }}</button>
<button v-if="form.icon" type="button" class="btn secondary" @click="form.icon = ''"><X />{{ t('lpClearIcon') }}</button>
</div>
</div>
<label class="lp-field"><span>{{ t('lpName') }}</span><input v-model="form.name" :placeholder="t('lpName')" /></label>
<label class="lp-field"><span>{{ t('lpPrimaryCat') }}</span>
<select v-model.number="form.categoryId">
<option :value="0">{{ t('lpCatNone') }}</option>
<option v-for="c in primaryCats" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</label>
<label class="lp-field"><span>{{ t('lpSecondaryCat') }}</span>
<input v-model.trim="form.category" list="lafm-cat-list" :placeholder="t('lpCategoryPh')" />
<datalist id="lafm-cat-list"><option v-for="c in CAT_PRESETS" :key="c" :value="c" /></datalist>
</label>
<div class="lp-row">
<label class="lp-field"><span>{{ t('lpKind') }}</span>
<select v-model="form.kind" @change="loadSuggest">
<option v-for="k in KINDS" :key="k" :value="k">{{ k }}</option>
</select>
</label>
<label class="lp-field"><span>{{ t('lpPort') }}</span><input v-model="form.port" type="number" min="0" max="65535" /></label>
</div>
<label class="lp-field"><span>{{ t('lpDir') }}</span>
<span class="lp-dir-row"><input v-model="form.dir" :placeholder="t('lpDir')" @change="detectDir(true)" /><button type="button" class="btn secondary" @click="pickDir"><FolderOpen /></button></span>
</label>
<label class="lp-field"><span>{{ t('lpStartCmd') }}</span><input v-model="form.startCmd" placeholder="npm run dev" /></label>
<div v-if="suggest.start?.length" class="lp-suggest">
<small>{{ t('lpSuggest') }}</small>
<button v-for="c in suggest.start" :key="c" type="button" @click="form.startCmd = c">{{ c }}</button>
</div>
<label class="lp-field"><span>{{ t('lpStopCmd') }}</span><input v-model="form.stopCmd" :placeholder="t('lpStopBlank')" /></label>
<div v-if="suggest.stop?.length" class="lp-suggest">
<small>{{ t('lpSuggest') }}</small>
<button v-for="c in suggest.stop" :key="c" type="button" @click="form.stopCmd = c">{{ c }}</button>
</div>
<p v-if="err || initialError" class="lp-err">{{ err || initialError }}</p>
</div>
<footer class="lp-modal-foot">
<button class="btn secondary" @click="emit('close')">{{ t('cancel') }}</button>
<button class="btn" @click="save">{{ t('save') }}</button>
</footer>
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,42 @@
<script setup>
// 自动刷新间隔选择0=不自动刷新(仅手动),其余为秒数。父组件负责持久化与定时器。
import { useI18n } from 'vue-i18n'
import { Timer } from 'lucide-vue-next'
defineProps({ modelValue: { type: Number, default: 30 } })
const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const OPTIONS = [0, 5, 10, 30, 60, 120]
</script>
<template>
<label class="refresh-pick" :title="t('autoRefreshEvery')">
<Timer />
<select :value="modelValue" @change="emit('update:modelValue', Number($event.target.value))">
<option v-for="o in OPTIONS" :key="o" :value="o">{{ o === 0 ? t('refreshManualOnly') : t('refreshEveryN', { n: o }) }}</option>
</select>
</label>
</template>
<style scoped>
.refresh-pick {
display: inline-flex;
align-items: center;
gap: .35rem;
padding: 0 .55rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel, transparent);
color: var(--muted);
}
.refresh-pick svg { width: 15px; height: 15px; }
.refresh-pick select {
border: 0;
background: transparent;
color: var(--text);
font-size: .85rem;
padding: .45rem 0;
outline: none;
cursor: pointer;
}
</style>

View File

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

View File

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

View File

@@ -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 }(管理员上传,随同步分发)

View File

@@ -1,9 +1,11 @@
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Shield, Users, Building2, Upload, BarChart3, RefreshCw, CheckCircle2, Ban, KeyRound, FolderOpen } from 'lucide-vue-next'
import { Shield, Users, Building2, Upload, BarChart3, RefreshCw, CheckCircle2, Ban, KeyRound, FolderOpen, UserRound, X, ListTodo, TicketCheck, StickyNote } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
import ChartView from '../components/ChartView.vue'
import RemoteImg from '../components/RemoteImg.vue'
const { t } = useI18n()
const store = useAppStore()
@@ -21,6 +23,8 @@ const users = ref([])
const teams = ref([])
const releases = ref([])
const releaseForm = reactive({ version: '', channel: 'stable', changelog: '', filePath: '' })
const userDetail = ref(null)
const userDetailLoading = ref(false)
const errText = e => {
const code = String(e).split(':')[0].trim()
@@ -108,6 +112,90 @@ async function loadUsers() { users.value = await call('AdminListUsers') }
async function loadTeams() { teams.value = await call('AdminListTeams') }
async function loadReleases() { releases.value = await call('AdminListReleases') }
// 用户详情弹窗:点击用户或团队所有者查看(任务/工单/笔记/团队数量)。
async function openUserDetail(id) {
if (!id) return
userDetailLoading.value = true
userDetail.value = { id }
try {
userDetail.value = await call('AdminGetUser', id)
} catch (e) {
userDetail.value = null
err.value = errText(e)
} finally {
userDetailLoading.value = false
}
}
// ---- 概览图表ECharts折线 DAU、柱状+折线 Token、饼图分布 ----
const PIE_COLORS = ['#7b73ff', '#4fd1a1', '#4da5ff', '#f4c84a', '#ef6683', '#23b5d3']
const axisX = data => ({ type: 'category', data, axisLabel: { color: 'var(--muted)', fontSize: 10 }, axisLine: { lineStyle: { color: 'var(--border)' } } })
const axisY = (extra = {}) => ({ type: 'value', axisLabel: { color: 'var(--muted)', fontSize: 10 }, splitLine: { lineStyle: { color: 'var(--border)', opacity: 0.45 } }, ...extra })
const dauChart = computed(() => {
const s = overview.value?.dauSeries || []
return {
backgroundColor: 'transparent',
grid: { left: 40, right: 16, top: 24, bottom: 26 },
tooltip: { trigger: 'axis' },
xAxis: axisX(s.map(p => p.date.slice(5))),
yAxis: axisY({ minInterval: 1 }),
series: [{
name: t('adminStatDAU'), type: 'line', smooth: true, showSymbol: false,
areaStyle: { opacity: 0.16 }, lineStyle: { width: 2, color: '#5da8ff' }, itemStyle: { color: '#5da8ff' },
data: s.map(p => p.count || 0)
}]
}
})
const tokenChart = computed(() => {
const s = overview.value?.tokenSeries || []
return {
backgroundColor: 'transparent',
grid: { left: 48, right: 40, top: 28, bottom: 26 },
tooltip: { trigger: 'axis' },
legend: { top: 0, textStyle: { color: 'var(--muted)', fontSize: 11 } },
xAxis: axisX(s.map(p => p.date.slice(5))),
yAxis: [axisY(), axisY({ minInterval: 1, splitLine: { show: false } })],
series: [
{ name: 'Prompt', type: 'bar', stack: 'tok', barMaxWidth: 16, itemStyle: { color: '#7b73ff', borderRadius: [0, 0, 0, 0] }, data: s.map(p => p.promptTokens || 0) },
{ name: 'Completion', type: 'bar', stack: 'tok', barMaxWidth: 16, itemStyle: { color: '#4fd1a1', borderRadius: [3, 3, 0, 0] }, data: s.map(p => p.completionTokens || 0) },
{ name: t('adminCalls'), type: 'line', yAxisIndex: 1, smooth: true, showSymbol: false, lineStyle: { width: 2, color: '#f4c84a' }, itemStyle: { color: '#f4c84a' }, data: s.map(p => p.calls || 0) }
]
}
})
const pieBase = data => ({
backgroundColor: 'transparent',
tooltip: { trigger: 'item' },
legend: { bottom: 0, textStyle: { color: 'var(--muted)', fontSize: 11 } },
series: [{
type: 'pie', radius: ['46%', '70%'], center: ['50%', '44%'],
label: { show: false }, avoidLabelOverlap: true,
data: data.map((x, i) => ({ ...x, itemStyle: { color: PIE_COLORS[i % PIE_COLORS.length] } }))
}]
})
const providerPie = computed(() => pieBase((overview.value?.providerSeries || []).map(p => ({
name: p.provider || 'unknown',
value: (p.promptTokens || 0) + (p.completionTokens || 0)
}))))
const hasProviderData = computed(() => (overview.value?.providerSeries || []).some(p => (p.promptTokens || 0) + (p.completionTokens || 0) > 0))
const dataPie = computed(() => {
const d = overview.value?.dataDist || {}
return pieBase([
{ name: t('todos'), value: d.todos || 0 },
{ name: t('tickets'), value: d.tickets || 0 },
{ name: t('notesPage'), value: d.notes || 0 },
{ name: t('teamTasks'), value: d.teamTasks || 0 }
])
})
const hasDataDist = computed(() => {
const d = overview.value?.dataDist || {}
return (d.todos || 0) + (d.tickets || 0) + (d.notes || 0) + (d.teamTasks || 0) > 0
})
async function refresh() {
busy.value = 'load'
err.value = ''
@@ -220,22 +308,24 @@ onUnmounted(() => { offFilesDropped?.() })
<div class="stat"><span>{{ t('adminStatDAU') }}</span><b>{{ overview.dauToday }}</b></div>
<div class="stat"><span>{{ t('adminStatTokens') }}</span><b>{{ (overview.tokenToday?.promptTokens||0)+(overview.tokenToday?.completionTokens||0) }}</b></div>
</div>
<div class="series">
<h3>{{ t('adminDAUSeries') }}</h3>
<div class="bars">
<div v-for="p in overview.dauSeries||[]" :key="'d'+p.date" class="bar" :title="p.date+': '+p.count">
<i :style="{height: Math.max(4, (p.count/Math.max(1,...(overview.dauSeries||[]).map(x=>x.count)))*80)+'px'}"></i>
<em>{{ p.date.slice(5) }}</em>
</div>
<div class="chart-grid">
<div class="chart-card">
<h3>{{ t('adminDAUSeries') }}</h3>
<ChartView class="admin-chart" :option="dauChart" />
</div>
</div>
<div class="series">
<h3>{{ t('adminTokenSeries') }}</h3>
<div class="bars">
<div v-for="p in overview.tokenSeries||[]" :key="'t'+p.date" class="bar" :title="p.date">
<i :style="{height: Math.max(4, (((p.promptTokens||0)+(p.completionTokens||0))/Math.max(1,...(overview.tokenSeries||[]).map(x=>(x.promptTokens||0)+(x.completionTokens||0))))*80)+'px'}"></i>
<em>{{ p.date.slice(5) }}</em>
</div>
<div class="chart-card">
<h3>{{ t('adminTokenSeries') }}</h3>
<ChartView class="admin-chart" :option="tokenChart" />
</div>
<div class="chart-card">
<h3>{{ t('adminProviderPie') }}</h3>
<ChartView v-if="hasProviderData" class="admin-chart" :option="providerPie" />
<div v-else class="chart-empty">{{ t('empty') }}</div>
</div>
<div class="chart-card">
<h3>{{ t('adminDataPie') }}</h3>
<ChartView v-if="hasDataDist" class="admin-chart" :option="dataPie" />
<div v-else class="chart-empty">{{ t('empty') }}</div>
</div>
</div>
</section>
@@ -246,7 +336,12 @@ onUnmounted(() => { offFilesDropped?.() })
<tbody>
<tr v-for="u in users" :key="u.id">
<td>{{ u.id }}</td>
<td>{{ u.username }}</td>
<td>
<button class="user-cell" :title="t('adminViewDetail')" @click="openUserDetail(u.id)">
<span class="ua"><RemoteImg v-if="u.avatar" :src="u.avatar" /><UserRound v-else /></span>
<b>{{ u.username }}</b>
</button>
</td>
<td>{{ u.nickname||'—' }}</td>
<td class="mono">{{ u.lastLoginIp||'—' }}</td>
<td>
@@ -271,7 +366,15 @@ onUnmounted(() => { offFilesDropped?.() })
<tr v-for="tm in teams" :key="tm.id">
<td>{{ tm.id }}</td>
<td>{{ tm.name }}</td>
<td>{{ tm.ownerName||tm.ownerId }}</td>
<td>
<button class="user-cell" :title="t('adminViewDetail')" @click="openUserDetail(tm.ownerId)">
<span class="ua"><RemoteImg v-if="tm.ownerAvatar" :src="tm.ownerAvatar" /><UserRound v-else /></span>
<span class="uc-main">
<b>{{ tm.ownerNickname||tm.ownerName||('#'+tm.ownerId) }}</b>
<small>ID {{ tm.ownerId }}<template v-if="tm.ownerName"> · {{ tm.ownerName }}</template></small>
</span>
</button>
</td>
<td>{{ tm.members }}</td>
<td>
<button class="btn sm" :class="{danger:tm.aiBanned}" @click="patchTeam(tm, tm.aiBanned?0:1)">
@@ -353,6 +456,42 @@ onUnmounted(() => { offFilesDropped?.() })
</div>
</div>
</div>
<div v-if="userDetail" class="modal-mask" @click.self="userDetail=null">
<div class="modal ud-modal">
<div class="ud-head">
<h3>{{ t('adminUserDetail') }}</h3>
<button type="button" class="ud-close" :title="t('close')" @click="userDetail=null"><X :size="16"/></button>
</div>
<p v-if="userDetailLoading" class="muted"><RefreshCw class="spin" :size="14"/> {{ t('loading') }}</p>
<template v-else>
<div class="ud-hero">
<span class="ua lg"><RemoteImg v-if="userDetail.avatar" :src="userDetail.avatar" /><UserRound v-else /></span>
<div class="ud-id">
<b>{{ userDetail.nickname||userDetail.username }}</b>
<small>ID {{ userDetail.id }} · {{ userDetail.username }}<template v-if="userDetail.title"> · {{ userDetail.title }}</template></small>
<div class="ud-badges">
<em v-if="userDetail.disabled" class="ud-badge danger">{{ t('adminColDisabled') }}</em>
<em v-if="userDetail.aiBanned" class="ud-badge warn">{{ t('adminBanAI') }}</em>
</div>
</div>
</div>
<div class="ud-counts">
<div class="ud-count"><ListTodo :size="16"/><b>{{ userDetail.todoCount||0 }}</b><span>{{ t('todos') }}</span></div>
<div class="ud-count"><TicketCheck :size="16"/><b>{{ userDetail.ticketCount||0 }}</b><span>{{ t('tickets') }}</span></div>
<div class="ud-count"><StickyNote :size="16"/><b>{{ userDetail.noteCount||0 }}</b><span>{{ t('notesPage') }}</span></div>
<div class="ud-count"><Building2 :size="16"/><b>{{ userDetail.teamCount||0 }}</b><span>{{ t('adminCntTeams') }}</span></div>
</div>
<div class="ud-meta">
<div><span>{{ t('adminJoinedAt') }}</span><b>{{ (userDetail.createdAt||'').slice(0,10)||'—' }}</b></div>
<div><span>{{ t('adminLastSeen') }}</span><b>{{ (userDetail.lastSeenAt||'').slice(0,16).replace('T',' ')||'—' }}</b></div>
<div><span>IP</span><b class="mono">{{ userDetail.lastLoginIp||'—' }}</b></div>
<div><span>{{ t('adminOwnedTeams') }}</span><b>{{ userDetail.ownedTeams||0 }}</b></div>
</div>
<p v-if="userDetail.bio" class="ud-bio">{{ userDetail.bio }}</p>
</template>
</div>
</div>
</div>
</template>
@@ -365,11 +504,46 @@ onUnmounted(() => { offFilesDropped?.() })
.stat-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.75rem;margin-bottom:1.25rem}
.stat{padding:1rem;border:1px solid var(--border);border-radius:10px;display:flex;flex-direction:column;gap:.35rem}
.stat b{font-size:1.6rem}
.series{margin-bottom:1.25rem}
.bars{display:flex;align-items:flex-end;gap:4px;height:100px;overflow-x:auto}
.bar{display:flex;flex-direction:column;align-items:center;justify-content:flex-end;min-width:28px;flex:1}
.bar i{display:block;width:100%;max-width:18px;background:var(--accent,#3b82f6);border-radius:4px 4px 0 0}
.bar em{font-size:9px;opacity:.6;margin-top:4px}
.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}
.chart-card{border:1px solid var(--border);border-radius:12px;padding:.85rem 1rem .5rem}
.chart-card h3{margin:0 0 .35rem;font-size:.9rem;opacity:.85}
.admin-chart{height:230px;width:100%}
.chart-empty{height:230px;display:flex;align-items:center;justify-content:center;opacity:.5;font-size:.85rem}
.user-cell{display:inline-flex;align-items:center;gap:.5rem;background:transparent;border:0;color:inherit;cursor:pointer;padding:.15rem .3rem;border-radius:8px;text-align:left}
.user-cell:hover{background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent)}
.user-cell b{font-weight:600}
.uc-main{display:flex;flex-direction:column;line-height:1.25}
.uc-main small{opacity:.6;font-size:.72rem}
.ua{width:26px;height:26px;border-radius:50%;overflow:hidden;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent);color:var(--accent,#3b82f6);flex:none}
.ua img{width:100%;height:100%;object-fit:cover}
.ua svg{width:15px;height:15px}
.ua.lg{width:52px;height:52px}
.ua.lg svg{width:26px;height:26px}
.ud-modal{width:min(460px,92vw)}
.ud-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.75rem}
.ud-head h3{margin:0}
.ud-close{background:transparent;border:0;color:inherit;cursor:pointer;opacity:.7;padding:.25rem;border-radius:6px}
.ud-close:hover{opacity:1;background:color-mix(in srgb,currentColor 10%,transparent)}
.ud-hero{display:flex;align-items:center;gap:.85rem;margin-bottom:1rem}
.ud-id{display:flex;flex-direction:column;gap:.15rem;min-width:0}
.ud-id b{font-size:1.05rem}
.ud-id small{opacity:.65}
.ud-badges{display:flex;gap:.35rem;margin-top:.15rem}
.ud-badge{font-size:.7rem;font-style:normal;padding:.1rem .4rem;border-radius:999px;border:1px solid currentColor}
.ud-badge.danger{color:#ef4444}
.ud-badge.warn{color:#f59e0b}
.ud-counts{display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem;margin-bottom:1rem}
.ud-count{border:1px solid var(--border);border-radius:10px;padding:.6rem .4rem;display:flex;flex-direction:column;align-items:center;gap:.2rem}
.ud-count svg{opacity:.7}
.ud-count b{font-size:1.15rem}
.ud-count span{font-size:.72rem;opacity:.65}
.ud-meta{display:grid;grid-template-columns:1fr 1fr;gap:.5rem .85rem}
.ud-meta div{display:flex;flex-direction:column;gap:.1rem}
.ud-meta span{font-size:.72rem;opacity:.6}
.ud-meta b{font-size:.85rem;font-weight:500}
.ud-bio{margin:.85rem 0 0;font-size:.85rem;opacity:.8;white-space:pre-wrap}
.spin{animation:ud-spin 1s linear infinite}
@keyframes ud-spin{to{transform:rotate(360deg)}}
.admin-table{width:100%;border-collapse:collapse;font-size:.9rem}
.admin-table th,.admin-table td{padding:.55rem .5rem;border-bottom:1px solid var(--border);text-align:left}
.mono{font-family:ui-monospace,monospace;font-size:.8rem}

View File

@@ -347,42 +347,15 @@ function consumePendingAction() {
}
watch(() => store.pendingAction, consumePendingAction)
// ---- 云端项目待绑定:同账号在其它机器添加的项目,本机需要选目录落地 ----
// ---- 云端项目待认领:同账号在其它机器添加的项目,横幅提示 → 全局认领弹窗选目录/忽略 ----
const cloudPending = ref([])
const bindTarget = ref(null) // { name, description, group } 待绑定项
const bindDir = ref('')
const bindBusy = ref(false)
const bindErr = ref('')
let offSync = null
async function loadPending() {
try { cloudPending.value = (await call('ListCloudPendingProjects')) || [] } catch { cloudPending.value = [] }
}
function openBind(it) {
bindTarget.value = it
bindDir.value = ''
bindErr.value = ''
}
async function browseBind() {
const p = await call('SelectDirectory')
if (p) bindDir.value = p
}
async function bindNow() {
if (bindBusy.value || !bindTarget.value) return
bindErr.value = ''
if (!bindDir.value.trim()) { bindErr.value = t('pathRequired'); return }
bindBusy.value = true
try {
await call('BindCloudProject', bindTarget.value.name, bindDir.value.trim())
bindTarget.value = null
await Promise.all([store.refresh(), loadPending()])
store.showToast({ type: 'success', key: 'cloudBindDone' })
} catch (e) {
bindErr.value = errorText(e)
} finally {
bindBusy.value = false
}
}
// 认领弹窗关闭后刷新横幅计数(认领/忽略都会减少待认领数)
watch(() => store.cloudClaimOpen, v => { if (!v) loadPending() })
onMounted(() => {
consumePendingAction()
loadPending()
@@ -413,13 +386,7 @@ watch(() => store.projects.length, () => loadKindFallback())
<small>{{ t('cloudPendingDesc') }}</small>
</header>
<div class="cloud-pending-list">
<div v-for="it in cloudPending" :key="it.name" class="cloud-pending-item">
<div class="cp-main">
<b>{{ it.name }}</b>
<small>{{ [it.group, it.description].filter(Boolean).join(' · ') }}</small>
</div>
<button class="btn secondary" @click="openBind(it)"><FolderOpen />{{ t('cloudBindBtn') }}</button>
</div>
<button class="btn primary" @click="store.cloudClaimOpen = true"><FolderOpen />{{ t('cloudClaimGo') }}</button>
</div>
</section>
<!-- 展开三张统计大卡折叠一行小卡片语言分布区一并收起 -->
@@ -606,14 +573,5 @@ watch(() => store.projects.length, () => loadKindFallback())
<footer><button type="button" class="btn secondary" :disabled="groupSaving" @click="groupModal = false">{{ t('cancel') }}</button><button class="btn primary" :disabled="groupSaving"><RefreshCw v-if="groupSaving" class="spin" />{{ groupSaving ? t('saving') : t('saveProjectGroup') }}</button></footer>
</form>
</div>
<div v-if="bindTarget" class="overlay" @click.self="!bindBusy && (bindTarget = null)">
<form class="modal compact-modal" @submit.prevent="bindNow">
<header><h2>{{ t('cloudBindTitle') }}</h2><button type="button" :disabled="bindBusy" @click="bindTarget = null">&times;</button></header>
<label>{{ t('projectName') }}<input :value="bindTarget.name" disabled /></label>
<label>{{ t('projectPath') }}<div class="browse"><input v-model="bindDir" :disabled="bindBusy" required /><button type="button" class="btn secondary" :disabled="bindBusy" @click="browseBind"><FolderOpen />{{ t('browse') }}</button></div></label>
<p v-if="bindErr" class="form-error">{{ bindErr }}</p>
<footer><button type="button" class="btn secondary" :disabled="bindBusy" @click="bindTarget = null">{{ t('cancel') }}</button><button class="btn primary" :disabled="bindBusy"><RefreshCw v-if="bindBusy" class="spin" />{{ bindBusy ? t('saving') : t('cloudBindBtn') }}</button></footer>
</form>
</div>
</Teleport>
</template>

View File

@@ -3,11 +3,13 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Browser } from '@wailsio/runtime'
import { Rocket, Plus, RefreshCw, Play, Square, Pencil, Trash2, FolderOpen, X, Hexagon, Zap, FileCode2, Coffee, Database, Globe, Boxes, Box, Cpu, MemoryStick, HardDrive, Sparkles, Search, ExternalLink, FolderGit2, ScrollText, LoaderCircle, Image, ImageUp, Tags, Settings2, RotateCcw, ChevronRight, Package, Pin } from 'lucide-vue-next'
import { Plus, RefreshCw, Play, Square, Pencil, Trash2, X, Hexagon, Zap, FileCode2, Coffee, Database, Globe, Boxes, Box, Cpu, MemoryStick, HardDrive, Sparkles, Search, ExternalLink, FolderGit2, ScrollText, LoaderCircle, Image, ImageUp, Tags, Settings2, RotateCcw, ChevronRight, Package, Pin } from 'lucide-vue-next'
import { call, on } from '../api'
import { useAppStore } from '../store'
import AIScopeDrawer from '../components/AIScopeDrawer.vue'
import PackCmdsModal from '../components/PackCmdsModal.vue'
import LaunchAppFormModal from '../components/LaunchAppFormModal.vue'
import RefreshIntervalPicker from '../components/RefreshIntervalPicker.vue'
import { getPackCmds } from '../packCmds'
// 启动台:扫描本机监听端口的服务 + 管理保存的应用(启动/停止/资源占用)。
@@ -24,7 +26,6 @@ const primaryQ = ref(Number(localStorage.getItem('cc-lp-primary') || 0) || 0) //
const catQ = ref(localStorage.getItem('cc-lp-cat') || '') // 二级筛选;空=全部
const editing = ref(null)
const editErr = ref('')
const suggest = ref({ start: [], stop: [] })
const busy = ref({})
const projectPick = ref(false)
const projects = ref([])
@@ -42,6 +43,8 @@ const flyEl = ref(null)
const flyStyle = ref({ left: '0px', top: '0px' })
const packModal = ref({ open: false, id: 0, title: '', dir: '' })
const packTick = ref(0) // 配置保存后刷新右键子菜单
// 自动刷新间隔0=仅手动刷新。本地持久化,默认 30 秒。
const refreshSec = ref(localStorage.getItem('cc-lp-refresh') == null ? 30 : Number(localStorage.getItem('cc-lp-refresh')) || 0)
let subLeaveTimer = 0
let timer = 0
let offChanged = null
@@ -131,7 +134,9 @@ async function load() {
loading.value = false
}
// applyProfile / openForm 只组装初始值,表单交互由 LaunchAppFormModal 组件完成。
function applyProfile(p, base = {}) {
editErr.value = ''
editing.value = {
id: base.id || 0,
name: p.name || base.name || '',
@@ -142,93 +147,29 @@ function applyProfile(p, base = {}) {
stopCmd: p.stopCmd || '',
icon: p.icon || '',
categoryId: base.categoryId || p.categoryId || primaryQ.value || 0,
category: base.category || p.category || ''
category: base.category || p.category || '',
_suggest: { start: p.start || [], stop: p.stop || [] }
}
suggest.value = { start: p.start || [], stop: p.stop || [] }
peekFormIcon()
}
async function openForm(x) {
function openForm(x) {
editErr.value = ''
ctx.value.open = false
editing.value = x
? { id: x.id || 0, name: x.name || '', kind: x.kind || 'other', port: x.port || x.ports?.[0] || 0, dir: x.dir || '', startCmd: x.startCmd || '', stopCmd: x.stopCmd || '', icon: x.icon || '', categoryId: x.categoryId || 0, category: x.category || '' }
: { id: 0, name: '', kind: 'other', port: 0, dir: '', startCmd: '', stopCmd: '', icon: '', categoryId: primaryQ.value || 0, category: catQ.value || '' }
if (editing.value.dir && !editing.value.startCmd) {
await detectDir(false)
} else {
await loadSuggest()
}
}
async function loadSuggest() {
try { suggest.value = await call('LaunchCmdSuggest', editing.value.kind) } catch { suggest.value = { start: [], stop: [] } }
}
async function detectDir(overwrite = true) {
if (!editing.value?.dir) return
try {
const p = await call('DetectLaunchProfile', editing.value.dir)
if (!p) return
if (overwrite || !editing.value.name) editing.value.name = p.name || editing.value.name
if (overwrite || !editing.value.kind || editing.value.kind === 'other') editing.value.kind = p.kind || editing.value.kind
if (overwrite || !editing.value.port) editing.value.port = p.port || editing.value.port
if (overwrite || !editing.value.startCmd) editing.value.startCmd = p.startCmd || editing.value.startCmd
suggest.value = { start: p.start || [], stop: p.stop || [] }
if (!suggest.value.start?.length) await loadSuggest()
} catch { await loadSuggest() }
}
async function pickDir() {
try {
const d = await call('SelectDirectory')
if (d) {
editing.value.dir = d
await detectDir(true)
}
} catch { /* 用户取消 */ }
}
async function onKindChange() {
await loadSuggest()
}
async function saveForm() {
async function onFormSaved() {
editing.value = null
editErr.value = ''
try {
await call('SaveLaunchApp', {
...editing.value,
port: Number(editing.value.port) || 0,
categoryId: Number(editing.value.categoryId) || 0
})
editing.value = null
await load()
} catch (e) {
editErr.value = String(e?.message || e)
}
await load()
}
async function fetchIcon(x) {
ctx.value.open = false
try {
await call('FetchLaunchIcon', x.id)
await load()
} catch {
try {
const u = await call('PeekLaunchIcon', x.port || 0, x.dir || '')
if (u && editing.value) editing.value.icon = u
} catch { /* 未找到 */ }
}
}
async function peekFormIcon() {
if (!editing.value) return
try {
editing.value.icon = await call('PeekLaunchIcon', Number(editing.value.port) || 0, editing.value.dir || '')
} catch { /* ignore */ }
}
async function pickLocalIcon() {
if (!editing.value) return
try {
const u = await call('PickLaunchIconImage')
if (u) editing.value.icon = u
} catch (e) { store.showToast({ type: 'error', text: String(e) }) }
}
function clearFormIcon() {
if (editing.value) editing.value.icon = ''
} catch { /* 未找到 */ }
}
async function pickCardIcon(x) {
ctx.value.open = false
@@ -511,11 +452,22 @@ const logAppName = computed(() => entries.value.find(e => e.id === logOpen.value
function onDocClick() { if (ctx.value.open) closeCtx() }
// 自动刷新定时器间隔可配置0=仅手动。
function restartTimer() {
clearInterval(timer)
timer = 0
if (refreshSec.value > 0) timer = setInterval(load, refreshSec.value * 1000)
}
watch(refreshSec, v => {
localStorage.setItem('cc-lp-refresh', String(v))
restartTimer()
})
onMounted(async () => {
loadCats()
loadKindIcons()
load()
timer = setInterval(load, 5000)
restartTimer()
offChanged = on('launchpad:changed', load)
offLog = on('launchpad:log', ev => {
const d = Array.isArray(ev) ? ev[0] : ev
@@ -559,6 +511,7 @@ watch(() => route.query.pin, async v => {
<div><h1>{{ t('launchpad') }}</h1><p>{{ t('launchpadSubtitle') }}</p></div>
<div class="lp-tools">
<button class="btn secondary" @click="aiOpen = true"><Sparkles />{{ t('aiScopeBtn') }}</button>
<RefreshIntervalPicker v-model="refreshSec" />
<button class="btn secondary" :disabled="loading" @click="load"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
<button class="btn secondary" @click="openProjectPick"><FolderGit2 />{{ t('lpAddFromProject') }}</button>
<button class="btn" @click="openForm(null)"><Plus />{{ t('lpAddApp') }}</button>
@@ -694,65 +647,6 @@ watch(() => route.query.pin, async v => {
@saved="packTick++"
/>
<div v-if="editing" class="overlay" @click.self="editing = null">
<section class="modal lp-modal">
<header class="lp-modal-head">
<h2><Rocket />{{ editing.id ? t('lpEditApp') : t('lpAddApp') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="editing = null"><X /></button>
</header>
<div class="lp-form">
<div class="lp-icon-edit">
<span class="lp-icon lg" :style="{ color: kindUI(editing.kind).color, background: `color-mix(in srgb, ${kindUI(editing.kind).color} 14%, transparent)` }">
<img v-if="editing.icon || kindIcons[editing.kind]" :src="editing.icon || kindIcons[editing.kind]" alt="" />
<component v-else :is="kindUI(editing.kind).icon" />
</span>
<div class="lp-icon-btns">
<button type="button" class="btn secondary" @click="pickLocalIcon"><ImageUp />{{ t('lpPickIcon') }}</button>
<button type="button" class="btn secondary" @click="peekFormIcon"><Image />{{ t('lpFetchIcon') }}</button>
<button v-if="editing.icon" type="button" class="btn secondary" @click="clearFormIcon"><X />{{ t('lpClearIcon') }}</button>
</div>
</div>
<label class="lp-field"><span>{{ t('lpName') }}</span><input v-model="editing.name" :placeholder="t('lpName')" /></label>
<label class="lp-field"><span>{{ t('lpPrimaryCat') }}</span>
<select v-model.number="editing.categoryId">
<option :value="0">{{ t('lpCatNone') }}</option>
<option v-for="c in primaryCats" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</label>
<label class="lp-field"><span>{{ t('lpSecondaryCat') }}</span>
<input v-model.trim="editing.category" list="lp-cat-list" :placeholder="t('lpCategoryPh')" />
<datalist id="lp-cat-list"><option v-for="c in CAT_PRESETS" :key="c" :value="c" /></datalist>
</label>
<div class="lp-row">
<label class="lp-field"><span>{{ t('lpKind') }}</span>
<select v-model="editing.kind" @change="onKindChange">
<option v-for="k in KINDS" :key="k" :value="k">{{ k }}</option>
</select>
</label>
<label class="lp-field"><span>{{ t('lpPort') }}</span><input v-model="editing.port" type="number" min="0" max="65535" /></label>
</div>
<label class="lp-field"><span>{{ t('lpDir') }}</span>
<span class="lp-dir-row"><input v-model="editing.dir" :placeholder="t('lpDir')" @change="detectDir(true)" /><button type="button" class="btn secondary" @click="pickDir"><FolderOpen /></button></span>
</label>
<label class="lp-field"><span>{{ t('lpStartCmd') }}</span><input v-model="editing.startCmd" placeholder="npm run dev" /></label>
<div v-if="suggest.start?.length" class="lp-suggest">
<small>{{ t('lpSuggest') }}</small>
<button v-for="c in suggest.start" :key="c" type="button" @click="editing.startCmd = c">{{ c }}</button>
</div>
<label class="lp-field"><span>{{ t('lpStopCmd') }}</span><input v-model="editing.stopCmd" :placeholder="t('lpStopBlank')" /></label>
<div v-if="suggest.stop?.length" class="lp-suggest">
<small>{{ t('lpSuggest') }}</small>
<button v-for="c in suggest.stop" :key="c" type="button" @click="editing.stopCmd = c">{{ c }}</button>
</div>
<p v-if="editErr" class="lp-err">{{ editErr }}</p>
</div>
<footer class="lp-modal-foot">
<button class="btn secondary" @click="editing = null">{{ t('cancel') }}</button>
<button class="btn" @click="saveForm">{{ t('save') }}</button>
</footer>
</section>
</div>
<div v-if="catMgr" class="overlay" @click.self="catMgr = false">
<section class="modal lp-modal compact-modal">
<header class="lp-modal-head">
@@ -810,6 +704,7 @@ watch(() => route.query.pin, async v => {
</section>
</div>
</Teleport>
<LaunchAppFormModal :initial="editing" :initial-error="editErr" @close="editing = null; editErr = ''" @saved="onFormSaved" />
<AIScopeDrawer v-if="aiOpen" kind="launchpad" :title="t('launchpad')" @close="aiOpen = false" />
</div>
</template>

View File

@@ -1,18 +1,22 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Browser } from '@wailsio/runtime'
import {
Activity, RefreshCw, Pin, Square, Search, ExternalLink, Cpu, MemoryStick,
HardDrive, Network, Gauge, ChevronDown, ChevronUp, Hexagon, Zap, FileCode2,
Coffee, Database, Globe, Boxes, Box
Coffee, Database, Globe, Boxes, Box, Rocket, X
} from 'lucide-vue-next'
import ChartView from '../components/ChartView.vue'
import LaunchAppFormModal from '../components/LaunchAppFormModal.vue'
import RefreshIntervalPicker from '../components/RefreshIntervalPicker.vue'
import { call, on } from '../api'
import { useAppStore } from '../store'
const { t } = useI18n()
const router = useRouter()
const store = useAppStore()
const entries = ref([])
const loading = ref(false)
@@ -24,6 +28,10 @@ const metrics = ref(null)
const history = ref([]) // { t, cpu, mem, gpu, recv, sent }
const showCharts = ref(localStorage.getItem('cc-pm-charts') !== '0')
const kindIcons = ref({})
const pinDraft = ref(null) // 「存为应用」弹窗(本页保存,不再跳转启动台)
const savedAsk = ref(false) // 保存成功后询问是否前往启动台
// 自动刷新间隔0=仅手动刷新。默认 30 秒。
const refreshSec = ref(localStorage.getItem('cc-pm-refresh') == null ? 30 : Number(localStorage.getItem('cc-pm-refresh')) || 0)
const HISTORY_MAX = 60
let timer = 0
let metricsTimer = 0
@@ -114,8 +122,30 @@ function openPort(p) {
try { Browser.OpenURL(`http://127.0.0.1:${n}`) } catch { /* ignore */ }
}
async function pin(x) {
router.push({ path: '/launchpad', query: { pin: '1', name: x.name || '', kind: x.kind || 'other', port: String(x.port || x.ports?.[0] || 0), dir: x.dir || '', pid: String(x.pid || 0) } })
// 存为应用:本页弹窗填写保存,保存成功后再询问是否前往启动台(不强制跳转)。
function pin(x) {
pinDraft.value = {
id: 0,
name: x.name || '',
kind: x.kind || 'other',
port: x.port || x.ports?.[0] || 0,
dir: x.dir || '',
startCmd: '',
stopCmd: '',
icon: '',
categoryId: 0,
category: ''
}
}
function onPinSaved() {
pinDraft.value = null
savedAsk.value = true
store.showToast({ type: 'success', key: 'pmSavedToast' })
load()
}
function goLaunchpad() {
savedAsk.value = false
router.push('/launchpad')
}
async function stop(x) {
@@ -169,12 +199,31 @@ const netChart = computed(() => lineOpt([
const gpuAvailable = computed(() => metrics.value && metrics.value.gpu >= 0)
// 自动刷新列表与系统指标共用同一间隔0=仅手动。
function restartTimers() {
clearInterval(timer)
clearInterval(metricsTimer)
timer = 0
metricsTimer = 0
if (refreshSec.value > 0) {
timer = setInterval(load, refreshSec.value * 1000)
metricsTimer = setInterval(loadMetrics, refreshSec.value * 1000)
}
}
watch(refreshSec, v => {
localStorage.setItem('cc-pm-refresh', String(v))
restartTimers()
})
function manualRefresh() {
load()
loadMetrics()
}
onMounted(async () => {
try { kindIcons.value = await call('ListKindIcons') || {} } catch { kindIcons.value = {} }
load()
loadMetrics()
timer = setInterval(load, 5000)
metricsTimer = setInterval(loadMetrics, 2000)
restartTimers()
offChanged = on('launchpad:changed', load)
})
onUnmounted(() => {
@@ -197,7 +246,8 @@ onUnmounted(() => {
<component :is="showCharts ? ChevronUp : ChevronDown" />
{{ showCharts ? t('pmHideCharts') : t('pmShowCharts') }}
</button>
<button class="btn secondary" :disabled="loading" @click="load"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
<RefreshIntervalPicker v-model="refreshSec" />
<button class="btn secondary" :disabled="loading" @click="manualRefresh"><RefreshCw :class="{ spinning: loading }" />{{ t('lpRefresh') }}</button>
</div>
</div>
</header>
@@ -274,5 +324,22 @@ onUnmounted(() => {
</article>
</div>
</section>
<LaunchAppFormModal :initial="pinDraft" @close="pinDraft = null" @saved="onPinSaved" />
<Teleport to="body">
<div v-if="savedAsk" class="overlay" @click.self="savedAsk = false">
<section class="modal exit-modal" @click.stop>
<header class="modal-head">
<h2><Rocket />{{ t('pmSavedTitle') }}</h2>
<button type="button" class="nm-close" :title="t('close')" @click="savedAsk = false"><X /></button>
</header>
<p class="exit-hint">{{ t('pmSavedAsk') }}</p>
<div class="exit-actions">
<button type="button" class="btn secondary" @click="savedAsk = false">{{ t('pmStayHere') }}</button>
<button type="button" class="btn primary" @click="goLaunchpad">{{ t('pmGoLaunchpad') }}</button>
</div>
</section>
</div>
</Teleport>
</div>
</template>

View File

@@ -153,6 +153,7 @@ function setTab(v) {
if (v === 'teams') loadTeams()
// 素材库可用性跟随全局配置,进入时先刷新配置再拉列表(管理员可能刚在设置页改过)
if (v === 'assets') { loadTeams(); loadFileStorage().then(() => reloadAssets()) }
if (v === 'sync') loadProjSync()
}
// 按时段问候,让页面更像“我的空间”而不是后台表单
const greeting = computed(() => {
@@ -333,6 +334,44 @@ async function syncNow() {
} catch (e) { msg.value = errText(e); store.showToast({ type: 'error', key: 'syncFailToast' }) }
finally { busy.value = '' }
}
// ---- 项目云同步:自动/手动模式、手动拉取、认领入口与忽略恢复 ----
const projSyncMode = ref('auto')
const ignoredProjects = ref([])
const pendingCount = ref(0)
async function loadProjSync() {
projSyncMode.value = store.settings.projectSyncMode || 'auto'
if (!sync.value.loggedIn) { ignoredProjects.value = []; pendingCount.value = 0; return }
try { ignoredProjects.value = (await call('ListCloudIgnoredProjects')) || [] } catch { ignoredProjects.value = [] }
try { pendingCount.value = ((await call('ListCloudPendingProjects')) || []).length } catch { pendingCount.value = 0 }
}
async function changeProjSyncMode() {
await store.saveSettings({ projectSyncMode: projSyncMode.value })
store.showToast({ type: 'success', key: projSyncMode.value === 'auto' ? 'projSyncAutoToast' : 'projSyncManualToast' })
}
async function syncProjectsNow() {
if (busy.value) return
busy.value = 'projsync'
try {
const st = await call('SyncProjectsNow')
store.syncStatus = st
store.showToast({ type: 'success', key: 'syncDoneToast', params: { pushed: st.pushed, pulled: st.pulled } })
await loadProjSync()
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
finally { busy.value = '' }
}
async function unignoreProject(name) {
try {
await call('UnignoreCloudProject', name)
store.showToast({ type: 'success', key: 'projUnignoredToast', params: { name } })
await loadProjSync()
} catch (e) { store.showToast({ type: 'error', text: errText(e) }) }
}
function openClaim() {
store.cloudClaimOpen = true
}
// 认领弹窗关闭后刷新待认领计数
watch(() => store.cloudClaimOpen, v => { if (!v && tab.value === 'sync') loadProjSync() })
async function syncLogout() {
await call('SyncLogout')
store.showToast({ type: 'success', key: 'logoutToast' })
@@ -359,6 +398,7 @@ onMounted(async () => {
await loadFileStorage()
if (tab.value === 'teams') loadTeams()
if (tab.value === 'assets') { loadTeams(); reloadAssets() }
if (tab.value === 'sync') loadProjSync()
addEventListener('keydown', onKey)
})
onUnmounted(() => removeEventListener('keydown', onKey))
@@ -591,6 +631,37 @@ onUnmounted(() => removeEventListener('keydown', onKey))
<span v-for="s in scopeTags" :key="s.key" class="pc-tag"><component :is="s.icon" />{{ t(s.key) }}</span>
</div>
</div>
<template v-if="sync.loggedIn">
<div class="proj-sync">
<header class="proj-sync-head">
<b><Folder />{{ t('projSyncTitle') }}</b>
<small>{{ t('projSyncHint') }}</small>
</header>
<div class="proj-sync-row">
<label class="proj-sync-mode">
<span>{{ t('projSyncModeLabel') }}</span>
<select v-model="projSyncMode" @change="changeProjSyncMode">
<option value="auto">{{ t('projSyncModeAuto') }}</option>
<option value="manual">{{ t('projSyncModeManual') }}</option>
</select>
</label>
<button class="btn secondary" :disabled="!!busy" @click="syncProjectsNow">
<RefreshCw :class="{ spin: busy === 'projsync' }" />{{ busy === 'projsync' ? t('syncingBtn') : t('projSyncNowBtn') }}
</button>
<button v-if="pendingCount" class="btn primary" @click="openClaim">
<CloudUpload />{{ t('projClaimBtn', { n: pendingCount }) }}
</button>
</div>
<div v-if="ignoredProjects.length" class="proj-ignored">
<small>{{ t('projIgnoredTitle') }}</small>
<div class="proj-ignored-list">
<span v-for="n in ignoredProjects" :key="n" class="proj-ignored-item">
{{ n }}<button type="button" :title="t('projUnignoreBtn')" @click="unignoreProject(n)"><X /></button>
</span>
</div>
</div>
</div>
</template>
</section>
</div>

View File

@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, Code2, GitCommitHorizontal, FolderTree, RefreshCw, Files, MessageSquareText, Rows3, Users, Plus, Minus, HardDrive, Folder, FileWarning, GitBranch, ExternalLink, TriangleAlert, ClipboardCheck, Flame, Sparkles, MessageCircleQuestion, Rocket } from 'lucide-vue-next'
import { ArrowLeft, Code2, GitCommitHorizontal, FolderTree, RefreshCw, Files, MessageSquareText, Rows3, Users, Plus, Minus, HardDrive, Folder, FileWarning, GitBranch, ExternalLink, TriangleAlert, ClipboardCheck, Flame, Sparkles, MessageCircleQuestion, Rocket, ListFilter, Trash2 } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import ChartView from '../components/ChartView.vue'
import GitHeatmap from '../components/GitHeatmap.vue'
@@ -67,8 +67,41 @@ async function load() {
loading.value = true
;[p.value, git.value, structure.value, insights.value] = await Promise.all([call('GetProject', +route.params.id), call('GetGitStats', +route.params.id), call('GetStructure', +route.params.id), call('GetProjectInsights', +route.params.id)])
try { diag.value = await call('GetGitDiagnostics', +route.params.id) } catch { diag.value = null }
loadProjRules()
loading.value = false
}
// ---- 项目专属排除规则:与全局规则叠加,仅本项目生效;随项目身份上云,认领后自动带回 ----
const projRules = ref([])
const rulePattern = ref('')
const ruleBusy = ref(false)
async function loadProjRules() {
try { projRules.value = (await call('GetProjectRules', +route.params.id)) || [] } catch { projRules.value = [] }
}
async function addProjRule() {
const v = rulePattern.value.trim()
if (!v || ruleBusy.value) return
ruleBusy.value = true
try {
await call('AddProjectRule', +route.params.id, v, 'custom')
rulePattern.value = ''
await loadProjRules()
store.showToast({ type: 'success', key: 'projRuleAddedToast' })
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
} finally {
ruleBusy.value = false
}
}
async function removeProjRule(r) {
try {
await call('DeleteRule', r.id)
await loadProjRules()
} catch (e) {
store.showToast({ type: 'error', text: String(e) })
}
}
async function refreshInsights() {
insightLoading.value = true
try {
@@ -155,6 +188,17 @@ onMounted(load)
<section class="panel shine-card"><h2>{{ t('languageDistribution') }}</h2><ChartView v-if="p.languages.length" :option="pie" /><div v-else class="empty">{{ t('empty') }}</div></section>
<section class="panel shine-card"><h2>{{ t('languageDetails') }}</h2><div class="language-list"><div v-for="(x, i) in p.languages" :key="x.name"><b><i :style="{ background: colors[i % colors.length] }" />{{ x.name }}</b><span>{{ x.files }} {{ t('files') }}</span><strong>{{ fmt(x.code) }} {{ t('lines') }}</strong></div></div></section>
</div>
<section class="panel proj-rules-panel">
<h2><ListFilter class="panel-icon" />{{ t('projRulesTitle') }}<small>{{ t('projRulesHint') }}</small></h2>
<div class="proj-rules-add">
<input v-model="rulePattern" :placeholder="t('rulePatternPh')" :disabled="ruleBusy" @keyup.enter="addProjRule" />
<button class="btn secondary" :disabled="ruleBusy || !rulePattern.trim()" @click="addProjRule"><Plus />{{ t('add') }}</button>
</div>
<div class="proj-rules-chips">
<button v-for="r in projRules" :key="r.id" type="button" class="proj-rule-chip" :title="t('delete')" @click="removeProjRule(r)">{{ r.pattern }}<Trash2 /></button>
<span v-if="!projRules.length" class="proj-rules-empty">{{ t('projRulesEmpty') }}</span>
</div>
</section>
</template>
<template v-else-if="tab === 'git'">

View File

@@ -9,6 +9,7 @@ import MarkdownView from '../components/MarkdownView.vue'
import DueQuickPick from '../components/DueQuickPick.vue'
import DatePicker from '../components/DatePicker.vue'
import LifecycleTimeline from '../components/LifecycleTimeline.vue'
import AITaskGenerator from '../components/AITaskGenerator.vue'
import { useMdEditor } from '../mdeditor'
const route = useRoute()
@@ -124,6 +125,10 @@ onUnmounted(() => offTasks())
</select>
</div>
<div class="todo-toolbar">
<AITaskGenerator kind="ticket" :default-project-id="projectFilter || store.projects[0]?.id || 0" @saved="reloadAndNotify" />
</div>
<section class="panel ticket-panel">
<article v-for="x in filtered" :key="x.id" class="ticket-row" :class="[x.status, x.priority, { overdue: overdue(x) }]">
<span class="ticket-type" :class="x.type" :title="t('ticketType.' + x.type)"><component :is="typeIcon[x.type] || ListChecks" /></span>

View File

@@ -8,6 +8,7 @@ import { useAppStore } from '../store'
import MarkdownView from '../components/MarkdownView.vue'
import DueQuickPick from '../components/DueQuickPick.vue'
import LifecycleTimeline from '../components/LifecycleTimeline.vue'
import AITaskGenerator from '../components/AITaskGenerator.vue'
import { useMdEditor } from '../mdeditor'
const route = useRoute()
@@ -126,6 +127,10 @@ onUnmounted(() => offTasks())
<button class="btn secondary" @click="load"><RefreshCw />{{ t('refresh') }}</button>
</div>
<div class="todo-toolbar">
<AITaskGenerator kind="todo" :default-project-id="projectFilter" @saved="reloadAndNotify" />
</div>
<div v-if="view === 'board'" class="todo-board">
<section v-for="col in columns" :key="col.status" class="todo-column" :class="col.status">
<header><component :is="statusIcon[col.status]" /><b>{{ t('todoStatus.' + col.status) }}</b><span>{{ col.items.length }}</span></header>

View File

@@ -182,6 +182,8 @@ type ExclusionRule struct {
Pattern string `json:"pattern"`
Category string `json:"category"`
Builtin bool `json:"builtin"`
// ProjectID 0=全局规则;>0 仅对该项目生效(与全局规则叠加)。
ProjectID int64 `json:"projectId"`
}
type LogEntry struct {
ID int64 `json:"id"`
@@ -265,6 +267,8 @@ type AppSettings struct {
// 内容图片存储:待办/工单 Markdown 里粘贴或插入的图片如何保存。
// base64内嵌 dataURL随内容同步到云端| path写入数据目录仅本机可见
ImageMode string `json:"imageMode"`
// 项目云同步模式auto登录后随每轮同步自动推拉项目清单/规则/统计)| manual仅手动触发
ProjectSyncMode string `json:"projectSyncMode"`
}
// AvatarPick 是选择头像图片的结果Value 为要保存的值Preview 为可直接显示的图片源。

182
sync.go
View File

@@ -381,17 +381,9 @@ func (a *App) syncOnce(silent bool) (pushed, pulled int, err error) {
pushed += ap
pulled += ag
}
// paths:<machineID> 行先于身份文档处理:本机路径先落库,身份合并后待绑定清单才准确
for _, d := range []struct {
name string
gen func() (string, error)
apply func(string) error
}{
{"paths:" + a.machineID(), a.machinePathsDoc, a.applyMachinePaths},
{"projects", a.projectsDoc, a.applyProjectsDoc},
{"rules", a.rulesDoc, a.applyRulesDoc},
} {
dp, dg, de := a.syncDoc(d.name, d.gen, d.apply)
// 项目清单/规则/统计文档:手动模式下跳过(由用户在个人主页点「立即同步项目」触发)
if st, se := a.store.Settings(); se != nil || st.ProjectSyncMode != "manual" {
dp, dg, de := a.syncProjectDocs()
if de != nil {
a.setSyncErr(de.Error())
a.emitSyncDone()
@@ -603,6 +595,8 @@ type projectDocEntry struct {
Description string `json:"description,omitempty"`
Group string `json:"group,omitempty"`
Favorite bool `json:"favorite,omitempty"`
// Rules 项目专属排除规则,随项目身份一起上云;其他机器认领后自动带回。
Rules []ruleDocEntry `json:"rules,omitempty"`
}
type ruleDocEntry struct {
@@ -610,8 +604,120 @@ type ruleDocEntry struct {
Category string `json:"category"`
}
// syncProjectDocs 同步项目相关文档:本机路径、项目身份(含项目级规则)、全局规则,
// 并把本机统计按机器码推到 stats:<machineID>(只推不拉,不同机器互不覆盖)。
func (a *App) syncProjectDocs() (pushed, pulled int, err error) {
// paths:<machineID> 行先于身份文档处理:本机路径先落库,身份合并后待绑定清单才准确。
for _, d := range []struct {
name string
gen func() (string, error)
apply func(string) error
}{
{"paths:" + a.machineID(), a.machinePathsDoc, a.applyMachinePaths},
{"projects", a.projectsDoc, a.applyProjectsDoc},
{"rules", a.rulesDoc, a.applyRulesDoc},
} {
dp, dg, de := a.syncDoc(d.name, d.gen, d.apply)
if de != nil {
return pushed, pulled, de
}
pushed += dp
pulled += dg
}
sp, se := a.pushStatsDoc()
if se != nil {
return pushed, pulled, se
}
return pushed + sp, pulled, nil
}
// SyncProjectsNow 手动同步项目清单/规则/统计(手动模式下的拉取入口;自动模式也可强制触发一轮)。
func (a *App) SyncProjectsNow() (SyncStatus, error) {
if e := a.ready(); e != nil {
return SyncStatus{}, e
}
if a.syncUserID() <= 0 || a.store.Meta("sync_access_token") == "" {
return SyncStatus{}, errors.New("SYNC_NOT_LOGGED_IN")
}
if !a.syncing.CompareAndSwap(false, true) {
return SyncStatus{}, errors.New("SYNC_IN_PROGRESS")
}
pushed, pulled, err := a.syncProjectDocs()
a.syncing.Store(false)
if err != nil {
a.setSyncErr(err.Error())
a.emitSyncDone()
st, _ := a.GetSyncStatus()
return st, err
}
a.syncOnline.Store(true)
a.setSyncErr("")
a.emitSyncDone()
st, _ := a.GetSyncStatus()
st.Pushed, st.Pulled = pushed, pulled
return st, nil
}
// projectStatEntry 单个项目在本机的统计快照stats:<machineID> 文档的值)。
type projectStatEntry struct {
TotalLines int64 `json:"totalLines"`
CodeLines int64 `json:"codeLines"`
CommentLines int64 `json:"commentLines"`
BlankLines int64 `json:"blankLines"`
Files int64 `json:"files"`
Commits int64 `json:"commits"`
LastAnalyzed string `json:"lastAnalyzed,omitempty"`
}
// statsDoc 生成本机统计文档 {项目名: 统计快照}。
func (a *App) statsDoc() (string, error) {
rows, e := a.store.db.Query(`SELECT p.name,
COALESCE((SELECT SUM(code+comments+blanks) FROM language_stats ls WHERE ls.project_id=p.id),0),
COALESCE((SELECT SUM(code) FROM language_stats ls WHERE ls.project_id=p.id),0),
COALESCE((SELECT SUM(comments) FROM language_stats ls WHERE ls.project_id=p.id),0),
COALESCE((SELECT SUM(blanks) FROM language_stats ls WHERE ls.project_id=p.id),0),
COALESCE((SELECT SUM(files) FROM language_stats ls WHERE ls.project_id=p.id),0),
(SELECT COUNT(*) FROM git_commits gc WHERE gc.project_id=p.id),
COALESCE((SELECT MAX(completed_at) FROM analysis_runs ar WHERE ar.project_id=p.id AND ar.status='completed'),'')
FROM projects p ORDER BY p.name`)
if e != nil {
return "", e
}
defer rows.Close()
m := map[string]projectStatEntry{}
for rows.Next() {
var name string
var it projectStatEntry
if e := rows.Scan(&name, &it.TotalLines, &it.CodeLines, &it.CommentLines, &it.BlankLines, &it.Files, &it.Commits, &it.LastAnalyzed); e != nil {
return "", e
}
if name != "" && (it.TotalLines > 0 || it.Commits > 0) {
m[name] = it
}
}
b, e := json.Marshal(m)
return string(b), e
}
// pushStatsDoc 把本机统计推到 stats:<machineID>(该行只有本机会写,无需拉取合并;
// 内容没变化时跳过请求)。
func (a *App) pushStatsDoc() (int, error) {
doc, e := a.statsDoc()
if e != nil {
return 0, e
}
if doc == "{}" || doc == a.store.Meta("sync_stats_doc") {
return 0, nil
}
if e := a.apiPutSetting("stats:"+a.machineID(), doc, nowRFC()); e != nil {
return 0, e
}
_ = a.store.SetMeta("sync_stats_doc", doc)
return 1, nil
}
func (a *App) projectsDoc() (string, error) {
rows, e := a.store.db.Query(`SELECT p.name,p.description,COALESCE(g.name,''),
rows, e := a.store.db.Query(`SELECT p.id,p.name,p.description,COALESCE(g.name,''),
EXISTS(SELECT 1 FROM favorites f WHERE f.project_id=p.id)
FROM projects p LEFT JOIN project_groups g ON g.id=p.group_id ORDER BY p.name`)
if e != nil {
@@ -619,12 +725,27 @@ func (a *App) projectsDoc() (string, error) {
}
defer rows.Close()
list := []projectDocEntry{}
ids := []int64{}
for rows.Next() {
var it projectDocEntry
if e := rows.Scan(&it.Name, &it.Description, &it.Group, &it.Favorite); e != nil {
var pid int64
if e := rows.Scan(&pid, &it.Name, &it.Description, &it.Group, &it.Favorite); e != nil {
return "", e
}
list = append(list, it)
ids = append(ids, pid)
}
if e := rows.Err(); e != nil {
return "", e
}
for i, pid := range ids {
prs, e := a.store.ProjectRules(pid)
if e != nil {
return "", e
}
for _, r := range prs {
list[i].Rules = append(list[i].Rules, ruleDocEntry{Pattern: r.Pattern, Category: r.Category})
}
}
b, e := json.Marshal(list)
return string(b), e
@@ -689,13 +810,42 @@ func (a *App) applyProjectsDoc(doc string) error {
} else {
_, _ = a.store.db.Exec(`DELETE FROM favorites WHERE project_id=?`, pid)
}
if e := a.applyProjectRules(pid, it.Rules); e != nil {
return e
}
}
a.rebuildCloudPending(list)
return nil
}
// applyProjectRules 以云端为准替换某项目的专属排除规则。
func (a *App) applyProjectRules(projectID int64, rules []ruleDocEntry) error {
tx, e := a.store.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
if _, e := tx.Exec(`DELETE FROM exclusion_rules WHERE project_id=? AND builtin=0`, projectID); e != nil {
return e
}
for _, it := range rules {
p := strings.TrimSpace(it.Pattern)
if p == "" {
continue
}
c := it.Category
if c == "" {
c = "custom"
}
if _, e := tx.Exec(`INSERT OR IGNORE INTO exclusion_rules(pattern,category,builtin,project_id) VALUES(?,?,0,?)`, p, c, projectID); e != nil {
return e
}
}
return tx.Commit()
}
func (a *App) rulesDoc() (string, error) {
rows, e := a.store.db.Query(`SELECT pattern,category FROM exclusion_rules WHERE builtin=0 ORDER BY pattern`)
rows, e := a.store.db.Query(`SELECT pattern,category FROM exclusion_rules WHERE builtin=0 AND project_id=0 ORDER BY pattern`)
if e != nil {
return "", e
}
@@ -722,7 +872,7 @@ func (a *App) applyRulesDoc(doc string) error {
return e
}
defer tx.Rollback()
if _, e := tx.Exec(`DELETE FROM exclusion_rules WHERE builtin=0`); e != nil {
if _, e := tx.Exec(`DELETE FROM exclusion_rules WHERE builtin=0 AND project_id=0`); e != nil {
return e
}
for _, it := range list {
@@ -734,7 +884,7 @@ func (a *App) applyRulesDoc(doc string) error {
if c == "" {
c = "custom"
}
if _, e := tx.Exec(`INSERT OR IGNORE INTO exclusion_rules(pattern,category,builtin) VALUES(?,?,0)`, p, c); e != nil {
if _, e := tx.Exec(`INSERT OR IGNORE INTO exclusion_rules(pattern,category,builtin,project_id) VALUES(?,?,0,0)`, p, c); e != nil {
return e
}
}