Compare commits

...

20 Commits

Author SHA1 Message Date
JustSong
73aa53f536 fix: remove the dot in model name (#57) 2023-05-13 12:24:49 +08:00
JustSong
da9ccb528d docs: update README 2023-05-13 11:48:37 +08:00
JustSong
44729da277 fix: provide a default value for api-version if not given (#57) 2023-05-13 11:41:57 +08:00
JustSong
7a3378b4b7 feat: Azure API supported without verification (#48, #57) 2023-05-13 11:36:36 +08:00
JustSong
fd19d7d246 fix: handle errors when update option map 2023-05-13 10:30:55 +08:00
JustSong
5c694a1503 feat: now supports custom smtp port 2023-05-12 11:44:38 +08:00
JustSong
9edc54ca69 fix: fix the default ratio for text-embedding-ada-002 2023-05-11 22:43:39 +08:00
JustSong
e6af636fa0 fix: the initial quota for new token now calculated correctly (#51) 2023-05-11 21:29:05 +08:00
JustSong
6e1ef75009 feat: support /v1/embeddings now (close #50) 2023-05-11 21:00:09 +08:00
JustSong
d9db16e999 feat: able to configure ratio for more models now (close #53) 2023-05-11 20:59:35 +08:00
JustSong
241ade2fae fix: delete a token with a negative quota will now update the account's quota correctly (close #51) 2023-05-11 20:05:49 +08:00
chunzhi
80065de8a3 feat: add Docker Compose support (#55)
* docker-compose.yml

* Create one-api.service

配置systemd守护进程

* Update docker-compose.yml

* Update one-api.service

* Update docker-compose.yml

* Update docker-compose.yml
2023-05-11 16:56:14 +08:00
JustSong
16f53b5afb feat: double check before deleting a user 2023-05-10 10:13:39 +08:00
JustSong
3071300c0c feat: support API /dashboard/billing/credit_grants now (#45) 2023-05-10 09:28:41 +08:00
JustSong
8b056bf408 docs: update README (#47) 2023-05-05 21:56:48 +08:00
JustSong
e5640857b1 docs: add funding link 2023-05-05 15:26:54 +08:00
JustSong
331177d97e fix: return quota to user when delete token (close #37) 2023-05-04 10:20:39 +08:00
JustSong
4fed003f1a chore: update placeholder text (#36) 2023-04-29 18:42:05 +08:00
JustSong
a1ea1bf696 chore: update placeholder text (#36) 2023-04-29 18:41:19 +08:00
JustSong
7c66fc6c21 fix: shouldn't close c.Request.Body too soon (close #35) 2023-04-29 14:49:10 +08:00
26 changed files with 362 additions and 264 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
custom: ['https://iamazing.cn/page/reward']

View File

@@ -42,14 +42,14 @@ _✨ All in one 的 OpenAI 接口,整合各种 API 访问方式,开箱即用
## 功能 ## 功能
1. 支持多种 API 访问渠道,欢迎 PR 或提 issue 添加更多渠道: 1. 支持多种 API 访问渠道,欢迎 PR 或提 issue 添加更多渠道:
+ [x] One API 服务端中继 + [x] OpenAI 官方通道
+ [x] [API2D](https://api2d.com/r/197971) + [x] [API2D](https://api2d.com/r/197971)
+ [ ] Azure OpenAI API + [x] Azure OpenAI API
+ [x] [CloseAI](https://console.openai-asia.com) + [x] [CloseAI](https://console.openai-asia.com)
+ [x] [OpenAI-SB](https://openai-sb.com) + [x] [OpenAI-SB](https://openai-sb.com)
+ [x] [OpenAI Max](https://openaimax.com) + [x] [OpenAI Max](https://openaimax.com)
+ [x] [OhMyGPT](https://www.ohmygpt.com) + [x] [OhMyGPT](https://www.ohmygpt.com)
+ [x] 自定义渠道 + [x] 自定义渠道:例如使用自行搭建的 OpenAI 代理
2. 支持通过负载均衡的方式访问多个渠道。 2. 支持通过负载均衡的方式访问多个渠道。
3. 支持单个访问渠道设置多个 API Key利用起来你的多个 API Key。 3. 支持单个访问渠道设置多个 API Key利用起来你的多个 API Key。
4. 支持 HTTP SSE可以通过流式传输实现打字机效果。 4. 支持 HTTP SSE可以通过流式传输实现打字机效果。

View File

@@ -34,6 +34,7 @@ var TurnstileCheckEnabled = false
var RegisterEnabled = true var RegisterEnabled = true
var SMTPServer = "" var SMTPServer = ""
var SMTPPort = 587
var SMTPAccount = "" var SMTPAccount = ""
var SMTPToken = "" var SMTPToken = ""
@@ -49,11 +50,6 @@ var TurnstileSecretKey = ""
var QuotaForNewUser = 100 var QuotaForNewUser = 100
// https://platform.openai.com/docs/models/model-endpoint-compatibility
var RatioGPT3dot5 float64 = 2
var RatioGPT4 float64 = 30
var RatioGPT4_32k float64 = 60
const ( const (
RoleGuestUser = 0 RoleGuestUser = 0
RoleCommonUser = 1 RoleCommonUser = 1

View File

@@ -8,7 +8,7 @@ func SendEmail(subject string, receiver string, content string) error {
m.SetHeader("To", receiver) m.SetHeader("To", receiver)
m.SetHeader("Subject", subject) m.SetHeader("Subject", subject)
m.SetBody("text/html", content) m.SetBody("text/html", content)
d := gomail.NewDialer(SMTPServer, 587, SMTPAccount, SMTPToken) d := gomail.NewDialer(SMTPServer, SMTPPort, SMTPAccount, SMTPToken)
err := d.DialAndSend(m) err := d.DialAndSend(m)
return err return err
} }

52
common/model-ratio.go Normal file
View File

@@ -0,0 +1,52 @@
package common
import "encoding/json"
// https://platform.openai.com/docs/models/model-endpoint-compatibility
// https://openai.com/pricing
// TODO: when a new api is enabled, check the pricing here
var ModelRatio = map[string]float64{
"gpt-4": 15,
"gpt-4-0314": 15,
"gpt-4-32k": 30,
"gpt-4-32k-0314": 30,
"gpt-3.5-turbo": 1,
"gpt-3.5-turbo-0301": 1,
"text-ada-001": 0.2,
"text-babbage-001": 0.25,
"text-curie-001": 1,
"text-davinci-002": 10,
"text-davinci-003": 10,
"text-davinci-edit-001": 10,
"code-davinci-edit-001": 10,
"whisper-1": 10,
"davinci": 10,
"curie": 10,
"babbage": 10,
"ada": 10,
"text-embedding-ada-002": 0.2,
"text-search-ada-doc-001": 10,
"text-moderation-stable": 10,
"text-moderation-latest": 10,
}
func ModelRatio2JSONString() string {
jsonBytes, err := json.Marshal(ModelRatio)
if err != nil {
SysError("Error marshalling model ratio: " + err.Error())
}
return string(jsonBytes)
}
func UpdateModelRatioByJSONString(jsonStr string) error {
return json.Unmarshal([]byte(jsonStr), &ModelRatio)
}
func GetModelRatio(name string) float64 {
ratio, ok := ModelRatio[name]
if !ok {
SysError("Model ratio not found: " + name)
return 1
}
return ratio
}

View File

@@ -68,12 +68,8 @@ func relayHelper(c *gin.Context) error {
channelType := c.GetInt("channel") channelType := c.GetInt("channel")
tokenId := c.GetInt("token_id") tokenId := c.GetInt("token_id")
consumeQuota := c.GetBool("consume_quota") consumeQuota := c.GetBool("consume_quota")
baseURL := common.ChannelBaseURLs[channelType]
if channelType == common.ChannelTypeCustom {
baseURL = c.GetString("base_url")
}
var textRequest TextRequest var textRequest TextRequest
if consumeQuota { if consumeQuota || channelType == common.ChannelTypeAzure {
requestBody, err := io.ReadAll(c.Request.Body) requestBody, err := io.ReadAll(c.Request.Body)
if err != nil { if err != nil {
return err return err
@@ -89,16 +85,35 @@ func relayHelper(c *gin.Context) error {
// Reset request body // Reset request body
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
} }
baseURL := common.ChannelBaseURLs[channelType]
requestURL := c.Request.URL.String() requestURL := c.Request.URL.String()
req, err := http.NewRequest(c.Request.Method, fmt.Sprintf("%s%s", baseURL, requestURL), c.Request.Body) if channelType == common.ChannelTypeCustom {
baseURL = c.GetString("base_url")
}
fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)
if channelType == common.ChannelTypeAzure {
// https://learn.microsoft.com/en-us/azure/cognitive-services/openai/chatgpt-quickstart?pivots=rest-api&tabs=command-line#rest-api
query := c.Request.URL.Query()
if query.Get("api-version") == "" {
requestURL = fmt.Sprintf("%s?api-version=2023-03-15-preview", requestURL)
}
baseURL = c.GetString("base_url")
task := strings.TrimPrefix(requestURL, "/v1/")
model_ := textRequest.Model
model_ = strings.Replace(model_, ".", "", -1)
fullRequestURL = fmt.Sprintf("%s/openai/deployments/%s/%s", baseURL, model_, task)
}
req, err := http.NewRequest(c.Request.Method, fullRequestURL, c.Request.Body)
if err != nil { if err != nil {
return err return err
} }
err = c.Request.Body.Close() if channelType == common.ChannelTypeAzure {
if err != nil { key := c.Request.Header.Get("Authorization")
return err key = strings.TrimPrefix(key, "Bearer ")
req.Header.Set("api-key", key)
} else {
req.Header.Set("Authorization", c.Request.Header.Get("Authorization"))
} }
req.Header.Set("Authorization", c.Request.Header.Get("Authorization"))
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
req.Header.Set("Accept", c.Request.Header.Get("Accept")) req.Header.Set("Accept", c.Request.Header.Get("Accept"))
req.Header.Set("Connection", c.Request.Header.Get("Connection")) req.Header.Set("Connection", c.Request.Header.Get("Connection"))
@@ -111,7 +126,10 @@ func relayHelper(c *gin.Context) error {
if err != nil { if err != nil {
return err return err
} }
err = c.Request.Body.Close()
if err != nil {
return err
}
var textResponse TextResponse var textResponse TextResponse
isStream := resp.Header.Get("Content-Type") == "text/event-stream" isStream := resp.Header.Get("Content-Type") == "text/event-stream"
var streamResponseText string var streamResponseText string
@@ -119,26 +137,24 @@ func relayHelper(c *gin.Context) error {
defer func() { defer func() {
if consumeQuota { if consumeQuota {
quota := 0 quota := 0
usingGPT4 := strings.HasPrefix(textRequest.Model, "gpt-4")
completionRatio := 1
if usingGPT4 {
completionRatio = 2
}
if isStream { if isStream {
var text string var promptText string
for _, message := range textRequest.Messages { for _, message := range textRequest.Messages {
text += fmt.Sprintf("%s: %s\n", message.Role, message.Content) promptText += fmt.Sprintf("%s: %s\n", message.Role, message.Content)
} }
text += fmt.Sprintf("%s: %s\n", "assistant", streamResponseText) completionText := fmt.Sprintf("%s: %s\n", "assistant", streamResponseText)
quota = countToken(text) + 3 quota = countToken(promptText) + countToken(completionText)*completionRatio + 3
} else { } else {
quota = textResponse.Usage.TotalTokens quota = textResponse.Usage.PromptTokens + textResponse.Usage.CompletionTokens*completionRatio
}
ratio := common.RatioGPT3dot5
if strings.HasPrefix(textRequest.Model, "gpt-4-32k") {
ratio = common.RatioGPT4_32k
} else if strings.HasPrefix(textRequest.Model, "gpt-4") {
ratio = common.RatioGPT4
} else {
ratio = common.RatioGPT3dot5
} }
ratio := common.GetModelRatio(textRequest.Model)
quota = int(float64(quota) * ratio) quota = int(float64(quota) * ratio)
err := model.ConsumeTokenQuota(tokenId, quota) err := model.DecreaseTokenQuota(tokenId, quota)
if err != nil { if err != nil {
common.SysError("Error consuming token remain quota: " + err.Error()) common.SysError("Error consuming token remain quota: " + err.Error())
} }

View File

@@ -75,6 +75,30 @@ func GetToken(c *gin.Context) {
return return
} }
func GetTokenStatus(c *gin.Context) {
tokenId := c.GetInt("token_id")
userId := c.GetInt("id")
token, err := model.GetTokenByIds(tokenId, userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
expiredAt := token.ExpiredTime
if expiredAt == -1 {
expiredAt = 0
}
c.JSON(http.StatusOK, gin.H{
"object": "credit_summary",
"total_granted": token.RemainQuota,
"total_used": 0, // not supported currently
"total_available": token.RemainQuota,
"expires_at": expiredAt * 1000,
})
}
func AddToken(c *gin.Context) { func AddToken(c *gin.Context) {
isAdmin := c.GetInt("role") >= common.RoleAdminUser isAdmin := c.GetInt("role") >= common.RoleAdminUser
token := model.Token{} token := model.Token{}
@@ -114,9 +138,7 @@ func AddToken(c *gin.Context) {
}) })
return return
} }
if quota > 0 { cleanToken.RemainQuota = quota
cleanToken.RemainQuota = quota
}
} }
err = cleanToken.Insert() err = cleanToken.Insert()
if err != nil { if err != nil {

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
version: '3.4'
services:
one-api:
image: ghcr.io/songquanpeng/one-api:latest
container_name: one-api
restart: always
command: --log-dir /app/logs
ports:
- "3000:3000"
volumes:
- /home/ubuntu/data/one-api:/data
- /home/ubuntu/data/one-api/logs:/app/logs
# environment:
# REDIS_CONN_STRING: redis://default:redispw@localhost:49153
# SESSION_SECRET: random_string
# SQL_DSN: root:123456@tcp(localhost:3306)/one-api
healthcheck:
test: ["CMD-SHELL", "curl -s http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk '{print $2}' | grep 'true'"]
interval: 30s
timeout: 10s
retries: 3

View File

@@ -63,7 +63,7 @@ func Distribute() func(c *gin.Context) {
} }
c.Set("channel", channel.Type) c.Set("channel", channel.Type)
c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key))
if channel.Type == common.ChannelTypeCustom { if channel.Type == common.ChannelTypeCustom || channel.Type == common.ChannelTypeAzure {
c.Set("base_url", channel.BaseURL) c.Set("base_url", channel.BaseURL)
} }
c.Next() c.Next()

View File

@@ -33,6 +33,7 @@ func InitOptionMap() {
common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled) common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled) common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
common.OptionMap["SMTPServer"] = "" common.OptionMap["SMTPServer"] = ""
common.OptionMap["SMTPPort"] = strconv.Itoa(common.SMTPPort)
common.OptionMap["SMTPAccount"] = "" common.OptionMap["SMTPAccount"] = ""
common.OptionMap["SMTPToken"] = "" common.OptionMap["SMTPToken"] = ""
common.OptionMap["Notice"] = "" common.OptionMap["Notice"] = ""
@@ -47,14 +48,15 @@ func InitOptionMap() {
common.OptionMap["TurnstileSiteKey"] = "" common.OptionMap["TurnstileSiteKey"] = ""
common.OptionMap["TurnstileSecretKey"] = "" common.OptionMap["TurnstileSecretKey"] = ""
common.OptionMap["QuotaForNewUser"] = strconv.Itoa(common.QuotaForNewUser) common.OptionMap["QuotaForNewUser"] = strconv.Itoa(common.QuotaForNewUser)
common.OptionMap["RatioGPT3dot5"] = strconv.FormatFloat(common.RatioGPT3dot5, 'f', -1, 64) common.OptionMap["ModelRatio"] = common.ModelRatio2JSONString()
common.OptionMap["RatioGPT4"] = strconv.FormatFloat(common.RatioGPT4, 'f', -1, 64)
common.OptionMap["RatioGPT4_32k"] = strconv.FormatFloat(common.RatioGPT4_32k, 'f', -1, 64)
common.OptionMap["TopUpLink"] = common.TopUpLink common.OptionMap["TopUpLink"] = common.TopUpLink
common.OptionMapRWMutex.Unlock() common.OptionMapRWMutex.Unlock()
options, _ := AllOption() options, _ := AllOption()
for _, option := range options { for _, option := range options {
updateOptionMap(option.Key, option.Value) err := updateOptionMap(option.Key, option.Value)
if err != nil {
common.SysError("Failed to update option map: " + err.Error())
}
} }
} }
@@ -71,11 +73,10 @@ func UpdateOption(key string, value string) error {
// otherwise it will execute Update (with all fields). // otherwise it will execute Update (with all fields).
DB.Save(&option) DB.Save(&option)
// Update OptionMap // Update OptionMap
updateOptionMap(key, value) return updateOptionMap(key, value)
return nil
} }
func updateOptionMap(key string, value string) { func updateOptionMap(key string, value string) (err error) {
common.OptionMapRWMutex.Lock() common.OptionMapRWMutex.Lock()
defer common.OptionMapRWMutex.Unlock() defer common.OptionMapRWMutex.Unlock()
common.OptionMap[key] = value common.OptionMap[key] = value
@@ -114,6 +115,9 @@ func updateOptionMap(key string, value string) {
switch key { switch key {
case "SMTPServer": case "SMTPServer":
common.SMTPServer = value common.SMTPServer = value
case "SMTPPort":
intValue, _ := strconv.Atoi(value)
common.SMTPPort = intValue
case "SMTPAccount": case "SMTPAccount":
common.SMTPAccount = value common.SMTPAccount = value
case "SMTPToken": case "SMTPToken":
@@ -138,13 +142,10 @@ func updateOptionMap(key string, value string) {
common.TurnstileSecretKey = value common.TurnstileSecretKey = value
case "QuotaForNewUser": case "QuotaForNewUser":
common.QuotaForNewUser, _ = strconv.Atoi(value) common.QuotaForNewUser, _ = strconv.Atoi(value)
case "RatioGPT3dot5": case "ModelRatio":
common.RatioGPT3dot5, _ = strconv.ParseFloat(value, 64) err = common.UpdateModelRatioByJSONString(value)
case "RatioGPT4":
common.RatioGPT4, _ = strconv.ParseFloat(value, 64)
case "RatioGPT4_32k":
common.RatioGPT4_32k, _ = strconv.ParseFloat(value, 64)
case "TopUpLink": case "TopUpLink":
common.TopUpLink = value common.TopUpLink = value
} }
return err
} }

View File

@@ -55,7 +55,7 @@ func Redeem(key string, tokenId int) (quota int, err error) {
if redemption.Status != common.RedemptionCodeStatusEnabled { if redemption.Status != common.RedemptionCodeStatusEnabled {
return 0, errors.New("该兑换码已被使用") return 0, errors.New("该兑换码已被使用")
} }
err = TopUpTokenQuota(tokenId, redemption.Quota) err = IncreaseTokenQuota(tokenId, redemption.Quota)
if err != nil { if err != nil {
return 0, err return 0, err
} }

View File

@@ -116,15 +116,26 @@ func DeleteTokenById(id int, userId int) (err error) {
if err != nil { if err != nil {
return err return err
} }
quota := token.RemainQuota
if quota != 0 {
if quota > 0 {
err = IncreaseUserQuota(userId, quota)
} else {
err = DecreaseUserQuota(userId, -quota)
}
}
if err != nil {
return err
}
return token.Delete() return token.Delete()
} }
func ConsumeTokenQuota(id int, quota int) (err error) { func IncreaseTokenQuota(id int, quota int) (err error) {
err = DB.Model(&Token{}).Where("id = ?", id).Update("remain_quota", gorm.Expr("remain_quota - ?", quota)).Error
return err
}
func TopUpTokenQuota(id int, quota int) (err error) {
err = DB.Model(&Token{}).Where("id = ?", id).Update("remain_quota", gorm.Expr("remain_quota + ?", quota)).Error err = DB.Model(&Token{}).Where("id = ?", id).Update("remain_quota", gorm.Expr("remain_quota + ?", quota)).Error
return err return err
} }
func DecreaseTokenQuota(id int, quota int) (err error) {
err = DB.Model(&Token{}).Where("id = ?", id).Update("remain_quota", gorm.Expr("remain_quota - ?", quota)).Error
return err
}

View File

@@ -225,6 +225,11 @@ func GetUserQuota(id int) (quota int, err error) {
return quota, err return quota, err
} }
func IncreaseUserQuota(id int, quota int) (err error) {
err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota + ?", quota)).Error
return err
}
func DecreaseUserQuota(id int, quota int) (err error) { func DecreaseUserQuota(id int, quota int) (err error) {
err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota - ?", quota)).Error err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota - ?", quota)).Error
return err return err

13
one-api.service Normal file
View File

@@ -0,0 +1,13 @@
[Unit]
Description=One API Service
After=network.target
[Service]
User=yourusername # 守护进程用户名
WorkingDirectory=/path/to/One-API # One API运行路径
ExecStart=/path/to/One-API/one-api --port 3000 --log-dir /path/to/One-API/logs # 端口
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

18
router/dashboard.go Normal file
View File

@@ -0,0 +1,18 @@
package router
import (
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"one-api/controller"
"one-api/middleware"
)
func SetDashboardRouter(router *gin.Engine) {
apiRouter := router.Group("/dashboard")
apiRouter.Use(gzip.Gzip(gzip.DefaultCompression))
apiRouter.Use(middleware.GlobalAPIRateLimit())
apiRouter.Use(middleware.TokenAuth())
{
apiRouter.GET("/billing/credit_grants", controller.GetTokenStatus)
}
}

View File

@@ -7,6 +7,7 @@ import (
func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
SetApiRouter(router) SetApiRouter(router)
SetDashboardRouter(router)
SetRelayRouter(router) SetRelayRouter(router)
setWebRouter(router, buildFS, indexPage) setWebRouter(router, buildFS, indexPage)
} }

View File

@@ -19,7 +19,7 @@ func SetRelayRouter(router *gin.Engine) {
relayV1Router.POST("/images/generations", controller.RelayNotImplemented) relayV1Router.POST("/images/generations", controller.RelayNotImplemented)
relayV1Router.POST("/images/edits", controller.RelayNotImplemented) relayV1Router.POST("/images/edits", controller.RelayNotImplemented)
relayV1Router.POST("/images/variations", controller.RelayNotImplemented) relayV1Router.POST("/images/variations", controller.RelayNotImplemented)
relayV1Router.POST("/embeddings", controller.RelayNotImplemented) relayV1Router.POST("/embeddings", controller.Relay)
relayV1Router.POST("/audio/transcriptions", controller.RelayNotImplemented) relayV1Router.POST("/audio/transcriptions", controller.RelayNotImplemented)
relayV1Router.POST("/audio/translations", controller.RelayNotImplemented) relayV1Router.POST("/audio/translations", controller.RelayNotImplemented)
relayV1Router.GET("/files", controller.RelayNotImplemented) relayV1Router.GET("/files", controller.RelayNotImplemented)
@@ -35,9 +35,4 @@ func SetRelayRouter(router *gin.Engine) {
relayV1Router.DELETE("/models/:model", controller.RelayNotImplemented) relayV1Router.DELETE("/models/:model", controller.RelayNotImplemented)
relayV1Router.POST("/moderations", controller.RelayNotImplemented) relayV1Router.POST("/moderations", controller.RelayNotImplemented)
} }
relayDashboardRouter := router.Group("/dashboard") // TODO: return system's own token info
relayDashboardRouter.Use(middleware.TokenAuth(), middleware.Distribute())
{
relayDashboardRouter.Any("/*path", controller.Relay)
}
} }

View File

@@ -19,7 +19,6 @@ import Channel from './pages/Channel';
import Token from './pages/Token'; import Token from './pages/Token';
import EditToken from './pages/Token/EditToken'; import EditToken from './pages/Token/EditToken';
import EditChannel from './pages/Channel/EditChannel'; import EditChannel from './pages/Channel/EditChannel';
import AddChannel from './pages/Channel/AddChannel';
import Redemption from './pages/Redemption'; import Redemption from './pages/Redemption';
import EditRedemption from './pages/Redemption/EditRedemption'; import EditRedemption from './pages/Redemption/EditRedemption';
@@ -93,7 +92,7 @@ function App() {
path='/channel/add' path='/channel/add'
element={ element={
<Suspense fallback={<Loading></Loading>}> <Suspense fallback={<Loading></Loading>}>
<AddChannel /> <EditChannel />
</Suspense> </Suspense>
} }
/> />

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Divider, Form, Grid, Header, Message } from 'semantic-ui-react'; import { Divider, Form, Grid, Header, Message } from 'semantic-ui-react';
import { API, removeTrailingSlash, showError } from '../helpers'; import { API, removeTrailingSlash, showError, verifyJSON } from '../helpers';
const SystemSetting = () => { const SystemSetting = () => {
let [inputs, setInputs] = useState({ let [inputs, setInputs] = useState({
@@ -12,6 +12,7 @@ const SystemSetting = () => {
GitHubClientSecret: '', GitHubClientSecret: '',
Notice: '', Notice: '',
SMTPServer: '', SMTPServer: '',
SMTPPort: '',
SMTPAccount: '', SMTPAccount: '',
SMTPToken: '', SMTPToken: '',
ServerAddress: '', ServerAddress: '',
@@ -25,9 +26,7 @@ const SystemSetting = () => {
TurnstileSecretKey: '', TurnstileSecretKey: '',
RegisterEnabled: '', RegisterEnabled: '',
QuotaForNewUser: 0, QuotaForNewUser: 0,
RatioGPT3dot5: 2, ModelRatio: '',
RatioGPT4: 30,
RatioGPT4_32k: 60,
TopUpLink: '' TopUpLink: ''
}); });
let originInputs = {}; let originInputs = {};
@@ -93,7 +92,7 @@ const SystemSetting = () => {
name === 'TurnstileSiteKey' || name === 'TurnstileSiteKey' ||
name === 'TurnstileSecretKey' || name === 'TurnstileSecretKey' ||
name === 'QuotaForNewUser' || name === 'QuotaForNewUser' ||
name.startsWith('Ratio') || name === 'ModelRatio' ||
name === 'TopUpLink' name === 'TopUpLink'
) { ) {
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
@@ -111,19 +110,17 @@ const SystemSetting = () => {
if (originInputs['QuotaForNewUser'] !== inputs.QuotaForNewUser) { if (originInputs['QuotaForNewUser'] !== inputs.QuotaForNewUser) {
await updateOption('QuotaForNewUser', inputs.QuotaForNewUser); await updateOption('QuotaForNewUser', inputs.QuotaForNewUser);
} }
if (originInputs['RatioGPT3dot5'] !== inputs.RatioGPT3dot5) { if (originInputs['ModelRatio'] !== inputs.ModelRatio) {
await updateOption('RatioGPT3dot5', inputs.RatioGPT3dot5); if (!verifyJSON(inputs.ModelRatio)) {
} showError('模型倍率不是合法的 JSON 字符串');
if (originInputs['RatioGPT4'] !== inputs.RatioGPT4) { return;
await updateOption('RatioGPT4', inputs.RatioGPT4); }
} await updateOption('ModelRatio', inputs.ModelRatio);
if (originInputs['RatioGPT4_32k'] !== inputs.RatioGPT4_32k) {
await updateOption('RatioGPT4_32k', inputs.RatioGPT4_32k);
} }
if (originInputs['TopUpLink'] !== inputs.TopUpLink) { if (originInputs['TopUpLink'] !== inputs.TopUpLink) {
await updateOption('TopUpLink', inputs.TopUpLink); await updateOption('TopUpLink', inputs.TopUpLink);
} }
} };
const submitSMTP = async () => { const submitSMTP = async () => {
if (originInputs['SMTPServer'] !== inputs.SMTPServer) { if (originInputs['SMTPServer'] !== inputs.SMTPServer) {
@@ -132,6 +129,12 @@ const SystemSetting = () => {
if (originInputs['SMTPAccount'] !== inputs.SMTPAccount) { if (originInputs['SMTPAccount'] !== inputs.SMTPAccount) {
await updateOption('SMTPAccount', inputs.SMTPAccount); await updateOption('SMTPAccount', inputs.SMTPAccount);
} }
if (
originInputs['SMTPPort'] !== inputs.SMTPPort &&
inputs.SMTPPort !== ''
) {
await updateOption('SMTPPort', inputs.SMTPPort);
}
if ( if (
originInputs['SMTPToken'] !== inputs.SMTPToken && originInputs['SMTPToken'] !== inputs.SMTPToken &&
inputs.SMTPToken !== '' inputs.SMTPToken !== ''
@@ -262,7 +265,7 @@ const SystemSetting = () => {
label='新用户初始配额' label='新用户初始配额'
name='QuotaForNewUser' name='QuotaForNewUser'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.QuotaForNewUser} value={inputs.QuotaForNewUser}
type='number' type='number'
min='0' min='0'
@@ -272,45 +275,21 @@ const SystemSetting = () => {
label='充值链接' label='充值链接'
name='TopUpLink' name='TopUpLink'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.TopUpLink} value={inputs.TopUpLink}
type='link' type='link'
placeholder='例如发卡网站的购买链接' placeholder='例如发卡网站的购买链接'
/> />
</Form.Group> </Form.Group>
<Form.Group widths={3}> <Form.Group widths='equal'>
<Form.Input <Form.TextArea
label='GPT-3.5 系列模型倍率' label='模型倍率'
name='RatioGPT3dot5' name='ModelRatio'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
value={inputs.RatioGPT3dot5} autoComplete='new-password'
type='number' value={inputs.ModelRatio}
step='0.01' placeholder='为一个 JSON 文本,键为模型名称,值为倍率'
min='0'
placeholder='例如2'
/>
<Form.Input
label='GPT-4 系列模型倍率'
name='RatioGPT4'
onChange={handleInputChange}
autoComplete='off'
value={inputs.RatioGPT4}
type='number'
step='0.01'
min='0'
placeholder='例如30'
/>
<Form.Input
label='GPT-4 32k 系列模型倍率'
name='RatioGPT4_32k'
onChange={handleInputChange}
autoComplete='off'
value={inputs.RatioGPT4_32k}
type='number'
step='0.01'
min='0'
placeholder='例如60'
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitOperationConfig}>保存运营设置</Form.Button> <Form.Button onClick={submitOperationConfig}>保存运营设置</Form.Button>
@@ -319,20 +298,28 @@ const SystemSetting = () => {
配置 SMTP 配置 SMTP
<Header.Subheader>用以支持系统的邮件发送</Header.Subheader> <Header.Subheader>用以支持系统的邮件发送</Header.Subheader>
</Header> </Header>
<Form.Group widths={3}> <Form.Group widths={4}>
<Form.Input <Form.Input
label='SMTP 服务器地址' label='SMTP 服务器地址'
name='SMTPServer' name='SMTPServer'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.SMTPServer} value={inputs.SMTPServer}
placeholder='例如smtp.qq.com' placeholder='例如smtp.qq.com'
/> />
<Form.Input
label='SMTP 端口'
name='SMTPPort'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.SMTPPort}
placeholder='默认: 587'
/>
<Form.Input <Form.Input
label='SMTP 账户' label='SMTP 账户'
name='SMTPAccount' name='SMTPAccount'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.SMTPAccount} value={inputs.SMTPAccount}
placeholder='通常是邮箱地址' placeholder='通常是邮箱地址'
/> />
@@ -341,7 +328,7 @@ const SystemSetting = () => {
name='SMTPToken' name='SMTPToken'
onChange={handleInputChange} onChange={handleInputChange}
type='password' type='password'
autoComplete='off' autoComplete='new-password'
value={inputs.SMTPToken} value={inputs.SMTPToken}
placeholder='敏感信息不会发送到前端显示' placeholder='敏感信息不会发送到前端显示'
/> />
@@ -368,7 +355,7 @@ const SystemSetting = () => {
label='GitHub Client ID' label='GitHub Client ID'
name='GitHubClientId' name='GitHubClientId'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.GitHubClientId} value={inputs.GitHubClientId}
placeholder='输入你注册的 GitHub OAuth APP 的 ID' placeholder='输入你注册的 GitHub OAuth APP 的 ID'
/> />
@@ -377,7 +364,7 @@ const SystemSetting = () => {
name='GitHubClientSecret' name='GitHubClientSecret'
onChange={handleInputChange} onChange={handleInputChange}
type='password' type='password'
autoComplete='off' autoComplete='new-password'
value={inputs.GitHubClientSecret} value={inputs.GitHubClientSecret}
placeholder='敏感信息不会发送到前端显示' placeholder='敏感信息不会发送到前端显示'
/> />
@@ -405,7 +392,7 @@ const SystemSetting = () => {
name='WeChatServerAddress' name='WeChatServerAddress'
placeholder='例如https://yourdomain.com' placeholder='例如https://yourdomain.com'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.WeChatServerAddress} value={inputs.WeChatServerAddress}
/> />
<Form.Input <Form.Input
@@ -413,7 +400,7 @@ const SystemSetting = () => {
name='WeChatServerToken' name='WeChatServerToken'
type='password' type='password'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.WeChatServerToken} value={inputs.WeChatServerToken}
placeholder='敏感信息不会发送到前端显示' placeholder='敏感信息不会发送到前端显示'
/> />
@@ -421,7 +408,7 @@ const SystemSetting = () => {
label='微信公众号二维码图片链接' label='微信公众号二维码图片链接'
name='WeChatAccountQRCodeImageURL' name='WeChatAccountQRCodeImageURL'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.WeChatAccountQRCodeImageURL} value={inputs.WeChatAccountQRCodeImageURL}
placeholder='输入一个图片链接' placeholder='输入一个图片链接'
/> />
@@ -445,7 +432,7 @@ const SystemSetting = () => {
label='Turnstile Site Key' label='Turnstile Site Key'
name='TurnstileSiteKey' name='TurnstileSiteKey'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='off' autoComplete='new-password'
value={inputs.TurnstileSiteKey} value={inputs.TurnstileSiteKey}
placeholder='输入你注册的 Turnstile Site Key' placeholder='输入你注册的 Turnstile Site Key'
/> />
@@ -454,7 +441,7 @@ const SystemSetting = () => {
name='TurnstileSecretKey' name='TurnstileSecretKey'
onChange={handleInputChange} onChange={handleInputChange}
type='password' type='password'
autoComplete='off' autoComplete='new-password'
value={inputs.TurnstileSecretKey} value={inputs.TurnstileSecretKey}
placeholder='敏感信息不会发送到前端显示' placeholder='敏感信息不会发送到前端显示'
/> />

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Label, Pagination, Table } from 'semantic-ui-react'; import { Button, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { API, showError, showSuccess } from '../helpers'; import { API, showError, showSuccess } from '../helpers';
@@ -237,15 +237,25 @@ const UsersTable = () => {
> >
降级 降级
</Button> </Button>
<Button <Popup
size={'small'} trigger={
negative <Button size='small' negative>
onClick={() => { 删除
manageUser(user.username, 'delete', idx); </Button>
}} }
on='click'
flowing
hoverable
> >
删除 <Button
</Button> negative
onClick={() => {
manageUser(user.username, 'delete', idx);
}}
>
删除用户 {user.username}
</Button>
</Popup>
<Button <Button
size={'small'} size={'small'}
onClick={() => { onClick={() => {

View File

@@ -152,4 +152,13 @@ export function downloadTextAsFile(text, filename) {
a.href = url; a.href = url;
a.download = filename; a.download = filename;
a.click(); a.click();
} }
export const verifyJSON = (str) => {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
};

View File

@@ -1,95 +0,0 @@
import React, { useState } from 'react';
import { Button, Form, Header, Segment } from 'semantic-ui-react';
import { API, showError, showSuccess } from '../../helpers';
import { CHANNEL_OPTIONS } from '../../constants';
const AddChannel = () => {
const originInputs = {
name: '',
type: 1,
key: '',
base_url: '',
};
const [inputs, setInputs] = useState(originInputs);
const { name, type, key } = inputs;
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const submit = async () => {
if (inputs.name === '' || inputs.key === '') return;
if (inputs.base_url.endsWith('/')) {
inputs.base_url = inputs.base_url.slice(0, inputs.base_url.length - 1);
}
const res = await API.post(`/api/channel/`, inputs);
const { success, message } = res.data;
if (success) {
showSuccess('渠道创建成功!');
setInputs(originInputs);
} else {
showError(message);
}
};
return (
<>
<Segment>
<Header as='h3'>创建新的渠道</Header>
<Form autoComplete='off'>
<Form.Field>
<Form.Select
label='类型'
name='type'
options={CHANNEL_OPTIONS}
value={inputs.type}
onChange={handleInputChange}
/>
</Form.Field>
{
type === 8 && (
<Form.Field>
<Form.Input
label='Base URL'
name='base_url'
placeholder={'请输入自定义渠道的 Base URL'}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='off'
/>
</Form.Field>
)
}
<Form.Field>
<Form.Input
label='名称'
name='name'
placeholder={'请输入名称'}
onChange={handleInputChange}
value={name}
autoComplete='off'
required
/>
</Form.Field>
<Form.Field>
<Form.Input
label='密钥'
name='key'
placeholder={'请输入密钥'}
onChange={handleInputChange}
value={key}
// type='password'
autoComplete='off'
required
/>
</Form.Field>
<Button type={'submit'} onClick={submit}>
提交
</Button>
</Form>
</Segment>
</>
);
};
export default AddChannel;

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Segment } from 'semantic-ui-react'; import { Button, Form, Header, Message, Segment } from 'semantic-ui-react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { API, showError, showSuccess } from '../../helpers'; import { API, showError, showSuccess } from '../../helpers';
import { CHANNEL_OPTIONS } from '../../constants'; import { CHANNEL_OPTIONS } from '../../constants';
@@ -7,13 +7,15 @@ import { CHANNEL_OPTIONS } from '../../constants';
const EditChannel = () => { const EditChannel = () => {
const params = useParams(); const params = useParams();
const channelId = params.id; const channelId = params.id;
const [loading, setLoading] = useState(true); const isEdit = channelId !== undefined;
const [inputs, setInputs] = useState({ const [loading, setLoading] = useState(isEdit);
const originInputs = {
name: '', name: '',
key: '',
type: 1, type: 1,
base_url: '', key: '',
}); base_url: ''
};
const [inputs, setInputs] = useState(originInputs);
const handleInputChange = (e, { name, value }) => { const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
}; };
@@ -30,17 +32,31 @@ const EditChannel = () => {
setLoading(false); setLoading(false);
}; };
useEffect(() => { useEffect(() => {
loadChannel().then(); if (isEdit) {
loadChannel().then();
}
}, []); }, []);
const submit = async () => { const submit = async () => {
if (inputs.base_url.endsWith('/')) { if (!isEdit && (inputs.name === '' || inputs.key === '')) return;
inputs.base_url = inputs.base_url.slice(0, inputs.base_url.length - 1); let localInputs = inputs;
if (localInputs.base_url.endsWith('/')) {
localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1);
}
let res;
if (isEdit) {
res = await API.put(`/api/channel/`, { ...localInputs, id: parseInt(channelId) });
} else {
res = await API.post(`/api/channel/`, localInputs);
} }
let res = await API.put(`/api/channel/`, { ...inputs, id: parseInt(channelId) });
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
showSuccess('渠道更新成功!'); if (isEdit) {
showSuccess('渠道更新成功!');
} else {
showSuccess('渠道创建成功!');
setInputs(originInputs);
}
} else { } else {
showError(message); showError(message);
} }
@@ -49,8 +65,8 @@ const EditChannel = () => {
return ( return (
<> <>
<Segment loading={loading}> <Segment loading={loading}>
<Header as='h3'>更新渠道信息</Header> <Header as='h3'>{isEdit ? '更新渠道信息' : '创建新的渠道'}</Header>
<Form autoComplete='off'> <Form autoComplete='new-password'>
<Form.Field> <Form.Field>
<Form.Select <Form.Select
label='类型' label='类型'
@@ -60,16 +76,35 @@ const EditChannel = () => {
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Field> </Form.Field>
{
inputs.type === 3 && (
<>
<Message>
注意创建资源时部署名称必须模型名称保持一致因为 One API 会把请求体中的 model 参数替换为你的部署名称模型名称中的点会被剔除
</Message>
<Form.Field>
<Form.Input
label='AZURE_OPENAI_ENDPOINT'
name='base_url'
placeholder={'请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com'}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
</>
)
}
{ {
inputs.type === 8 && ( inputs.type === 8 && (
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='Base URL' label='Base URL'
name='base_url' name='base_url'
placeholder={'请输入新的自定义渠道的 Base URL'} placeholder={'请输入自定义渠道的 Base URL例如https://openai.justsong.cn'}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.base_url} value={inputs.base_url}
autoComplete='off' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
) )
@@ -78,21 +113,21 @@ const EditChannel = () => {
<Form.Input <Form.Input
label='名称' label='名称'
name='name' name='name'
placeholder={'请输入新的名称'} placeholder={'请输入名称'}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.name} value={inputs.name}
autoComplete='off' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='密钥' label='密钥'
name='key' name='key'
placeholder={'请输入新的密钥'} placeholder={'请输入密钥'}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.key} value={inputs.key}
// type='password' // type='password'
autoComplete='off' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
<Button onClick={submit}>提交</Button> <Button onClick={submit}>提交</Button>

View File

@@ -73,7 +73,7 @@ const EditRedemption = () => {
<> <>
<Segment loading={loading}> <Segment loading={loading}>
<Header as='h3'>{isEdit ? '更新兑换码信息' : '创建新的兑换码'}</Header> <Header as='h3'>{isEdit ? '更新兑换码信息' : '创建新的兑换码'}</Header>
<Form autoComplete='off'> <Form autoComplete='new-password'>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='名称' label='名称'
@@ -81,7 +81,7 @@ const EditRedemption = () => {
placeholder={'请输入名称'} placeholder={'请输入名称'}
onChange={handleInputChange} onChange={handleInputChange}
value={name} value={name}
autoComplete='off' autoComplete='new-password'
required={!isEdit} required={!isEdit}
/> />
</Form.Field> </Form.Field>
@@ -92,7 +92,7 @@ const EditRedemption = () => {
placeholder={'请输入单个兑换码中包含的额度'} placeholder={'请输入单个兑换码中包含的额度'}
onChange={handleInputChange} onChange={handleInputChange}
value={quota} value={quota}
autoComplete='off' autoComplete='new-password'
type='number' type='number'
/> />
</Form.Field> </Form.Field>
@@ -105,7 +105,7 @@ const EditRedemption = () => {
placeholder={'请输入生成数量'} placeholder={'请输入生成数量'}
onChange={handleInputChange} onChange={handleInputChange}
value={count} value={count}
autoComplete='off' autoComplete='new-password'
type='number' type='number'
/> />
</Form.Field> </Form.Field>

View File

@@ -95,7 +95,7 @@ const EditToken = () => {
<> <>
<Segment loading={loading}> <Segment loading={loading}>
<Header as='h3'>{isEdit ? '更新令牌信息' : '创建新的令牌'}</Header> <Header as='h3'>{isEdit ? '更新令牌信息' : '创建新的令牌'}</Header>
<Form autoComplete='off'> <Form autoComplete='new-password'>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='名称' label='名称'
@@ -103,7 +103,7 @@ const EditToken = () => {
placeholder={'请输入名称'} placeholder={'请输入名称'}
onChange={handleInputChange} onChange={handleInputChange}
value={name} value={name}
autoComplete='off' autoComplete='new-password'
required={!isEdit} required={!isEdit}
/> />
</Form.Field> </Form.Field>
@@ -116,7 +116,7 @@ const EditToken = () => {
placeholder={'请输入额度'} placeholder={'请输入额度'}
onChange={handleInputChange} onChange={handleInputChange}
value={remain_quota} value={remain_quota}
autoComplete='off' autoComplete='new-password'
type='number' type='number'
disabled={unlimited_quota} disabled={unlimited_quota}
/> />
@@ -133,7 +133,7 @@ const EditToken = () => {
placeholder={'请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss-1 表示无限制'} placeholder={'请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss-1 表示无限制'}
onChange={handleInputChange} onChange={handleInputChange}
value={expired_time} value={expired_time}
autoComplete='off' autoComplete='new-password'
type='datetime-local' type='datetime-local'
/> />
</Form.Field> </Form.Field>

View File

@@ -60,7 +60,7 @@ const EditUser = () => {
<> <>
<Segment loading={loading}> <Segment loading={loading}>
<Header as='h3'>更新用户信息</Header> <Header as='h3'>更新用户信息</Header>
<Form autoComplete='off'> <Form autoComplete='new-password'>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='用户名' label='用户名'
@@ -68,7 +68,7 @@ const EditUser = () => {
placeholder={'请输入新的用户名'} placeholder={'请输入新的用户名'}
onChange={handleInputChange} onChange={handleInputChange}
value={username} value={username}
autoComplete='off' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
@@ -79,7 +79,7 @@ const EditUser = () => {
placeholder={'请输入新的密码'} placeholder={'请输入新的密码'}
onChange={handleInputChange} onChange={handleInputChange}
value={password} value={password}
autoComplete='off' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
@@ -89,7 +89,7 @@ const EditUser = () => {
placeholder={'请输入新的显示名称'} placeholder={'请输入新的显示名称'}
onChange={handleInputChange} onChange={handleInputChange}
value={display_name} value={display_name}
autoComplete='off' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
@@ -97,7 +97,7 @@ const EditUser = () => {
label='已绑定的 GitHub 账户' label='已绑定的 GitHub 账户'
name='github_id' name='github_id'
value={github_id} value={github_id}
autoComplete='off' autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly readOnly
/> />
@@ -107,7 +107,7 @@ const EditUser = () => {
label='已绑定的微信账户' label='已绑定的微信账户'
name='wechat_id' name='wechat_id'
value={wechat_id} value={wechat_id}
autoComplete='off' autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly readOnly
/> />
@@ -117,7 +117,7 @@ const EditUser = () => {
label='已绑定的邮箱账户' label='已绑定的邮箱账户'
name='email' name='email'
value={email} value={email}
autoComplete='off' autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly readOnly
/> />