feat: API key management improvements
- Rename routes: /dashboard/tokens → /dashboard/apikeys, /dashboard/manager/keys → /dashboard/manager/channels - Add KeyPlain field to store plaintext API keys for re-viewing - API key list shows masked key (sk-ot-123456****abcd) with eye toggle to reveal - Copy button with 2s feedback on key list - TokenNew shows full key + copy after creation - Increase key prefix display to 12 characters - Fix SQLite driver: replace gorm.io/driver/sqlite with ncruces/go-sqlite3/gormlite - Fix user.status === 'active' checks across frontend views - Add channel store for new channels API - Update Makefile frontend build to work reliably BREAKING CHANGE: Existing API keys created before this change will not show their plaintext value (only prefix visible).
This commit is contained in:
@@ -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
|
||||
|
||||
+11
-9
@@ -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
|
||||
)
|
||||
|
||||
+20
-16
@@ -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=
|
||||
|
||||
+336
-18
@@ -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()})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -41,15 +41,15 @@ const route = useRoute();
|
||||
const crumbsByRouteName: Record<string, Crumb[]> = {
|
||||
// 管理后台列表页:区域根
|
||||
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' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const totalChannels = ref(0);
|
||||
const channels = ref<Channel[]>([]);
|
||||
const channel = ref<Channel | null>(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<Channel> & { 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,
|
||||
};
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+20
-14
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<BreadcrumbHeader />
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm text-base-content/60">API keys authenticate OpenAI-compatible clients with your team.</p>
|
||||
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new API key">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />New API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="overflow-x-auto" v-if="keys.length">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||
<th class="pl-4">Name</th>
|
||||
<th>Key</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Quota/Day</th>
|
||||
<th class="text-right">Requests/Day</th>
|
||||
<th>Created</th>
|
||||
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="key in keys" :key="key.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||
<td class="pl-4 font-medium">{{ key.name }}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-mono text-xs text-base-content/60">
|
||||
{{ isRevealed(key.id) ? key.key_plain : maskedKey(key) }}
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
@click="toggleReveal(key.id)"
|
||||
:aria-label="isRevealed(key.id) ? 'Hide key' : 'Reveal key'"
|
||||
>
|
||||
<EyeOffIcon v-if="isRevealed(key.id)" class="h-3.5 w-3.5" />
|
||||
<EyeIcon v-else class="h-3.5 w-3.5 text-base-content/40" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
:class="copiedKeyId === key.id ? 'text-success' : ''"
|
||||
@click="copyKey(key)"
|
||||
aria-label="Copy key"
|
||||
>
|
||||
<CheckIcon v-if="copiedKeyId === key.id" class="h-3.5 w-3.5" />
|
||||
<ClipboardCopyIcon v-else class="h-3.5 w-3.5 text-base-content/40" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
||||
:class="key.status !== 'active' && 'toggle-error'" :checked="key.status === 'active'"
|
||||
@change="updateStatus(key)" :aria-label="`Toggle key ${key.name}`" />
|
||||
</td>
|
||||
<td class="text-right tabular-nums">
|
||||
<template v-if="key.quota_tokens_per_day">{{ key.quota_tokens_per_day }}</template>
|
||||
<template v-else class="text-base-content/40">—</template>
|
||||
</td>
|
||||
<td class="text-right tabular-nums">
|
||||
<template v-if="key.quota_requests_per_day">{{ key.quota_requests_per_day }}</template>
|
||||
<template v-else class="text-base-content/40">—</template>
|
||||
</td>
|
||||
<td class="tabular-nums text-base-content/70 text-sm">{{ formatDate(key.created_at) }}</td>
|
||||
<td class="pr-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button v-if="key.status !== 'revoked'" class="btn btn-ghost btn-xs btn-square text-error"
|
||||
@click="confirmDeleteKey(key)" aria-label="Revoke key">
|
||||
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
|
||||
<KeyRoundIcon class="h-10 w-10 text-base-content/20" aria-hidden="true" />
|
||||
<h2 class="text-sm font-semibold">No API keys yet</h2>
|
||||
<p class="max-w-xs text-sm text-base-content/60">
|
||||
Create an API key to connect OpenCat, BotGem and other OpenAI-compatible clients.
|
||||
</p>
|
||||
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination v-if="totalItems > 0" :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
|
||||
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" />
|
||||
|
||||
<!-- New key modal -->
|
||||
<dialog ref="modalRef" class="modal">
|
||||
<div class="modal-box max-w-3xl px-0 sm:px-6">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
||||
</form>
|
||||
<TokenNew @closeModal="closeModal" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button aria-label="Close dialog">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import Pagination from '@/components/common/Pagination.vue';
|
||||
import TokenNew from '@/views/dashboard/TokenNew.vue';
|
||||
import { useKeyStore } from '@/stores/key';
|
||||
import { useToast } from '@/composables/toast';
|
||||
import type { ApiKey } from '@/types';
|
||||
import {
|
||||
PlusIcon, TrashIcon, KeyRoundIcon,
|
||||
EyeIcon, EyeOffIcon, ClipboardCopyIcon, CheckIcon
|
||||
} from '@lucide/vue';
|
||||
|
||||
const keyStore = useKeyStore();
|
||||
const { setToast } = useToast();
|
||||
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const totalItems = computed(() => keyStore.totalKeys);
|
||||
const keys = computed(() => keyStore.keys);
|
||||
|
||||
const revealedKeys = ref<Set<number>>(new Set());
|
||||
const copiedKeyId = ref<number | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchKeys();
|
||||
})
|
||||
|
||||
const fetchKeys = async (size?: number, page?: number) => {
|
||||
currentPage.value = page || currentPage.value;
|
||||
await keyStore.fetchKeys(size ?? pageSize.value, currentPage.value);
|
||||
}
|
||||
|
||||
const changePage = async (page: number, size: number) => {
|
||||
if (page == currentPage.value && size == pageSize.value) {
|
||||
return
|
||||
}
|
||||
currentPage.value = page;
|
||||
pageSize.value = size;
|
||||
await fetchKeys();
|
||||
};
|
||||
|
||||
const isRevealed = (id: number) => revealedKeys.value.has(id);
|
||||
|
||||
const toggleReveal = (id: number) => {
|
||||
if (revealedKeys.value.has(id)) {
|
||||
revealedKeys.value.delete(id);
|
||||
} else {
|
||||
revealedKeys.value.add(id);
|
||||
}
|
||||
// Force reactivity update
|
||||
revealedKeys.value = new Set(revealedKeys.value);
|
||||
};
|
||||
|
||||
const maskedKey = (key: ApiKey) => {
|
||||
const plain = key.key_plain;
|
||||
if (!plain) return key.key_prefix + '...';
|
||||
if (plain.length <= 16) return plain.slice(0, 4) + '****' + plain.slice(-4);
|
||||
return plain.slice(0, 12) + '****' + plain.slice(-4);
|
||||
};
|
||||
|
||||
const copyKey = async (key: ApiKey) => {
|
||||
const value = key.key_plain;
|
||||
if (!value) {
|
||||
setToast('Key value not available', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
copiedKeyId.value = key.id;
|
||||
setToast(`Key "${key.name}" copied`, 'success');
|
||||
setTimeout(() => { copiedKeyId.value = null; }, 2000);
|
||||
} catch {
|
||||
setToast('Failed to copy', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (key: any) => {
|
||||
try {
|
||||
const action = key.status === 'active' ? 'disable' : 'enable';
|
||||
const res = await keyStore.keyOption(action, [key.id]);
|
||||
if (res.data?.code === 200) {
|
||||
setToast(`Key ${key.name} has been ${action}`, 'success');
|
||||
}
|
||||
await fetchKeys();
|
||||
} catch (error: any) {
|
||||
console.error('Status update failed:', error);
|
||||
setToast('Status update failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteKey = async (key: any) => {
|
||||
if (confirm(`Revoke key "${key.name}"? This cannot be undone.`)) {
|
||||
await deleteKey(key);
|
||||
}
|
||||
}
|
||||
|
||||
const deleteKey = async (key: any) => {
|
||||
try {
|
||||
const res = await keyStore.keyOption('delete', [key.id]);
|
||||
if (res.data?.code === 200) {
|
||||
setToast(`Key ${key.name} revoked`, 'success');
|
||||
}
|
||||
await fetchKeys();
|
||||
} catch (error: any) {
|
||||
setToast('Failed to revoke key', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString?: string | number): string => {
|
||||
if (!dateString) return '—';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
};
|
||||
|
||||
// Modal
|
||||
const modalRef = ref<HTMLDialogElement | null>(null);
|
||||
const openModal = () => {
|
||||
modalRef.value?.showModal();
|
||||
};
|
||||
const closeModal = async () => {
|
||||
if (modalRef.value) {
|
||||
modalRef.value.close();
|
||||
}
|
||||
await fetchKeys();
|
||||
};
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<header class="mb-4 pr-8">
|
||||
<h2 class="text-lg font-semibold tracking-tight">Create New API Key</h2>
|
||||
<h2 class="text-lg font-semibold tracking-tight">Create New Channel</h2>
|
||||
<p class="mt-0.5 text-sm text-base-content/60">Connect an upstream LLM provider to your team.</p>
|
||||
</header>
|
||||
|
||||
@@ -11,53 +11,48 @@
|
||||
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
|
||||
</div>
|
||||
|
||||
<form class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createApiKey">
|
||||
<form class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createChannel">
|
||||
<div class="card-body gap-5 p-4 sm:p-6">
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="key-name" class="mb-1 block text-sm font-medium">
|
||||
<label for="ch-name" class="mb-1 block text-sm font-medium">
|
||||
Name <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="key-name" name="name" type="text" v-model="newApiKey.name" placeholder="e.g. prod-openai"
|
||||
<input id="ch-name" name="name" type="text" v-model="newChannel.name" placeholder="e.g. prod-openai"
|
||||
autocomplete="off" spellcheck="false" class="input input-bordered w-full" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-type" class="mb-1 block text-sm font-medium">
|
||||
Type <span class="text-error" aria-hidden="true">*</span>
|
||||
<label for="ch-provider" class="mb-1 block text-sm font-medium">
|
||||
Provider <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select id="key-type" name="type" v-model="newApiKey.type" required
|
||||
class="select select-bordered w-full pl-10">
|
||||
<option disabled value="">Select provider</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="azure">Azure</option>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="openai-compatible">OpenAI Compatible</option>
|
||||
</select>
|
||||
<img :src="apiKeyImageUrl(newApiKey.type)" alt="" width="20" height="20"
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full bg-base-200 p-0.5" />
|
||||
</div>
|
||||
<select id="ch-provider" name="provider" v-model="newChannel.provider" required
|
||||
class="select select-bordered w-full">
|
||||
<option disabled value="">Select provider</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="compatible">OpenAI Compatible</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-apikey" class="mb-1 block text-sm font-medium">
|
||||
API Key <span class="text-error" aria-hidden="true">*</span>
|
||||
<label for="ch-base-url" class="mb-1 block text-sm font-medium">
|
||||
Base URL <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="key-apikey" name="apikey" type="text" v-model="newApiKey.apikey"
|
||||
placeholder="sk-…" autocomplete="off" spellcheck="false"
|
||||
<input id="ch-base-url" name="base_url" type="url" v-model="newChannel.base_url"
|
||||
placeholder="https://api.openai.com" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full font-mono text-sm" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-endpoint" class="mb-1 block text-sm font-medium">Endpoint</label>
|
||||
<input id="key-endpoint" name="endpoint" type="url" v-model="newApiKey.endpoint"
|
||||
placeholder="https://api.example.com/v1" spellcheck="false"
|
||||
class="input input-bordered w-full font-mono text-sm" />
|
||||
<label for="ch-api-key" class="mb-1 block text-sm font-medium">
|
||||
API Key <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="ch-api-key" name="api_key" type="password" v-model="newChannel.api_key"
|
||||
placeholder="sk-…" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full font-mono text-sm" required />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -70,57 +65,14 @@
|
||||
<div class="collapse-content px-3">
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="key-resource" class="mb-1 block text-sm font-medium">Resource Name</label>
|
||||
<input id="key-resource" name="resource_name" type="text" v-model="newApiKey.resource_name"
|
||||
placeholder="Azure resource name" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full" />
|
||||
<label for="ch-priority" class="mb-1 block text-sm font-medium">Priority</label>
|
||||
<input id="ch-priority" name="priority" type="number" v-model.number="newChannel.priority"
|
||||
placeholder="0" autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-secret" class="mb-1 block text-sm font-medium">API Secret</label>
|
||||
<input id="key-secret" name="api_secret" type="text" v-model="newApiKey.api_secret"
|
||||
placeholder="Optional secret" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-prefix" class="mb-1 block text-sm font-medium">Model Prefix</label>
|
||||
<input id="key-prefix" name="model_prefix" type="text" v-model="newApiKey.model_prefix"
|
||||
placeholder="e.g. azure-gpt" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-alias" class="mb-1 block text-sm font-medium">Model Alias</label>
|
||||
<textarea id="key-alias" name="model_alias" v-model="newApiKey.model_alias" rows="2"
|
||||
placeholder='{"gpt-4o": "my-gpt4o"}' spellcheck="false"
|
||||
class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-parameters" class="mb-1 block text-sm font-medium">Parameters (JSON)</label>
|
||||
<textarea id="key-parameters" name="parameters" v-model="newApiKey.parameters" rows="2"
|
||||
placeholder="{}" spellcheck="false"
|
||||
class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-sm font-medium">Support Models</span>
|
||||
<TagInput v-model="newApiKey.support_models_array" clearable
|
||||
placeholder="Type a model and press Enter" @change="onchange_supportmodel" />
|
||||
<span class="mt-1 block text-xs text-base-content/50">Restrict which models this key can serve. Empty allows all.</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||
<div class="flex h-9 items-center gap-3">
|
||||
<input type="checkbox" name="active" v-model="newApiKey.active" role="switch"
|
||||
class="toggle toggle-sm" :class="newApiKey.active ? 'toggle-success' : 'toggle-error'"
|
||||
aria-label="Key active" />
|
||||
<span class="text-sm text-base-content/70">
|
||||
{{ newApiKey.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
<label for="ch-weight" class="mb-1 block text-sm font-medium">Weight</label>
|
||||
<input id="ch-weight" name="weight" type="number" v-model.number="newChannel.weight"
|
||||
placeholder="1" autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,7 +82,7 @@
|
||||
<button type="button" @click="cancel" class="btn btn-ghost btn-sm">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="!isFormValid || creating">
|
||||
<span v-if="creating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Create API Key
|
||||
Create Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,129 +92,74 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useKeyStore } from '@/stores/key';
|
||||
import { CircleAlert } from '@lucide/vue';
|
||||
import { useToast } from '@/composables/toast';
|
||||
import type { NewApiKeyPayload } from '@/types';
|
||||
import { useChannelStore, type NewChannelPayload } from '@/stores/channel'
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
import { useToast } from '@/composables/toast'
|
||||
|
||||
const keyStore = useKeyStore()
|
||||
const channelStore = useChannelStore()
|
||||
const { setToast } = useToast()
|
||||
const error = ref<string | null>(null)
|
||||
const creating = ref(false)
|
||||
|
||||
// Control advanced options visibility
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
// Initialize API key object
|
||||
const newApiKey = ref<NewApiKeyPayload>({
|
||||
const newChannel = ref<NewChannelPayload>({
|
||||
name: '',
|
||||
type: '',
|
||||
apikey: '',
|
||||
active: true,
|
||||
endpoint: '',
|
||||
resource_name: '',
|
||||
// deployment_name: '',
|
||||
api_secret: '',
|
||||
model_prefix: '',
|
||||
model_alias: '',
|
||||
parameters: '{}',
|
||||
support_models: '[]',
|
||||
support_models_array: [],
|
||||
provider: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
priority: 0,
|
||||
weight: 1,
|
||||
})
|
||||
|
||||
const resetNewApiKey = () => {
|
||||
newApiKey.value = {
|
||||
const resetNewChannel = () => {
|
||||
newChannel.value = {
|
||||
name: '',
|
||||
type: '',
|
||||
apikey: '',
|
||||
active: true,
|
||||
endpoint: '',
|
||||
resource_name: '',
|
||||
// deployment_name: '',
|
||||
api_secret: '',
|
||||
model_prefix: '',
|
||||
model_alias: '',
|
||||
parameters: '{}',
|
||||
support_models: '[]',
|
||||
support_models_array: [],
|
||||
provider: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
priority: 0,
|
||||
weight: 1,
|
||||
}
|
||||
}
|
||||
|
||||
const onchange_supportmodel = () => {
|
||||
newApiKey.value.support_models = JSON.stringify(newApiKey.value.support_models_array)
|
||||
}
|
||||
|
||||
// Form validation
|
||||
const isFormValid = computed(() => {
|
||||
return newApiKey.value.name &&
|
||||
newApiKey.value.type &&
|
||||
newApiKey.value.apikey
|
||||
return newChannel.value.name &&
|
||||
newChannel.value.provider &&
|
||||
newChannel.value.base_url &&
|
||||
newChannel.value.api_key
|
||||
})
|
||||
|
||||
const cancel = () => {
|
||||
resetNewApiKey()
|
||||
resetNewChannel()
|
||||
emit('closeModal', true)
|
||||
}
|
||||
|
||||
const apiKeyImageMap: Record<string, string> = {
|
||||
'openai': '/assets/openai.svg',
|
||||
'claude': '/assets/claude.svg',
|
||||
'gemini': '/assets/gemini.svg',
|
||||
'azure': '/assets/azure.svg',
|
||||
'github': '/assets/github.svg'
|
||||
|
||||
};
|
||||
|
||||
const apiKeyImageUrl = (keytype: string) => {
|
||||
return apiKeyImageMap[keytype] || '/assets/logo.svg';
|
||||
};
|
||||
|
||||
const createApiKey = async () => {
|
||||
const createChannel = async () => {
|
||||
if (!isFormValid.value) {
|
||||
setToast('Please fill in all required fields (Name, Type, API Key).', 'error')
|
||||
setToast('Please fill in all required fields.', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
try {
|
||||
if (!Array.isArray(newApiKey.value.support_models_array)) {
|
||||
setToast('Support Models must be a JSON array.', 'error');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
setToast('Invalid JSON format for Support Models.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to parse parameters JSON
|
||||
try {
|
||||
JSON.parse(newApiKey.value.parameters || '{}');
|
||||
} catch (e) {
|
||||
setToast('Invalid JSON format for Parameters.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await keyStore.createKey(newApiKey.value);
|
||||
if (res.data?.code === 200) {
|
||||
error.value = null;
|
||||
resetNewApiKey();
|
||||
setToast('API Key created successfully.', 'success')
|
||||
const res = await channelStore.createChannel(newChannel.value)
|
||||
if (res.data?.id) {
|
||||
error.value = null
|
||||
resetNewChannel()
|
||||
setToast('Channel created successfully.', 'success')
|
||||
emit('closeModal', true)
|
||||
} else {
|
||||
setToast(res.data?.error || res.data?.message || 'Failed to create API Key', 'error')
|
||||
setToast(res.data?.error || 'Failed to create channel', 'error')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log('createApiKey error:', err)
|
||||
error.value = err?.message || String(err) || 'Failed to create API Key'
|
||||
error.value = err.response?.data?.error || 'Failed to create channel'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'closeModal', value: boolean): void
|
||||
}>()
|
||||
|
||||
</script>
|
||||
|
||||
@@ -1,52 +1,40 @@
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<BreadcrumbHeader title="渠道详情" />
|
||||
<BreadcrumbHeader title="Channel Details" />
|
||||
|
||||
<div v-if="key" class="space-y-5">
|
||||
<div v-if="ch" class="space-y-5">
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<form @submit.prevent="updateKey" class="card-body gap-5 p-4 sm:p-6">
|
||||
<form @submit.prevent="updateCh" class="card-body gap-5 p-4 sm:p-6">
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h2>
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="key-name" class="mb-1 block text-sm font-medium">
|
||||
<label for="ch-name" class="mb-1 block text-sm font-medium">
|
||||
Name <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="key-name" name="name" type="text" v-model="key.name" autocomplete="off" spellcheck="false"
|
||||
<input id="ch-name" name="name" type="text" v-model="ch.name" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-type" class="mb-1 block text-sm font-medium">
|
||||
Type <span class="text-error" aria-hidden="true">*</span>
|
||||
<label for="ch-provider" class="mb-1 block text-sm font-medium">
|
||||
Provider
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select id="key-type" name="type" v-model="key.type" required
|
||||
class="select select-bordered w-full pl-10">
|
||||
<option disabled value="">Select provider</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="azure">Azure</option>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="openai-compatible">OpenAI Compatible</option>
|
||||
</select>
|
||||
<img :src="apiKeyImageUrl(key.type)" alt="" width="20" height="20"
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full bg-base-200 p-0.5" />
|
||||
</div>
|
||||
<div class="input input-bordered w-full bg-base-200">{{ ch.provider }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-apikey" class="mb-1 block text-sm font-medium">
|
||||
API Key <span class="text-error" aria-hidden="true">*</span>
|
||||
<label for="ch-base-url" class="mb-1 block text-sm font-medium">
|
||||
Base URL <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="key-apikey" name="apikey" type="text" v-model="key.apikey" autocomplete="off"
|
||||
spellcheck="false" class="input input-bordered w-full font-mono text-sm" required />
|
||||
<input id="ch-base-url" name="base_url" type="url" v-model="ch.base_url" spellcheck="false"
|
||||
class="input input-bordered w-full font-mono text-sm" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-endpoint" class="mb-1 block text-sm font-medium">Endpoint</label>
|
||||
<input id="key-endpoint" name="endpoint" type="url" v-model="key.endpoint" spellcheck="false"
|
||||
<label for="ch-api-key" class="mb-1 block text-sm font-medium">API Key</label>
|
||||
<input id="ch-api-key" name="api_key" type="password" v-model="api_key" autocomplete="off"
|
||||
spellcheck="false" placeholder="Leave blank to keep current"
|
||||
class="input input-bordered w-full font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,48 +48,36 @@
|
||||
<div class="collapse-content px-3">
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="key-resource" class="mb-1 block text-sm font-medium">Resource Name</label>
|
||||
<input id="key-resource" name="resource_name" type="text" v-model="key.resource_name"
|
||||
autocomplete="off" spellcheck="false" class="input input-bordered w-full" />
|
||||
<label for="ch-priority" class="mb-1 block text-sm font-medium">Priority</label>
|
||||
<input id="ch-priority" name="priority" type="number" v-model.number="ch.priority"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-secret" class="mb-1 block text-sm font-medium">API Secret</label>
|
||||
<input id="key-secret" name="api_secret" type="text" v-model="key.api_secret" autocomplete="off"
|
||||
spellcheck="false" class="input input-bordered w-full font-mono text-sm" />
|
||||
<label for="ch-weight" class="mb-1 block text-sm font-medium">Weight</label>
|
||||
<input id="ch-weight" name="weight" type="number" v-model.number="ch.weight"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-prefix" class="mb-1 block text-sm font-medium">Model Prefix</label>
|
||||
<input id="key-prefix" name="model_prefix" type="text" v-model="key.model_prefix" autocomplete="off"
|
||||
spellcheck="false" class="input input-bordered w-full" />
|
||||
<label for="ch-timeout" class="mb-1 block text-sm font-medium">Timeout (ms)</label>
|
||||
<input id="ch-timeout" name="timeout_ms" type="number" v-model.number="ch.timeout_ms"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-alias" class="mb-1 block text-sm font-medium">Model Alias</label>
|
||||
<textarea id="key-alias" name="model_alias" v-model="key.model_alias" rows="2" placeholder='{}'
|
||||
spellcheck="false" class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||
<label for="ch-max-concurrency" class="mb-1 block text-sm font-medium">Max Concurrency</label>
|
||||
<input id="ch-max-concurrency" name="max_concurrency" type="number" v-model.number="ch.max_concurrency"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-parameters" class="mb-1 block text-sm font-medium">Parameters (JSON)</label>
|
||||
<textarea id="key-parameters" name="parameters" v-model="key.parameters" rows="2" placeholder="{}"
|
||||
spellcheck="false" class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-sm font-medium">Support Models</span>
|
||||
<TagInput v-model="key.support_models_array" clearable placeholder="Type a model and press Enter"
|
||||
@change="onchange_supportmodel" />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||
<div class="flex h-9 items-center gap-3">
|
||||
<input type="checkbox" name="active" v-model="key.active" role="switch" class="toggle toggle-sm"
|
||||
:class="key.active ? 'toggle-success' : 'toggle-error'" aria-label="Key active" />
|
||||
<input type="checkbox" name="enabled" :checked="ch.enabled" role="switch" class="toggle toggle-sm"
|
||||
:class="ch.enabled ? 'toggle-success' : 'toggle-error'" @change="toggleEnabled" aria-label="Channel enabled" />
|
||||
<span class="text-sm text-base-content/70">
|
||||
{{ key.active ? 'Active' : 'Inactive' }}
|
||||
{{ ch.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,7 +86,7 @@
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4">
|
||||
<button type="button" @click="cancel" class="btn btn-ghost btn-sm">Back</button>
|
||||
<button type="button" @click="goBack" class="btn btn-ghost btn-sm">Back</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="updating">
|
||||
<span v-if="updating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Save Changes
|
||||
@@ -123,7 +99,7 @@
|
||||
<!-- Loading state -->
|
||||
<div v-else class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading key details">
|
||||
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading channel details">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,76 +108,62 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useKeyStore } from '../../stores/key';
|
||||
import { useChannelStore, type Channel } from '../../stores/channel';
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import TagInput from '@/components/common/TagInput.vue';
|
||||
import { useToast } from '@/composables/toast';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const keyStore = useKeyStore();
|
||||
const channelStore = useChannelStore();
|
||||
const { setToast } = useToast();
|
||||
const updating = ref(false);
|
||||
const api_key = ref('');
|
||||
|
||||
const keyId = computed(() => route.query.id);
|
||||
|
||||
const key = computed(() => keyStore.key);
|
||||
const loading = computed(() => keyStore.loading);
|
||||
const channelId = computed(() => route.query.id);
|
||||
const ch = computed(() => channelStore.channel);
|
||||
|
||||
onMounted(async () => {
|
||||
console.log('keyId', keyId.value)
|
||||
if (keyId.value) {
|
||||
await keyStore.fetchKey(keyId.value as string);
|
||||
if (channelId.value) {
|
||||
await channelStore.fetchChannel(channelId.value as string);
|
||||
}
|
||||
});
|
||||
|
||||
const keyOption = reactive([
|
||||
{name: 'openai', label: 'OpenAI'},
|
||||
{name: 'claude', label: 'Claude'},
|
||||
{name: 'gemini', label: 'Gemini'},
|
||||
{name: 'azure', label: 'Azure'},
|
||||
{name: 'github', label: 'Github'},
|
||||
{name: 'openai-compatible', label: 'OpenAI Compatible'}
|
||||
])
|
||||
|
||||
const apiKeyImageMap: Record<string, string> = {
|
||||
'openai': '/assets/openai.svg',
|
||||
'claude': '/assets/claude.svg',
|
||||
'gemini': '/assets/gemini.svg',
|
||||
'azure': '/assets/azure.svg',
|
||||
'github': '/assets/github.svg'
|
||||
const toggleEnabled = () => {
|
||||
if (!ch.value) return;
|
||||
ch.value.enabled = !ch.value.enabled;
|
||||
};
|
||||
|
||||
const apiKeyImageUrl = (keytype: string) => {
|
||||
return apiKeyImageMap[keytype] || '/assets/logo.svg';
|
||||
};
|
||||
|
||||
const onchange_supportmodel = () => {
|
||||
if (!key.value) return;
|
||||
key.value.support_models = JSON.stringify(key.value.support_models_array)
|
||||
}
|
||||
|
||||
const updateKey = async () => {
|
||||
if (!key.value) return;
|
||||
const updateCh = async () => {
|
||||
if (!ch.value) return;
|
||||
updating.value = true;
|
||||
try {
|
||||
const res = await keyStore.updateKey(key.value);
|
||||
console.log('updateKey', res)
|
||||
if (res.data?.code == 200) {
|
||||
setToast(`Key ${key.value.name} updated`, 'success');
|
||||
const payload: Partial<Channel> & { api_key?: string } = {
|
||||
name: ch.value.name,
|
||||
base_url: ch.value.base_url,
|
||||
priority: ch.value.priority,
|
||||
weight: ch.value.weight,
|
||||
timeout_ms: ch.value.timeout_ms,
|
||||
max_concurrency: ch.value.max_concurrency,
|
||||
enabled: ch.value.enabled,
|
||||
};
|
||||
if (api_key.value) {
|
||||
payload.api_key = api_key.value;
|
||||
}
|
||||
await keyStore.refreshKey(key.value.id);
|
||||
const res = await channelStore.updateChannel(ch.value.id, payload);
|
||||
if (res.data?.id) {
|
||||
setToast(`Channel ${ch.value.name} updated`, 'success');
|
||||
}
|
||||
await channelStore.fetchChannel(ch.value.id);
|
||||
} catch (err: any) {
|
||||
console.error('Error updating key:', err);
|
||||
console.error('Error updating channel:', err);
|
||||
} finally {
|
||||
updating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
router.push({ name: 'ApiKey' });
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push({ name: 'Channels' });
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<BreadcrumbHeader />
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm text-base-content/60">Upstream provider keys used to serve model requests.</p>
|
||||
<p class="text-sm text-base-content/60">Upstream provider channels used to serve model requests.</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-ghost btn-square btn-sm" aria-label="Batch actions">
|
||||
@@ -27,71 +27,62 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new API key">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />New API Key
|
||||
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new channel">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />New Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status filter -->
|
||||
<details class="dropdown dropdown-end" v-if="keys.length">
|
||||
<summary class="btn btn-outline btn-sm h-8 min-h-8 border-dashed">
|
||||
<ListFilterIcon class="h-4 w-4" aria-hidden="true" />
|
||||
Status
|
||||
<span v-if="selectedStatuses.length" class="badge badge-primary badge-sm">{{ selectedStatuses.length }}</span>
|
||||
</summary>
|
||||
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-32 rounded-box border border-base-300/60 bg-base-100 p-1.5 shadow-lg">
|
||||
<li v-for="status in statusOptions" :key="status">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" class="checkbox checkbox-xs" :checked="selectedStatuses.some(item => item.status === status)"
|
||||
@change="toggleStatusFilter(status)" />
|
||||
{{ status }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="overflow-x-auto" v-if="keys.length">
|
||||
<div class="overflow-x-auto" v-if="channels.length">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||
<th class="pl-4">
|
||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll"
|
||||
aria-label="Select all keys" />
|
||||
aria-label="Select all channels" />
|
||||
</th>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Active</th>
|
||||
<th>Provider</th>
|
||||
<th>Base URL</th>
|
||||
<th>Health</th>
|
||||
<th>Status</th>
|
||||
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="key in keys" :key="key.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||
<tr v-for="ch in channels" :key="ch.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||
<td class="pl-4">
|
||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="key.selected"
|
||||
@change="toggleUserSelection(key)" :aria-label="`Select key ${key.name}`" />
|
||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="ch.selected"
|
||||
@change="toggleSelection(ch)" :aria-label="`Select channel ${ch.name}`" />
|
||||
</td>
|
||||
<td class="max-w-40 truncate font-medium">{{ ch.name }}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<img :src="displayIcon(key.type)" alt="" width="20" height="20" class="h-5 w-5 rounded-full bg-base-200 p-0.5" />
|
||||
<span class="text-sm capitalize">{{ key.type }}</span>
|
||||
<img :src="providerIcon(ch.provider)" alt="" width="20" height="20" class="h-5 w-5 rounded-full bg-base-200 p-0.5" />
|
||||
<span class="text-sm capitalize">{{ ch.provider }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="max-w-40 truncate font-medium">{{ key.name }}</td>
|
||||
<td class="max-w-48 truncate font-mono text-xs text-base-content/60">{{ ch.base_url }}</td>
|
||||
<td>
|
||||
<span class="badge badge-xs"
|
||||
:class="ch.health_status === 'healthy' ? 'badge-success badge-soft' : ch.health_status === 'cooldown' ? 'badge-error badge-soft' : 'badge-warning badge-soft'">
|
||||
{{ ch.health_status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
||||
:class="!key.active && 'toggle-error'" v-model="key.active" @change="updateStatus(key)"
|
||||
:aria-label="`Toggle key ${key.name}`" />
|
||||
:class="!ch.enabled && 'toggle-error'" :checked="ch.enabled" @change="toggleEnabled(ch)"
|
||||
:aria-label="`Toggle channel ${ch.name}`" />
|
||||
</td>
|
||||
<td class="pr-3">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-square" @click="viewKey(key)" aria-label="View key details">
|
||||
<button class="btn btn-ghost btn-xs btn-square" @click="viewChannel(ch)" aria-label="View channel details">
|
||||
<EyeIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs btn-square text-error" @click="confirmDeleteKey(key)"
|
||||
aria-label="Delete key">
|
||||
<button class="btn btn-ghost btn-xs btn-square text-error" @click="confirmDeleteChannel(ch)"
|
||||
aria-label="Delete channel">
|
||||
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -103,13 +94,13 @@
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
|
||||
<KeyRoundIcon class="h-10 w-10 text-base-content/20" aria-hidden="true" />
|
||||
<h2 class="text-sm font-semibold">No API keys yet</h2>
|
||||
<GlobeIcon class="h-10 w-10 text-base-content/20" aria-hidden="true" />
|
||||
<h2 class="text-sm font-semibold">No channels yet</h2>
|
||||
<p class="max-w-xs text-sm text-base-content/60">
|
||||
Add an OpenAI, Claude, Gemini or compatible provider key to start serving requests.
|
||||
Add an upstream provider channel to start serving model requests.
|
||||
</p>
|
||||
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create API Key
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,7 +109,7 @@
|
||||
<Pagination v-if="totalItems > 0" :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
|
||||
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" />
|
||||
|
||||
<!-- New key modal -->
|
||||
<!-- New channel modal -->
|
||||
<dialog ref="modalRef" class="modal">
|
||||
<div class="modal-box max-w-3xl px-0 sm:px-6">
|
||||
<form method="dialog">
|
||||
@@ -134,193 +125,125 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import Pagination from '@/components/common/Pagination.vue';
|
||||
import KeyNew from '@/views/dashboard/KeyNew.vue';
|
||||
import { useKeyStore } from '@/stores/key';
|
||||
import { useChannelStore, type Channel } from '@/stores/channel';
|
||||
import { useToast } from '@/composables/toast';
|
||||
import type { ApiKey } from '@/types';
|
||||
|
||||
import {
|
||||
BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon,
|
||||
TrashIcon, KeyRoundIcon, ListFilterIcon
|
||||
TrashIcon, GlobeIcon
|
||||
} from '@lucide/vue';
|
||||
|
||||
const router = useRouter();
|
||||
const keyStore = useKeyStore();
|
||||
const channelStore = useChannelStore();
|
||||
const { setToast } = useToast();
|
||||
|
||||
onMounted(async () => {
|
||||
await keyStore.fetchKeys();
|
||||
await fetchChannels();
|
||||
})
|
||||
|
||||
const keys = computed(() => keyStore.keys);
|
||||
const channels = computed(() => channelStore.channels);
|
||||
|
||||
// 用户数据
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const totalItems = computed(() => keyStore.totalKeys);
|
||||
const pageSize = ref(20);
|
||||
const totalItems = computed(() => channelStore.totalChannels);
|
||||
|
||||
|
||||
|
||||
// 封装公共的用户列表获取方法
|
||||
const fetchKeys = async (size?: number, page?: number, active?: boolean[] | boolean) => {
|
||||
const fetchChannels = async (size?: number, page?: number) => {
|
||||
currentPage.value = page || currentPage.value;
|
||||
await keyStore.fetchKeys(size ?? pageSize.value, page ?? currentPage.value, active ?? selectedStatuses.map(status => status.value));
|
||||
await channelStore.fetchChannels(size ?? pageSize.value, currentPage.value);
|
||||
};
|
||||
|
||||
// 分页与页面大小变化
|
||||
const changePage = async (page: number, size: number) => {
|
||||
if (page == currentPage.value && size == pageSize.value) {
|
||||
return
|
||||
}
|
||||
if (page == currentPage.value && size == pageSize.value) return;
|
||||
currentPage.value = page;
|
||||
pageSize.value = size;
|
||||
await fetchKeys();
|
||||
await fetchChannels();
|
||||
};
|
||||
|
||||
// 复选框选择状态
|
||||
const selectAll = ref(false)
|
||||
const selectedKeys = ref<ApiKey[]>([])
|
||||
const selectAll = ref(false);
|
||||
const selectedChannels = ref<Channel[]>([]);
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (keys.value.length === 0) {
|
||||
return
|
||||
}
|
||||
keys.value.forEach(key => key.selected = selectAll.value)
|
||||
|
||||
if (selectAll.value) {
|
||||
// Select all on the current page
|
||||
selectedKeys.value = keys.value.map(key => key)
|
||||
} else {
|
||||
// Clear all selections
|
||||
selectedKeys.value = []
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const toggleUserSelection = (key: ApiKey) => {
|
||||
if (selectedKeys.value.includes(key)) {
|
||||
selectedKeys.value = selectedKeys.value.filter(selected => selected !== key);
|
||||
} else {
|
||||
selectedKeys.value.push(key);
|
||||
}
|
||||
selectAll.value = selectedKeys.value.length === keys.value.length;
|
||||
channels.value.forEach(ch => ch.selected = selectAll.value);
|
||||
selectedChannels.value = selectAll.value ? [...channels.value] : [];
|
||||
};
|
||||
|
||||
// 状态筛选
|
||||
const statusOptions = ['Active', 'Inactive'];
|
||||
const selectedStatuses = reactive<{ status: string; value: boolean }[]>([]);
|
||||
|
||||
const toggleStatusFilter = async (status: string) => {
|
||||
const statusValue = status === 'Active';
|
||||
const index = selectedStatuses.findIndex(item => item.status === status);
|
||||
|
||||
if (index > -1) {
|
||||
selectedStatuses.splice(index, 1);
|
||||
const toggleSelection = (ch: Channel) => {
|
||||
if (selectedChannels.value.includes(ch)) {
|
||||
selectedChannels.value = selectedChannels.value.filter(s => s !== ch);
|
||||
} else {
|
||||
selectedStatuses.push({ status, value: statusValue });
|
||||
selectedChannels.value.push(ch);
|
||||
}
|
||||
|
||||
await fetchKeys(undefined, 1, undefined);
|
||||
selectAll.value = selectedChannels.value.length === channels.value.length;
|
||||
};
|
||||
|
||||
// 处理批量操作
|
||||
const handleBatchAction = async (action: string) => {
|
||||
if (selectedKeys.value.length === 0) {
|
||||
return setToast('请选择数据', 'error');
|
||||
if (selectedChannels.value.length === 0) {
|
||||
return setToast('Please select channels first', 'error');
|
||||
}
|
||||
if (!['enable', 'disable', 'delete'].includes(action)) {
|
||||
return setToast(`无效的操作 ${action}`, 'error');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await keyStore.keyOption(action, selectedKeys.value.map(item => item.id));
|
||||
if (res.data?.code === 200) {
|
||||
setToast(`Key ${action} Success`, 'success');
|
||||
const ids = selectedChannels.value.map(ch => ch.id);
|
||||
if (action === 'delete') {
|
||||
for (const id of ids) {
|
||||
await channelStore.deleteChannel(id);
|
||||
}
|
||||
} else {
|
||||
setToast(res.data.error || `${action} Failed`, 'error');
|
||||
for (const id of ids) {
|
||||
await channelStore.updateChannel(id, { enabled: action === 'enable' });
|
||||
}
|
||||
}
|
||||
selectedKeys.value = [];
|
||||
setToast(`Channels ${action} succeeded`, 'success');
|
||||
selectedChannels.value = [];
|
||||
selectAll.value = false;
|
||||
await fetchKeys();
|
||||
|
||||
await fetchChannels();
|
||||
} catch (error: any) {
|
||||
console.error(`批量操作 ${action} 失败:`, error);
|
||||
setToast('批量操作失败', 'error');
|
||||
setToast(`Batch ${action} failed`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 更新用户状态
|
||||
const updateStatus = async (key: ApiKey) => {
|
||||
const toggleEnabled = async (ch: Channel) => {
|
||||
try {
|
||||
const action = key.active ? 'enable' : 'disable';
|
||||
const res = await keyStore.keyOption(action, [key.id]);
|
||||
|
||||
if (res.data?.code === 200) {
|
||||
setToast(`Key ${key.name} has been ${action}`, 'success');
|
||||
}
|
||||
await fetchKeys();
|
||||
await channelStore.updateChannel(ch.id, { enabled: !ch.enabled });
|
||||
setToast(`Channel ${ch.name} ${ch.enabled ? 'disabled' : 'enabled'}`, 'success');
|
||||
await fetchChannels();
|
||||
} catch (error: any) {
|
||||
console.error('状态更新失败:', error);
|
||||
setToast('状态更新失败', 'error');
|
||||
setToast('Status update failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const viewKey = (key: ApiKey) => {
|
||||
router.push({ name: 'ApiKeyView', query: { id: key.id } });
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
const confirmDeleteKey = async (key: ApiKey) => {
|
||||
if (confirm(`确认删除 ${key.name}?`)) {
|
||||
await deleteKey(key);
|
||||
}
|
||||
const viewChannel = (ch: Channel) => {
|
||||
router.push({ name: 'ChannelView', query: { id: ch.id } });
|
||||
};
|
||||
|
||||
const deleteKey = async (key: ApiKey) => {
|
||||
try {
|
||||
const res = await keyStore.keyOption('delete', [key.id]);
|
||||
if (res.data?.code === 200) {
|
||||
setToast('删除成功', 'success');
|
||||
const confirmDeleteChannel = async (ch: Channel) => {
|
||||
if (confirm(`Delete channel "${ch.name}"?`)) {
|
||||
try {
|
||||
await channelStore.deleteChannel(ch.id);
|
||||
setToast(`Channel ${ch.name} deleted`, 'success');
|
||||
await fetchChannels();
|
||||
} catch (error: any) {
|
||||
setToast('Delete failed', 'error');
|
||||
}
|
||||
|
||||
await fetchKeys();
|
||||
} catch (error: any) {
|
||||
console.error('删除失败:', error);
|
||||
setToast('删除失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const displayIcon = (apitype: string) => {
|
||||
switch (apitype) {
|
||||
case 'openai':
|
||||
return '/assets/openai.svg';
|
||||
case 'claude':
|
||||
return '/assets/claude.svg';
|
||||
case 'gemini':
|
||||
return '/assets/gemini.svg'
|
||||
case 'azure':
|
||||
return '/assets/azure.svg';
|
||||
case 'github':
|
||||
return '/assets/github.svg';
|
||||
default:
|
||||
return '/assets/logo.svg';
|
||||
}
|
||||
const providerIcon = (provider: string) => {
|
||||
const map: Record<string, string> = {
|
||||
openai: '/assets/openai.svg',
|
||||
anthropic: '/assets/claude.svg',
|
||||
compatible: '/assets/logo.svg',
|
||||
};
|
||||
return map[provider] || '/assets/logo.svg';
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const modalRef = ref<HTMLDialogElement | null>(null);
|
||||
const openModal = () => {
|
||||
modalRef.value?.showModal();
|
||||
};
|
||||
const openModal = () => { modalRef.value?.showModal(); };
|
||||
const closeModal = async () => {
|
||||
if (modalRef.value) {
|
||||
modalRef.value.close();
|
||||
}
|
||||
await fetchKeys();
|
||||
modalRef.value?.close();
|
||||
await fetchChannels();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -63,8 +63,8 @@
|
||||
<dt class="shrink-0 text-base-content/60">状态</dt>
|
||||
<dd>
|
||||
<span class="badge badge-sm"
|
||||
:class="user?.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user?.active ? 'Active' : 'Inactive' }}
|
||||
:class="user?.status === 'active' ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user?.status === 'active' ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
</div>
|
||||
<div class="ml-auto hidden flex-col items-end gap-1 text-sm sm:flex">
|
||||
<span class="badge badge-sm"
|
||||
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user.active ? 'Active' : 'Inactive' }}
|
||||
:class="user.status === 'active' ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user.status === 'active' ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1 text-base-content/60">
|
||||
Quota:
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
<h2 class="truncate text-lg font-semibold tracking-tight">{{ user?.name || user?.username }}</h2>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-sm"
|
||||
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user.active ? 'Active' : 'Inactive' }}
|
||||
:class="user.status === 'active' ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user.status === 'active' ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
<span class="badge badge-sm"
|
||||
:class="user.role > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<header class="mb-4 pr-8">
|
||||
<h2 class="text-lg font-semibold tracking-tight">Create New Token</h2>
|
||||
<p class="mt-0.5 text-sm text-base-content/60">Tokens authenticate OpenAI-compatible clients with your team.</p>
|
||||
<h2 class="text-lg font-semibold tracking-tight">Create New API Key</h2>
|
||||
<p class="mt-0.5 text-sm text-base-content/60">API keys authenticate OpenAI-compatible clients with your team.</p>
|
||||
</header>
|
||||
|
||||
<div v-if="error" role="alert" class="alert alert-error mb-4 text-sm">
|
||||
@@ -11,16 +11,60 @@
|
||||
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
|
||||
</div>
|
||||
|
||||
<form class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createToken">
|
||||
<!-- Success State: Show created key -->
|
||||
<div v-if="createdKey" class="card border border-success/30 bg-success/5 shadow-sm">
|
||||
<div class="card-body gap-4 p-4 sm:p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-success/10">
|
||||
<CheckCircleIcon class="h-5 w-5 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">API Key Created</h3>
|
||||
<p class="text-xs text-base-content/60">Copy this key now. It won't be shown again.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">Your API Key</span>
|
||||
</label>
|
||||
<div class="join w-full">
|
||||
<input
|
||||
type="text"
|
||||
:value="createdKey"
|
||||
readonly
|
||||
class="input input-bordered join-item flex-1 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success join-item gap-1.5"
|
||||
@click="copyToClipboard"
|
||||
>
|
||||
<ClipboardCopyIcon class="h-4 w-4" />
|
||||
{{ copied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end border-t border-base-300/40 pt-4">
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="closeAfterCreate">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Form -->
|
||||
<form v-else class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createKey">
|
||||
<div class="card-body gap-5 p-4 sm:p-6">
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="token-name" class="mb-1 block text-sm font-medium">
|
||||
<label for="key-name" class="mb-1 block text-sm font-medium">
|
||||
Name <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="token-name" name="name" type="text" v-model="newToken.name"
|
||||
<input id="key-name" name="name" type="text" v-model="newKey.name"
|
||||
placeholder="e.g. my-laptop" autocomplete="off"
|
||||
class="input input-bordered w-full" required />
|
||||
</div>
|
||||
@@ -35,56 +79,19 @@
|
||||
<div class="collapse-content px-3">
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="token-key" class="mb-1 block text-sm font-medium">Key</label>
|
||||
<div class="relative">
|
||||
<input id="token-key" name="key" :type="isTokenVisible ? 'text' : 'password'" v-model="newToken.key"
|
||||
autocomplete="off" spellcheck="false" placeholder="Leave blank to generate"
|
||||
class="input input-bordered w-full pr-10" />
|
||||
<button type="button" @click="toggleTokenVisibility"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||
:aria-label="isTokenVisible ? 'Hide token key' : 'Show token key'"
|
||||
id="token-visibility-toggle">
|
||||
<EyeOff v-if="!isTokenVisible" class="h-4 w-4" aria-hidden="true" />
|
||||
<Eye v-else class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<label for="key-quota-tokens" class="mb-1 block text-sm font-medium">Quota Tokens/Day</label>
|
||||
<input id="key-quota-tokens" name="quota_tokens_per_day" type="number" inputmode="numeric"
|
||||
v-model.number="newKey.quota_tokens_per_day"
|
||||
placeholder="0 = unlimited" autocomplete="off"
|
||||
class="input input-bordered w-full" :min="0" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="token-expired" class="mb-1 block text-sm font-medium">Expired at</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="token-expired" name="expired_at" type="date" v-model="newToken.format_expired_at"
|
||||
class="input input-bordered w-full" :disabled="newToken.never_expired" />
|
||||
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
|
||||
<input type="checkbox" name="never_expired" v-model="newToken.never_expired" class="checkbox checkbox-sm" />
|
||||
Never
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="token-quota" class="mb-1 block text-sm font-medium">Quota</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="token-quota" name="quota" type="number" inputmode="numeric" v-model="newToken.quota"
|
||||
placeholder="e.g. 10" autocomplete="off"
|
||||
class="input input-bordered w-full flex-grow" :disabled="newToken.unlimited_quota" />
|
||||
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
|
||||
<input type="checkbox" name="unlimited_quota" v-model="newToken.unlimited_quota" class="checkbox checkbox-sm" />
|
||||
Unlimited
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||
<div class="flex h-9 items-center gap-3">
|
||||
<input type="checkbox" name="active" v-model="newToken.active" role="switch"
|
||||
class="toggle toggle-sm" :class="newToken.active ? 'toggle-success' : 'toggle-error'"
|
||||
aria-label="Token active" />
|
||||
<span class="text-sm text-base-content/70">
|
||||
{{ newToken.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
<label for="key-quota-requests" class="mb-1 block text-sm font-medium">Quota Requests/Day</label>
|
||||
<input id="key-quota-requests" name="quota_requests_per_day" type="number" inputmode="numeric"
|
||||
v-model.number="newKey.quota_requests_per_day"
|
||||
placeholder="0 = unlimited" autocomplete="off"
|
||||
class="input input-bordered w-full" :min="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +103,7 @@
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="!isFormValid || creating">
|
||||
<span v-if="creating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Create Token
|
||||
Create API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,111 +112,86 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Eye, EyeOff, CircleAlert } from '@lucide/vue'
|
||||
import { dateToUnix } from '@/utils/format-date';
|
||||
import { useToast } from '@/composables/toast';
|
||||
import type { TokenPayload } from '@/types';
|
||||
import { ref, computed } from 'vue'
|
||||
import { useKeyStore } from '@/stores/key'
|
||||
import { CircleAlert, CheckCircleIcon, ClipboardCopyIcon } from '@lucide/vue'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import type { NewApiKeyPayload } from '@/types'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const keyStore = useKeyStore()
|
||||
const { setToast } = useToast()
|
||||
const error = ref<string | null>(null)
|
||||
const creating = ref(false)
|
||||
const user = computed(() => authStore.user);
|
||||
const createdKey = ref<string | null>(null)
|
||||
const copied = ref(false)
|
||||
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
|
||||
const newToken = ref<TokenPayload>({
|
||||
const newKey = ref<NewApiKeyPayload>({
|
||||
name: '',
|
||||
key: '',
|
||||
user_id: user.value?.user_id as number | undefined,
|
||||
active: true,
|
||||
quota: 0,
|
||||
unlimited_quota: true,
|
||||
expired_at: 0,
|
||||
format_expired_at: '',
|
||||
never_expired: true,
|
||||
quota_tokens_per_day: undefined,
|
||||
quota_requests_per_day: undefined,
|
||||
})
|
||||
|
||||
const resetnewToken = () => {
|
||||
newToken.value = {
|
||||
const resetNewKey = () => {
|
||||
newKey.value = {
|
||||
name: '',
|
||||
key: '',
|
||||
user_id: '',
|
||||
active: true,
|
||||
quota: 0,
|
||||
unlimited_quota: true,
|
||||
expired_at: 0,
|
||||
format_expired_at: '',
|
||||
never_expired: true,
|
||||
quota_tokens_per_day: undefined,
|
||||
quota_requests_per_day: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => newToken.value.never_expired,
|
||||
(newNeverExpiredValue) => {
|
||||
if (newNeverExpiredValue) {
|
||||
newToken.value.expired_at = 0;
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => newToken.value.format_expired_at,
|
||||
(format_expired_at) => {
|
||||
if (!newToken.value.never_expired && format_expired_at) {
|
||||
newToken.value.expired_at = dateToUnix(format_expired_at);
|
||||
} else {
|
||||
newToken.value.expired_at = 0;
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return newToken.value.name
|
||||
return newKey.value.name
|
||||
})
|
||||
|
||||
const createToken = async () => {
|
||||
const createKey = async () => {
|
||||
if (!isFormValid.value) {
|
||||
setToast('Please fill in all required fields Name.', 'error')
|
||||
setToast('Please fill in the name field.', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await authStore.createToken(newToken.value)
|
||||
if (res.data?.code === 200) {
|
||||
error.value = null;
|
||||
resetnewToken();
|
||||
setToast('Token created successfully.', 'success')
|
||||
emit('closeModal', true)
|
||||
const res = await keyStore.createKey(newKey.value)
|
||||
if (res.data?.key) {
|
||||
error.value = null
|
||||
createdKey.value = res.data.key
|
||||
resetNewKey()
|
||||
} else {
|
||||
console.log(res)
|
||||
error.value = res.data?.error || 'Failed to create token'
|
||||
error.value = res.data?.error || 'Failed to create API key'
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || 'Failed to create token'
|
||||
|
||||
error.value = err.response?.data?.error || 'Failed to create API key'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
resetnewToken()
|
||||
emit('closeModal', false)
|
||||
const copyToClipboard = async () => {
|
||||
if (!createdKey.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey.value)
|
||||
copied.value = true
|
||||
setToast('API Key copied to clipboard', 'success')
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch {
|
||||
setToast('Failed to copy. Please select and copy manually.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 显示密码
|
||||
const isTokenVisible = ref(false);
|
||||
const closeAfterCreate = () => {
|
||||
createdKey.value = null
|
||||
copied.value = false
|
||||
emit('closeModal', true)
|
||||
}
|
||||
|
||||
function toggleTokenVisibility() {
|
||||
isTokenVisible.value = !isTokenVisible.value;
|
||||
const cancel = () => {
|
||||
resetNewKey()
|
||||
emit('closeModal', false)
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'closeModal', value: boolean): void
|
||||
}>()
|
||||
|
||||
</script>
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<BreadcrumbHeader />
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm text-base-content/60">Tokens authenticate OpenAI-compatible clients with your team.</p>
|
||||
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new token">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />New Token
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-if="user" class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="overflow-x-auto" v-if="user.tokens && user.tokens.length">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||
<th class="pl-4">Token</th>
|
||||
<th>Status</th>
|
||||
<th>Expired</th>
|
||||
<th class="text-right">Quota</th>
|
||||
<th class="text-right">Used</th>
|
||||
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="token in user.tokens" :key="token.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||
<td class="pl-4 font-medium truncate max-w-[120px] sm:max-w-[180px]">{{ token.name }}</td>
|
||||
<td>
|
||||
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
||||
:class="!token.active && 'toggle-error'" v-model="token.active"
|
||||
@change="updateStatus(token)" :aria-label="`Toggle token ${token.name}`" />
|
||||
</td>
|
||||
<td class="tabular-nums text-base-content/70">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at ?? 0) }}</td>
|
||||
<td class="text-right tabular-nums">
|
||||
<template v-if="token.unlimited_quota">
|
||||
<Infinity class="inline h-4 w-4 text-base-content/60" aria-label="Unlimited quota" />
|
||||
<span class="sr-only">Unlimited</span>
|
||||
</template>
|
||||
<template v-else>{{ token.quota }}</template>
|
||||
</td>
|
||||
<td class="text-right tabular-nums">{{ token.used_quota }}</td>
|
||||
<td class="pr-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-square" @click="viewToken(token)" aria-label="Preview token key">
|
||||
<EyeIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button v-if="(token.used_quota ?? 0) > 0" class="btn btn-ghost btn-xs btn-square text-info"
|
||||
@click="cleanUsedToken(token)" aria-label="Reset used quota">
|
||||
<Eraser class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error"
|
||||
@click="confirmRevokeToken(token)" aria-label="Revoke token">
|
||||
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
|
||||
<Braces class="h-10 w-10 text-base-content/20" aria-hidden="true" />
|
||||
<h2 class="text-sm font-semibold">No tokens yet</h2>
|
||||
<p class="max-w-xs text-sm text-base-content/60">
|
||||
Create a token to connect OpenCat, BotGem and other OpenAI-compatible clients.
|
||||
</p>
|
||||
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create Token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New token modal -->
|
||||
<dialog ref="modalRef" class="modal">
|
||||
<div class="modal-box max-w-3xl px-0 sm:px-6">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
||||
</form>
|
||||
<TokenNew @closeModal="closeModal" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button aria-label="Close dialog">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Token QR modal -->
|
||||
<dialog ref="tokenRef" class="modal">
|
||||
<div class="modal-box max-w-sm px-0 sm:px-6">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
||||
</form>
|
||||
<QRCodeCard :value="qrCodeValue" :size="120" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button aria-label="Close dialog">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed } from 'vue';
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import QRCodeCard from '@/components/common/QRCodeCard.vue';
|
||||
import TokenNew from '@/views/dashboard/TokenNew.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import {
|
||||
EyeIcon, PlusIcon, TrashIcon, Infinity, Eraser, Braces
|
||||
} from '@lucide/vue';
|
||||
import { unixToDate } from '@/utils/format-date';
|
||||
import { useToast } from '@/composables/toast';
|
||||
import type { TokenInfo } from '@/types';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const user = computed(() => authStore.user);
|
||||
const { setToast } = useToast();
|
||||
|
||||
onMounted(async () => {
|
||||
await authStore.refreshProfile();
|
||||
})
|
||||
|
||||
watch(() => authStore.user, (newUser) => {
|
||||
if (newUser && newUser.expired_at && newUser.expired_at > 0) {
|
||||
newUser.format_expired_at = unixToDate(newUser.expired_at);
|
||||
}
|
||||
})
|
||||
|
||||
const updateStatus = async (token: TokenInfo) => {
|
||||
console.log(token);
|
||||
try {
|
||||
const res = await authStore.updateToken({ userid: token.userid, id: token.id, name: token.name, active: token.active });
|
||||
if (res.data?.code == 200) {
|
||||
setToast(`Token ${token.name} updated`, 'success');
|
||||
}
|
||||
} catch (error: any) {
|
||||
token.active = !token.active
|
||||
console.log(error.response.data.error);
|
||||
setToast(error.response.data.error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const confirmRevokeToken = async (token: TokenInfo) => {
|
||||
if (confirm(`确认删除 ${token.name}?`)) {
|
||||
await revokeToken(token);
|
||||
}
|
||||
}
|
||||
|
||||
const revokeToken = async (token: TokenInfo) => {
|
||||
try {
|
||||
const res = await authStore.deleteToken(token.id);
|
||||
if (res.data?.code == 200) {
|
||||
setToast(`Token ${token.name} revoked`, 'success');
|
||||
}
|
||||
await authStore.refreshProfile();
|
||||
|
||||
} catch (error: any) {
|
||||
setToast(error.response.data.error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const cleanUsedToken = async (token: TokenInfo) => {
|
||||
|
||||
if (token.used_quota == 0 || token.used_quota == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await authStore.resetToken(token.id);
|
||||
console.log('cleanUsedToken', res);
|
||||
if (res.data?.code == 200) {
|
||||
setToast(`Token ${token.name} used quota reset`, 'success');
|
||||
}
|
||||
await authStore.refreshProfile();
|
||||
} catch (error: any) {
|
||||
console.log(error);
|
||||
setToast(error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const showTokenModel = ref(false);
|
||||
const tokenRef = ref<HTMLDialogElement | null>(null);
|
||||
const viewToken = (token: TokenInfo) => {
|
||||
const dialog = tokenRef.value;
|
||||
if (dialog) {
|
||||
if (!dialog.hasAttribute('open')) {
|
||||
qrCodeValue.value = token.key || '';
|
||||
dialog.showModal();
|
||||
} else {
|
||||
if (dialog.hasAttribute('open')) {
|
||||
dialog.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
showTokenModel.value = !showTokenModel.value
|
||||
}
|
||||
|
||||
const qrCodeValue = ref('');
|
||||
|
||||
|
||||
// 关闭模态框
|
||||
const modalRef = ref<HTMLDialogElement | null>(null);
|
||||
const openModal = () => {
|
||||
modalRef.value?.showModal();
|
||||
};
|
||||
const closeModal = async () => {
|
||||
if (modalRef.value) {
|
||||
modalRef.value.close();
|
||||
}
|
||||
await authStore.refreshProfile();
|
||||
};
|
||||
</script>
|
||||
@@ -89,7 +89,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
||||
:class="!user.active && 'toggle-error'" v-model="user.active" @change="updateStatus(user)"
|
||||
:class="user.status !== 'active' && 'toggle-error'" :checked="user.status === 'active'" @change="updateStatus(user)"
|
||||
:aria-label="`Toggle user ${user.username}`" />
|
||||
</td>
|
||||
<td class="text-right tabular-nums">
|
||||
@@ -265,7 +265,7 @@ const handleBatchAction = async (action: string) => {
|
||||
// 更新用户状态
|
||||
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) {
|
||||
|
||||
@@ -70,39 +70,6 @@
|
||||
<option :value="10">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="user-language" class="mb-1 block text-sm font-medium">Language</label>
|
||||
<select id="user-language" name="language" v-model="newUser.language" class="select select-bordered w-full">
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="user-quota" class="mb-1 block text-sm font-medium">Quota</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="user-quota" name="quota" type="number" inputmode="numeric" v-model="newUser.quota"
|
||||
placeholder="e.g. 10" autocomplete="off"
|
||||
class="input input-bordered w-full flex-grow" :disabled="newUser.unlimited_quota" />
|
||||
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
|
||||
<input type="checkbox" name="unlimited_quota" v-model="newUser.unlimited_quota" class="checkbox checkbox-sm" />
|
||||
Unlimited
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||
<div class="flex h-9 items-center gap-3">
|
||||
<input type="checkbox" name="active" v-model="newUser.active" role="switch"
|
||||
class="toggle toggle-sm" :class="newUser.active ? 'toggle-success' : 'toggle-error'"
|
||||
aria-label="User active" />
|
||||
<span class="text-sm text-base-content/70">
|
||||
{{ newUser.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -135,28 +102,18 @@ const showAdvancedOptions = ref(false)
|
||||
|
||||
// Initialize user object
|
||||
const newUser = ref<NewUserPayload>({
|
||||
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;
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
<h2 class="truncate text-lg font-semibold tracking-tight">{{ user?.name || user?.username }}</h2>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-sm"
|
||||
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user.active ? 'Active' : 'Inactive' }}
|
||||
:class="user.status === 'active' ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user.status === 'active' ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
<span class="badge badge-sm"
|
||||
:class="user.role > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">
|
||||
@@ -31,8 +31,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" class="toggle toggle-md" :class="user.active ? 'toggle-success' : 'toggle-error'"
|
||||
v-model="user.active" @change="updateStatus(user)" :aria-label="`Toggle user ${user.username} status`" />
|
||||
<input type="checkbox" class="toggle toggle-md" :class="user.status === 'active' ? 'toggle-success' : 'toggle-error'"
|
||||
:checked="user.status === 'active'" @change="updateStatus(user)" :aria-label="`Toggle user ${user.username} status`" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user