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:
182
sync.go
182
sync.go
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user