diff --git a/apps/server/data/opsxapi.db b/apps/server/data/opsxapi.db new file mode 100644 index 0000000..cc04c10 Binary files /dev/null and b/apps/server/data/opsxapi.db differ diff --git a/apps/server/data/opsxapi.db-shm b/apps/server/data/opsxapi.db-shm new file mode 100644 index 0000000..3613e95 Binary files /dev/null and b/apps/server/data/opsxapi.db-shm differ diff --git a/apps/server/data/opsxapi.db-wal b/apps/server/data/opsxapi.db-wal new file mode 100644 index 0000000..cecbc57 Binary files /dev/null and b/apps/server/data/opsxapi.db-wal differ diff --git a/apps/server/go.mod b/apps/server/go.mod index 3dc0e7e..f3430c7 100644 --- a/apps/server/go.mod +++ b/apps/server/go.mod @@ -1,6 +1,6 @@ module opsxapi/server -go 1.23.0 +go 1.25.0 require ( github.com/gin-gonic/gin v1.10.0 @@ -13,26 +13,34 @@ require ( github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.23.0 // indirect golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.20.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.15.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect + modernc.org/sqlite v1.59.0 // indirect ) diff --git a/apps/server/go.sum b/apps/server/go.sum index 71fb00b..51a3c95 100644 --- a/apps/server/go.sum +++ b/apps/server/go.sum @@ -9,6 +9,8 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -28,6 +30,8 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -40,15 +44,21 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -78,6 +88,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= @@ -89,5 +101,13 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/apps/server/internal/api/api.go b/apps/server/internal/api/api.go new file mode 100644 index 0000000..284baa4 --- /dev/null +++ b/apps/server/internal/api/api.go @@ -0,0 +1,238 @@ +// Package api:账号 / 团队 / 云端空间同步接口(Bearer Token 鉴权)。 +package api + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "opsxapi/server/internal/store" +) + +type API struct { + store *store.Store +} + +func New(path string) (*API, error) { + s, err := store.Open(path) + if err != nil { + return nil, err + } + return &API{store: s}, nil +} + +func (a *API) Register(r *gin.Engine) { + g := r.Group("/api") + g.POST("/auth/register", a.handleRegister) + g.POST("/auth/login", a.handleLogin) + g.POST("/auth/logout", a.auth(a.handleLogout)) + g.GET("/me", a.auth(a.handleMe)) + g.POST("/teams", a.auth(a.handleCreateTeam)) + g.POST("/teams/:id/members", a.auth(a.handleAddMember)) + g.GET("/workspaces/:id", a.auth(a.handleGetWorkspace)) + g.PUT("/workspaces/:id", a.auth(a.handlePutWorkspace)) +} + +/* ---------------- 中间件 ---------------- */ + +func (a *API) auth(fn func(c *gin.Context, user *store.User)) gin.HandlerFunc { + return func(c *gin.Context) { + h := c.GetHeader("Authorization") + token := strings.TrimSpace(strings.TrimPrefix(h, "Bearer")) + if token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) + return + } + user, err := a.store.UserByToken(token) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已失效"}) + return + } + c.Set("token", token) + fn(c, user) + } +} + +func userID(c *gin.Context) int64 { return c.GetInt64("uid") } + +/* ---------------- 鉴权 ---------------- */ + +func (a *API) handleRegister(c *gin.Context) { + var req struct { + Name string `json:"name"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请填写有效邮箱与至少 6 位密码"}) + return + } + name := strings.TrimSpace(req.Name) + if name == "" { + name = strings.SplitN(req.Email, "@", 2)[0] + } + if _, err := a.store.UserByEmail(strings.ToLower(req.Email)); err == nil { + c.JSON(http.StatusConflict, gin.H{"error": "该邮箱已注册"}) + return + } + user, err := a.store.CreateUser(name, strings.ToLower(req.Email), req.Password) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败:" + err.Error()}) + return + } + token, err := a.store.IssueToken(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "签发令牌失败"}) + return + } + c.JSON(http.StatusCreated, gin.H{"token": token, "user": user}) +} + +func (a *API) handleLogin(c *gin.Context) { + var req struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请填写邮箱与密码"}) + return + } + user, err := a.store.CheckPassword(strings.ToLower(req.Email), req.Password) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码不正确"}) + return + } + token, err := a.store.IssueToken(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "签发令牌失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"token": token, "user": user}) +} + +func (a *API) handleLogout(c *gin.Context, _ *store.User) { + if token, _ := c.Get("token"); token != nil { + _ = a.store.DeleteToken(token.(string)) + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +/* ---------------- 账号概览 ---------------- */ + +func (a *API) handleMe(c *gin.Context, user *store.User) { + teams, err := a.store.TeamsOfUser(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + spaces, err := a.store.WorkspacesOfUser(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + // data 不随列表下发 + for i := range spaces { + spaces[i].Data = "" + } + c.JSON(http.StatusOK, gin.H{"user": user, "teams": teams, "workspaces": spaces, "serverTime": time.Now().Unix()}) +} + +/* ---------------- 团队 ---------------- */ + +func (a *API) handleCreateTeam(c *gin.Context, user *store.User) { + var req struct { + Name string `json:"name" binding:"required,min=1,max=40"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请填写团队名称"}) + return + } + team, err := a.store.CreateTeam(user.ID, strings.TrimSpace(req.Name)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败:" + err.Error()}) + return + } + c.JSON(http.StatusCreated, team) +} + +func (a *API) handleAddMember(c *gin.Context, user *store.User) { + teamID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"}) + return + } + owner, err := a.store.IsTeamOwner(teamID, user.ID) + if err != nil || !owner { + c.JSON(http.StatusForbidden, gin.H{"error": "仅团队创建者可添加成员"}) + return + } + var req struct { + Email string `json:"email" binding:"required,email"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请填写成员邮箱"}) + return + } + member, err := a.store.AddTeamMember(teamID, strings.ToLower(req.Email)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "该邮箱尚未注册 OpsxAPI 账号"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "member": member}) +} + +/* ---------------- 工作区同步 ---------------- */ + +func (a *API) workspaceOf(c *gin.Context, user *store.User) *store.Workspace { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的空间"}) + return nil + } + ok, err := a.store.CanAccessWorkspace(id, user.ID) + if err != nil || !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该空间"}) + return nil + } + ws, err := a.store.WorkspaceByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "空间不存在"}) + return nil + } + return ws +} + +func (a *API) handleGetWorkspace(c *gin.Context, user *store.User) { + ws := a.workspaceOf(c, user) + if ws == nil { + return + } + c.JSON(http.StatusOK, ws) +} + +func (a *API) handlePutWorkspace(c *gin.Context, user *store.User) { + ws := a.workspaceOf(c, user) + if ws == nil { + return + } + var req struct { + Data string `json:"data" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "缺少数据体"}) + return + } + if len(req.Data) > 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