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:
Sakurasan
2026-08-30 15:21:49 +08:00
parent 96e4853d6e
commit 094230a293
26 changed files with 1141 additions and 912 deletions
+9 -3
View File
@@ -1,12 +1,18 @@
.PHONY: build run test clean fmt lint .PHONY: build run test clean fmt lint frontend
BINARY_NAME=openteam BINARY_NAME=openteam
BUILD_DIR=bin BUILD_DIR=bin
BACKEND_DIR=backend BACKEND_DIR=backend
# Build # Build
build: build: frontend
cd $(BACKEND_DIR) && CGO_ENABLED=1 go build -o ../$(BUILD_DIR)/$(BINARY_NAME) ./cmd/openteam 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
run: build run: build
+11 -9
View File
@@ -1,6 +1,6 @@
module opencatd-open module opencatd-open
go 1.23.2 go 1.25.0
require ( require (
github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/cors v1.7.2
@@ -12,15 +12,15 @@ require (
github.com/google/wire v0.6.0 github.com/google/wire v0.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9 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/pkoukk/tiktoken-go v0.1.7
github.com/sashabaranov/go-openai v1.42.0 github.com/sashabaranov/go-openai v1.42.0
github.com/spf13/cobra v1.9.1 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 golang.org/x/time v0.10.0
gorm.io/driver/mysql v1.5.7 gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.11 gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1
gorm.io/gorm v1.30.0
) )
require ( require (
@@ -52,20 +52,22 @@ require (
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-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/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
golang.org/x/arch v0.16.0 // indirect golang.org/x/arch v0.16.0 // indirect
golang.org/x/net v0.39.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.13.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.32.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.24.0 // indirect golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+20 -16
View File
@@ -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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 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= 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-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 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 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= 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.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.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= 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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 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.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 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-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.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.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.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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.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.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 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.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.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 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 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 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= 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/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 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= 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.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+336 -18
View File
@@ -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 --- // --- Auth ---
func (h *Handler) Register(c *gin.Context) { func (h *Handler) Register(c *gin.Context) {
var req struct { var req struct {
Username string `json:"username" binding:"required"` Username string `json:"username" binding:"required"`
Password string `json:"password" 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 { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 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) hash := crypto.Sha256Hex(req.Password)
email := req.Email
if email == "" {
email = req.Username + "@placeholder.local"
}
user := &store.User{ user := &store.User{
Username: req.Username, Username: req.Username,
Email: req.Email, Email: email,
PasswordHash: hash, PasswordHash: hash,
Role: role, Role: role,
Status: store.UserStatusActive, Status: store.UserStatusActive,
@@ -127,34 +151,121 @@ func (h *Handler) Me(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return 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{ c.JSON(http.StatusOK, gin.H{
"code": 200, "code": 200,
"data": gin.H{ "data": userToResponse(user),
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": roleNum,
"status": user.Status,
},
}) })
} }
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 --- // --- Users ---
func (h *Handler) ListUsers(c *gin.Context) { func (h *Handler) ListUsers(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) 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) users, total, err := h.userDAO.List(limit, offset)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return 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) { func (h *Handler) CreateUser(c *gin.Context) {
@@ -188,7 +299,87 @@ func (h *Handler) CreateUser(c *gin.Context) {
return 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) { 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) { func (h *Handler) ListApiKeys(c *gin.Context) {
userID, _ := c.Get("user_id") userID, _ := c.Get("user_id")
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) 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) keys, total, err := h.apiKeyDAO.ListByUserID(userID.(uint64), limit, offset)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 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}) 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) { func (h *Handler) CreateApiKey(c *gin.Context) {
var req struct { var req struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
@@ -236,7 +455,8 @@ func (h *Handler) CreateApiKey(c *gin.Context) {
UserID: userID.(uint64), UserID: userID.(uint64),
Name: req.Name, Name: req.Name,
KeyHash: apikey.Hash(keyValue), KeyHash: apikey.Hash(keyValue),
KeyPrefix: keyValue[:8], KeyPlain: keyValue,
KeyPrefix: keyValue[:12],
QuotaTokensPerDay: req.QuotaTokensPerDay, QuotaTokensPerDay: req.QuotaTokensPerDay,
QuotaRequestsPerDay: req.QuotaRequestsPerDay, QuotaRequestsPerDay: req.QuotaRequestsPerDay,
Status: store.KeyStatusActive, Status: store.KeyStatusActive,
@@ -249,10 +469,80 @@ func (h *Handler) CreateApiKey(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"key": keyValue, "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) { func (h *Handler) DeleteApiKey(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@@ -269,8 +559,22 @@ func (h *Handler) DeleteApiKey(c *gin.Context) {
// --- Channels --- // --- Channels ---
func (h *Handler) ListChannels(c *gin.Context) { func (h *Handler) ListChannels(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) 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) channels, total, err := h.channelDAO.List(limit, offset)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -401,8 +705,22 @@ func (h *Handler) DeleteChannel(c *gin.Context) {
// --- Models --- // --- Models ---
func (h *Handler) ListModels(c *gin.Context) { func (h *Handler) ListModels(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) 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) models, total, err := h.modelDAO.List(limit, offset)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+2 -2
View File
@@ -6,9 +6,9 @@ import (
"opencatd-open/pkg/config" "opencatd-open/pkg/config"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"github.com/ncruces/go-sqlite3/gormlite"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -52,7 +52,7 @@ func sqliteDialector(dsn string) gorm.Dialector {
if dsn == "" { if dsn == "" {
dsn = "opencatd.db" dsn = "opencatd.db"
} }
return sqlite.Open(dsn) return gormlite.Open(dsn)
} }
func postgresDialector(dsn string) gorm.Dialector { func postgresDialector(dsn string) gorm.Dialector {
+1
View File
@@ -58,6 +58,7 @@ type APIKey struct {
UserID uint64 `gorm:"index;not null" json:"user_id"` UserID uint64 `gorm:"index;not null" json:"user_id"`
Name string `gorm:"size:64;not null" json:"name"` Name string `gorm:"size:64;not null" json:"name"`
KeyHash string `gorm:"uniqueIndex;size:64;not null" json:"-"` 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"` KeyPrefix string `gorm:"size:32;not null" json:"key_prefix"`
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day,omitempty"` QuotaTokensPerDay *int64 `json:"quota_tokens_per_day,omitempty"`
QuotaRequestsPerDay *int `json:"quota_requests_per_day,omitempty"` QuotaRequestsPerDay *int `json:"quota_requests_per_day,omitempty"`
+8
View File
@@ -80,16 +80,24 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
// User profile // User profile
apiGroup.GET("/me", apiHandler.Me) apiGroup.GET("/me", apiHandler.Me)
apiGroup.GET("/profile", apiHandler.Me) apiGroup.GET("/profile", apiHandler.Me)
apiGroup.POST("/profile/update", apiHandler.UpdateProfile)
apiGroup.POST("/profile/update/password", apiHandler.UpdatePassword)
// User management (admin) // User management (admin)
apiGroup.GET("/users", apiHandler.ListUsers) apiGroup.GET("/users", apiHandler.ListUsers)
apiGroup.GET("/users/:id", apiHandler.GetUser)
apiGroup.POST("/users", apiHandler.CreateUser) apiGroup.POST("/users", apiHandler.CreateUser)
apiGroup.PUT("/users/:id", apiHandler.UpdateUser)
apiGroup.DELETE("/users/:id", apiHandler.DeleteUser) apiGroup.DELETE("/users/:id", apiHandler.DeleteUser)
apiGroup.POST("/users/batch/:option", apiHandler.BatchUsers)
// API Key management // API Key management
apiGroup.GET("/keys", apiHandler.ListApiKeys) apiGroup.GET("/keys", apiHandler.ListApiKeys)
apiGroup.GET("/keys/:id", apiHandler.GetApiKey)
apiGroup.POST("/keys", apiHandler.CreateApiKey) apiGroup.POST("/keys", apiHandler.CreateApiKey)
apiGroup.PUT("/keys/:id", apiHandler.UpdateApiKey)
apiGroup.DELETE("/keys/:id", apiHandler.DeleteApiKey) apiGroup.DELETE("/keys/:id", apiHandler.DeleteApiKey)
apiGroup.POST("/keys/batch/:option", apiHandler.BatchApiKeys)
// Channel management // Channel management
apiGroup.GET("/channels", apiHandler.ListChannels) apiGroup.GET("/channels", apiHandler.ListChannels)
@@ -41,15 +41,15 @@ const route = useRoute();
const crumbsByRouteName: Record<string, Crumb[]> = { const crumbsByRouteName: Record<string, Crumb[]> = {
// 管理后台列表页:区域根 // 管理后台列表页:区域根
User: [{ label: '管理后台', path: '/dashboard/manager/users' }], User: [{ label: '管理后台', path: '/dashboard/manager/users' }],
ApiKey: [{ label: '管理后台', path: '/dashboard/manager/users' }], Channels: [{ label: '管理后台', path: '/dashboard/manager/users' }],
// 详情页:区域根 / 列表页(末级为当前页标题) // 详情页:区域根 / 列表页(末级为当前页标题)
UserView: [ UserView: [
{ label: '管理后台', path: '/dashboard/manager/users' }, { label: '管理后台', path: '/dashboard/manager/users' },
{ label: '用户管理', path: '/dashboard/manager/users' }, { label: '用户管理', path: '/dashboard/manager/users' },
], ],
ApiKeyView: [ ChannelView: [
{ label: '管理后台', path: '/dashboard/manager/users' }, { label: '管理后台', path: '/dashboard/manager/users' },
{ label: '渠道管理', path: '/dashboard/manager/keys' }, { label: '渠道管理', path: '/dashboard/manager/channels' },
], ],
}; };
+2 -1
View File
@@ -8,7 +8,8 @@ const router = createRouter({
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
const isAuthenticated = localStorage.getItem('token') const isAuthenticated = localStorage.getItem('token')
if (to.meta.requiresAuth && !isAuthenticated) { const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
if (requiresAuth && !isAuthenticated) {
next('/login') next('/login')
} else { } else {
next() next()
+138
View File
@@ -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,
};
});
+2 -2
View File
@@ -24,8 +24,8 @@ export const useKeyStore = defineStore('key', () => {
}, },
}); });
keys.value = response.data.data?.keys ?? []; keys.value = response.data.data ?? [];
totalKeys.value = response.data.data?.total ?? 0; totalKeys.value = response.data.total ?? 0;
} catch (err: any) { } catch (err: any) {
error.value = err.response?.data?.error || '获取ApiKeys失败'; error.value = err.response?.data?.error || '获取ApiKeys失败';
throw error throw error
+2 -2
View File
@@ -37,8 +37,8 @@ export const useUserStore = defineStore('user', () => {
active, active,
}, },
}); });
users.value = response.data.data?.users ?? []; users.value = response.data.data ?? [];
totalUsers.value = response.data.data?.total ?? 0; totalUsers.value = response.data.total ?? 0;
} catch (err: any) { } catch (err: any) {
error.value = err.response?.data?.error || '获取用户列表失败'; error.value = err.response?.data?.error || '获取用户列表失败';
throw err; throw err;
+20 -14
View File
@@ -8,7 +8,8 @@ export type UserInfo = {
avatar_url?: string avatar_url?: string
avatar?: string avatar?: string
role: number role: number
active: boolean status?: string
active?: boolean
email_verified?: boolean email_verified?: boolean
timezone?: string timezone?: string
language?: string language?: string
@@ -41,10 +42,23 @@ export type TokenInfo = {
export type ApiKey = { export type ApiKey = {
id: number id: number
type: string user_id?: number
name: string 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 apikey?: string
active: boolean active?: boolean
endpoint?: string endpoint?: string
resource_name?: string resource_name?: string
api_secret?: string api_secret?: string
@@ -85,17 +99,8 @@ export type TokenPayload = {
export type NewApiKeyPayload = { export type NewApiKeyPayload = {
name: string name: string
type: string quota_tokens_per_day?: number
apikey: string quota_requests_per_day?: number
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[]
} }
export type NewUserPayload = { export type NewUserPayload = {
@@ -104,6 +109,7 @@ export type NewUserPayload = {
email?: string email?: string
name?: string name?: string
role?: number role?: number
status?: string
active?: boolean active?: boolean
quota?: number quota?: number
unlimited_quota?: boolean unlimited_quota?: boolean
+5 -5
View File
@@ -38,7 +38,7 @@ export const routes: RouteRecordRaw[] = [
redirect: '/dashboard/overview', redirect: '/dashboard/overview',
children: [ children: [
{ path: 'overview', name: 'Overview', component: () => import('@/views/dashboard/Overview.vue'), meta: { title: '仪表盘' } }, { 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', path: 'manager',
name: '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', 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/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: '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: 'channels', name: 'Channels', component: () => import('@/views/dashboard/Keys.vue'), meta: { title: '渠道管理' } },
{ path: 'keys/view', name: 'ApiKeyView', component: () => import('@/views/dashboard/KeyView.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[] = [ export const consoleMenu: MenuLink[] = [
{ label: '仪表盘', to: '/dashboard/overview', icon: GaugeIcon }, { 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 }, { label: '账户设置', to: '/dashboard/settings/profile', icon: SettingsIcon },
] ]
// 管理后台菜单(role >= 10) // 管理后台菜单(role >= 10)
export const adminMenu: MenuLink[] = [ export const adminMenu: MenuLink[] = [
{ label: '用户管理', to: '/dashboard/manager/users', icon: UsersRoundIcon }, { label: '用户管理', to: '/dashboard/manager/users', icon: UsersRoundIcon },
{ label: '渠道管理', to: '/dashboard/manager/keys', icon: GlobeIcon }, { label: '渠道管理', to: '/dashboard/manager/channels', icon: GlobeIcon },
] ]
+243
View File
@@ -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>
+58 -161
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="mx-auto w-full max-w-3xl"> <div class="mx-auto w-full max-w-3xl">
<header class="mb-4 pr-8"> <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> <p class="mt-0.5 text-sm text-base-content/60">Connect an upstream LLM provider to your team.</p>
</header> </header>
@@ -11,53 +11,48 @@
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button> <button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
</div> </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"> <div class="card-body gap-5 p-4 sm:p-6">
<section class="space-y-4"> <section class="space-y-4">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3> <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 class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
<div> <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> Name <span class="text-error" aria-hidden="true">*</span>
</label> </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 /> autocomplete="off" spellcheck="false" class="input input-bordered w-full" required />
</div> </div>
<div> <div>
<label for="key-type" class="mb-1 block text-sm font-medium"> <label for="ch-provider" class="mb-1 block text-sm font-medium">
Type <span class="text-error" aria-hidden="true">*</span> Provider <span class="text-error" aria-hidden="true">*</span>
</label> </label>
<div class="relative"> <select id="ch-provider" name="provider" v-model="newChannel.provider" required
<select id="key-type" name="type" v-model="newApiKey.type" required class="select select-bordered w-full">
class="select select-bordered w-full pl-10">
<option disabled value="">Select provider</option> <option disabled value="">Select provider</option>
<option value="openai">OpenAI</option> <option value="openai">OpenAI</option>
<option value="claude">Claude</option> <option value="anthropic">Anthropic</option>
<option value="gemini">Gemini</option> <option value="compatible">OpenAI Compatible</option>
<option value="azure">Azure</option>
<option value="github">GitHub</option>
<option value="openai-compatible">OpenAI Compatible</option>
</select> </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>
</div> </div>
<div> <div>
<label for="key-apikey" class="mb-1 block text-sm font-medium"> <label for="ch-base-url" class="mb-1 block text-sm font-medium">
API Key <span class="text-error" aria-hidden="true">*</span> Base URL <span class="text-error" aria-hidden="true">*</span>
</label> </label>
<input id="key-apikey" name="apikey" type="text" v-model="newApiKey.apikey" <input id="ch-base-url" name="base_url" type="url" v-model="newChannel.base_url"
placeholder="sk-…" autocomplete="off" spellcheck="false" placeholder="https://api.openai.com" autocomplete="off" spellcheck="false"
class="input input-bordered w-full font-mono text-sm" required /> class="input input-bordered w-full font-mono text-sm" required />
</div> </div>
<div> <div>
<label for="key-endpoint" class="mb-1 block text-sm font-medium">Endpoint</label> <label for="ch-api-key" class="mb-1 block text-sm font-medium">
<input id="key-endpoint" name="endpoint" type="url" v-model="newApiKey.endpoint" API Key <span class="text-error" aria-hidden="true">*</span>
placeholder="https://api.example.com/v1" spellcheck="false" </label>
class="input input-bordered w-full font-mono text-sm" /> <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>
</div> </div>
</section> </section>
@@ -70,57 +65,14 @@
<div class="collapse-content px-3"> <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 class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
<div> <div>
<label for="key-resource" class="mb-1 block text-sm font-medium">Resource Name</label> <label for="ch-priority" class="mb-1 block text-sm font-medium">Priority</label>
<input id="key-resource" name="resource_name" type="text" v-model="newApiKey.resource_name" <input id="ch-priority" name="priority" type="number" v-model.number="newChannel.priority"
placeholder="Azure resource name" autocomplete="off" spellcheck="false" placeholder="0" autocomplete="off" class="input input-bordered w-full" />
class="input input-bordered w-full" />
</div> </div>
<div> <div>
<label for="key-secret" class="mb-1 block text-sm font-medium">API Secret</label> <label for="ch-weight" class="mb-1 block text-sm font-medium">Weight</label>
<input id="key-secret" name="api_secret" type="text" v-model="newApiKey.api_secret" <input id="ch-weight" name="weight" type="number" v-model.number="newChannel.weight"
placeholder="Optional secret" autocomplete="off" spellcheck="false" placeholder="1" autocomplete="off" class="input input-bordered w-full" />
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>
</div> </div>
</div> </div>
</div> </div>
@@ -130,7 +82,7 @@
<button type="button" @click="cancel" class="btn btn-ghost btn-sm">Cancel</button> <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"> <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> <span v-if="creating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Create API Key Create Channel
</button> </button>
</div> </div>
</div> </div>
@@ -140,129 +92,74 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useKeyStore } from '@/stores/key'; import { useChannelStore, type NewChannelPayload } from '@/stores/channel'
import { CircleAlert } from '@lucide/vue'; import { CircleAlert } from '@lucide/vue'
import { useToast } from '@/composables/toast'; import { useToast } from '@/composables/toast'
import type { NewApiKeyPayload } from '@/types';
const keyStore = useKeyStore() const channelStore = useChannelStore()
const { setToast } = useToast() const { setToast } = useToast()
const error = ref<string | null>(null) const error = ref<string | null>(null)
const creating = ref(false) const creating = ref(false)
// Control advanced options visibility
const showAdvancedOptions = ref(false) const showAdvancedOptions = ref(false)
// Initialize API key object const newChannel = ref<NewChannelPayload>({
const newApiKey = ref<NewApiKeyPayload>({
name: '', name: '',
type: '', provider: '',
apikey: '', base_url: '',
active: true, api_key: '',
endpoint: '', priority: 0,
resource_name: '', weight: 1,
// deployment_name: '',
api_secret: '',
model_prefix: '',
model_alias: '',
parameters: '{}',
support_models: '[]',
support_models_array: [],
}) })
const resetNewApiKey = () => { const resetNewChannel = () => {
newApiKey.value = { newChannel.value = {
name: '', name: '',
type: '', provider: '',
apikey: '', base_url: '',
active: true, api_key: '',
endpoint: '', priority: 0,
resource_name: '', weight: 1,
// deployment_name: '',
api_secret: '',
model_prefix: '',
model_alias: '',
parameters: '{}',
support_models: '[]',
support_models_array: [],
} }
} }
const onchange_supportmodel = () => {
newApiKey.value.support_models = JSON.stringify(newApiKey.value.support_models_array)
}
// Form validation
const isFormValid = computed(() => { const isFormValid = computed(() => {
return newApiKey.value.name && return newChannel.value.name &&
newApiKey.value.type && newChannel.value.provider &&
newApiKey.value.apikey newChannel.value.base_url &&
newChannel.value.api_key
}) })
const cancel = () => { const cancel = () => {
resetNewApiKey() resetNewChannel()
emit('closeModal', true) emit('closeModal', true)
} }
const apiKeyImageMap: Record<string, string> = { const createChannel = async () => {
'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 () => {
if (!isFormValid.value) { if (!isFormValid.value) {
setToast('Please fill in all required fields (Name, Type, API Key).', 'error') setToast('Please fill in all required fields.', 'error')
return return
} }
creating.value = true creating.value = true
try { try {
try { const res = await channelStore.createChannel(newChannel.value)
if (!Array.isArray(newApiKey.value.support_models_array)) { if (res.data?.id) {
setToast('Support Models must be a JSON array.', 'error'); error.value = null
return; resetNewChannel()
} setToast('Channel created successfully.', 'success')
} 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')
emit('closeModal', true) emit('closeModal', true)
} else { } 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) { } catch (err: any) {
console.log('createApiKey error:', err) error.value = err.response?.data?.error || 'Failed to create channel'
error.value = err?.message || String(err) || 'Failed to create API Key'
} finally { } finally {
creating.value = false creating.value = false
} }
} }
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'closeModal', value: boolean): void (e: 'closeModal', value: boolean): void
}>() }>()
</script> </script>
+65 -103
View File
@@ -1,52 +1,40 @@
<template> <template>
<div class="space-y-5"> <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"> <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"> <section class="space-y-4">
<h2 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h2> <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 class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
<div> <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> Name <span class="text-error" aria-hidden="true">*</span>
</label> </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 /> class="input input-bordered w-full" required />
</div> </div>
<div> <div>
<label for="key-type" class="mb-1 block text-sm font-medium"> <label for="ch-provider" class="mb-1 block text-sm font-medium">
Type <span class="text-error" aria-hidden="true">*</span> Provider
</label> </label>
<div class="relative"> <div class="input input-bordered w-full bg-base-200">{{ ch.provider }}</div>
<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> </div>
<div> <div>
<label for="key-apikey" class="mb-1 block text-sm font-medium"> <label for="ch-base-url" class="mb-1 block text-sm font-medium">
API Key <span class="text-error" aria-hidden="true">*</span> Base URL <span class="text-error" aria-hidden="true">*</span>
</label> </label>
<input id="key-apikey" name="apikey" type="text" v-model="key.apikey" autocomplete="off" <input id="ch-base-url" name="base_url" type="url" v-model="ch.base_url" spellcheck="false"
spellcheck="false" class="input input-bordered w-full font-mono text-sm" required /> class="input input-bordered w-full font-mono text-sm" required />
</div> </div>
<div> <div>
<label for="key-endpoint" class="mb-1 block text-sm font-medium">Endpoint</label> <label for="ch-api-key" class="mb-1 block text-sm font-medium">API Key</label>
<input id="key-endpoint" name="endpoint" type="url" v-model="key.endpoint" spellcheck="false" <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" /> class="input input-bordered w-full font-mono text-sm" />
</div> </div>
</div> </div>
@@ -60,48 +48,36 @@
<div class="collapse-content px-3"> <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 class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
<div> <div>
<label for="key-resource" class="mb-1 block text-sm font-medium">Resource Name</label> <label for="ch-priority" class="mb-1 block text-sm font-medium">Priority</label>
<input id="key-resource" name="resource_name" type="text" v-model="key.resource_name" <input id="ch-priority" name="priority" type="number" v-model.number="ch.priority"
autocomplete="off" spellcheck="false" class="input input-bordered w-full" /> autocomplete="off" class="input input-bordered w-full" />
</div> </div>
<div> <div>
<label for="key-secret" class="mb-1 block text-sm font-medium">API Secret</label> <label for="ch-weight" class="mb-1 block text-sm font-medium">Weight</label>
<input id="key-secret" name="api_secret" type="text" v-model="key.api_secret" autocomplete="off" <input id="ch-weight" name="weight" type="number" v-model.number="ch.weight"
spellcheck="false" class="input input-bordered w-full font-mono text-sm" /> autocomplete="off" class="input input-bordered w-full" />
</div> </div>
<div> <div>
<label for="key-prefix" class="mb-1 block text-sm font-medium">Model Prefix</label> <label for="ch-timeout" class="mb-1 block text-sm font-medium">Timeout (ms)</label>
<input id="key-prefix" name="model_prefix" type="text" v-model="key.model_prefix" autocomplete="off" <input id="ch-timeout" name="timeout_ms" type="number" v-model.number="ch.timeout_ms"
spellcheck="false" class="input input-bordered w-full" /> autocomplete="off" class="input input-bordered w-full" />
</div> </div>
<div> <div>
<label for="key-alias" class="mb-1 block text-sm font-medium">Model Alias</label> <label for="ch-max-concurrency" class="mb-1 block text-sm font-medium">Max Concurrency</label>
<textarea id="key-alias" name="model_alias" v-model="key.model_alias" rows="2" placeholder='{}' <input id="ch-max-concurrency" name="max_concurrency" type="number" v-model.number="ch.max_concurrency"
spellcheck="false" class="textarea textarea-bordered w-full font-mono text-sm"></textarea> autocomplete="off" class="input input-bordered w-full" />
</div> </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> <div>
<span class="mb-1 block text-sm font-medium">Status</span> <span class="mb-1 block text-sm font-medium">Status</span>
<div class="flex h-9 items-center gap-3"> <div class="flex h-9 items-center gap-3">
<input type="checkbox" name="active" v-model="key.active" role="switch" class="toggle toggle-sm" <input type="checkbox" name="enabled" :checked="ch.enabled" role="switch" class="toggle toggle-sm"
:class="key.active ? 'toggle-success' : 'toggle-error'" aria-label="Key active" /> :class="ch.enabled ? 'toggle-success' : 'toggle-error'" @change="toggleEnabled" aria-label="Channel enabled" />
<span class="text-sm text-base-content/70"> <span class="text-sm text-base-content/70">
{{ key.active ? 'Active' : 'Inactive' }} {{ ch.enabled ? 'Enabled' : 'Disabled' }}
</span> </span>
</div> </div>
</div> </div>
@@ -110,7 +86,7 @@
</section> </section>
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4"> <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"> <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> <span v-if="updating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Save Changes Save Changes
@@ -123,7 +99,7 @@
<!-- Loading state --> <!-- Loading state -->
<div v-else class="card border border-base-300/60 bg-base-100 shadow-sm"> <div v-else class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body"> <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> <span class="loading loading-spinner loading-lg text-primary"></span>
</div> </div>
</div> </div>
@@ -132,76 +108,62 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; 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 BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import TagInput from '@/components/common/TagInput.vue';
import { useToast } from '@/composables/toast'; import { useToast } from '@/composables/toast';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const keyStore = useKeyStore(); const channelStore = useChannelStore();
const { setToast } = useToast(); const { setToast } = useToast();
const updating = ref(false); const updating = ref(false);
const api_key = ref('');
const keyId = computed(() => route.query.id); const channelId = computed(() => route.query.id);
const ch = computed(() => channelStore.channel);
const key = computed(() => keyStore.key);
const loading = computed(() => keyStore.loading);
onMounted(async () => { onMounted(async () => {
console.log('keyId', keyId.value) if (channelId.value) {
if (keyId.value) { await channelStore.fetchChannel(channelId.value as string);
await keyStore.fetchKey(keyId.value as string);
} }
}); });
const keyOption = reactive([ const toggleEnabled = () => {
{name: 'openai', label: 'OpenAI'}, if (!ch.value) return;
{name: 'claude', label: 'Claude'}, ch.value.enabled = !ch.value.enabled;
{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 apiKeyImageUrl = (keytype: string) => { const updateCh = async () => {
return apiKeyImageMap[keytype] || '/assets/logo.svg'; if (!ch.value) return;
};
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;
updating.value = true; updating.value = true;
try { try {
const res = await keyStore.updateKey(key.value); const payload: Partial<Channel> & { api_key?: string } = {
console.log('updateKey', res) name: ch.value.name,
if (res.data?.code == 200) { base_url: ch.value.base_url,
setToast(`Key ${key.value.name} updated`, 'success'); 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) { } catch (err: any) {
console.error('Error updating key:', err); console.error('Error updating channel:', err);
} finally { } finally {
updating.value = false; updating.value = false;
} }
}; };
const cancel = () => { const goBack = () => {
router.push({ name: 'ApiKey' }); router.push({ name: 'Channels' });
} };
</script> </script>
+92 -169
View File
@@ -3,7 +3,7 @@
<BreadcrumbHeader /> <BreadcrumbHeader />
<div class="flex flex-wrap items-center justify-between gap-3"> <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="flex items-center gap-2">
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-square btn-sm" aria-label="Batch actions"> <button tabindex="0" class="btn btn-ghost btn-square btn-sm" aria-label="Batch actions">
@@ -27,71 +27,62 @@
</li> </li>
</ul> </ul>
</div> </div>
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create 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 API Key <PlusIcon class="h-4 w-4" aria-hidden="true" />New Channel
</button> </button>
</div> </div>
</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 --> <!-- Table -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm"> <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"> <table class="table table-sm">
<thead> <thead>
<tr class="text-xs uppercase tracking-wider text-base-content/50"> <tr class="text-xs uppercase tracking-wider text-base-content/50">
<th class="pl-4"> <th class="pl-4">
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll" <input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll"
aria-label="Select all keys" /> aria-label="Select all channels" />
</th> </th>
<th>Type</th>
<th>Name</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> <th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
</tr> </tr>
</thead> </thead>
<tbody> <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"> <td class="pl-4">
<input type="checkbox" class="checkbox checkbox-xs" v-model="key.selected" <input type="checkbox" class="checkbox checkbox-xs" v-model="ch.selected"
@change="toggleUserSelection(key)" :aria-label="`Select key ${key.name}`" /> @change="toggleSelection(ch)" :aria-label="`Select channel ${ch.name}`" />
</td> </td>
<td class="max-w-40 truncate font-medium">{{ ch.name }}</td>
<td> <td>
<div class="flex items-center gap-2"> <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" /> <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">{{ key.type }}</span> <span class="text-sm capitalize">{{ ch.provider }}</span>
</div> </div>
</td> </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> <td>
<input type="checkbox" class="toggle toggle-success toggle-sm" <input type="checkbox" class="toggle toggle-success toggle-sm"
:class="!key.active && 'toggle-error'" v-model="key.active" @change="updateStatus(key)" :class="!ch.enabled && 'toggle-error'" :checked="ch.enabled" @change="toggleEnabled(ch)"
:aria-label="`Toggle key ${key.name}`" /> :aria-label="`Toggle channel ${ch.name}`" />
</td> </td>
<td class="pr-3"> <td class="pr-3">
<div class="flex items-center justify-end gap-1"> <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" /> <EyeIcon class="h-4 w-4" aria-hidden="true" />
</button> </button>
<button class="btn btn-ghost btn-xs btn-square text-error" @click="confirmDeleteKey(key)" <button class="btn btn-ghost btn-xs btn-square text-error" @click="confirmDeleteChannel(ch)"
aria-label="Delete key"> aria-label="Delete channel">
<TrashIcon class="h-4 w-4" aria-hidden="true" /> <TrashIcon class="h-4 w-4" aria-hidden="true" />
</button> </button>
</div> </div>
@@ -103,13 +94,13 @@
<!-- Empty state --> <!-- Empty state -->
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center"> <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" /> <GlobeIcon class="h-10 w-10 text-base-content/20" aria-hidden="true" />
<h2 class="text-sm font-semibold">No API keys yet</h2> <h2 class="text-sm font-semibold">No channels yet</h2>
<p class="max-w-xs text-sm text-base-content/60"> <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> </p>
<button class="btn btn-primary btn-sm mt-2" @click="openModal"> <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> </button>
</div> </div>
</div> </div>
@@ -118,7 +109,7 @@
<Pagination v-if="totalItems > 0" :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize" <Pagination v-if="totalItems > 0" :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" /> :pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" />
<!-- New key modal --> <!-- New channel modal -->
<dialog ref="modalRef" class="modal"> <dialog ref="modalRef" class="modal">
<div class="modal-box max-w-3xl px-0 sm:px-6"> <div class="modal-box max-w-3xl px-0 sm:px-6">
<form method="dialog"> <form method="dialog">
@@ -134,193 +125,125 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'; import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import Pagination from '@/components/common/Pagination.vue'; import Pagination from '@/components/common/Pagination.vue';
import KeyNew from '@/views/dashboard/KeyNew.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 { useToast } from '@/composables/toast';
import type { ApiKey } from '@/types';
import { import {
BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon, BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon,
TrashIcon, KeyRoundIcon, ListFilterIcon TrashIcon, GlobeIcon
} from '@lucide/vue'; } from '@lucide/vue';
const router = useRouter(); const router = useRouter();
const keyStore = useKeyStore(); const channelStore = useChannelStore();
const { setToast } = useToast(); const { setToast } = useToast();
onMounted(async () => { onMounted(async () => {
await keyStore.fetchKeys(); await fetchChannels();
}) })
const keys = computed(() => keyStore.keys); const channels = computed(() => channelStore.channels);
// 用户数据
const currentPage = ref(1); const currentPage = ref(1);
const pageSize = ref(10); const pageSize = ref(20);
const totalItems = computed(() => keyStore.totalKeys); const totalItems = computed(() => channelStore.totalChannels);
const fetchChannels = async (size?: number, page?: number) => {
// 封装公共的用户列表获取方法
const fetchKeys = async (size?: number, page?: number, active?: boolean[] | boolean) => {
currentPage.value = page || currentPage.value; 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) => { const changePage = async (page: number, size: number) => {
if (page == currentPage.value && size == pageSize.value) { if (page == currentPage.value && size == pageSize.value) return;
return
}
currentPage.value = page; currentPage.value = page;
pageSize.value = size; pageSize.value = size;
await fetchKeys(); await fetchChannels();
}; };
// 复选框选择状态 const selectAll = ref(false);
const selectAll = ref(false) const selectedChannels = ref<Channel[]>([]);
const selectedKeys = ref<ApiKey[]>([])
const toggleSelectAll = () => { const toggleSelectAll = () => {
if (keys.value.length === 0) { channels.value.forEach(ch => ch.selected = selectAll.value);
return selectedChannels.value = selectAll.value ? [...channels.value] : [];
}
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;
}; };
// 状态筛选 const toggleSelection = (ch: Channel) => {
const statusOptions = ['Active', 'Inactive']; if (selectedChannels.value.includes(ch)) {
const selectedStatuses = reactive<{ status: string; value: boolean }[]>([]); selectedChannels.value = selectedChannels.value.filter(s => s !== ch);
const toggleStatusFilter = async (status: string) => {
const statusValue = status === 'Active';
const index = selectedStatuses.findIndex(item => item.status === status);
if (index > -1) {
selectedStatuses.splice(index, 1);
} else { } else {
selectedStatuses.push({ status, value: statusValue }); selectedChannels.value.push(ch);
} }
selectAll.value = selectedChannels.value.length === channels.value.length;
await fetchKeys(undefined, 1, undefined);
}; };
// 处理批量操作
const handleBatchAction = async (action: string) => { const handleBatchAction = async (action: string) => {
if (selectedKeys.value.length === 0) { if (selectedChannels.value.length === 0) {
return setToast('请选择数据', 'error'); return setToast('Please select channels first', 'error');
} }
if (!['enable', 'disable', 'delete'].includes(action)) {
return setToast(`无效的操作 ${action}`, 'error');
}
try { try {
const res = await keyStore.keyOption(action, selectedKeys.value.map(item => item.id)); const ids = selectedChannels.value.map(ch => ch.id);
if (res.data?.code === 200) { if (action === 'delete') {
setToast(`Key ${action} Success`, 'success'); for (const id of ids) {
await channelStore.deleteChannel(id);
}
} else { } 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; selectAll.value = false;
await fetchKeys(); await fetchChannels();
} catch (error: any) { } catch (error: any) {
console.error(`批量操作 ${action} 失败:`, error); setToast(`Batch ${action} failed`, 'error');
setToast('批量操作失败', 'error');
} }
}; };
// 更新用户状态 const toggleEnabled = async (ch: Channel) => {
const updateStatus = async (key: ApiKey) => {
try { try {
const action = key.active ? 'enable' : 'disable'; await channelStore.updateChannel(ch.id, { enabled: !ch.enabled });
const res = await keyStore.keyOption(action, [key.id]); setToast(`Channel ${ch.name} ${ch.enabled ? 'disabled' : 'enabled'}`, 'success');
await fetchChannels();
if (res.data?.code === 200) {
setToast(`Key ${key.name} has been ${action}`, 'success');
}
await fetchKeys();
} catch (error: any) { } catch (error: any) {
console.error('状态更新失败:', error); setToast('Status update failed', 'error');
setToast('状态更新失败', 'error');
} }
}; };
const viewKey = (key: ApiKey) => { const viewChannel = (ch: Channel) => {
router.push({ name: 'ApiKeyView', query: { id: key.id } }); router.push({ name: 'ChannelView', query: { id: ch.id } });
}
// 删除用户
const confirmDeleteKey = async (key: ApiKey) => {
if (confirm(`确认删除 ${key.name}?`)) {
await deleteKey(key);
}
}; };
const deleteKey = async (key: ApiKey) => { const confirmDeleteChannel = async (ch: Channel) => {
if (confirm(`Delete channel "${ch.name}"?`)) {
try { try {
const res = await keyStore.keyOption('delete', [key.id]); await channelStore.deleteChannel(ch.id);
if (res.data?.code === 200) { setToast(`Channel ${ch.name} deleted`, 'success');
setToast('删除成功', 'success'); await fetchChannels();
}
await fetchKeys();
} catch (error: any) { } catch (error: any) {
console.error('删除失败:', error); setToast('Delete failed', 'error');
setToast('删除失败', 'error'); }
} }
}; };
const displayIcon = (apitype: string) => { const providerIcon = (provider: string) => {
switch (apitype) { const map: Record<string, string> = {
case 'openai': openai: '/assets/openai.svg',
return '/assets/openai.svg'; anthropic: '/assets/claude.svg',
case 'claude': compatible: '/assets/logo.svg',
return '/assets/claude.svg'; };
case 'gemini': return map[provider] || '/assets/logo.svg';
return '/assets/gemini.svg' };
case 'azure':
return '/assets/azure.svg';
case 'github':
return '/assets/github.svg';
default:
return '/assets/logo.svg';
}
}
// 关闭模态框
const modalRef = ref<HTMLDialogElement | null>(null); const modalRef = ref<HTMLDialogElement | null>(null);
const openModal = () => { const openModal = () => { modalRef.value?.showModal(); };
modalRef.value?.showModal();
};
const closeModal = async () => { const closeModal = async () => {
if (modalRef.value) { modalRef.value?.close();
modalRef.value.close(); await fetchChannels();
}
await fetchKeys();
}; };
</script> </script>
+2 -2
View File
@@ -63,8 +63,8 @@
<dt class="shrink-0 text-base-content/60">状态</dt> <dt class="shrink-0 text-base-content/60">状态</dt>
<dd> <dd>
<span class="badge badge-sm" <span class="badge badge-sm"
:class="user?.active ? 'badge-success badge-soft' : 'badge-error badge-soft'"> :class="user?.status === 'active' ? 'badge-success badge-soft' : 'badge-error badge-soft'">
{{ user?.active ? 'Active' : 'Inactive' }} {{ user?.status === 'active' ? 'Active' : 'Inactive' }}
</span> </span>
</dd> </dd>
</div> </div>
+2 -2
View File
@@ -24,8 +24,8 @@
</div> </div>
<div class="ml-auto hidden flex-col items-end gap-1 text-sm sm:flex"> <div class="ml-auto hidden flex-col items-end gap-1 text-sm sm:flex">
<span class="badge badge-sm" <span class="badge badge-sm"
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'"> :class="user.status === 'active' ? 'badge-success badge-soft' : 'badge-error badge-soft'">
{{ user.active ? 'Active' : 'Inactive' }} {{ user.status === 'active' ? 'Active' : 'Inactive' }}
</span> </span>
<span class="flex items-center gap-1 text-base-content/60"> <span class="flex items-center gap-1 text-base-content/60">
Quota: Quota:
+2 -2
View File
@@ -21,8 +21,8 @@
<h2 class="truncate text-lg font-semibold tracking-tight">{{ user?.name || user?.username }}</h2> <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"> <div class="mt-1 flex flex-wrap items-center gap-2">
<span class="badge badge-sm" <span class="badge badge-sm"
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'"> :class="user.status === 'active' ? 'badge-success badge-soft' : 'badge-error badge-soft'">
{{ user.active ? 'Active' : 'Inactive' }} {{ user.status === 'active' ? 'Active' : 'Inactive' }}
</span> </span>
<span class="badge badge-sm" <span class="badge badge-sm"
:class="user.role > 0 ? 'badge-warning badge-soft' : 'badge-ghost'"> :class="user.role > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">
+103 -121
View File
@@ -1,8 +1,8 @@
<template> <template>
<div class="mx-auto w-full max-w-3xl"> <div class="mx-auto w-full max-w-3xl">
<header class="mb-4 pr-8"> <header class="mb-4 pr-8">
<h2 class="text-lg font-semibold tracking-tight">Create New Token</h2> <h2 class="text-lg font-semibold tracking-tight">Create New API Key</h2>
<p class="mt-0.5 text-sm text-base-content/60">Tokens authenticate OpenAI-compatible clients with your team.</p> <p class="mt-0.5 text-sm text-base-content/60">API keys authenticate OpenAI-compatible clients with your team.</p>
</header> </header>
<div v-if="error" role="alert" class="alert alert-error mb-4 text-sm"> <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> <button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
</div> </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"> <div class="card-body gap-5 p-4 sm:p-6">
<section class="space-y-4"> <section class="space-y-4">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3> <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 class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
<div> <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> Name <span class="text-error" aria-hidden="true">*</span>
</label> </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" placeholder="e.g. my-laptop" autocomplete="off"
class="input input-bordered w-full" required /> class="input input-bordered w-full" required />
</div> </div>
@@ -35,56 +79,19 @@
<div class="collapse-content px-3"> <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 class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
<div> <div>
<label for="token-key" class="mb-1 block text-sm font-medium">Key</label> <label for="key-quota-tokens" class="mb-1 block text-sm font-medium">Quota Tokens/Day</label>
<div class="relative"> <input id="key-quota-tokens" name="quota_tokens_per_day" type="number" inputmode="numeric"
<input id="token-key" name="key" :type="isTokenVisible ? 'text' : 'password'" v-model="newToken.key" v-model.number="newKey.quota_tokens_per_day"
autocomplete="off" spellcheck="false" placeholder="Leave blank to generate" placeholder="0 = unlimited" autocomplete="off"
class="input input-bordered w-full pr-10" /> class="input input-bordered w-full" :min="0" />
<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>
</div> </div>
<div> <div>
<label for="token-expired" class="mb-1 block text-sm font-medium">Expired at</label> <label for="key-quota-requests" class="mb-1 block text-sm font-medium">Quota Requests/Day</label>
<div class="flex items-center gap-3"> <input id="key-quota-requests" name="quota_requests_per_day" type="number" inputmode="numeric"
<input id="token-expired" name="expired_at" type="date" v-model="newToken.format_expired_at" v-model.number="newKey.quota_requests_per_day"
class="input input-bordered w-full" :disabled="newToken.never_expired" /> placeholder="0 = unlimited" autocomplete="off"
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm"> class="input input-bordered w-full" :min="0" />
<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>
</div> </div>
</div> </div>
</div> </div>
@@ -96,7 +103,7 @@
</button> </button>
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="!isFormValid || creating"> <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> <span v-if="creating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Create Token Create API Key
</button> </button>
</div> </div>
</div> </div>
@@ -105,111 +112,86 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useKeyStore } from '@/stores/key'
import { Eye, EyeOff, CircleAlert } from '@lucide/vue' import { CircleAlert, CheckCircleIcon, ClipboardCopyIcon } from '@lucide/vue'
import { dateToUnix } from '@/utils/format-date'; import { useToast } from '@/composables/toast'
import { useToast } from '@/composables/toast'; import type { NewApiKeyPayload } from '@/types'
import type { TokenPayload } from '@/types';
const authStore = useAuthStore() const keyStore = useKeyStore()
const { setToast } = useToast() const { setToast } = useToast()
const error = ref<string | null>(null) const error = ref<string | null>(null)
const creating = ref(false) const creating = ref(false)
const user = computed(() => authStore.user); const createdKey = ref<string | null>(null)
const copied = ref(false)
const showAdvancedOptions = ref(false) const showAdvancedOptions = ref(false)
const newKey = ref<NewApiKeyPayload>({
const newToken = ref<TokenPayload>({
name: '', name: '',
key: '', quota_tokens_per_day: undefined,
user_id: user.value?.user_id as number | undefined, quota_requests_per_day: undefined,
active: true,
quota: 0,
unlimited_quota: true,
expired_at: 0,
format_expired_at: '',
never_expired: true,
}) })
const resetnewToken = () => { const resetNewKey = () => {
newToken.value = { newKey.value = {
name: '', name: '',
key: '', quota_tokens_per_day: undefined,
user_id: '', quota_requests_per_day: undefined,
active: true,
quota: 0,
unlimited_quota: true,
expired_at: 0,
format_expired_at: '',
never_expired: true,
} }
} }
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(() => { const isFormValid = computed(() => {
return newToken.value.name return newKey.value.name
}) })
const createToken = async () => { const createKey = async () => {
if (!isFormValid.value) { if (!isFormValid.value) {
setToast('Please fill in all required fields Name.', 'error') setToast('Please fill in the name field.', 'error')
return return
} }
creating.value = true creating.value = true
try { try {
const res = await authStore.createToken(newToken.value) const res = await keyStore.createKey(newKey.value)
if (res.data?.code === 200) { if (res.data?.key) {
error.value = null; error.value = null
resetnewToken(); createdKey.value = res.data.key
setToast('Token created successfully.', 'success') resetNewKey()
emit('closeModal', true)
} else { } else {
console.log(res) error.value = res.data?.error || 'Failed to create API key'
error.value = res.data?.error || 'Failed to create token'
} }
} catch (err: any) { } 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 { } finally {
creating.value = false creating.value = false
} }
} }
const cancel = () => { const copyToClipboard = async () => {
resetnewToken() if (!createdKey.value) return
emit('closeModal', false) 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 closeAfterCreate = () => {
const isTokenVisible = ref(false); createdKey.value = null
copied.value = false
emit('closeModal', true)
}
function toggleTokenVisibility() { const cancel = () => {
isTokenVisible.value = !isTokenVisible.value; resetNewKey()
emit('closeModal', false)
} }
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'closeModal', value: boolean): void (e: 'closeModal', value: boolean): void
}>() }>()
</script> </script>
-213
View File
@@ -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>
+2 -2
View File
@@ -89,7 +89,7 @@
</td> </td>
<td> <td>
<input type="checkbox" class="toggle toggle-success toggle-sm" <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}`" /> :aria-label="`Toggle user ${user.username}`" />
</td> </td>
<td class="text-right tabular-nums"> <td class="text-right tabular-nums">
@@ -265,7 +265,7 @@ const handleBatchAction = async (action: string) => {
// 更新用户状态 // 更新用户状态
const updateStatus = async (user: UserInfo) => { const updateStatus = async (user: UserInfo) => {
try { try {
const action = user.active ? 'enable' : 'disable'; const action = user.status === 'active' ? 'disable' : 'enable';
const res = await userStore.userOption(action, [user.id]); const res = await userStore.userOption(action, [user.id]);
if (res.data?.code === 200) { if (res.data?.code === 200) {
+4 -52
View File
@@ -70,39 +70,6 @@
<option :value="10">Admin</option> <option :value="10">Admin</option>
</select> </select>
</div> </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>
</div> </div>
</section> </section>
@@ -135,28 +102,18 @@ const showAdvancedOptions = ref(false)
// Initialize user object // Initialize user object
const newUser = ref<NewUserPayload>({ const newUser = ref<NewUserPayload>({
name: '',
username: '', username: '',
email: '', email: '',
password: '', password: '',
role: 0, // Default to Regular User role: 0,
active: true, // Default to Active
quota: 0, // Default quota value (relevant if not unlimited)
unlimited_quota: true, // Default to unlimited
language: 'en', // Default language
}) })
const resetNewUser = () => { const resetNewUser = () => {
newUser.value = { newUser.value = {
name: '',
username: '', username: '',
email: '', email: '',
password: '', password: '',
role: 0, // Default to Regular User role: 0,
active: true, // Default to Active
quota: 0, // Default quota value (relevant if not unlimited)
unlimited_quota: true, // Default to unlimited
language: 'en', // Default language
} }
} }
@@ -179,13 +136,8 @@ const createUser = async () => {
username: newUser.value.username, username: newUser.value.username,
password: newUser.value.password, password: newUser.value.password,
email: newUser.value.email, email: newUser.value.email,
name: newUser.value.name || newUser.value.username, // Use username if name is empty role: newUser.value.role === 10 ? 'admin' : 'user',
role: newUser.value.role, } as any);
active: newUser.value.active,
quota: newUser.value.quota,
unlimited_quota: newUser.value.unlimited_quota,
language: newUser.value.language
});
if (res.data?.code === 200) { if (res.data?.code === 200) {
error.value = null; error.value = null;
+5 -6
View File
@@ -21,8 +21,8 @@
<h2 class="truncate text-lg font-semibold tracking-tight">{{ user?.name || user?.username }}</h2> <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"> <div class="mt-1 flex flex-wrap items-center gap-2">
<span class="badge badge-sm" <span class="badge badge-sm"
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'"> :class="user.status === 'active' ? 'badge-success badge-soft' : 'badge-error badge-soft'">
{{ user.active ? 'Active' : 'Inactive' }} {{ user.status === 'active' ? 'Active' : 'Inactive' }}
</span> </span>
<span class="badge badge-sm" <span class="badge badge-sm"
:class="user.role > 0 ? 'badge-warning badge-soft' : 'badge-ghost'"> :class="user.role > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">
@@ -31,8 +31,8 @@
</div> </div>
</div> </div>
<input type="checkbox" class="toggle toggle-md" :class="user.active ? 'toggle-success' : 'toggle-error'" <input type="checkbox" class="toggle toggle-md" :class="user.status === 'active' ? 'toggle-success' : 'toggle-error'"
v-model="user.active" @change="updateStatus(user)" :aria-label="`Toggle user ${user.username} status`" /> :checked="user.status === 'active'" @change="updateStatus(user)" :aria-label="`Toggle user ${user.username} status`" />
</div> </div>
</div> </div>
@@ -226,7 +226,7 @@ const loading = computed(() => userStore.loading); // Access loading state
// 更新状态 // 更新状态
const updateStatus = async (user: UserInfo) => { const updateStatus = async (user: UserInfo) => {
try { try {
const action = user.active ? 'enable' : 'disable'; const action = user.status === 'active' ? 'disable' : 'enable';
const res = await userStore.userOption(action, [user.id]); const res = await userStore.userOption(action, [user.id]);
if (res.data?.code === 200) { if (res.data?.code === 200) {
setToast(`User ${user.id} ${action} Success`, 'success'); setToast(`User ${user.id} ${action} Success`, 'success');
@@ -235,7 +235,6 @@ const updateStatus = async (user: UserInfo) => {
} }
await userStore.refreshUser(user.id); await userStore.refreshUser(user.id);
} catch (error: any) { } catch (error: any) {
user.active = !user.active;
console.error('状态更新失败:', error); console.error('状态更新失败:', error);
} }
}; };