Files
code-utils/cloudbind.go
李琦 c430a0f6da 1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。
2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。

3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。

4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。

5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。

6. 项目云同步与认领

全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。
统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。
个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。
途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00

281 lines
8.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// cloudbind.go 实现“项目身份与机器路径分离”的同步支撑:
// - machineID本机稳定标识哈希后缓存云端行名 paths:<machineID>
// - machinePathsDoc / applyMachinePaths只推拉本机的 {项目名:路径} 行
// - 云端有身份但本机没路径的项目进入待绑定清单,由 Dashboard 引导用户选目录绑定。
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"os"
"strings"
)
// machineID 返回本机稳定标识(原始 ID 哈希后取 16 个十六进制字符),缓存于 meta。
func (a *App) machineID() string {
if a.store == nil {
return "unknown"
}
if v := a.store.Meta("machine_id"); v != "" {
return v
}
raw := rawMachineID()
if raw == "" {
raw, _ = os.Hostname()
}
if raw == "" {
raw = "unknown-machine"
}
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(raw))))
id := hex.EncodeToString(sum[:8])
_ = a.store.SetMeta("machine_id", id)
return id
}
// machinePathsDoc 生成本机路径文档 {项目名: 本机路径}map 序列化按键排序,内容稳定可比较)。
func (a *App) machinePathsDoc() (string, error) {
rows, e := a.store.db.Query(`SELECT name,path FROM projects ORDER BY name`)
if e != nil {
return "", e
}
defer rows.Close()
m := map[string]string{}
for rows.Next() {
var name, path string
if e := rows.Scan(&name, &path); e != nil {
return "", e
}
if name != "" && path != "" {
m[name] = path
}
}
b, e := json.Marshal(m)
return string(b), e
}
// applyMachinePaths 套用本机路径文档(换库/重装同机场景):
// 本地没有该名项目且路径非空 → 直接入库;已有同名项目但路径不同 → 以远端为准更新。
func (a *App) applyMachinePaths(doc string) error {
var m map[string]string
if json.Unmarshal([]byte(doc), &m) != nil {
return nil // 远端数据异常时忽略,不阻断同步
}
for name, path := range m {
name, path = strings.TrimSpace(name), strings.TrimSpace(path)
if name == "" || path == "" {
continue
}
var pid int64
var cur string
e := a.store.db.QueryRow(`SELECT id,path FROM projects WHERE name=? LIMIT 1`, name).Scan(&pid, &cur)
switch {
case e == sql.ErrNoRows:
now := nowRFC()
// path 全局唯一同路径已有别名项目时不强插IGNORE
if _, e := a.store.db.Exec(`INSERT OR IGNORE INTO projects(name,path,description,group_id,created_at,updated_at) VALUES(?,?,'',1,?,?)`,
name, path, now, now); e != nil {
return e
}
case e != nil:
return e
case cur != path:
if _, e := a.store.db.Exec(`UPDATE projects SET path=?,updated_at=? WHERE id=?
AND NOT EXISTS(SELECT 1 FROM projects p2 WHERE p2.path=? AND p2.id!=?)`,
path, nowRFC(), pid, path, pid); e != nil {
return e
}
}
}
return nil
}
// rebuildCloudPending 以最近一次云端身份文档为基准,重算仍未落地本机的项目清单(幂等)。
func (a *App) rebuildCloudPending(entries []projectDocEntry) {
pending := []projectDocEntry{}
for _, it := range entries {
name := strings.TrimSpace(it.Name)
if name == "" {
continue
}
var pid int64
if a.store.db.QueryRow(`SELECT id FROM projects WHERE name=? LIMIT 1`, name).Scan(&pid) == sql.ErrNoRows {
it.Path = "" // 待绑定清单不携带其它机器的路径
pending = append(pending, it)
}
}
b, _ := json.Marshal(pending)
_ = a.store.SetMeta("cloud_projects_pending", string(b))
}
// 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
}
out := []projectDocEntry{}
raw := a.store.Meta("cloud_projects_pending")
if raw == "" {
return out, nil
}
var list []projectDocEntry
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 {
out = append(out, it)
}
}
return out, nil
}
// BindCloudProject 把云端项目绑定到本机目录:带身份信息入库、移出待绑定清单,并触发一轮推送。
func (a *App) BindCloudProject(name, path string) (Project, error) {
if e := a.ready(); e != nil {
return Project{}, e
}
name, path = strings.TrimSpace(name), strings.TrimSpace(path)
if name == "" {
return Project{}, errors.New("NAME_REQUIRED")
}
if fi, e := os.Stat(path); e != nil || !fi.IsDir() {
return Project{}, errors.New("DIR_NOT_FOUND")
}
entry := projectDocEntry{Name: name}
if raw := a.store.Meta("cloud_projects_pending"); raw != "" {
var list []projectDocEntry
if json.Unmarshal([]byte(raw), &list) == nil {
rest := make([]projectDocEntry, 0, len(list))
for _, it := range list {
if strings.TrimSpace(it.Name) == name {
entry = it
continue
}
rest = append(rest, it)
}
b, _ := json.Marshal(rest)
_ = a.store.SetMeta("cloud_projects_pending", string(b))
}
}
gid, e := a.ensureGroupID(entry.Group)
if e != nil {
return Project{}, e
}
var pid int64
now := nowRFC()
switch e := a.store.db.QueryRow(`SELECT id FROM projects WHERE path=?`, path).Scan(&pid); {
case e == sql.ErrNoRows:
r, e2 := a.store.db.Exec(`INSERT INTO projects(name,path,description,group_id,created_at,updated_at) VALUES(?,?,?,?,?,?)`,
name, path, entry.Description, gid, now, now)
if e2 != nil {
return Project{}, e2
}
pid, _ = r.LastInsertId()
case e != nil:
return Project{}, e
default:
// 该目录已是本地项目:改名并入云端身份,避免出现两个同路径项目。
if _, e := a.store.db.Exec(`UPDATE projects SET name=?,description=?,group_id=?,updated_at=? WHERE id=?`,
name, entry.Description, gid, now, pid); e != nil {
return Project{}, e
}
}
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)
}
return a.store.GetProject(pid)
}