feat: API key management improvements
- Rename routes: /dashboard/tokens → /dashboard/apikeys, /dashboard/manager/keys → /dashboard/manager/channels - Add KeyPlain field to store plaintext API keys for re-viewing - API key list shows masked key (sk-ot-123456****abcd) with eye toggle to reveal - Copy button with 2s feedback on key list - TokenNew shows full key + copy after creation - Increase key prefix display to 12 characters - Fix SQLite driver: replace gorm.io/driver/sqlite with ncruces/go-sqlite3/gormlite - Fix user.status === 'active' checks across frontend views - Add channel store for new channels API - Update Makefile frontend build to work reliably BREAKING CHANGE: Existing API keys created before this change will not show their plaintext value (only prefix visible).
This commit is contained in:
@@ -1,12 +1,18 @@
|
|||||||
.PHONY: build run test clean fmt lint
|
.PHONY: build run test clean fmt lint frontend
|
||||||
|
|
||||||
BINARY_NAME=openteam
|
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
@@ -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
@@ -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
@@ -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()})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type { AxiosResponse } from 'axios';
|
||||||
|
import request from '@/api/client';
|
||||||
|
|
||||||
|
export type Channel = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
base_url: string
|
||||||
|
weight: number
|
||||||
|
priority: number
|
||||||
|
timeout_ms: number
|
||||||
|
max_concurrency: number
|
||||||
|
health_status: string
|
||||||
|
enabled: boolean
|
||||||
|
formats?: string[]
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
selected?: boolean
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewChannelPayload = {
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
base_url: string
|
||||||
|
api_key: string
|
||||||
|
priority?: number
|
||||||
|
weight?: number
|
||||||
|
formats?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChannelStore = defineStore('channel', () => {
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const totalChannels = ref(0);
|
||||||
|
const channels = ref<Channel[]>([]);
|
||||||
|
const channel = ref<Channel | null>(null);
|
||||||
|
|
||||||
|
const fetchChannels = async (pageSize = 20, page = 1) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.get('/channels', {
|
||||||
|
params: { pageSize, page },
|
||||||
|
});
|
||||||
|
channels.value = response.data.data ?? [];
|
||||||
|
totalChannels.value = response.data.total ?? 0;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.error || 'Failed to fetch channels';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchChannel = async (id: number | string) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await request.get(`/channels/${id}`);
|
||||||
|
channel.value = response.data.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.error || 'Failed to fetch channel';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createChannel = async (data: NewChannelPayload) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response: AxiosResponse = await request.post('/channels', data);
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.error || 'Failed to create channel';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateChannel = async (id: number | string, data: Partial<Channel> & { api_key?: string }) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response: AxiosResponse = await request.put(`/channels/${id}`, data);
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.error || 'Failed to update channel';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteChannel = async (id: number | string) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response: AxiosResponse = await request.delete(`/channels/${id}`);
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.error || 'Failed to delete channel';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchChannels = async (option: string, ids: (number | string)[]) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response: AxiosResponse = await request.delete(`/channels/batch/${option}`, { data: { ids } });
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.error || 'Batch operation failed';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading, error,
|
||||||
|
channel, channels, totalChannels,
|
||||||
|
fetchChannels,
|
||||||
|
fetchChannel,
|
||||||
|
createChannel,
|
||||||
|
updateChannel,
|
||||||
|
deleteChannel,
|
||||||
|
batchChannels,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -24,8 +24,8 @@ export const useKeyStore = defineStore('key', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
keys.value = response.data.data?.keys ?? [];
|
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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p class="text-sm text-base-content/60">API keys authenticate OpenAI-compatible clients with your team.</p>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new API key">
|
||||||
|
<PlusIcon class="h-4 w-4" aria-hidden="true" />New API Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<div class="overflow-x-auto" v-if="keys.length">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||||
|
<th class="pl-4">Name</th>
|
||||||
|
<th>Key</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-right">Quota/Day</th>
|
||||||
|
<th class="text-right">Requests/Day</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="key in keys" :key="key.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||||
|
<td class="pl-4 font-medium">{{ key.name }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="font-mono text-xs text-base-content/60">
|
||||||
|
{{ isRevealed(key.id) ? key.key_plain : maskedKey(key) }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs btn-square"
|
||||||
|
@click="toggleReveal(key.id)"
|
||||||
|
:aria-label="isRevealed(key.id) ? 'Hide key' : 'Reveal key'"
|
||||||
|
>
|
||||||
|
<EyeOffIcon v-if="isRevealed(key.id)" class="h-3.5 w-3.5" />
|
||||||
|
<EyeIcon v-else class="h-3.5 w-3.5 text-base-content/40" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs btn-square"
|
||||||
|
:class="copiedKeyId === key.id ? 'text-success' : ''"
|
||||||
|
@click="copyKey(key)"
|
||||||
|
aria-label="Copy key"
|
||||||
|
>
|
||||||
|
<CheckIcon v-if="copiedKeyId === key.id" class="h-3.5 w-3.5" />
|
||||||
|
<ClipboardCopyIcon v-else class="h-3.5 w-3.5 text-base-content/40" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
||||||
|
:class="key.status !== 'active' && 'toggle-error'" :checked="key.status === 'active'"
|
||||||
|
@change="updateStatus(key)" :aria-label="`Toggle key ${key.name}`" />
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums">
|
||||||
|
<template v-if="key.quota_tokens_per_day">{{ key.quota_tokens_per_day }}</template>
|
||||||
|
<template v-else class="text-base-content/40">—</template>
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums">
|
||||||
|
<template v-if="key.quota_requests_per_day">{{ key.quota_requests_per_day }}</template>
|
||||||
|
<template v-else class="text-base-content/40">—</template>
|
||||||
|
</td>
|
||||||
|
<td class="tabular-nums text-base-content/70 text-sm">{{ formatDate(key.created_at) }}</td>
|
||||||
|
<td class="pr-3 text-right">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<button v-if="key.status !== 'revoked'" class="btn btn-ghost btn-xs btn-square text-error"
|
||||||
|
@click="confirmDeleteKey(key)" aria-label="Revoke key">
|
||||||
|
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
|
||||||
|
<KeyRoundIcon class="h-10 w-10 text-base-content/20" aria-hidden="true" />
|
||||||
|
<h2 class="text-sm font-semibold">No API keys yet</h2>
|
||||||
|
<p class="max-w-xs text-sm text-base-content/60">
|
||||||
|
Create an API key to connect OpenCat, BotGem and other OpenAI-compatible clients.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
|
||||||
|
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create API Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<Pagination v-if="totalItems > 0" :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
|
||||||
|
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" />
|
||||||
|
|
||||||
|
<!-- New key modal -->
|
||||||
|
<dialog ref="modalRef" class="modal">
|
||||||
|
<div class="modal-box max-w-3xl px-0 sm:px-6">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
||||||
|
</form>
|
||||||
|
<TokenNew @closeModal="closeModal" />
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button aria-label="Close dialog">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||||
|
import Pagination from '@/components/common/Pagination.vue';
|
||||||
|
import TokenNew from '@/views/dashboard/TokenNew.vue';
|
||||||
|
import { useKeyStore } from '@/stores/key';
|
||||||
|
import { useToast } from '@/composables/toast';
|
||||||
|
import type { ApiKey } from '@/types';
|
||||||
|
import {
|
||||||
|
PlusIcon, TrashIcon, KeyRoundIcon,
|
||||||
|
EyeIcon, EyeOffIcon, ClipboardCopyIcon, CheckIcon
|
||||||
|
} from '@lucide/vue';
|
||||||
|
|
||||||
|
const keyStore = useKeyStore();
|
||||||
|
const { setToast } = useToast();
|
||||||
|
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const pageSize = ref(20);
|
||||||
|
const totalItems = computed(() => keyStore.totalKeys);
|
||||||
|
const keys = computed(() => keyStore.keys);
|
||||||
|
|
||||||
|
const revealedKeys = ref<Set<number>>(new Set());
|
||||||
|
const copiedKeyId = ref<number | null>(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchKeys();
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchKeys = async (size?: number, page?: number) => {
|
||||||
|
currentPage.value = page || currentPage.value;
|
||||||
|
await keyStore.fetchKeys(size ?? pageSize.value, currentPage.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const changePage = async (page: number, size: number) => {
|
||||||
|
if (page == currentPage.value && size == pageSize.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentPage.value = page;
|
||||||
|
pageSize.value = size;
|
||||||
|
await fetchKeys();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRevealed = (id: number) => revealedKeys.value.has(id);
|
||||||
|
|
||||||
|
const toggleReveal = (id: number) => {
|
||||||
|
if (revealedKeys.value.has(id)) {
|
||||||
|
revealedKeys.value.delete(id);
|
||||||
|
} else {
|
||||||
|
revealedKeys.value.add(id);
|
||||||
|
}
|
||||||
|
// Force reactivity update
|
||||||
|
revealedKeys.value = new Set(revealedKeys.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const maskedKey = (key: ApiKey) => {
|
||||||
|
const plain = key.key_plain;
|
||||||
|
if (!plain) return key.key_prefix + '...';
|
||||||
|
if (plain.length <= 16) return plain.slice(0, 4) + '****' + plain.slice(-4);
|
||||||
|
return plain.slice(0, 12) + '****' + plain.slice(-4);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyKey = async (key: ApiKey) => {
|
||||||
|
const value = key.key_plain;
|
||||||
|
if (!value) {
|
||||||
|
setToast('Key value not available', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
copiedKeyId.value = key.id;
|
||||||
|
setToast(`Key "${key.name}" copied`, 'success');
|
||||||
|
setTimeout(() => { copiedKeyId.value = null; }, 2000);
|
||||||
|
} catch {
|
||||||
|
setToast('Failed to copy', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStatus = async (key: any) => {
|
||||||
|
try {
|
||||||
|
const action = key.status === 'active' ? 'disable' : 'enable';
|
||||||
|
const res = await keyStore.keyOption(action, [key.id]);
|
||||||
|
if (res.data?.code === 200) {
|
||||||
|
setToast(`Key ${key.name} has been ${action}`, 'success');
|
||||||
|
}
|
||||||
|
await fetchKeys();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Status update failed:', error);
|
||||||
|
setToast('Status update failed', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteKey = async (key: any) => {
|
||||||
|
if (confirm(`Revoke key "${key.name}"? This cannot be undone.`)) {
|
||||||
|
await deleteKey(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteKey = async (key: any) => {
|
||||||
|
try {
|
||||||
|
const res = await keyStore.keyOption('delete', [key.id]);
|
||||||
|
if (res.data?.code === 200) {
|
||||||
|
setToast(`Key ${key.name} revoked`, 'success');
|
||||||
|
}
|
||||||
|
await fetchKeys();
|
||||||
|
} catch (error: any) {
|
||||||
|
setToast('Failed to revoke key', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString?: string | number): string => {
|
||||||
|
if (!dateString) return '—';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
} catch {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
const modalRef = ref<HTMLDialogElement | null>(null);
|
||||||
|
const openModal = () => {
|
||||||
|
modalRef.value?.showModal();
|
||||||
|
};
|
||||||
|
const closeModal = async () => {
|
||||||
|
if (modalRef.value) {
|
||||||
|
modalRef.value.close();
|
||||||
|
}
|
||||||
|
await fetchKeys();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="space-y-5">
|
|
||||||
<BreadcrumbHeader />
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<p class="text-sm text-base-content/60">Tokens authenticate OpenAI-compatible clients with your team.</p>
|
|
||||||
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new token">
|
|
||||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />New Token
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table -->
|
|
||||||
<div v-if="user" class="card border border-base-300/60 bg-base-100 shadow-sm">
|
|
||||||
<div class="overflow-x-auto" v-if="user.tokens && user.tokens.length">
|
|
||||||
<table class="table table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
|
||||||
<th class="pl-4">Token</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Expired</th>
|
|
||||||
<th class="text-right">Quota</th>
|
|
||||||
<th class="text-right">Used</th>
|
|
||||||
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="token in user.tokens" :key="token.id" class="border-base-300/40 hover:bg-base-200/50">
|
|
||||||
<td class="pl-4 font-medium truncate max-w-[120px] sm:max-w-[180px]">{{ token.name }}</td>
|
|
||||||
<td>
|
|
||||||
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
|
||||||
:class="!token.active && 'toggle-error'" v-model="token.active"
|
|
||||||
@change="updateStatus(token)" :aria-label="`Toggle token ${token.name}`" />
|
|
||||||
</td>
|
|
||||||
<td class="tabular-nums text-base-content/70">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at ?? 0) }}</td>
|
|
||||||
<td class="text-right tabular-nums">
|
|
||||||
<template v-if="token.unlimited_quota">
|
|
||||||
<Infinity class="inline h-4 w-4 text-base-content/60" aria-label="Unlimited quota" />
|
|
||||||
<span class="sr-only">Unlimited</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>{{ token.quota }}</template>
|
|
||||||
</td>
|
|
||||||
<td class="text-right tabular-nums">{{ token.used_quota }}</td>
|
|
||||||
<td class="pr-3 text-right">
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
<button class="btn btn-ghost btn-xs btn-square" @click="viewToken(token)" aria-label="Preview token key">
|
|
||||||
<EyeIcon class="h-4 w-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<button v-if="(token.used_quota ?? 0) > 0" class="btn btn-ghost btn-xs btn-square text-info"
|
|
||||||
@click="cleanUsedToken(token)" aria-label="Reset used quota">
|
|
||||||
<Eraser class="h-4 w-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error"
|
|
||||||
@click="confirmRevokeToken(token)" aria-label="Revoke token">
|
|
||||||
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
|
||||||
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
|
|
||||||
<Braces class="h-10 w-10 text-base-content/20" aria-hidden="true" />
|
|
||||||
<h2 class="text-sm font-semibold">No tokens yet</h2>
|
|
||||||
<p class="max-w-xs text-sm text-base-content/60">
|
|
||||||
Create a token to connect OpenCat, BotGem and other OpenAI-compatible clients.
|
|
||||||
</p>
|
|
||||||
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
|
|
||||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create Token
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- New token modal -->
|
|
||||||
<dialog ref="modalRef" class="modal">
|
|
||||||
<div class="modal-box max-w-3xl px-0 sm:px-6">
|
|
||||||
<form method="dialog">
|
|
||||||
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
|
||||||
</form>
|
|
||||||
<TokenNew @closeModal="closeModal" />
|
|
||||||
</div>
|
|
||||||
<form method="dialog" class="modal-backdrop">
|
|
||||||
<button aria-label="Close dialog">close</button>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<!-- Token QR modal -->
|
|
||||||
<dialog ref="tokenRef" class="modal">
|
|
||||||
<div class="modal-box max-w-sm px-0 sm:px-6">
|
|
||||||
<form method="dialog">
|
|
||||||
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
|
||||||
</form>
|
|
||||||
<QRCodeCard :value="qrCodeValue" :size="120" />
|
|
||||||
</div>
|
|
||||||
<form method="dialog" class="modal-backdrop">
|
|
||||||
<button aria-label="Close dialog">close</button>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, watch, computed } from 'vue';
|
|
||||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
|
||||||
import QRCodeCard from '@/components/common/QRCodeCard.vue';
|
|
||||||
import TokenNew from '@/views/dashboard/TokenNew.vue';
|
|
||||||
import { useAuthStore } from '@/stores/auth';
|
|
||||||
import {
|
|
||||||
EyeIcon, PlusIcon, TrashIcon, Infinity, Eraser, Braces
|
|
||||||
} from '@lucide/vue';
|
|
||||||
import { unixToDate } from '@/utils/format-date';
|
|
||||||
import { useToast } from '@/composables/toast';
|
|
||||||
import type { TokenInfo } from '@/types';
|
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
const user = computed(() => authStore.user);
|
|
||||||
const { setToast } = useToast();
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await authStore.refreshProfile();
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => authStore.user, (newUser) => {
|
|
||||||
if (newUser && newUser.expired_at && newUser.expired_at > 0) {
|
|
||||||
newUser.format_expired_at = unixToDate(newUser.expired_at);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateStatus = async (token: TokenInfo) => {
|
|
||||||
console.log(token);
|
|
||||||
try {
|
|
||||||
const res = await authStore.updateToken({ userid: token.userid, id: token.id, name: token.name, active: token.active });
|
|
||||||
if (res.data?.code == 200) {
|
|
||||||
setToast(`Token ${token.name} updated`, 'success');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
token.active = !token.active
|
|
||||||
console.log(error.response.data.error);
|
|
||||||
setToast(error.response.data.error, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmRevokeToken = async (token: TokenInfo) => {
|
|
||||||
if (confirm(`确认删除 ${token.name}?`)) {
|
|
||||||
await revokeToken(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const revokeToken = async (token: TokenInfo) => {
|
|
||||||
try {
|
|
||||||
const res = await authStore.deleteToken(token.id);
|
|
||||||
if (res.data?.code == 200) {
|
|
||||||
setToast(`Token ${token.name} revoked`, 'success');
|
|
||||||
}
|
|
||||||
await authStore.refreshProfile();
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
setToast(error.response.data.error, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanUsedToken = async (token: TokenInfo) => {
|
|
||||||
|
|
||||||
if (token.used_quota == 0 || token.used_quota == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await authStore.resetToken(token.id);
|
|
||||||
console.log('cleanUsedToken', res);
|
|
||||||
if (res.data?.code == 200) {
|
|
||||||
setToast(`Token ${token.name} used quota reset`, 'success');
|
|
||||||
}
|
|
||||||
await authStore.refreshProfile();
|
|
||||||
} catch (error: any) {
|
|
||||||
console.log(error);
|
|
||||||
setToast(error, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const showTokenModel = ref(false);
|
|
||||||
const tokenRef = ref<HTMLDialogElement | null>(null);
|
|
||||||
const viewToken = (token: TokenInfo) => {
|
|
||||||
const dialog = tokenRef.value;
|
|
||||||
if (dialog) {
|
|
||||||
if (!dialog.hasAttribute('open')) {
|
|
||||||
qrCodeValue.value = token.key || '';
|
|
||||||
dialog.showModal();
|
|
||||||
} else {
|
|
||||||
if (dialog.hasAttribute('open')) {
|
|
||||||
dialog.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showTokenModel.value = !showTokenModel.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const qrCodeValue = ref('');
|
|
||||||
|
|
||||||
|
|
||||||
// 关闭模态框
|
|
||||||
const modalRef = ref<HTMLDialogElement | null>(null);
|
|
||||||
const openModal = () => {
|
|
||||||
modalRef.value?.showModal();
|
|
||||||
};
|
|
||||||
const closeModal = async () => {
|
|
||||||
if (modalRef.value) {
|
|
||||||
modalRef.value.close();
|
|
||||||
}
|
|
||||||
await authStore.refreshProfile();
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user