From aabd20bd753750d37f1549120dab68a82d42f705 Mon Sep 17 00:00:00 2001 From: logan Date: Fri, 18 Sep 2026 11:42:34 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E4=B8=8E=E5=9B=A2=E9=98=9F?= =?UTF-8?q?=EF=BC=9ASQLite=20=E4=BA=91=E7=AB=AF=E7=A9=BA=E9=97=B4=EF=BC=88?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=20/=20=E5=9B=A2=E9=98=9F=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=20+=20=E8=87=AA=E5=8A=A8=E5=90=8C=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 服务端(Gin + modernc.org/sqlite,data/opsxapi.db WAL): - users/tokens/teams/team_members/workspaces 表;注册登录(加盐哈希 + Bearer 令牌) - /api:auth register/login/logout、me(用户+团队+空间)、teams CRUD/邀请成员、workspaces 读写(20MB 上限,成员鉴权) - 前端: - account.ts 客户端 + store 账号态:登录/注册/登出/团队/空间切换 - 顶栏账号区:未登录「登录」按钮;登录后用户胶囊(头像+空间名+已同步/同步中/失败)+ 空间切换菜单 + ⋯(立即同步/团队管理/退出) - 空间模型:本地(localStorage)与云端(SQLite)隔离,切云端空空间自动播种本地数据;云端改动 1.5s 去抖自动同步;切回本地恢复快照 - 登录/注册弹窗 + 团队管理弹窗(建团队、邮箱邀请);vite 代理 /api;docs 新增「账号与团队」页 - 验证:curl E2E(注册/冲突 409/错误密码 401/团队共享写/越权 401)+ 浏览器全链路(注册→播种上云 17KB→云端编辑自动同步→本地快照隔离) --- apps/server/data/opsxapi.db | Bin 0 -> 4096 bytes apps/server/data/opsxapi.db-shm | Bin 0 -> 32768 bytes apps/server/data/opsxapi.db-wal | Bin 0 -> 325512 bytes apps/server/go.mod | 14 +- apps/server/go.sum | 20 ++ apps/server/internal/api/api.go | 238 ++++++++++++ .../internal/docs/content/20-account.md | 39 ++ .../internal/docs/content/manifest.json | 11 + apps/server/internal/server/router.go | 13 +- apps/server/internal/store/store.go | 340 ++++++++++++++++++ apps/web/src/styles.css | 6 + apps/web/vite.config.ts | 4 + packages/ui/src/App.tsx | 3 + packages/ui/src/account.ts | 98 +++++ packages/ui/src/components/TopBar.tsx | 115 +++++- .../src/components/dialogs/AccountDialogs.tsx | 220 ++++++++++++ packages/ui/src/workspaceStore.ts | 254 +++++++++++-- packages/ui/tsconfig.tsbuildinfo | 1 + 18 files changed, 1338 insertions(+), 38 deletions(-) create mode 100644 apps/server/data/opsxapi.db create mode 100644 apps/server/data/opsxapi.db-shm create mode 100644 apps/server/data/opsxapi.db-wal create mode 100644 apps/server/internal/api/api.go create mode 100644 apps/server/internal/docs/content/20-account.md create mode 100644 apps/server/internal/store/store.go create mode 100644 packages/ui/src/account.ts create mode 100644 packages/ui/src/components/dialogs/AccountDialogs.tsx create mode 100644 packages/ui/tsconfig.tsbuildinfo diff --git a/apps/server/data/opsxapi.db b/apps/server/data/opsxapi.db new file mode 100644 index 0000000000000000000000000000000000000000..cc04c103cdf5416b37dcbcd114b399f735eadcdf GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYC*>h8Lt=fNV2HHI9bB nXb6mkz-S1JhQMeDjE2By2#kinXb6mkz-S1JhQMeDP#6LLQSS$G literal 0 HcmV?d00001 diff --git a/apps/server/data/opsxapi.db-shm b/apps/server/data/opsxapi.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..3613e95cd92acda77dfa7637664081781c70b53c GIT binary patch literal 32768 zcmeI*%Tg0T6vpu{AzVX3xM=`E5D_m##R!TD$VCMN+3*zXtl|OOu;58txrGN{1q&8D zf-eH6sYz;Ts;FX;Rnz~jI@NQA?lXOU>^tTE6}d5Dk&#NfHV;~yoLwHDFYi|m)(-#d z9i2`UCjR}sSoxhVr(AEPZicIS6&um(`eff$)}qBCXW{Zzi?-;wJ(@dg?u?$hqB$SU z-O*gIIbO@vb8FrET1~g-E^FLAx8Hrg_HFl_c+L2GxBf15U4L$E(W1wqWYKF8KVN)J zpZ%@hV!*=vf8y&bWgvh60tg_000IagfB*srAbTfwTfyyGdkB@6&E$Jp%VYphXY#SabKF6IwtZtw2r>Ro1vB^jsCq z>qFWCtVh6Gfi}DE?x{X$O*`K9z`+q{OrTwlG^Nj4*RRG3unPfi1v>S@+p}|U1ez1b z>!sc{SAyLLcq-7XSDN+o>>L|`<^+lw(i2VVwW|84CH-iw9J>+lNT8%qJ<}I`llv_K z1_B`o^l3)#LTZ>^5vU6cXjqeaqlNk#9uNpcU|AcXv`U`{Bm}-Da#94s5m*VQRXRl= qA+VarNf8J~U^ARn=@fy4z*ZtBMIao3pW(DhrwAkjwi7w&Z35S~jx^!` literal 0 HcmV?d00001 diff --git a/apps/server/data/opsxapi.db-wal b/apps/server/data/opsxapi.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..cecbc5741718dc38e46fdbdc04b9e504ef2631bf GIT binary patch literal 325512 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

}WQ}taCSzuu;8kL?BinDrE3s-- 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 20*1024*1024 { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "工作区数据超过 20MB 上限"}) + return + } + now, err := a.store.SaveWorkspace(ws.ID, req.Data) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败:" + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"updatedAt": now}) +} diff --git a/apps/server/internal/docs/content/20-account.md b/apps/server/internal/docs/content/20-account.md new file mode 100644 index 0000000..5a19db7 --- /dev/null +++ b/apps/server/internal/docs/content/20-account.md @@ -0,0 +1,39 @@ +# 账号与团队 + +登录后可使用**云端空间**:个人空间「我的空间」与团队共享空间,数据保存在本机服务的 SQLite 数据库(`apps/server/data/opsxapi.db`),不上传任何第三方。 + +## 注册 / 登录 + +顶栏右上 **登录** 按钮打开弹窗: + +- 注册只需 昵称 + 邮箱 + 密码(≥ 6 位); +- 登录态保存在本机,令牌失效会自动清除。 + +## 空间切换 + +登录后顶栏出现 **用户胶囊**(头像首字母 + 当前空间名 + 同步状态),点击弹出空间列表: + +| 空间 | 说明 | +| --- | --- | +| 本地空间 | 未登录时默认;数据在浏览器 localStorage,不上云 | +| 我的空间 | 个人云端空间,注册即有;换浏览器 / 换设备登录即得同一份数据 | +| 团队 · ×× | 团队共享空间,成员读写同一份数据 | + +- 首次切入空的云端空间时,会自动把**当前本地数据播种上去**; +- 在云端空间内的所有改动(接口、环境、测试用例、设置)**自动同步**(改动后约 1.5 秒),胶囊显示「已同步 / 同步中 / 同步失败」; +- 切回本地空间时恢复离开前的本地快照,两端互不覆盖; +- 同步策略为**后写覆盖**:多人同时编辑以最后一次保存为准。 + +## 团队管理 + +用户胶囊旁 **⋯** 菜单 → 团队管理: + +- **新建团队**:创建后自动获得同名共享空间; +- **添加成员**:输入已注册成员邮箱(创建者可操作),成员登录后即可看到并切换到该团队空间。 + +## 数据与安全 + +- 密码加盐哈希后存储;令牌为随机 Bearer Token,可随时退出登录作废; +- 云端空间单份数据上限 20MB; +- 团队空间权限:全体成员可读写,仅创建者可管理成员; +- 不登录不影响任何本地功能。 diff --git a/apps/server/internal/docs/content/manifest.json b/apps/server/internal/docs/content/manifest.json index d0e2a2e..7ad130d 100644 --- a/apps/server/internal/docs/content/manifest.json +++ b/apps/server/internal/docs/content/manifest.json @@ -123,6 +123,17 @@ } ] }, + { + "name": "账号与团队", + "pages": [ + { + "file": "20-account.md", + "slug": "account", + "title": "账号与团队", + "updated": "2026-09-17" + } + ] + }, { "name": "参考", "pages": [ diff --git a/apps/server/internal/server/router.go b/apps/server/internal/server/router.go index d81c821..c0109f1 100644 --- a/apps/server/internal/server/router.go +++ b/apps/server/internal/server/router.go @@ -2,10 +2,13 @@ package server import ( "net/http" + "os" "strings" "github.com/gin-gonic/gin" + "opsxapi/server/internal/api" "opsxapi/server/internal/docs" + "opsxapi/server/internal/echo" "opsxapi/server/internal/proxy" "opsxapi/server/internal/site" ) @@ -27,6 +30,14 @@ func NewRouter() *gin.Engine { r.GET("/v1/proxy/ws/attach", tunnel.Attach) r.POST("/v1/proxy/ws/close", tunnel.Close) + // 账号 / 团队 / 云端空间(SQLite) + accountAPI, err := api.New(os.Getenv("OPSXAPI_DB")) + if err != nil { + panic("init sqlite store: " + err.Error()) + } + accountAPI.Register(r) + + echo.Mount(r) docs.New().Register(r) site.New().Register(r) @@ -47,7 +58,7 @@ func localhostCORS() gin.HandlerFunc { if _, ok := allowed[strings.TrimSuffix(origin, "/")]; ok { c.Header("Access-Control-Allow-Origin", origin) c.Header("Vary", "Origin") - c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie, X-Opsx-Probe, X-Opsx-Cookie") c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") } if c.Request.Method == http.MethodOptions { diff --git a/apps/server/internal/store/store.go b/apps/server/internal/store/store.go new file mode 100644 index 0000000..1d996bc --- /dev/null +++ b/apps/server/internal/store/store.go @@ -0,0 +1,340 @@ +// Package store:SQLite 轻量存储(账号 / 团队 / 云端工作区)。 +// 纯 Go 驱动 modernc.org/sqlite,无需 cgo;库文件默认 ./data/opsxapi.db。 +package store + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +var ErrNotFound = errors.New("not found") + +type Store struct { + db *sql.DB +} + +func Open(path string) (*Store, error) { + if path == "" { + path = "data/opsxapi.db" + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) // sqlite 单写者,避免锁竞争 + s := &Store{db: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) migrate() error { + _, err := s.db.Exec(` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + pass_hash TEXT NOT NULL, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS tokens ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS teams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + owner_id INTEGER NOT NULL REFERENCES users(id), + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS team_members ( + team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', + created_at INTEGER NOT NULL, + PRIMARY KEY (team_id, user_id) +); +CREATE TABLE IF NOT EXISTS workspaces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + team_id INTEGER REFERENCES teams(id) ON DELETE CASCADE, + owner_id INTEGER NOT NULL REFERENCES users(id), + data TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_tokens_user ON tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_members_user ON team_members(user_id); +CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_id); +`) + return err +} + +/* ---------------- 密码 ---------------- */ + +func hashPassword(password string) string { + salt := make([]byte, 12) + _, _ = rand.Read(salt) + sum := sha256.Sum256(append(salt, []byte(password)...)) + return hex.EncodeToString(salt) + "$" + hex.EncodeToString(sum[:]) +} + +func verifyPassword(password, stored string) bool { + idx := -1 + for i := 0; i < len(stored); i++ { + if stored[i] == '$' { + idx = i + break + } + } + if idx <= 0 { + return false + } + salt, _ := hex.DecodeString(stored[:idx]) + sum := sha256.Sum256(append(salt, []byte(password)...)) + return hex.EncodeToString(sum[:]) == stored[idx+1:] +} + +/* ---------------- 用户 / 令牌 ---------------- */ + +type User struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` +} + +func (s *Store) CreateUser(name, email, password string) (*User, error) { + res, err := s.db.Exec( + `INSERT INTO users(name, email, pass_hash, created_at) VALUES(?,?,?,?)`, + name, email, hashPassword(password), time.Now().Unix(), + ) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + // 个人空间随账号创建 + if _, err := s.db.Exec( + `INSERT INTO workspaces(name, team_id, owner_id, data, updated_at) VALUES(?,NULL,?,'',?)`, + "我的空间", id, time.Now().Unix(), + ); err != nil { + return nil, err + } + return &User{ID: id, Name: name, Email: email}, nil +} + +func (s *Store) UserByEmail(email string) (*User, error) { + u := &User{} + err := s.db.QueryRow(`SELECT id, name, email FROM users WHERE email = ?`, email). + Scan(&u.ID, &u.Name, &u.Email) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return u, err +} + +func (s *Store) CheckPassword(email, password string) (*User, error) { + var hash string + u := &User{} + err := s.db.QueryRow(`SELECT id, name, email, pass_hash FROM users WHERE email = ?`, email). + Scan(&u.ID, &u.Name, &u.Email, &hash) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + if !verifyPassword(password, hash) { + return nil, ErrNotFound + } + return u, nil +} + +func (s *Store) IssueToken(userID int64) (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + token := hex.EncodeToString(raw) + _, err := s.db.Exec(`INSERT INTO tokens(token, user_id, created_at) VALUES(?,?,?)`, token, userID, time.Now().Unix()) + return token, err +} + +func (s *Store) UserByToken(token string) (*User, error) { + u := &User{} + err := s.db.QueryRow( + `SELECT u.id, u.name, u.email FROM tokens t JOIN users u ON u.id = t.user_id WHERE t.token = ?`, token, + ).Scan(&u.ID, &u.Name, &u.Email) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return u, err +} + +func (s *Store) DeleteToken(token string) error { + _, err := s.db.Exec(`DELETE FROM tokens WHERE token = ?`, token) + return err +} + +/* ---------------- 团队 ---------------- */ + +type Team struct { + ID int64 `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + Owner string `json:"owner"` +} + +func (s *Store) CreateTeam(ownerID int64, name string) (*Team, error) { + now := time.Now().Unix() + res, err := s.db.Exec(`INSERT INTO teams(name, owner_id, created_at) VALUES(?,?,?)`, name, ownerID, now) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + if _, err := s.db.Exec(`INSERT INTO team_members(team_id, user_id, role, created_at) VALUES(?,?,'owner',?)`, id, ownerID, now); err != nil { + return nil, err + } + if _, err := s.db.Exec( + `INSERT INTO workspaces(name, team_id, owner_id, data, updated_at) VALUES(?,?,?,'',?)`, + name, id, ownerID, now, + ); err != nil { + return nil, err + } + return &Team{ID: id, Name: name, Role: "owner"}, nil +} + +func (s *Store) TeamsOfUser(userID int64) ([]Team, error) { + rows, err := s.db.Query(` +SELECT t.id, t.name, m.role, IFNULL((SELECT u.name FROM users u WHERE u.id = t.owner_id), '') +FROM team_members m JOIN teams t ON t.id = m.team_id +WHERE m.user_id = ? ORDER BY t.id`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Team + for rows.Next() { + var t Team + if err := rows.Scan(&t.ID, &t.Name, &t.Role, &t.Owner); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +func (s *Store) AddTeamMember(teamID int64, email string) (*User, error) { + u, err := s.UserByEmail(email) + if err != nil { + return nil, err + } + _, err = s.db.Exec( + `INSERT OR IGNORE INTO team_members(team_id, user_id, role, created_at) VALUES(?,?,'member',?)`, + teamID, u.ID, time.Now().Unix(), + ) + return u, err +} + +func (s *Store) IsTeamOwner(teamID, userID int64) (bool, error) { + var one int + err := s.db.QueryRow(`SELECT 1 FROM teams WHERE id = ? AND owner_id = ?`, teamID, userID).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err +} + +/* ---------------- 工作区(云端空间) ---------------- */ + +type Workspace struct { + ID int64 `json:"id"` + Name string `json:"name"` + TeamID *int64 `json:"teamId"` + OwnerID int64 `json:"ownerId"` + Data string `json:"data"` + UpdatedAt int64 `json:"updatedAt"` +} + +// WorkspacesOfUser:个人空间 + 所在团队的空间(含个人空间自动补齐) +func (s *Store) WorkspacesOfUser(userID int64) ([]Workspace, error) { + // 确保个人空间存在(兼容老数据) + var one int + err := s.db.QueryRow(`SELECT 1 FROM workspaces WHERE owner_id = ? AND team_id IS NULL`, userID).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + if _, err := s.db.Exec( + `INSERT INTO workspaces(name, team_id, owner_id, data, updated_at) VALUES(?,NULL,?,'',?)`, + "我的空间", userID, time.Now().Unix(), + ); err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + + rows, err := s.db.Query(` +SELECT w.id, w.name, w.team_id, w.owner_id, IFNULL(w.data,''), w.updated_at +FROM workspaces w +WHERE w.owner_id = ? + OR w.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?) +ORDER BY w.team_id IS NOT NULL, w.id`, userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Workspace + for rows.Next() { + var w Workspace + if err := rows.Scan(&w.ID, &w.Name, &w.TeamID, &w.OwnerID, &w.Data, &w.UpdatedAt); err != nil { + return nil, err + } + out = append(out, w) + } + return out, rows.Err() +} + +// CanAccessWorkspace:本人个人空间或其所在团队的空间 +func (s *Store) CanAccessWorkspace(wsID, userID int64) (bool, error) { + var one int + err := s.db.QueryRow(` +SELECT 1 FROM workspaces w +WHERE w.id = ? AND (w.owner_id = ? + OR (w.team_id IS NOT NULL AND w.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)))`, + wsID, userID, userID).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err +} + +func (s *Store) WorkspaceByID(wsID int64) (*Workspace, error) { + w := &Workspace{} + err := s.db.QueryRow( + `SELECT id, name, team_id, owner_id, IFNULL(data,''), updated_at FROM workspaces WHERE id = ?`, wsID, + ).Scan(&w.ID, &w.Name, &w.TeamID, &w.OwnerID, &w.Data, &w.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return w, err +} + +func (s *Store) SaveWorkspace(wsID int64, data string) (int64, error) { + now := time.Now().Unix() + _, err := s.db.Exec(`UPDATE workspaces SET data = ?, updated_at = ? WHERE id = ?`, data, now, wsID) + return now, err +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index a6c17a4..c7fb8f2 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -474,6 +474,12 @@ color: var(--accent-strong); } + /* ---------- 账号 / 空间 ---------- */ + .ops-user-avatar { + @apply grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full text-[10px] font-bold text-white; + background: linear-gradient(135deg, var(--accent), var(--accent-strong, var(--accent))); + } + /* ---------- URL 胶囊输入(前置 URL 模式) ---------- */ .url-pill { display: flex; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ab70e35..b10800e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -16,6 +16,10 @@ export default defineConfig({ port: 5173, strictPort: true, proxy: { + "/api": { + target: "http://127.0.0.1:8787", + changeOrigin: true, + }, "/v1": { target: "http://127.0.0.1:8787", changeOrigin: true, diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 52add06..5d6363b 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -20,6 +20,7 @@ import { SseEditor } from "./components/editors/SseEditor"; import { WebsocketEditor } from "./components/editors/WebsocketEditor"; import { ExportDialog } from "./components/dialogs/ExportDialog"; import { ImportDialog } from "./components/dialogs/ImportDialog"; +import { AuthDialog, TeamDialog } from "./components/dialogs/AccountDialogs"; import { EnvironmentsDialog, NewItemDialog, @@ -93,6 +94,8 @@ export function OpsxWorkspace() { {dialog?.type === "environments" && } {dialog?.type === "settings" && } {dialog?.type === "rename" && } + {dialog?.type === "auth" && } + {dialog?.type === "team" && } ); } diff --git a/packages/ui/src/account.ts b/packages/ui/src/account.ts new file mode 100644 index 0000000..cfe2768 --- /dev/null +++ b/packages/ui/src/account.ts @@ -0,0 +1,98 @@ +/** + * 账号 / 团队 / 云端空间客户端(同源 /api,经 Vite 代理或桌面直连本地服务)。 + */ +export type AccountUser = { id: number; name: string; email: string }; +export type AccountTeam = { id: number; name: string; role: string; owner: string }; +export type AccountWorkspace = { id: number; name: string; teamId: number | null; ownerId: number; updatedAt: number }; + +class ApiError extends Error { + constructor(message: string, public status: number) { + super(message); + } +} + +async function call(path: string, token: string | null, init?: RequestInit): Promise { + const res = await fetch(`/api${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(init?.headers ?? {}), + }, + }); + const text = await res.text(); + const data = text ? (JSON.parse(text) as T & { error?: string }) : ({} as T & { error?: string }); + if (!res.ok) throw new ApiError(data.error || `请求失败(${res.status})`, res.status); + return data as T; +} + +export function authRegister(name: string, email: string, password: string) { + return call<{ token: string; user: AccountUser }>("/auth/register", null, { + method: "POST", + body: JSON.stringify({ name, email, password }), + }); +} + +export function authLogin(email: string, password: string) { + return call<{ token: string; user: AccountUser }>("/auth/login", null, { + method: "POST", + body: JSON.stringify({ email, password }), + }); +} + +export function authLogout(token: string) { + return call<{ ok: boolean }>("/auth/logout", token, { method: "POST" }); +} + +export function fetchMe(token: string) { + return call<{ user: AccountUser; teams: AccountTeam[]; workspaces: AccountWorkspace[] }>("/me", token); +} + +export function createTeam(token: string, name: string) { + return call("/teams", token, { method: "POST", body: JSON.stringify({ name }) }); +} + +export function addTeamMember(token: string, teamId: number, email: string) { + return call<{ ok: boolean; member: AccountUser }>(`/teams/${teamId}/members`, token, { + method: "POST", + body: JSON.stringify({ email }), + }); +} + +export function fetchWorkspace(token: string, id: number) { + return call<{ id: number; name: string; teamId: number | null; data: string; updatedAt: number }>( + `/workspaces/${id}`, + token, + ); +} + +export function saveWorkspace(token: string, id: number, data: string) { + return call<{ updatedAt: number }>(`/workspaces/${id}`, token, { + method: "PUT", + body: JSON.stringify({ data }), + }); +} + +/* ---------------- 本地令牌存储 ---------------- */ + +const AUTH_KEY = "opsxapi.auth.v1"; + +export function loadToken(): string | null { + try { + const raw = localStorage.getItem(AUTH_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as { token?: string }; + return parsed.token ?? null; + } catch { + return null; + } +} + +export function saveToken(token: string | null): void { + try { + if (token) localStorage.setItem(AUTH_KEY, JSON.stringify({ token })); + else localStorage.removeItem(AUTH_KEY); + } catch { + /* ignore */ + } +} diff --git a/packages/ui/src/components/TopBar.tsx b/packages/ui/src/components/TopBar.tsx index 68a3957..bf31259 100644 --- a/packages/ui/src/components/TopBar.tsx +++ b/packages/ui/src/components/TopBar.tsx @@ -3,7 +3,7 @@ */ import { useEffect, useState } from "react"; import { useWorkspace } from "../workspaceStore"; -import { ContextMenu, type MenuItem } from "./common"; +import { ContextMenu, cx, type MenuItem } from "./common"; import { EnvBadge } from "./EnvBadge"; import { Icon, type IconName } from "./icons"; import { Logo } from "./Logo"; @@ -31,6 +31,11 @@ export function TopBar() { const [newMenu, setNewMenu] = useState<{ x: number; y: number } | null>(null); const [exportMenu, setExportMenu] = useState<{ x: number; y: number } | null>(null); const [moreMenu, setMoreMenu] = useState<{ x: number; y: number } | null>(null); + const [spaceMenu, setSpaceMenu] = useState<{ x: number; y: number } | null>(null); + const [accountMenu, setAccountMenu] = useState<{ x: number; y: number } | null>(null); + const account = useWorkspace((s) => s.account); + const switchSpace = useWorkspace((s) => s.switchSpace); + const syncNow = useWorkspace((s) => s.syncNow); useEffect(() => { document.title = "OpsxAPI · 接口工作台"; @@ -89,6 +94,66 @@ export function TopBar() { }, ]; + const activeSpace = + account.activeSpaceId === null ? null : account.spaces.find((sp) => sp.id === account.activeSpaceId) ?? null; + const spaceName = activeSpace + ? `${activeSpace.teamId !== null ? "团队 · " : ""}${activeSpace.name}` + : "本地空间"; + + const spaceItems: MenuItem[] = [ + { + label: ( + + + 本地空间 + {account.activeSpaceId === null && ● 当前} + + ), + onClick: () => void switchSpace(null), + }, + ...account.spaces.map((sp) => ({ + label: ( + + + {sp.teamId !== null ? `团队 · ${sp.name}` : sp.name} + {sp.id === account.activeSpaceId ? ( + ● 当前 + ) : ( + + {new Date(sp.updatedAt * 1000).toLocaleDateString("zh-CN")} + + )} + + ), + onClick: () => void switchSpace(sp.id), + })), + ]; + + const accountItems: MenuItem[] = account.user + ? [ + ...(account.activeSpaceId !== null + ? [ + { + label: account.syncing ? "同步中…" : account.lastSyncAt ? `已同步 ${new Date(account.lastSyncAt).toLocaleTimeString("zh-CN")}` : "立即同步到云端", + icon: , + onClick: () => void syncNow(), + }, + ] + : []), + { type: "sep" as const }, + { + label: "团队管理…", + icon: , + onClick: () => setDialog({ type: "team" }), + }, + { + label: "退出登录", + icon: , + onClick: () => void useWorkspace.getState().authLogout(), + }, + ] + : []; + const moreItems: MenuItem[] = [ { label: "设置…", @@ -224,6 +289,46 @@ export function TopBar() {

+ {account.user ? ( + <> + + + + ) : ( + + )} + +
+
+ setSpaceMenu(null)} + /> + setAccountMenu(null)} + /> setMoreMenu(null)} diff --git a/packages/ui/src/components/dialogs/AccountDialogs.tsx b/packages/ui/src/components/dialogs/AccountDialogs.tsx new file mode 100644 index 0000000..1b6735c --- /dev/null +++ b/packages/ui/src/components/dialogs/AccountDialogs.tsx @@ -0,0 +1,220 @@ +/** + * 账号弹窗:登录 / 注册;团队管理:建团队、邀请成员。 + */ +import { useState } from "react"; +import { useWorkspace } from "../../workspaceStore"; +import { Modal, cx } from "../common"; +import { Icon } from "../icons"; + +/* ---------------- 登录 / 注册 ---------------- */ + +export function AuthDialog() { + const authLogin = useWorkspace((s) => s.authLogin); + const authRegister = useWorkspace((s) => s.authRegister); + const setDialog = useWorkspace((s) => s.setDialog); + + const [mode, setMode] = useState<"login" | "register">("login"); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + const submit = async () => { + setBusy(true); + setError(""); + try { + if (mode === "login") await authLogin(email, password); + else await authRegister(name, email, password); + setDialog(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + return ( + setDialog(null)} + width="400px" + footer={ +
+ + +
+ } + > +
+
+ + 账号数据保存在本机服务(SQLite),用于个人 / 团队云端空间同步 +
+ {mode === "register" && ( +
+ + setName(e.target.value)} + /> +
+ )} +
+ + setEmail(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + autoFocus + /> +
+
+ + setPassword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + /> +
+ {error &&
{error}
} +
+
+ ); +} + +/* ---------------- 团队管理 ---------------- */ + +export function TeamDialog() { + const account = useWorkspace((s) => s.account); + const createTeam = useWorkspace((s) => s.createTeam); + const addTeamMember = useWorkspace((s) => s.addTeamMember); + const setDialog = useWorkspace((s) => s.setDialog); + + const [teamName, setTeamName] = useState(""); + const [inviteEmail, setInviteEmail] = useState>({}); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [okMsg, setOkMsg] = useState(""); + + const doCreate = async () => { + if (!teamName.trim()) return; + setBusy(true); + setError(""); + try { + await createTeam(teamName); + setTeamName(""); + setOkMsg("团队已创建,左侧空间列表可切换到团队空间"); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const doInvite = async (teamId: number) => { + const email = (inviteEmail[teamId] ?? "").trim(); + if (!email) return; + setBusy(true); + setError(""); + try { + await addTeamMember(teamId, email); + setInviteEmail((m) => ({ ...m, [teamId]: "" })); + setOkMsg(`已添加成员 ${email}`); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + return ( + setDialog(null)} + width="560px" + footer={ +
+ 团队成员共享同一份云端空间,切换即协同 + +
+ } + > +
+
+ +
+ setTeamName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && doCreate()} + /> + +
+
+ + {account.teams.length === 0 ? ( +
还没有团队,创建一个即可获得团队共享空间
+ ) : ( +
+ {account.teams.map((t) => ( +
+
+ + + + {t.name} + + {t.role === "owner" ? "创建者" : "成员"} + + 创建者 {t.owner} +
+ {t.role === "owner" && ( +
+ setInviteEmail((m) => ({ ...m, [t.id]: e.target.value }))} + onKeyDown={(e) => e.key === "Enter" && doInvite(t.id)} + /> + +
+ )} +
+ ))} +
+ )} + + {error &&
{error}
} + {okMsg &&
{okMsg}
} +
+
+ ); +} diff --git a/packages/ui/src/workspaceStore.ts b/packages/ui/src/workspaceStore.ts index 436636e..de2904c 100644 --- a/packages/ui/src/workspaceStore.ts +++ b/packages/ui/src/workspaceStore.ts @@ -35,6 +35,18 @@ import { normalizeMockSettings, uniqueName, } from "@opsxapi/core"; +import { + addTeamMember as addTeamMemberApi, + authLogin as authLoginApi, + authLogout as authLogoutApi, + authRegister as authRegisterApi, + createTeam as createTeamApi, + fetchMe, + fetchWorkspace, + loadToken, + saveToken, + saveWorkspace, +} from "./account"; import { create } from "zustand"; const STORAGE_KEY = "opsxapi.workspace.v2"; @@ -75,7 +87,9 @@ export type Dialog = | { type: "environments" } | { type: "settings"; tab?: "appearance" | "network" | "mock" | "ai" | "data" | "about" } | { type: "rename"; id: string } - | { type: "confirm"; title: string; message: string; action: () => void }; + | { type: "confirm"; title: string; message: string; action: () => void } + | { type: "auth" } + | { type: "team" }; export function emptyRuntime(): RequestRuntime { return { @@ -113,8 +127,29 @@ type WorkspaceStore = PersistedShape & { /** 主视图:接口工作台 / 自动化测试 */ view: "api" | "testing"; + /** 账号与云端空间(null 空间 = 本地) */ + account: { + token: string | null; + user: { id: number; name: string; email: string } | null; + teams: { id: number; name: string; role: string; owner: string }[]; + spaces: { id: number; name: string; teamId: number | null; updatedAt: number }[]; + activeSpaceId: number | null; + syncing: boolean; + lastSyncAt: number | null; + syncError: string | null; + }; + init(): void; setView(view: "api" | "testing"): void; + authLogin(email: string, password: string): Promise; + authRegister(name: string, email: string, password: string): Promise; + authLogout(): Promise; + refreshAccount(): Promise; + createTeam(name: string): Promise; + addTeamMember(teamId: number, email: string): Promise; + /** 切换空间:null=本地;服务端空间为空时用当前数据播种 */ + switchSpace(spaceId: number | null): Promise; + syncNow(): Promise; addCollection(name?: string): string; renameNode(id: string, name: string): void; @@ -261,29 +296,33 @@ function migrateBaseUrl( return { collections: collections.map((c) => ({ ...c, children: fix(c.children) })), environments: envs }; } +/** 任意来源(本地/云端)的工作区数据归一化 */ +function normalizeWorkspaceData(data: Partial): WorkspaceData | null { + if (!Array.isArray(data.collections) || !Array.isArray(data.environments)) return null; + const migrated = migrateBaseUrl(sanitizeCollections(data.collections), data.environments); + return { + version: 2, + collections: migrated.collections, + environments: migrated.environments, + activeEnvironmentId: data.activeEnvironmentId ?? data.environments[0]?.id ?? null, + history: Array.isArray(data.history) ? data.history : [], + settings: { + theme: data.settings?.theme === "light" ? "light" : "dark", + timeoutMs: data.settings?.timeoutMs ?? 30000, + mock: normalizeMockSettings(data.settings?.mock), + ai: normalizeAiSettings(data.settings?.ai), + }, + testCases: Array.isArray(data.testCases) ? data.testCases : [], + testRuns: Array.isArray(data.testRuns) ? data.testRuns.slice(0, 50) : [], + schedules: Array.isArray(data.schedules) ? data.schedules : [], + }; +} + function loadPersisted(): WorkspaceData | null { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; - const data = JSON.parse(raw) as Partial; - if (!Array.isArray(data.collections) || !Array.isArray(data.environments)) return null; - const migrated = migrateBaseUrl(sanitizeCollections(data.collections), data.environments); - return { - version: 2, - collections: migrated.collections, - environments: migrated.environments, - activeEnvironmentId: data.activeEnvironmentId ?? data.environments[0]?.id ?? null, - history: Array.isArray(data.history) ? data.history : [], - settings: { - theme: data.settings?.theme === "light" ? "light" : "dark", - timeoutMs: data.settings?.timeoutMs ?? 30000, - mock: normalizeMockSettings(data.settings?.mock), - ai: normalizeAiSettings(data.settings?.ai), - }, - testCases: Array.isArray(data.testCases) ? data.testCases : [], - testRuns: Array.isArray(data.testRuns) ? data.testRuns.slice(0, 50) : [], - schedules: Array.isArray(data.schedules) ? data.schedules : [], - }; + return normalizeWorkspaceData(JSON.parse(raw) as Partial); } catch { return null; } @@ -291,28 +330,35 @@ function loadPersisted(): WorkspaceData | null { let saveTimer: ReturnType | null = null; +function snapshotForPersist(state: WorkspaceStore): PersistedShape & { version: number } { + return { + version: 2, + collections: state.collections, + environments: state.environments, + activeEnvironmentId: state.activeEnvironmentId, + history: state.history.slice(0, 100), + settings: state.settings, + testCases: state.testCases, + testRuns: state.testRuns.slice(0, 50), + schedules: state.schedules, + }; +} + function schedulePersist(state: WorkspaceStore): void { if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { try { - const data: PersistedShape & { version: number } = { - version: 2, - collections: state.collections, - environments: state.environments, - activeEnvironmentId: state.activeEnvironmentId, - history: state.history.slice(0, 100), - settings: state.settings, - testCases: state.testCases, - testRuns: state.testRuns.slice(0, 50), - schedules: state.schedules, - }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshotForPersist(state))); } catch (e) { console.warn("[opsxapi] 工作区保存失败", e); } }, 400); } +/** 云端空间同步(去抖 1.5s) */ +let syncTimer: ReturnType | null = null; + + /** 当前启用环境的前置 URL */ export function activeEnvBaseUrl(): string { const s = useWorkspace.getState(); @@ -326,7 +372,29 @@ export function applyThemeToDocument(theme: ThemeMode): void { } export const useWorkspace = create((set, get) => { - const persist = () => schedulePersist(get()); + const persist = () => { + const { activeSpaceId, token } = get().account; + if (activeSpaceId !== null && token) { + if (syncTimer) clearTimeout(syncTimer); + syncTimer = setTimeout(() => void get().syncNow(), 1500); + return; + } + schedulePersist(get()); + }; + + const applyWorkspaceData = (data: WorkspaceData) => { + set({ + collections: data.collections, + environments: data.environments, + activeEnvironmentId: data.activeEnvironmentId, + history: data.history, + settings: data.settings, + testCases: data.testCases, + testRuns: data.testRuns, + schedules: data.schedules, + }); + applyThemeToDocument(data.settings.theme); + }; return { collections: [], @@ -338,6 +406,16 @@ export const useWorkspace = create((set, get) => { testRuns: [], schedules: [], view: "api", + account: { + token: null, + user: null, + teams: [], + spaces: [], + activeSpaceId: null, + syncing: false, + lastSyncAt: null, + syncError: null, + }, openTabIds: [], activeTabId: null, @@ -368,6 +446,116 @@ export const useWorkspace = create((set, get) => { set({ expanded }); // 落盘:确保迁移(旧演示地址)与自愈(补 data)结果写回 localStorage schedulePersist(get()); + // 恢复登录态(令牌失效时静默清除) + const token = loadToken(); + if (token) { + set({ account: { ...get().account, token } }); + void get().refreshAccount().catch(() => { + saveToken(null); + set({ account: { ...get().account, token: null, user: null, teams: [], spaces: [] } }); + }); + } + }, + + async authLogin(email, password) { + const r = await authLoginApi(email.trim(), password); + saveToken(r.token); + set({ account: { ...get().account, token: r.token, user: r.user } }); + await get().refreshAccount(); + }, + + async authRegister(name, email, password) { + const r = await authRegisterApi(name.trim(), email.trim(), password); + saveToken(r.token); + set({ account: { ...get().account, token: r.token, user: r.user } }); + await get().refreshAccount(); + }, + + async authLogout() { + const { token, activeSpaceId } = get().account; + if (activeSpaceId !== null) await get().switchSpace(null); + if (token) void authLogoutApi(token).catch(() => undefined); + saveToken(null); + set({ + account: { + token: null, + user: null, + teams: [], + spaces: [], + activeSpaceId: null, + syncing: false, + lastSyncAt: null, + syncError: null, + }, + }); + }, + + async refreshAccount() { + const { token } = get().account; + if (!token) return; + const r = await fetchMe(token); + set({ + account: { + ...get().account, + user: r.user, + teams: r.teams ?? [], + spaces: r.workspaces ?? [], + }, + }); + }, + + async createTeam(name) { + const { token } = get().account; + if (!token) throw new Error("未登录"); + await createTeamApi(token, name.trim()); + await get().refreshAccount(); + }, + + async addTeamMember(teamId, email) { + const { token } = get().account; + if (!token) throw new Error("未登录"); + await addTeamMemberApi(token, teamId, email.trim()); + await get().refreshAccount(); + }, + + async switchSpace(spaceId) { + const acc = get().account; + if (acc.activeSpaceId === spaceId) return; + if (spaceId === null) { + set({ account: { ...get().account, activeSpaceId: null, syncError: null } }); + const local = loadPersisted() ?? createSeedWorkspace(); + applyWorkspaceData(local); + schedulePersist(get()); + return; + } + if (!acc.token) throw new Error("未登录"); + const ws = await fetchWorkspace(acc.token, spaceId); + set({ account: { ...get().account, activeSpaceId: spaceId } }); + if (!ws.data.trim()) { + // 云端为空:用当前本地数据播种 + await get().syncNow(); + return; + } + const parsed = normalizeWorkspaceData(JSON.parse(ws.data) as Partial); + applyWorkspaceData(parsed ?? createSeedWorkspace()); + }, + + async syncNow() { + const { token, activeSpaceId } = get().account; + if (!token || activeSpaceId === null) return; + set({ account: { ...get().account, syncing: true, syncError: null } }); + try { + const r = await saveWorkspace(token, activeSpaceId, JSON.stringify(snapshotForPersist(get()))); + set({ account: { ...get().account, syncing: false, lastSyncAt: r.updatedAt * 1000 } }); + } catch (e) { + set({ + account: { + ...get().account, + syncing: false, + syncError: e instanceof Error ? e.message : String(e), + }, + }); + } }, addCollection(name) { diff --git a/packages/ui/tsconfig.tsbuildinfo b/packages/ui/tsconfig.tsbuildinfo new file mode 100644 index 0000000..97a6d29 --- /dev/null +++ b/packages/ui/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/account.ts","./src/ai.ts","./src/index.ts","./src/scheduler.ts","./src/send.ts","./src/testrunner.ts","./src/workspacestore.ts","./src/components/envbadge.tsx","./src/components/logo.tsx","./src/components/sidebar.tsx","./src/components/tabbar.tsx","./src/components/testingview.tsx","./src/components/topbar.tsx","./src/components/urlpathinput.tsx","./src/components/varhintinput.tsx","./src/components/common.tsx","./src/components/icons.tsx","./src/components/dialogs/aicasedialog.tsx","./src/components/dialogs/exportdialog.tsx","./src/components/dialogs/importdialog.tsx","./src/components/dialogs/scheduledialog.tsx","./src/components/dialogs/workspacedialogs.tsx","./src/components/editors/graphqleditor.tsx","./src/components/editors/httpeditor.tsx","./src/components/editors/mqtteditor.tsx","./src/components/editors/responsepanel.tsx","./src/components/editors/socketioeditor.tsx","./src/components/editors/sseeditor.tsx","./src/components/editors/websocketeditor.tsx","./src/components/editors/livecommon.tsx"],"errors":true,"version":"5.9.3"} \ No newline at end of file