diff --git a/Makefile b/Makefile index 646e064..039979c 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,18 @@ -.PHONY: build run test clean fmt lint +.PHONY: build run test clean fmt lint frontend BINARY_NAME=openteam BUILD_DIR=bin BACKEND_DIR=backend # Build -build: - cd $(BACKEND_DIR) && CGO_ENABLED=1 go build -o ../$(BUILD_DIR)/$(BINARY_NAME) ./cmd/openteam +build: frontend + cd $(BACKEND_DIR) && CGO_ENABLED=0 go build -o ../$(BUILD_DIR)/$(BINARY_NAME) ./cmd/openteam + +# Build frontend and copy dist +frontend: + cd frontend && pnpm install && pnpm build + rm -rf $(BACKEND_DIR)/cmd/openteam/dist + cp -r frontend/dist $(BACKEND_DIR)/cmd/openteam/dist # Run run: build diff --git a/backend/go.mod b/backend/go.mod index 64126a5..a874a4f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module opencatd-open -go 1.23.2 +go 1.25.0 require ( github.com/gin-contrib/cors v1.7.2 @@ -12,15 +12,15 @@ require ( github.com/google/wire v0.6.0 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 + github.com/ncruces/go-sqlite3/gormlite v0.34.0 github.com/pkoukk/tiktoken-go v0.1.7 github.com/sashabaranov/go-openai v1.42.0 github.com/spf13/cobra v1.9.1 - golang.org/x/crypto v0.37.0 + golang.org/x/crypto v0.50.0 golang.org/x/time v0.10.0 gorm.io/driver/mysql v1.5.7 gorm.io/driver/postgres v1.5.11 - gorm.io/driver/sqlite v1.6.0 - gorm.io/gorm v1.30.0 + gorm.io/gorm v1.31.1 ) require ( @@ -52,20 +52,22 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mitchellh/mapstructure v1.5.0 // 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-sqlite3 v0.34.0 // indirect + github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300 // indirect + github.com/ncruces/julianday v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/x448/float16 v0.8.4 // indirect golang.org/x/arch v0.16.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index eb69816..7834141 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -91,8 +91,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -100,6 +98,14 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w 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-sqlite3 v0.34.0 h1:q2I6wHTLWIoz6ehYkKdG5dGQc66eJv7ZGnekhvuMfK8= +github.com/ncruces/go-sqlite3 v0.34.0/go.mod h1:qpBxsSdGPnO9K5OExuv5GEsrGQ7Rk6JsJFH6wn2DwwU= +github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300 h1:cRdxCt3BDfMu0vfSdoqaAPD+dzIXPkGREjqyZMLN2Ak= +github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300/go.mod h1:R2kJLPoSA/GBX/b8x7zwOq/KLAw6rLMY1l3Hi76SQIo= +github.com/ncruces/go-sqlite3/gormlite v0.34.0 h1:QLlOy/i7OabsFUQ+d5KyXmq2hw9sMh/CRW435+eQMRY= +github.com/ncruces/go-sqlite3/gormlite v0.34.0/go.mod h1:CMv+6YhqLmPBXYACiQtrWA0q/JLIMTKB4E65SUfLgF0= +github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= +github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw= @@ -141,8 +147,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -154,15 +160,15 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -173,8 +179,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -188,8 +194,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -212,9 +218,7 @@ gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= -gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= -gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= -gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index c0d0225..9917986 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -37,13 +37,33 @@ func NewHandler(db *gorm.DB) *Handler { } } +// --- Helpers --- + +func userToResponse(user *store.User) gin.H { + roleNum := 1 + if user.Role == store.RoleAdmin { + roleNum = 10 + } + return gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "role": roleNum, + "status": user.Status, + "balance": user.Balance, + "created_at": user.CreatedAt, + "updated_at": user.UpdatedAt, + "last_login_at": user.LastLoginAt, + } +} + // --- Auth --- func (h *Handler) Register(c *gin.Context) { var req struct { Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` - Email string `json:"email" binding:"required,email"` + Email string `json:"email" binding:"omitempty,email"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -60,9 +80,13 @@ func (h *Handler) Register(c *gin.Context) { } hash := crypto.Sha256Hex(req.Password) + email := req.Email + if email == "" { + email = req.Username + "@placeholder.local" + } user := &store.User{ Username: req.Username, - Email: req.Email, + Email: email, PasswordHash: hash, Role: role, Status: store.UserStatusActive, @@ -127,34 +151,121 @@ func (h *Handler) Me(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) return } - // Map role string to number for frontend compatibility - roleNum := 1 // default user - if user.Role == store.RoleAdmin { - roleNum = 10 - } c.JSON(http.StatusOK, gin.H{ "code": 200, - "data": gin.H{ - "id": user.ID, - "username": user.Username, - "email": user.Email, - "role": roleNum, - "status": user.Status, - }, + "data": userToResponse(user), }) } +func (h *Handler) UpdateProfile(c *gin.Context) { + userID, _ := c.Get("user_id") + user, err := h.userDAO.GetByID(userID.(uint64)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + var req struct { + Email string `json:"email"` + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Email != "" { + user.Email = req.Email + } + + if err := h.userDAO.Update(user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "profile updated"}) +} + +func (h *Handler) UpdatePassword(c *gin.Context) { + userID, _ := c.Get("user_id") + user, err := h.userDAO.GetByID(userID.(uint64)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + var req struct { + Password string `json:"password" binding:"required"` + NewPassword string `json:"newpassword" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Verify old password + oldHash := crypto.Sha256Hex(req.Password) + if user.PasswordHash != oldHash { + c.JSON(http.StatusBadRequest, gin.H{"error": "incorrect password"}) + return + } + + // Update to new password + user.PasswordHash = crypto.Sha256Hex(req.NewPassword) + if err := h.userDAO.Update(user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "password updated"}) +} + // --- Users --- func (h *Handler) ListUsers(c *gin.Context) { + // Support both limit/offset and pageSize/page parameters limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + + // If pageSize/page are provided, use them instead + if pageSize := c.Query("pageSize"); pageSize != "" { + if size, err := strconv.Atoi(pageSize); err == nil && size > 0 { + limit = size + } + } + if page := c.Query("page"); page != "" { + if p, err := strconv.Atoi(page); err == nil && p > 0 { + offset = (p - 1) * limit + } + } + users, total, err := h.userDAO.List(limit, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"data": users, "total": total}) + + data := make([]gin.H, len(users)) + for i, u := range users { + data[i] = userToResponse(u) + } + c.JSON(http.StatusOK, gin.H{"data": data, "total": total}) +} + +func (h *Handler) GetUser(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + user, err := h.userDAO.GetByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "data": userToResponse(user), + }) } func (h *Handler) CreateUser(c *gin.Context) { @@ -188,7 +299,87 @@ func (h *Handler) CreateUser(c *gin.Context) { return } - c.JSON(http.StatusOK, user) + c.JSON(http.StatusOK, userToResponse(user)) +} + +func (h *Handler) UpdateUser(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + user, err := h.userDAO.GetByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + var req struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Username != "" { + user.Username = req.Username + } + if req.Email != "" { + user.Email = req.Email + } + if req.Password != "" { + user.PasswordHash = crypto.Sha256Hex(req.Password) + } + if req.Role != "" { + user.Role = req.Role + } + if req.Status != "" { + user.Status = req.Status + } + + if err := h.userDAO.Update(user); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, userToResponse(user)) +} + +func (h *Handler) BatchUsers(c *gin.Context) { + option := c.Param("option") + var req struct { + IDs []uint64 `json:"ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + for _, id := range req.IDs { + user, err := h.userDAO.GetByID(id) + if err != nil { + continue + } + + switch option { + case "enable": + user.Status = store.UserStatusActive + case "disable": + user.Status = store.UserStatusDisabled + case "delete": + h.userDAO.Delete(id) + continue + } + h.userDAO.Update(user) + } + + c.JSON(http.StatusOK, gin.H{"message": "batch operation completed"}) } func (h *Handler) DeleteUser(c *gin.Context) { @@ -208,8 +399,22 @@ func (h *Handler) DeleteUser(c *gin.Context) { func (h *Handler) ListApiKeys(c *gin.Context) { userID, _ := c.Get("user_id") + // Support both limit/offset and pageSize/page parameters limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + + // If pageSize/page are provided, use them instead + if pageSize := c.Query("pageSize"); pageSize != "" { + if size, err := strconv.Atoi(pageSize); err == nil && size > 0 { + limit = size + } + } + if page := c.Query("page"); page != "" { + if p, err := strconv.Atoi(page); err == nil && p > 0 { + offset = (p - 1) * limit + } + } + keys, total, err := h.apiKeyDAO.ListByUserID(userID.(uint64), limit, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -218,6 +423,20 @@ func (h *Handler) ListApiKeys(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": keys, "total": total}) } +func (h *Handler) GetApiKey(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + key, err := h.apiKeyDAO.GetByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "key not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": key}) +} + func (h *Handler) CreateApiKey(c *gin.Context) { var req struct { Name string `json:"name" binding:"required"` @@ -236,7 +455,8 @@ func (h *Handler) CreateApiKey(c *gin.Context) { UserID: userID.(uint64), Name: req.Name, KeyHash: apikey.Hash(keyValue), - KeyPrefix: keyValue[:8], + KeyPlain: keyValue, + KeyPrefix: keyValue[:12], QuotaTokensPerDay: req.QuotaTokensPerDay, QuotaRequestsPerDay: req.QuotaRequestsPerDay, Status: store.KeyStatusActive, @@ -249,10 +469,80 @@ func (h *Handler) CreateApiKey(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "key": keyValue, - "id": key.ID, + "data": key, }) } +func (h *Handler) UpdateApiKey(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + key, err := h.apiKeyDAO.GetByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "key not found"}) + return + } + + var req struct { + Name string `json:"name"` + QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"` + QuotaRequestsPerDay *int `json:"quota_requests_per_day"` + AllowedModels []string `json:"allowed_models"` + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Name != "" { + key.Name = req.Name + } + if req.QuotaTokensPerDay != nil { + key.QuotaTokensPerDay = req.QuotaTokensPerDay + } + if req.QuotaRequestsPerDay != nil { + key.QuotaRequestsPerDay = req.QuotaRequestsPerDay + } + if req.AllowedModels != nil { + key.AllowedModels = req.AllowedModels + } + if req.Status != "" { + key.Status = req.Status + } + + if err := h.apiKeyDAO.Update(key); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": key}) +} + +func (h *Handler) BatchApiKeys(c *gin.Context) { + option := c.Param("option") + var req struct { + IDs []uint64 `json:"ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + switch option { + case "delete": + if err := h.apiKeyDAO.BatchDelete(req.IDs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + c.JSON(http.StatusOK, gin.H{"message": "batch operation completed"}) +} + func (h *Handler) DeleteApiKey(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { @@ -269,8 +559,22 @@ func (h *Handler) DeleteApiKey(c *gin.Context) { // --- Channels --- func (h *Handler) ListChannels(c *gin.Context) { + // Support both limit/offset and pageSize/page parameters limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + + // If pageSize/page are provided, use them instead + if pageSize := c.Query("pageSize"); pageSize != "" { + if size, err := strconv.Atoi(pageSize); err == nil && size > 0 { + limit = size + } + } + if page := c.Query("page"); page != "" { + if p, err := strconv.Atoi(page); err == nil && p > 0 { + offset = (p - 1) * limit + } + } + channels, total, err := h.channelDAO.List(limit, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -401,8 +705,22 @@ func (h *Handler) DeleteChannel(c *gin.Context) { // --- Models --- func (h *Handler) ListModels(c *gin.Context) { + // Support both limit/offset and pageSize/page parameters limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + + // If pageSize/page are provided, use them instead + if pageSize := c.Query("pageSize"); pageSize != "" { + if size, err := strconv.Atoi(pageSize); err == nil && size > 0 { + limit = size + } + } + if page := c.Query("page"); page != "" { + if p, err := strconv.Atoi(page); err == nil && p > 0 { + offset = (p - 1) * limit + } + } + models, total, err := h.modelDAO.List(limit, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/backend/internal/store/db.go b/backend/internal/store/db.go index b6cb855..b0c6cbd 100644 --- a/backend/internal/store/db.go +++ b/backend/internal/store/db.go @@ -6,9 +6,9 @@ import ( "opencatd-open/pkg/config" _ "github.com/lib/pq" + "github.com/ncruces/go-sqlite3/gormlite" "gorm.io/driver/mysql" "gorm.io/driver/postgres" - "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -52,7 +52,7 @@ func sqliteDialector(dsn string) gorm.Dialector { if dsn == "" { dsn = "opencatd.db" } - return sqlite.Open(dsn) + return gormlite.Open(dsn) } func postgresDialector(dsn string) gorm.Dialector { diff --git a/backend/internal/store/models.go b/backend/internal/store/models.go index c6a8a52..ea1ee1e 100644 --- a/backend/internal/store/models.go +++ b/backend/internal/store/models.go @@ -58,6 +58,7 @@ type APIKey struct { UserID uint64 `gorm:"index;not null" json:"user_id"` Name string `gorm:"size:64;not null" json:"name"` KeyHash string `gorm:"uniqueIndex;size:64;not null" json:"-"` + KeyPlain string `gorm:"size:255;not null" json:"key_plain"` KeyPrefix string `gorm:"size:32;not null" json:"key_prefix"` QuotaTokensPerDay *int64 `json:"quota_tokens_per_day,omitempty"` QuotaRequestsPerDay *int `json:"quota_requests_per_day,omitempty"` diff --git a/backend/router/setRouter.go b/backend/router/setRouter.go index 0f22750..c43eb3e 100644 --- a/backend/router/setRouter.go +++ b/backend/router/setRouter.go @@ -80,16 +80,24 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) { // User profile apiGroup.GET("/me", apiHandler.Me) apiGroup.GET("/profile", apiHandler.Me) + apiGroup.POST("/profile/update", apiHandler.UpdateProfile) + apiGroup.POST("/profile/update/password", apiHandler.UpdatePassword) // User management (admin) apiGroup.GET("/users", apiHandler.ListUsers) + apiGroup.GET("/users/:id", apiHandler.GetUser) apiGroup.POST("/users", apiHandler.CreateUser) + apiGroup.PUT("/users/:id", apiHandler.UpdateUser) apiGroup.DELETE("/users/:id", apiHandler.DeleteUser) + apiGroup.POST("/users/batch/:option", apiHandler.BatchUsers) // API Key management apiGroup.GET("/keys", apiHandler.ListApiKeys) + apiGroup.GET("/keys/:id", apiHandler.GetApiKey) apiGroup.POST("/keys", apiHandler.CreateApiKey) + apiGroup.PUT("/keys/:id", apiHandler.UpdateApiKey) apiGroup.DELETE("/keys/:id", apiHandler.DeleteApiKey) + apiGroup.POST("/keys/batch/:option", apiHandler.BatchApiKeys) // Channel management apiGroup.GET("/channels", apiHandler.ListChannels) diff --git a/frontend/src/components/dashboard/BreadcrumbHeader.vue b/frontend/src/components/dashboard/BreadcrumbHeader.vue index 8714b68..ec9bdd0 100644 --- a/frontend/src/components/dashboard/BreadcrumbHeader.vue +++ b/frontend/src/components/dashboard/BreadcrumbHeader.vue @@ -41,15 +41,15 @@ const route = useRoute(); const crumbsByRouteName: Record = { // 管理后台列表页:区域根 User: [{ label: '管理后台', path: '/dashboard/manager/users' }], - ApiKey: [{ label: '管理后台', path: '/dashboard/manager/users' }], + Channels: [{ label: '管理后台', path: '/dashboard/manager/users' }], // 详情页:区域根 / 列表页(末级为当前页标题) UserView: [ { label: '管理后台', path: '/dashboard/manager/users' }, { label: '用户管理', path: '/dashboard/manager/users' }, ], - ApiKeyView: [ + ChannelView: [ { label: '管理后台', path: '/dashboard/manager/users' }, - { label: '渠道管理', path: '/dashboard/manager/keys' }, + { label: '渠道管理', path: '/dashboard/manager/channels' }, ], }; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 3fe063d..d69d2a4 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -8,7 +8,8 @@ const router = createRouter({ router.beforeEach((to, from, next) => { const isAuthenticated = localStorage.getItem('token') - if (to.meta.requiresAuth && !isAuthenticated) { + const requiresAuth = to.matched.some(record => record.meta.requiresAuth) + if (requiresAuth && !isAuthenticated) { next('/login') } else { next() diff --git a/frontend/src/stores/channel.ts b/frontend/src/stores/channel.ts new file mode 100644 index 0000000..febebfc --- /dev/null +++ b/frontend/src/stores/channel.ts @@ -0,0 +1,138 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import type { AxiosResponse } from 'axios'; +import request from '@/api/client'; + +export type Channel = { + id: number + name: string + provider: string + base_url: string + weight: number + priority: number + timeout_ms: number + max_concurrency: number + health_status: string + enabled: boolean + formats?: string[] + created_at?: string + updated_at?: string + selected?: boolean + [key: string]: unknown +} + +export type NewChannelPayload = { + name: string + provider: string + base_url: string + api_key: string + priority?: number + weight?: number + formats?: string[] +} + +export const useChannelStore = defineStore('channel', () => { + const loading = ref(false); + const error = ref(null); + const totalChannels = ref(0); + const channels = ref([]); + const channel = ref(null); + + const fetchChannels = async (pageSize = 20, page = 1) => { + loading.value = true; + error.value = null; + try { + const response = await request.get('/channels', { + params: { pageSize, page }, + }); + channels.value = response.data.data ?? []; + totalChannels.value = response.data.total ?? 0; + } catch (err: any) { + error.value = err.response?.data?.error || 'Failed to fetch channels'; + throw err; + } finally { + loading.value = false; + } + }; + + const fetchChannel = async (id: number | string) => { + loading.value = true; + error.value = null; + try { + const response = await request.get(`/channels/${id}`); + channel.value = response.data.data; + } catch (err: any) { + error.value = err.response?.data?.error || 'Failed to fetch channel'; + throw err; + } finally { + loading.value = false; + } + }; + + const createChannel = async (data: NewChannelPayload) => { + loading.value = true; + error.value = null; + try { + const response: AxiosResponse = await request.post('/channels', data); + return response; + } catch (err: any) { + error.value = err.response?.data?.error || 'Failed to create channel'; + throw err; + } finally { + loading.value = false; + } + }; + + const updateChannel = async (id: number | string, data: Partial & { api_key?: string }) => { + loading.value = true; + error.value = null; + try { + const response: AxiosResponse = await request.put(`/channels/${id}`, data); + return response; + } catch (err: any) { + error.value = err.response?.data?.error || 'Failed to update channel'; + throw err; + } finally { + loading.value = false; + } + }; + + const deleteChannel = async (id: number | string) => { + loading.value = true; + error.value = null; + try { + const response: AxiosResponse = await request.delete(`/channels/${id}`); + return response; + } catch (err: any) { + error.value = err.response?.data?.error || 'Failed to delete channel'; + throw err; + } finally { + loading.value = false; + } + }; + + const batchChannels = async (option: string, ids: (number | string)[]) => { + loading.value = true; + error.value = null; + try { + const response: AxiosResponse = await request.delete(`/channels/batch/${option}`, { data: { ids } }); + return response; + } catch (err: any) { + error.value = err.response?.data?.error || 'Batch operation failed'; + throw err; + } finally { + loading.value = false; + } + }; + + return { + loading, error, + channel, channels, totalChannels, + fetchChannels, + fetchChannel, + createChannel, + updateChannel, + deleteChannel, + batchChannels, + }; +}); diff --git a/frontend/src/stores/key.ts b/frontend/src/stores/key.ts index e8214cd..27a9417 100644 --- a/frontend/src/stores/key.ts +++ b/frontend/src/stores/key.ts @@ -24,8 +24,8 @@ export const useKeyStore = defineStore('key', () => { }, }); - keys.value = response.data.data?.keys ?? []; - totalKeys.value = response.data.data?.total ?? 0; + keys.value = response.data.data ?? []; + totalKeys.value = response.data.total ?? 0; } catch (err: any) { error.value = err.response?.data?.error || '获取ApiKeys失败'; throw error diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts index b8bb76e..ac5227d 100644 --- a/frontend/src/stores/user.ts +++ b/frontend/src/stores/user.ts @@ -37,8 +37,8 @@ export const useUserStore = defineStore('user', () => { active, }, }); - users.value = response.data.data?.users ?? []; - totalUsers.value = response.data.data?.total ?? 0; + users.value = response.data.data ?? []; + totalUsers.value = response.data.total ?? 0; } catch (err: any) { error.value = err.response?.data?.error || '获取用户列表失败'; throw err; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a732a22..3fbf1eb 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -8,7 +8,8 @@ export type UserInfo = { avatar_url?: string avatar?: string role: number - active: boolean + status?: string + active?: boolean email_verified?: boolean timezone?: string language?: string @@ -41,10 +42,23 @@ export type TokenInfo = { export type ApiKey = { id: number - type: string + user_id?: number name: string + key_hash?: string + key_plain?: string + key_prefix?: string + quota_tokens_per_day?: number + quota_requests_per_day?: number + allowed_models?: string[] + expires_at?: number + status: string + last_used_at?: number + created_at?: number + updated_at?: number + // 兼容旧字段 + type?: string apikey?: string - active: boolean + active?: boolean endpoint?: string resource_name?: string api_secret?: string @@ -85,17 +99,8 @@ export type TokenPayload = { export type NewApiKeyPayload = { name: string - type: string - apikey: string - active: boolean - endpoint?: string - resource_name?: string - api_secret?: string - model_prefix?: string - model_alias?: string - parameters?: string - support_models?: string - support_models_array?: string[] + quota_tokens_per_day?: number + quota_requests_per_day?: number } export type NewUserPayload = { @@ -104,6 +109,7 @@ export type NewUserPayload = { email?: string name?: string role?: number + status?: string active?: boolean quota?: number unlimited_quota?: boolean diff --git a/frontend/src/utils/router_menu.ts b/frontend/src/utils/router_menu.ts index a210b8a..eab1495 100644 --- a/frontend/src/utils/router_menu.ts +++ b/frontend/src/utils/router_menu.ts @@ -38,7 +38,7 @@ export const routes: RouteRecordRaw[] = [ redirect: '/dashboard/overview', children: [ { path: 'overview', name: 'Overview', component: () => import('@/views/dashboard/Overview.vue'), meta: { title: '仪表盘' } }, - { path: 'tokens', name: 'Tokens', component: () => import('@/views/dashboard/Tokens.vue'), meta: { title: 'API Keys' } }, + { path: 'apikeys', name: 'ApiKeys', component: () => import('@/views/dashboard/ApiKeys.vue'), meta: { title: 'API Keys' } }, { path: 'manager', name: 'Manager', @@ -48,8 +48,8 @@ export const routes: RouteRecordRaw[] = [ { path: 'users', name: 'User', component: () => import('@/views/dashboard/User.vue'), meta: { title: '用户管理' } }, { path: 'users/new', name: 'UserNew', component: () => import('@/views/dashboard/UserNew.vue'), meta: { title: '新建用户' } }, { path: 'users/view', name: 'UserView', component: () => import('@/views/dashboard/UserView.vue'), meta: { title: '用户详情' } }, - { path: 'keys', name: 'ApiKey', component: () => import('@/views/dashboard/Keys.vue'), meta: { title: '渠道管理' } }, - { path: 'keys/view', name: 'ApiKeyView', component: () => import('@/views/dashboard/KeyView.vue'), meta: { title: '渠道详情' } }, + { path: 'channels', name: 'Channels', component: () => import('@/views/dashboard/Keys.vue'), meta: { title: '渠道管理' } }, + { path: 'channels/view', name: 'ChannelView', component: () => import('@/views/dashboard/KeyView.vue'), meta: { title: '渠道详情' } }, ], }, { @@ -68,12 +68,12 @@ export const routes: RouteRecordRaw[] = [ // 控制台菜单(所有登录用户) export const consoleMenu: MenuLink[] = [ { label: '仪表盘', to: '/dashboard/overview', icon: GaugeIcon }, - { label: 'API Keys', to: '/dashboard/tokens', icon: KeyRoundIcon }, + { label: 'API Keys', to: '/dashboard/apikeys', icon: KeyRoundIcon }, { label: '账户设置', to: '/dashboard/settings/profile', icon: SettingsIcon }, ] // 管理后台菜单(role >= 10) export const adminMenu: MenuLink[] = [ { label: '用户管理', to: '/dashboard/manager/users', icon: UsersRoundIcon }, - { label: '渠道管理', to: '/dashboard/manager/keys', icon: GlobeIcon }, + { label: '渠道管理', to: '/dashboard/manager/channels', icon: GlobeIcon }, ] diff --git a/frontend/src/views/dashboard/ApiKeys.vue b/frontend/src/views/dashboard/ApiKeys.vue new file mode 100644 index 0000000..8bc1d36 --- /dev/null +++ b/frontend/src/views/dashboard/ApiKeys.vue @@ -0,0 +1,243 @@ + + + diff --git a/frontend/src/views/dashboard/KeyNew.vue b/frontend/src/views/dashboard/KeyNew.vue index 23ec735..555e67e 100644 --- a/frontend/src/views/dashboard/KeyNew.vue +++ b/frontend/src/views/dashboard/KeyNew.vue @@ -1,7 +1,7 @@ diff --git a/frontend/src/views/dashboard/Overview.vue b/frontend/src/views/dashboard/Overview.vue index c123507..a82d8ee 100644 --- a/frontend/src/views/dashboard/Overview.vue +++ b/frontend/src/views/dashboard/Overview.vue @@ -63,8 +63,8 @@
状态
- {{ user?.active ? 'Active' : 'Inactive' }} + :class="user?.status === 'active' ? 'badge-success badge-soft' : 'badge-error badge-soft'"> + {{ user?.status === 'active' ? 'Active' : 'Inactive' }}
diff --git a/frontend/src/views/dashboard/Profile.vue b/frontend/src/views/dashboard/Profile.vue index fecd165..0532aed 100644 --- a/frontend/src/views/dashboard/Profile.vue +++ b/frontend/src/views/dashboard/Profile.vue @@ -24,8 +24,8 @@ @@ -135,28 +102,18 @@ const showAdvancedOptions = ref(false) // Initialize user object const newUser = ref({ - name: '', username: '', email: '', password: '', - role: 0, // Default to Regular User - active: true, // Default to Active - quota: 0, // Default quota value (relevant if not unlimited) - unlimited_quota: true, // Default to unlimited - language: 'en', // Default language + role: 0, }) const resetNewUser = () => { newUser.value = { - name: '', username: '', email: '', password: '', - role: 0, // Default to Regular User - active: true, // Default to Active - quota: 0, // Default quota value (relevant if not unlimited) - unlimited_quota: true, // Default to unlimited - language: 'en', // Default language + role: 0, } } @@ -179,13 +136,8 @@ const createUser = async () => { username: newUser.value.username, password: newUser.value.password, email: newUser.value.email, - name: newUser.value.name || newUser.value.username, // Use username if name is empty - role: newUser.value.role, - active: newUser.value.active, - quota: newUser.value.quota, - unlimited_quota: newUser.value.unlimited_quota, - language: newUser.value.language - }); + role: newUser.value.role === 10 ? 'admin' : 'user', + } as any); if (res.data?.code === 200) { error.value = null; diff --git a/frontend/src/views/dashboard/UserView.vue b/frontend/src/views/dashboard/UserView.vue index bafb499..dfb39d3 100644 --- a/frontend/src/views/dashboard/UserView.vue +++ b/frontend/src/views/dashboard/UserView.vue @@ -21,8 +21,8 @@

{{ user?.name || user?.username }}

- {{ user.active ? 'Active' : 'Inactive' }} + :class="user.status === 'active' ? 'badge-success badge-soft' : 'badge-error badge-soft'"> + {{ user.status === 'active' ? 'Active' : 'Inactive' }} @@ -31,8 +31,8 @@
- + @@ -226,7 +226,7 @@ const loading = computed(() => userStore.loading); // Access loading state // 更新状态 const updateStatus = async (user: UserInfo) => { try { - const action = user.active ? 'enable' : 'disable'; + const action = user.status === 'active' ? 'disable' : 'enable'; const res = await userStore.userOption(action, [user.id]); if (res.data?.code === 200) { setToast(`User ${user.id} ${action} Success`, 'success'); @@ -235,7 +235,6 @@ const updateStatus = async (user: UserInfo) => { } await userStore.refreshUser(user.id); } catch (error: any) { - user.active = !user.active; console.error('状态更新失败:', error); } };