账号与团队:SQLite 云端空间(个人 / 团队共享 + 自动同步)
- 服务端(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→云端编辑自动同步→本地快照隔离)
This commit is contained in:
1 parent
045e8c65c3
commit
aabd20bd75
18 files changed
+1338
-38
No files matched your search
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
-3
@@ -1,6 +1,6 @@
|
|||||||
module opsxapi/server
|
module opsxapi/server
|
||||||
|
|
||||||
go 1.23.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
@@ -13,26 +13,34 @@ require (
|
|||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // 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/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // 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/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // 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/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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/crypto v0.23.0 // indirect
|
golang.org/x/crypto v0.23.0 // indirect
|
||||||
golang.org/x/net v0.25.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
|
golang.org/x/text v0.15.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.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
|
||||||
)
|
)
|
||||||
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
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=
|
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 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
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/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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
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/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 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.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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
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.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 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 账号与团队
|
||||||
|
|
||||||
|
登录后可使用**云端空间**:个人空间「我的空间」与团队共享空间,数据保存在本机服务的 SQLite 数据库(`apps/server/data/opsxapi.db`),不上传任何第三方。
|
||||||
|
|
||||||
|
## 注册 / 登录
|
||||||
|
|
||||||
|
顶栏右上 **登录** 按钮打开弹窗:
|
||||||
|
|
||||||
|
- 注册只需 昵称 + 邮箱 + 密码(≥ 6 位);
|
||||||
|
- 登录态保存在本机,令牌失效会自动清除。
|
||||||
|
|
||||||
|
## 空间切换
|
||||||
|
|
||||||
|
登录后顶栏出现 **用户胶囊**(头像首字母 + 当前空间名 + 同步状态),点击弹出空间列表:
|
||||||
|
|
||||||
|
| 空间 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 本地空间 | 未登录时默认;数据在浏览器 localStorage,不上云 |
|
||||||
|
| 我的空间 | 个人云端空间,注册即有;换浏览器 / 换设备登录即得同一份数据 |
|
||||||
|
| 团队 · ×× | 团队共享空间,成员读写同一份数据 |
|
||||||
|
|
||||||
|
- 首次切入空的云端空间时,会自动把**当前本地数据播种上去**;
|
||||||
|
- 在云端空间内的所有改动(接口、环境、测试用例、设置)**自动同步**(改动后约 1.5 秒),胶囊显示「已同步 / 同步中 / 同步失败」;
|
||||||
|
- 切回本地空间时恢复离开前的本地快照,两端互不覆盖;
|
||||||
|
- 同步策略为**后写覆盖**:多人同时编辑以最后一次保存为准。
|
||||||
|
|
||||||
|
## 团队管理
|
||||||
|
|
||||||
|
用户胶囊旁 **⋯** 菜单 → 团队管理:
|
||||||
|
|
||||||
|
- **新建团队**:创建后自动获得同名共享空间;
|
||||||
|
- **添加成员**:输入已注册成员邮箱(创建者可操作),成员登录后即可看到并切换到该团队空间。
|
||||||
|
|
||||||
|
## 数据与安全
|
||||||
|
|
||||||
|
- 密码加盐哈希后存储;令牌为随机 Bearer Token,可随时退出登录作废;
|
||||||
|
- 云端空间单份数据上限 20MB;
|
||||||
|
- 团队空间权限:全体成员可读写,仅创建者可管理成员;
|
||||||
|
- 不登录不影响任何本地功能。
|
||||||
@@ -123,6 +123,17 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "账号与团队",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"file": "20-account.md",
|
||||||
|
"slug": "account",
|
||||||
|
"title": "账号与团队",
|
||||||
|
"updated": "2026-09-17"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "参考",
|
"name": "参考",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"opsxapi/server/internal/api"
|
||||||
"opsxapi/server/internal/docs"
|
"opsxapi/server/internal/docs"
|
||||||
|
"opsxapi/server/internal/echo"
|
||||||
"opsxapi/server/internal/proxy"
|
"opsxapi/server/internal/proxy"
|
||||||
"opsxapi/server/internal/site"
|
"opsxapi/server/internal/site"
|
||||||
)
|
)
|
||||||
@@ -27,6 +30,14 @@ func NewRouter() *gin.Engine {
|
|||||||
r.GET("/v1/proxy/ws/attach", tunnel.Attach)
|
r.GET("/v1/proxy/ws/attach", tunnel.Attach)
|
||||||
r.POST("/v1/proxy/ws/close", tunnel.Close)
|
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)
|
docs.New().Register(r)
|
||||||
site.New().Register(r)
|
site.New().Register(r)
|
||||||
|
|
||||||
@@ -47,7 +58,7 @@ func localhostCORS() gin.HandlerFunc {
|
|||||||
if _, ok := allowed[strings.TrimSuffix(origin, "/")]; ok {
|
if _, ok := allowed[strings.TrimSuffix(origin, "/")]; ok {
|
||||||
c.Header("Access-Control-Allow-Origin", origin)
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
c.Header("Vary", "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")
|
c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
}
|
}
|
||||||
if c.Request.Method == http.MethodOptions {
|
if c.Request.Method == http.MethodOptions {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -474,6 +474,12 @@
|
|||||||
color: var(--accent-strong);
|
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 胶囊输入(前置 URL 模式) ---------- */
|
||||||
.url-pill {
|
.url-pill {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://127.0.0.1:8787",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
"/v1": {
|
"/v1": {
|
||||||
target: "http://127.0.0.1:8787",
|
target: "http://127.0.0.1:8787",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { SseEditor } from "./components/editors/SseEditor";
|
|||||||
import { WebsocketEditor } from "./components/editors/WebsocketEditor";
|
import { WebsocketEditor } from "./components/editors/WebsocketEditor";
|
||||||
import { ExportDialog } from "./components/dialogs/ExportDialog";
|
import { ExportDialog } from "./components/dialogs/ExportDialog";
|
||||||
import { ImportDialog } from "./components/dialogs/ImportDialog";
|
import { ImportDialog } from "./components/dialogs/ImportDialog";
|
||||||
|
import { AuthDialog, TeamDialog } from "./components/dialogs/AccountDialogs";
|
||||||
import {
|
import {
|
||||||
EnvironmentsDialog,
|
EnvironmentsDialog,
|
||||||
NewItemDialog,
|
NewItemDialog,
|
||||||
@@ -93,6 +94,8 @@ export function OpsxWorkspace() {
|
|||||||
{dialog?.type === "environments" && <EnvironmentsDialog />}
|
{dialog?.type === "environments" && <EnvironmentsDialog />}
|
||||||
{dialog?.type === "settings" && <SettingsDialog tab={dialog.tab} />}
|
{dialog?.type === "settings" && <SettingsDialog tab={dialog.tab} />}
|
||||||
{dialog?.type === "rename" && <RenameDialog id={dialog.id} />}
|
{dialog?.type === "rename" && <RenameDialog id={dialog.id} />}
|
||||||
|
{dialog?.type === "auth" && <AuthDialog />}
|
||||||
|
{dialog?.type === "team" && <TeamDialog />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T>(path: string, token: string | null, init?: RequestInit): Promise<T> {
|
||||||
|
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<AccountTeam>("/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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useWorkspace } from "../workspaceStore";
|
import { useWorkspace } from "../workspaceStore";
|
||||||
import { ContextMenu, type MenuItem } from "./common";
|
import { ContextMenu, cx, type MenuItem } from "./common";
|
||||||
import { EnvBadge } from "./EnvBadge";
|
import { EnvBadge } from "./EnvBadge";
|
||||||
import { Icon, type IconName } from "./icons";
|
import { Icon, type IconName } from "./icons";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
@@ -31,6 +31,11 @@ export function TopBar() {
|
|||||||
const [newMenu, setNewMenu] = useState<{ x: number; y: number } | null>(null);
|
const [newMenu, setNewMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [exportMenu, setExportMenu] = 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 [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(() => {
|
useEffect(() => {
|
||||||
document.title = "OpsxAPI · 接口工作台";
|
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: (
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<Icon name="shield-check" size={13} className="ops-faint" />
|
||||||
|
<span className="truncate">本地空间</span>
|
||||||
|
{account.activeSpaceId === null && <span className="ml-auto pl-3 text-[10px] ops-success">● 当前</span>}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
onClick: () => void switchSpace(null),
|
||||||
|
},
|
||||||
|
...account.spaces.map((sp) => ({
|
||||||
|
label: (
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<Icon name={sp.teamId !== null ? "library" : "shield-check"} size={13} className="ops-faint" />
|
||||||
|
<span className="truncate">{sp.teamId !== null ? `团队 · ${sp.name}` : sp.name}</span>
|
||||||
|
{sp.id === account.activeSpaceId ? (
|
||||||
|
<span className="ml-auto pl-3 text-[10px] ops-success">● 当前</span>
|
||||||
|
) : (
|
||||||
|
<span className="ops-faint ml-auto pl-3 text-[10px]">
|
||||||
|
{new Date(sp.updatedAt * 1000).toLocaleDateString("zh-CN")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
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: <Icon name="arrow-left-right" size={14} />,
|
||||||
|
onClick: () => void syncNow(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{ type: "sep" as const },
|
||||||
|
{
|
||||||
|
label: "团队管理…",
|
||||||
|
icon: <Icon name="library" size={14} />,
|
||||||
|
onClick: () => setDialog({ type: "team" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "退出登录",
|
||||||
|
icon: <Icon name="circle-x" size={14} />,
|
||||||
|
onClick: () => void useWorkspace.getState().authLogout(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
const moreItems: MenuItem[] = [
|
const moreItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
label: "设置…",
|
label: "设置…",
|
||||||
@@ -224,6 +289,46 @@ export function TopBar() {
|
|||||||
|
|
||||||
<div className="mx-1 h-5 w-px" style={{ background: "var(--border)" }} />
|
<div className="mx-1 h-5 w-px" style={{ background: "var(--border)" }} />
|
||||||
|
|
||||||
|
{account.user ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="ops-envswitch"
|
||||||
|
title={`当前空间:${spaceName}\n点击切换本地 / 个人 / 团队空间`}
|
||||||
|
onClick={(e) => {
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
setSpaceMenu((m) => (m ? null : { x: rect.left, y: rect.bottom + 4 }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="ops-user-avatar">{account.user.name.slice(0, 1).toUpperCase()}</span>
|
||||||
|
<span className="max-w-36 truncate">{spaceName}</span>
|
||||||
|
{account.syncing ? (
|
||||||
|
<span className="ops-faint text-[10px]">同步中…</span>
|
||||||
|
) : account.activeSpaceId !== null ? (
|
||||||
|
<span className={cx("text-[10px]", account.syncError ? "ops-danger" : "ops-success")} title={account.syncError ?? undefined}>
|
||||||
|
{account.syncError ? "同步失败" : "已同步"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<Icon name="chevron-down" size={13} className="ops-faint" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-ghost !px-2"
|
||||||
|
title={`${account.user.name}(${account.user.email})`}
|
||||||
|
onClick={(e) => {
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
setAccountMenu({ x: rect.right, y: rect.bottom + 4 });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="ellipsis" size={14} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog({ type: "auth" })} title="登录 / 注册,开启云端空间">
|
||||||
|
<Icon name="shield-check" size={13} /> 登录
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-1 h-5 w-px" style={{ background: "var(--border)" }} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="ops-btn ops-btn-ghost"
|
className="ops-btn ops-btn-ghost"
|
||||||
onClick={() => setTheme(settings.theme === "dark" ? "light" : "dark")}
|
onClick={() => setTheme(settings.theme === "dark" ? "light" : "dark")}
|
||||||
@@ -259,6 +364,14 @@ export function TopBar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ContextMenu
|
||||||
|
menu={spaceMenu ? { x: spaceMenu.x, y: spaceMenu.y, items: spaceItems } : null}
|
||||||
|
onClose={() => setSpaceMenu(null)}
|
||||||
|
/>
|
||||||
|
<ContextMenu
|
||||||
|
menu={accountMenu ? { x: accountMenu.x, y: accountMenu.y, items: accountItems } : null}
|
||||||
|
onClose={() => setAccountMenu(null)}
|
||||||
|
/>
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
menu={moreMenu ? { x: moreMenu.x, y: moreMenu.y, items: moreItems } : null}
|
menu={moreMenu ? { x: moreMenu.x, y: moreMenu.y, items: moreItems } : null}
|
||||||
onClose={() => setMoreMenu(null)}
|
onClose={() => setMoreMenu(null)}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
title={mode === "login" ? "登录" : "注册账号"}
|
||||||
|
onClose={() => setDialog(null)}
|
||||||
|
width="400px"
|
||||||
|
footer={
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<button
|
||||||
|
className="ops-btn ops-btn-ghost !px-2 text-[12px]"
|
||||||
|
onClick={() => {
|
||||||
|
setMode(mode === "login" ? "register" : "login");
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === "login" ? "没有账号?注册" : "已有账号?登录"}
|
||||||
|
</button>
|
||||||
|
<button className="ops-btn ops-btn-primary" disabled={busy} onClick={submit}>
|
||||||
|
{busy ? "请稍候…" : mode === "login" ? "登录" : "注册并登录"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="ops-hint">
|
||||||
|
<Icon name="info" size={14} />
|
||||||
|
<span>账号数据保存在本机服务(SQLite),用于个人 / 团队云端空间同步</span>
|
||||||
|
</div>
|
||||||
|
{mode === "register" && (
|
||||||
|
<div>
|
||||||
|
<label className="ops-label">昵称</label>
|
||||||
|
<input
|
||||||
|
className="ops-input"
|
||||||
|
placeholder="怎么称呼你"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="ops-label">邮箱</label>
|
||||||
|
<input
|
||||||
|
className="ops-input"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="ops-label">密码</label>
|
||||||
|
<input
|
||||||
|
className="ops-input"
|
||||||
|
type="password"
|
||||||
|
placeholder="至少 6 位"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <div className="ops-danger text-[12px]">{error}</div>}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 团队管理 ---------------- */
|
||||||
|
|
||||||
|
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<Record<number, string>>({});
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
title="团队管理"
|
||||||
|
onClose={() => setDialog(null)}
|
||||||
|
width="560px"
|
||||||
|
footer={
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<span className="ops-faint text-[11px]">团队成员共享同一份云端空间,切换即协同</span>
|
||||||
|
<button className="ops-btn ops-btn-primary" onClick={() => setDialog(null)}>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="ops-label">新建团队</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="ops-input flex-1"
|
||||||
|
placeholder="团队名称,如:支付网关组"
|
||||||
|
value={teamName}
|
||||||
|
onChange={(e) => setTeamName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && doCreate()}
|
||||||
|
/>
|
||||||
|
<button className="ops-btn ops-btn-secondary" disabled={busy} onClick={doCreate}>
|
||||||
|
<Icon name="plus" size={13} /> 创建
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{account.teams.length === 0 ? (
|
||||||
|
<div className="ops-faint py-4 text-center text-xs">还没有团队,创建一个即可获得团队共享空间</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{account.teams.map((t) => (
|
||||||
|
<div key={t.id} className="rounded-xl px-3 py-2.5" style={{ border: "1px solid var(--border)" }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="grid h-6 w-6 place-items-center rounded-md" style={{ background: "var(--panel-2)" }}>
|
||||||
|
<Icon name="library" size={13} />
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] font-medium">{t.name}</span>
|
||||||
|
<span className={cx("ops-chip")} style={{ padding: "1px 8px", fontSize: 10 }}>
|
||||||
|
{t.role === "owner" ? "创建者" : "成员"}
|
||||||
|
</span>
|
||||||
|
<span className="ops-faint ml-auto text-[11px]">创建者 {t.owner}</span>
|
||||||
|
</div>
|
||||||
|
{t.role === "owner" && (
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
className="ops-input ops-input-sm flex-1"
|
||||||
|
placeholder="成员邮箱(需已注册)"
|
||||||
|
value={inviteEmail[t.id] ?? ""}
|
||||||
|
onChange={(e) => setInviteEmail((m) => ({ ...m, [t.id]: e.target.value }))}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && doInvite(t.id)}
|
||||||
|
/>
|
||||||
|
<button className="ops-btn ops-btn-secondary !py-1 text-[12px]" disabled={busy} onClick={() => doInvite(t.id)}>
|
||||||
|
添加成员
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="ops-danger text-[12px]">{error}</div>}
|
||||||
|
{okMsg && <div className="ops-success text-[12px]">{okMsg}</div>}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,18 @@ import {
|
|||||||
normalizeMockSettings,
|
normalizeMockSettings,
|
||||||
uniqueName,
|
uniqueName,
|
||||||
} from "@opsxapi/core";
|
} 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";
|
import { create } from "zustand";
|
||||||
|
|
||||||
const STORAGE_KEY = "opsxapi.workspace.v2";
|
const STORAGE_KEY = "opsxapi.workspace.v2";
|
||||||
@@ -75,7 +87,9 @@ export type Dialog =
|
|||||||
| { type: "environments" }
|
| { type: "environments" }
|
||||||
| { type: "settings"; tab?: "appearance" | "network" | "mock" | "ai" | "data" | "about" }
|
| { type: "settings"; tab?: "appearance" | "network" | "mock" | "ai" | "data" | "about" }
|
||||||
| { type: "rename"; id: string }
|
| { 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 {
|
export function emptyRuntime(): RequestRuntime {
|
||||||
return {
|
return {
|
||||||
@@ -113,8 +127,29 @@ type WorkspaceStore = PersistedShape & {
|
|||||||
/** 主视图:接口工作台 / 自动化测试 */
|
/** 主视图:接口工作台 / 自动化测试 */
|
||||||
view: "api" | "testing";
|
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;
|
init(): void;
|
||||||
setView(view: "api" | "testing"): void;
|
setView(view: "api" | "testing"): void;
|
||||||
|
authLogin(email: string, password: string): Promise<void>;
|
||||||
|
authRegister(name: string, email: string, password: string): Promise<void>;
|
||||||
|
authLogout(): Promise<void>;
|
||||||
|
refreshAccount(): Promise<void>;
|
||||||
|
createTeam(name: string): Promise<void>;
|
||||||
|
addTeamMember(teamId: number, email: string): Promise<void>;
|
||||||
|
/** 切换空间:null=本地;服务端空间为空时用当前数据播种 */
|
||||||
|
switchSpace(spaceId: number | null): Promise<void>;
|
||||||
|
syncNow(): Promise<void>;
|
||||||
|
|
||||||
addCollection(name?: string): string;
|
addCollection(name?: string): string;
|
||||||
renameNode(id: string, name: string): void;
|
renameNode(id: string, name: string): void;
|
||||||
@@ -261,29 +296,33 @@ function migrateBaseUrl(
|
|||||||
return { collections: collections.map((c) => ({ ...c, children: fix(c.children) })), environments: envs };
|
return { collections: collections.map((c) => ({ ...c, children: fix(c.children) })), environments: envs };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 任意来源(本地/云端)的工作区数据归一化 */
|
||||||
|
function normalizeWorkspaceData(data: Partial<WorkspaceData>): 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 {
|
function loadPersisted(): WorkspaceData | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const data = JSON.parse(raw) as Partial<WorkspaceData>;
|
return normalizeWorkspaceData(JSON.parse(raw) as Partial<WorkspaceData>);
|
||||||
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 : [],
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -291,28 +330,35 @@ function loadPersisted(): WorkspaceData | null {
|
|||||||
|
|
||||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
let saveTimer: ReturnType<typeof setTimeout> | 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 {
|
function schedulePersist(state: WorkspaceStore): void {
|
||||||
if (saveTimer) clearTimeout(saveTimer);
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
saveTimer = setTimeout(() => {
|
saveTimer = setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
const data: PersistedShape & { version: number } = {
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshotForPersist(state)));
|
||||||
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));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[opsxapi] 工作区保存失败", e);
|
console.warn("[opsxapi] 工作区保存失败", e);
|
||||||
}
|
}
|
||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 云端空间同步(去抖 1.5s) */
|
||||||
|
let syncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
|
||||||
/** 当前启用环境的前置 URL */
|
/** 当前启用环境的前置 URL */
|
||||||
export function activeEnvBaseUrl(): string {
|
export function activeEnvBaseUrl(): string {
|
||||||
const s = useWorkspace.getState();
|
const s = useWorkspace.getState();
|
||||||
@@ -326,7 +372,29 @@ export function applyThemeToDocument(theme: ThemeMode): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
export const useWorkspace = create<WorkspaceStore>((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 {
|
return {
|
||||||
collections: [],
|
collections: [],
|
||||||
@@ -338,6 +406,16 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
|||||||
testRuns: [],
|
testRuns: [],
|
||||||
schedules: [],
|
schedules: [],
|
||||||
view: "api",
|
view: "api",
|
||||||
|
account: {
|
||||||
|
token: null,
|
||||||
|
user: null,
|
||||||
|
teams: [],
|
||||||
|
spaces: [],
|
||||||
|
activeSpaceId: null,
|
||||||
|
syncing: false,
|
||||||
|
lastSyncAt: null,
|
||||||
|
syncError: null,
|
||||||
|
},
|
||||||
|
|
||||||
openTabIds: [],
|
openTabIds: [],
|
||||||
activeTabId: null,
|
activeTabId: null,
|
||||||
@@ -368,6 +446,116 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
|||||||
set({ expanded });
|
set({ expanded });
|
||||||
// 落盘:确保迁移(旧演示地址)与自愈(补 data)结果写回 localStorage
|
// 落盘:确保迁移(旧演示地址)与自愈(补 data)结果写回 localStorage
|
||||||
schedulePersist(get());
|
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<WorkspaceData>);
|
||||||
|
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) {
|
addCollection(name) {
|
||||||
|
|||||||
@@ -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"}
|
||||||
Reference in new issue
Block a user