Compare commits

..

57 Commits

Author SHA1 Message Date
JustSong
5997fce454 chore: update button style 2025-02-01 00:36:33 +08:00
JustSong
0df6d7a131 chore: fix prompt 2025-02-01 00:27:05 +08:00
JustSong
93fdb60de5 feat: update log table style 2025-02-01 00:21:04 +08:00
JustSong
4db834da95 chore: update default theme style 2025-02-01 00:13:09 +08:00
JustSong
6818ed5ca8 chore: update default theme style 2025-02-01 00:07:41 +08:00
JustSong
7be3b5547d chore: update default theme style 2025-02-01 00:06:19 +08:00
JustSong
2d7ea61d67 chore: update default theme style 2025-02-01 00:02:30 +08:00
JustSong
83b34be067 chore: update default theme style 2025-02-01 00:01:06 +08:00
JustSong
d5d879afdc chore: update default theme style 2025-01-31 23:54:45 +08:00
JustSong
0f205a3aa3 chore: update default theme style 2025-01-31 23:53:00 +08:00
JustSong
76c3f87351 chore: update default theme style 2025-01-31 23:46:05 +08:00
JustSong
6d9a92f8f7 chore: update default theme style 2025-01-31 23:44:39 +08:00
JustSong
835f0e0d67 chore: update default theme style 2025-01-31 23:38:40 +08:00
JustSong
a6981f0d51 chore: update default theme style 2025-01-31 23:33:14 +08:00
JustSong
678d613179 chore: update default theme style 2025-01-31 23:31:41 +08:00
JustSong
be089a072b chore: update default theme style 2025-01-31 23:25:32 +08:00
JustSong
45d10aa3df chore: update default theme style 2025-01-31 23:24:11 +08:00
JustSong
9cdd48ac22 feat: update log table style
Some checks are pending
CI / Unit tests (push) Waiting to run
CI / commit_lint (push) Waiting to run
2025-01-31 23:21:42 +08:00
JustSong
310e7120e5 chore: update default theme style 2025-01-31 23:20:57 +08:00
JustSong
3d29713268 chore: update default theme style 2025-01-31 23:10:02 +08:00
JustSong
f2c7c424e9 chore: update default theme style 2025-01-31 23:08:07 +08:00
JustSong
38a42bb265 chore: update default theme style 2025-01-31 23:01:03 +08:00
JustSong
fa2e8f44b1 chore: update default theme style 2025-01-31 22:55:45 +08:00
JustSong
9f74101543 chore: update default theme style 2025-01-31 22:53:40 +08:00
JustSong
28a271a896 chore: update default theme style 2025-01-31 22:50:48 +08:00
JustSong
e8ea87fff3 chore: update home page style 2025-01-31 22:45:57 +08:00
JustSong
abe2d2dba8 chore: update style 2025-01-31 22:38:39 +08:00
JustSong
4bcaa064d6 chore: update style 2025-01-31 22:27:26 +08:00
JustSong
52d81e0e24 feat: remove first section for overview 2025-01-31 22:24:13 +08:00
JustSong
dc8c3bc69e feat: basic overview is done 2025-01-31 22:18:02 +08:00
JustSong
b4e69df802 fix: do not send access_token 2025-01-31 21:53:56 +08:00
JustSong
d9f74bdff3 feat: support new log type 2025-01-31 21:49:34 +08:00
JustSong
fa2a772731 feat: able to query test log 2025-01-31 21:23:12 +08:00
JustSong
4f68f3e1b3 chore: update log content 2025-01-31 20:16:56 +08:00
JustSong
0bab887b2d chore: update log content 2025-01-31 20:15:04 +08:00
JustSong
0230d36643 feat: update log table style 2025-01-31 20:06:43 +08:00
JustSong
bad57d049a feat: update log table style 2025-01-31 20:02:51 +08:00
JustSong
dc470ce82e feat: show stream & elapsed time in log detail 2025-01-31 19:34:22 +08:00
JustSong
ea0721d525 feat: update log content format 2025-01-31 18:15:43 +08:00
JustSong
d0402f9086 feat: record request_id 2025-01-31 17:54:04 +08:00
JustSong
1fead8e7f7 chore: add debug log for distributor 2025-01-31 17:26:33 +08:00
Fennng
09911a301d feat: support hunyuan-embedding (#2035)
* feat: support hunyuan-embedding

* chore: improve implementation

---------

Co-authored-by: LUO Feng <luofeng@flowpp.com>
Co-authored-by: JustSong <quanpengsong@gmail.com>
2025-01-31 16:48:02 +08:00
chenzikun
f95e6b78b8 fix: fix berry copy token (#2041)
* [bugfix]修复copy问题

* [update]两阶段编译代码

---------

Co-authored-by: zicorn <a24395@autel.com>
2025-01-31 16:12:59 +08:00
JustSong
605bb06667 feat: update logger 2025-01-31 16:00:53 +08:00
Laisky.Cai
d88e07fd9a feat: add deepseek-reasoner & gemini-2.0-flash-thinking-exp-01-21 (#2045)
Some checks are pending
CI / Unit tests (push) Waiting to run
CI / commit_lint (push) Waiting to run
* feat: add MILLI_USD constant and update pricing for deepseek services

* feat: add support for new Gemini model version 'gemini-2.0-flash-thinking-exp-01-21'
2025-01-31 15:15:59 +08:00
JustSong
3915ce9814 chore: update ci yaml
Some checks failed
CI / Unit tests (push) Has been cancelled
CI / commit_lint (push) Has been cancelled
2024-12-27 22:01:37 +08:00
JustSong
999defc88b chore: update readme 2024-12-27 21:59:38 +08:00
JustSong
b51c47bc77 docs: update README.md 2024-12-27 21:45:51 +08:00
JustSong
4f25cde132 fix: add branch check 2024-12-27 20:41:20 +08:00
JustSong
d89e9d7e44 fix: add branch limitation and drop pull_request trigger for ci.yml 2024-12-27 20:34:04 +08:00
Qiying Wang
a858292b54 feat: support gpt-4o-2024-11-20 (#1941)
Some checks failed
CI / Unit tests (push) Has been cancelled
CI / commit_lint (push) Has been cancelled
2024-12-22 19:49:50 +08:00
Yuwei Ba
ff589b5e4a chore: update model mapping implementation for audio (#1932)
* fixed model mapping

* chore: update implementation

---------

Co-authored-by: JustSong <quanpengsong@gmail.com>
2024-12-22 19:33:11 +08:00
Ke Wang
95e8c16338 feat: add balance query support for DeepSeek (#1946)
* Support Balance Query for DeepSeek

* Fix
2024-12-22 19:26:33 +08:00
lihangfu
381172cb36 feat: support Redis Sentinel and Redis Cluster (#1952)
* feature: support Redis Sentinel and Redis Cluster

* chore: update implementation

---------

Co-authored-by: JustSong <quanpengsong@gmail.com>
2024-12-22 19:21:24 +08:00
ZhangTianrong
59eae186a3 fix: remove the duplicate claude-3-5-haiku-20241022 in Anthropic's base model list (#1957)
* Update constants.go

Remove the duplicate `claude-3-5-haiku-20241022` causing issue 1928

* fix: fix syntax error

---------

Co-authored-by: JustSong <quanpengsong@gmail.com>
2024-12-22 18:58:29 +08:00
bestlaw66
ce52f355bb docs: add tutorial section for BT Panel installation (#1985)
* Update README.md

在国内有大部分用户都在使用宝塔面板管理服务器,因此增加使用宝塔面板部署的教程,可视化的部署方式可以帮助用户更加便捷的部署one-api

* docs: update readme

---------

Co-authored-by: JustSong <quanpengsong@gmail.com>
2024-12-22 18:55:04 +08:00
Ke Wang
cb9d0a74c9 fix: fix balance query for siliconflow (#1960) 2024-12-22 18:48:47 +08:00
68 changed files with 3702 additions and 1672 deletions

View File

@@ -12,8 +12,6 @@ name: CI
# would trigger our jobs twice on pull requests (once from "push" event and once # would trigger our jobs twice on pull requests (once from "push" event and once
# from "pull_request->synchronize") # from "pull_request->synchronize")
on: on:
pull_request:
types: [opened, reopened, synchronize]
push: push:
branches: branches:
- 'main' - 'main'

View File

@@ -175,6 +175,10 @@ sudo service nginx restart
初始账号用户名为 `root`,密码为 `123456` 初始账号用户名为 `root`,密码为 `123456`
### 通过宝塔面板进行一键部署
1. 安装宝塔面板9.2.0及以上版本,前往 [宝塔面板](https://www.bt.cn/new/download.html?r=dk_oneapi) 官网,选择正式版的脚本下载安装;
2. 安装后登录宝塔面板,在左侧菜单栏中点击 `Docker`,首次进入会提示安装 `Docker` 服务,点击立即安装,按提示完成安装;
3. 安装完成后在应用商店中搜索 `One-API`,点击安装,配置域名等基本信息即可完成安装;
### 基于 Docker Compose 进行部署 ### 基于 Docker Compose 进行部署
@@ -218,7 +222,7 @@ docker-compose ps
3. 所有从服务器必须设置 `NODE_TYPE` 为 `slave`,不设置则默认为主服务器。 3. 所有从服务器必须设置 `NODE_TYPE` 为 `slave`,不设置则默认为主服务器。
4. 设置 `SYNC_FREQUENCY` 后服务器将定期从数据库同步配置,在使用远程数据库的情况下,推荐设置该项并启用 Redis无论主从。 4. 设置 `SYNC_FREQUENCY` 后服务器将定期从数据库同步配置,在使用远程数据库的情况下,推荐设置该项并启用 Redis无论主从。
5. 从服务器可以选择设置 `FRONTEND_BASE_URL`,以重定向页面请求到主服务器。 5. 从服务器可以选择设置 `FRONTEND_BASE_URL`,以重定向页面请求到主服务器。
6. 从服务器上**分别**装好 Redis设置好 `REDIS_CONN_STRING`,这样可以做到在缓存未过期的情况下数据库零访问,可以减少延迟。 6. 从服务器上**分别**装好 Redis设置好 `REDIS_CONN_STRING`,这样可以做到在缓存未过期的情况下数据库零访问,可以减少延迟Redis 集群或者哨兵模式的支持请参考环境变量说明)
7. 如果主服务器访问数据库延迟也比较高,则也需要启用 Redis并设置 `SYNC_FREQUENCY`,以定期从数据库同步配置。 7. 如果主服务器访问数据库延迟也比较高,则也需要启用 Redis并设置 `SYNC_FREQUENCY`,以定期从数据库同步配置。
环境变量的具体使用方法详见[此处](#环境变量)。 环境变量的具体使用方法详见[此处](#环境变量)。
@@ -347,6 +351,11 @@ graph LR
1. `REDIS_CONN_STRING`:设置之后将使用 Redis 作为缓存使用。 1. `REDIS_CONN_STRING`:设置之后将使用 Redis 作为缓存使用。
+ 例子:`REDIS_CONN_STRING=redis://default:redispw@localhost:49153` + 例子:`REDIS_CONN_STRING=redis://default:redispw@localhost:49153`
+ 如果数据库访问延迟很低,没有必要启用 Redis启用后反而会出现数据滞后的问题。 + 如果数据库访问延迟很低,没有必要启用 Redis启用后反而会出现数据滞后的问题。
+ 如果需要使用哨兵或者集群模式:
+ 则需要把该环境变量设置为节点列表,例如:`localhost:49153,localhost:49154,localhost:49155`。
+ 除此之外还需要设置以下环境变量:
+ `REDIS_PASSWORD`Redis 集群或者哨兵模式下的密码设置。
+ `REDIS_MASTER_NAME`Redis 哨兵模式下主节点的名称。
2. `SESSION_SECRET`:设置之后将使用固定的会话密钥,这样系统重新启动后已登录用户的 cookie 将依旧有效。 2. `SESSION_SECRET`:设置之后将使用固定的会话密钥,这样系统重新启动后已登录用户的 cookie 将依旧有效。
+ 例子:`SESSION_SECRET=random_string` + 例子:`SESSION_SECRET=random_string`
3. `SQL_DSN`:设置之后将使用指定数据库而非 SQLite请使用 MySQL 或 PostgreSQL。 3. `SQL_DSN`:设置之后将使用指定数据库而非 SQLite请使用 MySQL 或 PostgreSQL。
@@ -401,6 +410,7 @@ graph LR
27. `INITIAL_ROOT_TOKEN`:如果设置了该值,则在系统首次启动时会自动创建一个值为该环境变量值的 root 用户令牌。 27. `INITIAL_ROOT_TOKEN`:如果设置了该值,则在系统首次启动时会自动创建一个值为该环境变量值的 root 用户令牌。
28. `INITIAL_ROOT_ACCESS_TOKEN`:如果设置了该值,则在系统首次启动时会自动创建一个值为该环境变量的 root 用户创建系统管理令牌。 28. `INITIAL_ROOT_ACCESS_TOKEN`:如果设置了该值,则在系统首次启动时会自动创建一个值为该环境变量的 root 用户创建系统管理令牌。
29. `ENFORCE_INCLUDE_USAGE`:是否强制在 stream 模型下返回 usage默认不开启可选值为 `true` 和 `false`。 29. `ENFORCE_INCLUDE_USAGE`:是否强制在 stream 模型下返回 usage默认不开启可选值为 `true` 和 `false`。
30. `TEST_PROMPT`:测试模型时的用户 prompt默认为 `Print your model name exactly and do not output without any other text.`。
### 命令行参数 ### 命令行参数
1. `--port <port_number>`: 指定服务器监听的端口号,默认为 `3000`。 1. `--port <port_number>`: 指定服务器监听的端口号,默认为 `3000`。

View File

@@ -1,13 +1,14 @@
package config package config
import ( import (
"github.com/songquanpeng/one-api/common/env"
"os" "os"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/songquanpeng/one-api/common/env"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -162,3 +163,4 @@ var UserContentRequestProxy = env.String("USER_CONTENT_REQUEST_PROXY", "")
var UserContentRequestTimeout = env.Int("USER_CONTENT_REQUEST_TIMEOUT", 30) var UserContentRequestTimeout = env.Int("USER_CONTENT_REQUEST_TIMEOUT", 30)
var EnforceIncludeUsage = env.Bool("ENFORCE_INCLUDE_USAGE", false) var EnforceIncludeUsage = env.Bool("ENFORCE_INCLUDE_USAGE", false)
var TestPrompt = env.String("TEST_PROMPT", "Print your model name exactly and do not output without any other text.")

View File

@@ -1,9 +1,8 @@
package helper package helper
import ( import (
"context"
"fmt" "fmt"
"github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/random"
"html/template" "html/template"
"log" "log"
"net" "net"
@@ -11,6 +10,10 @@ import (
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
"github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/random"
) )
func OpenBrowser(url string) { func OpenBrowser(url string) {
@@ -106,6 +109,18 @@ func GenRequestID() string {
return GetTimeString() + random.GetRandomNumberString(8) return GetTimeString() + random.GetRandomNumberString(8)
} }
func SetRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, RequestIdKey, id)
}
func GetRequestID(ctx context.Context) string {
rawRequestId := ctx.Value(RequestIdKey)
if rawRequestId == nil {
return ""
}
return rawRequestId.(string)
}
func GetResponseID(c *gin.Context) string { func GetResponseID(c *gin.Context) string {
logID := c.GetString(RequestIdKey) logID := c.GetString(RequestIdKey)
return fmt.Sprintf("chatcmpl-%s", logID) return fmt.Sprintf("chatcmpl-%s", logID)

View File

@@ -13,3 +13,8 @@ func GetTimeString() string {
now := time.Now() now := time.Now()
return fmt.Sprintf("%s%d", now.Format("20060102150405"), now.UnixNano()%1e9) return fmt.Sprintf("%s%d", now.Format("20060102150405"), now.UnixNano()%1e9)
} }
// CalcElapsedTime return the elapsed time in milliseconds (ms)
func CalcElapsedTime(start time.Time) int64 {
return time.Now().Sub(start).Milliseconds()
}

View File

@@ -7,19 +7,25 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings"
"sync" "sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/config" "github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/helper" "github.com/songquanpeng/one-api/common/helper"
) )
type loggerLevel string
const ( const (
loggerDEBUG = "DEBUG" loggerDEBUG loggerLevel = "DEBUG"
loggerINFO = "INFO" loggerINFO loggerLevel = "INFO"
loggerWarn = "WARN" loggerWarn loggerLevel = "WARN"
loggerError = "ERR" loggerError loggerLevel = "ERROR"
loggerFatal loggerLevel = "FATAL"
) )
var setupLogOnce sync.Once var setupLogOnce sync.Once
@@ -44,27 +50,26 @@ func SetupLogger() {
} }
func SysLog(s string) { func SysLog(s string) {
t := time.Now() logHelper(nil, loggerINFO, s)
_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
} }
func SysLogf(format string, a ...any) { func SysLogf(format string, a ...any) {
SysLog(fmt.Sprintf(format, a...)) logHelper(nil, loggerINFO, fmt.Sprintf(format, a...))
} }
func SysError(s string) { func SysError(s string) {
t := time.Now() logHelper(nil, loggerError, s)
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
} }
func SysErrorf(format string, a ...any) { func SysErrorf(format string, a ...any) {
SysError(fmt.Sprintf(format, a...)) logHelper(nil, loggerError, fmt.Sprintf(format, a...))
} }
func Debug(ctx context.Context, msg string) { func Debug(ctx context.Context, msg string) {
if config.DebugEnabled { if !config.DebugEnabled {
logHelper(ctx, loggerDEBUG, msg) return
} }
logHelper(ctx, loggerDEBUG, msg)
} }
func Info(ctx context.Context, msg string) { func Info(ctx context.Context, msg string) {
@@ -80,37 +85,65 @@ func Error(ctx context.Context, msg string) {
} }
func Debugf(ctx context.Context, format string, a ...any) { func Debugf(ctx context.Context, format string, a ...any) {
Debug(ctx, fmt.Sprintf(format, a...)) logHelper(ctx, loggerDEBUG, fmt.Sprintf(format, a...))
} }
func Infof(ctx context.Context, format string, a ...any) { func Infof(ctx context.Context, format string, a ...any) {
Info(ctx, fmt.Sprintf(format, a...)) logHelper(ctx, loggerINFO, fmt.Sprintf(format, a...))
} }
func Warnf(ctx context.Context, format string, a ...any) { func Warnf(ctx context.Context, format string, a ...any) {
Warn(ctx, fmt.Sprintf(format, a...)) logHelper(ctx, loggerWarn, fmt.Sprintf(format, a...))
} }
func Errorf(ctx context.Context, format string, a ...any) { func Errorf(ctx context.Context, format string, a ...any) {
Error(ctx, fmt.Sprintf(format, a...)) logHelper(ctx, loggerError, fmt.Sprintf(format, a...))
} }
func logHelper(ctx context.Context, level string, msg string) { func FatalLog(s string) {
logHelper(nil, loggerFatal, s)
}
func FatalLogf(format string, a ...any) {
logHelper(nil, loggerFatal, fmt.Sprintf(format, a...))
}
func logHelper(ctx context.Context, level loggerLevel, msg string) {
writer := gin.DefaultErrorWriter writer := gin.DefaultErrorWriter
if level == loggerINFO { if level == loggerINFO {
writer = gin.DefaultWriter writer = gin.DefaultWriter
} }
id := ctx.Value(helper.RequestIdKey) var requestId string
if id == nil { if ctx != nil {
id = helper.GenRequestID() rawRequestId := helper.GetRequestID(ctx)
if rawRequestId != "" {
requestId = fmt.Sprintf(" | %s", rawRequestId)
}
} }
lineInfo, funcName := getLineInfo()
now := time.Now() now := time.Now()
_, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg) _, _ = fmt.Fprintf(writer, "[%s] %v%s%s %s%s \n", level, now.Format("2006/01/02 - 15:04:05"), requestId, lineInfo, funcName, msg)
SetupLogger() SetupLogger()
if level == loggerFatal {
os.Exit(1)
}
} }
func FatalLog(v ...any) { func getLineInfo() (string, string) {
t := time.Now() funcName := "[unknown] "
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v) pc, file, line, ok := runtime.Caller(3)
os.Exit(1) if ok {
if fn := runtime.FuncForPC(pc); fn != nil {
parts := strings.Split(fn.Name(), ".")
funcName = "[" + parts[len(parts)-1] + "] "
}
} else {
file = "unknown"
line = 0
}
parts := strings.Split(file, "one-api/")
if len(parts) > 1 {
file = parts[1]
}
return fmt.Sprintf(" | %s:%d", file, line), funcName
} }

View File

@@ -2,13 +2,15 @@ package common
import ( import (
"context" "context"
"os"
"strings"
"time"
"github.com/go-redis/redis/v8" "github.com/go-redis/redis/v8"
"github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/common/logger"
"os"
"time"
) )
var RDB *redis.Client var RDB redis.Cmdable
var RedisEnabled = true var RedisEnabled = true
// InitRedisClient This function is called after init() // InitRedisClient This function is called after init()
@@ -23,13 +25,23 @@ func InitRedisClient() (err error) {
logger.SysLog("SYNC_FREQUENCY not set, Redis is disabled") logger.SysLog("SYNC_FREQUENCY not set, Redis is disabled")
return nil return nil
} }
logger.SysLog("Redis is enabled") redisConnString := os.Getenv("REDIS_CONN_STRING")
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING")) if os.Getenv("REDIS_MASTER_NAME") == "" {
if err != nil { logger.SysLog("Redis is enabled")
logger.FatalLog("failed to parse Redis connection string: " + err.Error()) opt, err := redis.ParseURL(redisConnString)
if err != nil {
logger.FatalLog("failed to parse Redis connection string: " + err.Error())
}
RDB = redis.NewClient(opt)
} else {
// cluster mode
logger.SysLog("Redis cluster mode enabled")
RDB = redis.NewUniversalClient(&redis.UniversalOptions{
Addrs: strings.Split(redisConnString, ","),
Password: os.Getenv("REDIS_PASSWORD"),
MasterName: os.Getenv("REDIS_MASTER_NAME"),
})
} }
RDB = redis.NewClient(opt)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()

View File

@@ -5,16 +5,18 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"strconv"
"time"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/config" "github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/common/logger"
"github.com/songquanpeng/one-api/common/random" "github.com/songquanpeng/one-api/common/random"
"github.com/songquanpeng/one-api/controller" "github.com/songquanpeng/one-api/controller"
"github.com/songquanpeng/one-api/model" "github.com/songquanpeng/one-api/model"
"net/http"
"strconv"
"time"
) )
type GitHubOAuthResponse struct { type GitHubOAuthResponse struct {
@@ -81,6 +83,7 @@ func getGitHubUserInfoByCode(code string) (*GitHubUser, error) {
} }
func GitHubOAuth(c *gin.Context) { func GitHubOAuth(c *gin.Context) {
ctx := c.Request.Context()
session := sessions.Default(c) session := sessions.Default(c)
state := c.Query("state") state := c.Query("state")
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
@@ -136,7 +139,7 @@ func GitHubOAuth(c *gin.Context) {
user.Role = model.RoleCommonUser user.Role = model.RoleCommonUser
user.Status = model.UserStatusEnabled user.Status = model.UserStatusEnabled
if err := user.Insert(0); err != nil { if err := user.Insert(ctx, 0); err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": err.Error(), "message": err.Error(),

View File

@@ -5,15 +5,17 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"strconv"
"time"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/config" "github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/common/logger"
"github.com/songquanpeng/one-api/controller" "github.com/songquanpeng/one-api/controller"
"github.com/songquanpeng/one-api/model" "github.com/songquanpeng/one-api/model"
"net/http"
"strconv"
"time"
) )
type LarkOAuthResponse struct { type LarkOAuthResponse struct {
@@ -79,6 +81,7 @@ func getLarkUserInfoByCode(code string) (*LarkUser, error) {
} }
func LarkOAuth(c *gin.Context) { func LarkOAuth(c *gin.Context) {
ctx := c.Request.Context()
session := sessions.Default(c) session := sessions.Default(c)
state := c.Query("state") state := c.Query("state")
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
@@ -125,7 +128,7 @@ func LarkOAuth(c *gin.Context) {
user.Role = model.RoleCommonUser user.Role = model.RoleCommonUser
user.Status = model.UserStatusEnabled user.Status = model.UserStatusEnabled
if err := user.Insert(0); err != nil { if err := user.Insert(ctx, 0); err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": err.Error(), "message": err.Error(),

View File

@@ -5,15 +5,17 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"strconv"
"time"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/config" "github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/common/logger"
"github.com/songquanpeng/one-api/controller" "github.com/songquanpeng/one-api/controller"
"github.com/songquanpeng/one-api/model" "github.com/songquanpeng/one-api/model"
"net/http"
"strconv"
"time"
) )
type OidcResponse struct { type OidcResponse struct {
@@ -87,6 +89,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
} }
func OidcAuth(c *gin.Context) { func OidcAuth(c *gin.Context) {
ctx := c.Request.Context()
session := sessions.Default(c) session := sessions.Default(c)
state := c.Query("state") state := c.Query("state")
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
@@ -142,7 +145,7 @@ func OidcAuth(c *gin.Context) {
} else { } else {
user.DisplayName = "OIDC User" user.DisplayName = "OIDC User"
} }
err := user.Insert(0) err := user.Insert(ctx, 0)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,

View File

@@ -4,14 +4,16 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/config" "github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/ctxkey" "github.com/songquanpeng/one-api/common/ctxkey"
"github.com/songquanpeng/one-api/controller" "github.com/songquanpeng/one-api/controller"
"github.com/songquanpeng/one-api/model" "github.com/songquanpeng/one-api/model"
"net/http"
"strconv"
"time"
) )
type wechatLoginResponse struct { type wechatLoginResponse struct {
@@ -52,6 +54,7 @@ func getWeChatIdByCode(code string) (string, error) {
} }
func WeChatAuth(c *gin.Context) { func WeChatAuth(c *gin.Context) {
ctx := c.Request.Context()
if !config.WeChatAuthEnabled { if !config.WeChatAuthEnabled {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "管理员未开启通过微信登录以及注册", "message": "管理员未开启通过微信登录以及注册",
@@ -87,7 +90,7 @@ func WeChatAuth(c *gin.Context) {
user.Role = model.RoleCommonUser user.Role = model.RoleCommonUser
user.Status = model.UserStatusEnabled user.Status = model.UserStatusEnabled
if err := user.Insert(0); err != nil { if err := user.Insert(ctx, 0); err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": err.Error(), "message": err.Error(),

View File

@@ -4,16 +4,17 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/songquanpeng/one-api/common/client" "github.com/songquanpeng/one-api/common/client"
"github.com/songquanpeng/one-api/common/config" "github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/common/logger"
"github.com/songquanpeng/one-api/model" "github.com/songquanpeng/one-api/model"
"github.com/songquanpeng/one-api/monitor" "github.com/songquanpeng/one-api/monitor"
"github.com/songquanpeng/one-api/relay/channeltype" "github.com/songquanpeng/one-api/relay/channeltype"
"io"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -101,6 +102,16 @@ type SiliconFlowUsageResponse struct {
} `json:"data"` } `json:"data"`
} }
type DeepSeekUsageResponse struct {
IsAvailable bool `json:"is_available"`
BalanceInfos []struct {
Currency string `json:"currency"`
TotalBalance string `json:"total_balance"`
GrantedBalance string `json:"granted_balance"`
ToppedUpBalance string `json:"topped_up_balance"`
} `json:"balance_infos"`
}
// GetAuthHeader get auth header // GetAuthHeader get auth header
func GetAuthHeader(token string) http.Header { func GetAuthHeader(token string) http.Header {
h := http.Header{} h := http.Header{}
@@ -237,7 +248,36 @@ func updateChannelSiliconFlowBalance(channel *model.Channel) (float64, error) {
if response.Code != 20000 { if response.Code != 20000 {
return 0, fmt.Errorf("code: %d, message: %s", response.Code, response.Message) return 0, fmt.Errorf("code: %d, message: %s", response.Code, response.Message)
} }
balance, err := strconv.ParseFloat(response.Data.Balance, 64) balance, err := strconv.ParseFloat(response.Data.TotalBalance, 64)
if err != nil {
return 0, err
}
channel.UpdateBalance(balance)
return balance, nil
}
func updateChannelDeepSeekBalance(channel *model.Channel) (float64, error) {
url := "https://api.deepseek.com/user/balance"
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
if err != nil {
return 0, err
}
response := DeepSeekUsageResponse{}
err = json.Unmarshal(body, &response)
if err != nil {
return 0, err
}
index := -1
for i, balanceInfo := range response.BalanceInfos {
if balanceInfo.Currency == "CNY" {
index = i
break
}
}
if index == -1 {
return 0, errors.New("currency CNY not found")
}
balance, err := strconv.ParseFloat(response.BalanceInfos[index].TotalBalance, 64)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -271,6 +311,8 @@ func updateChannelBalance(channel *model.Channel) (float64, error) {
return updateChannelAIGC2DBalance(channel) return updateChannelAIGC2DBalance(channel)
case channeltype.SiliconFlow: case channeltype.SiliconFlow:
return updateChannelSiliconFlowBalance(channel) return updateChannelSiliconFlowBalance(channel)
case channeltype.DeepSeek:
return updateChannelDeepSeekBalance(channel)
default: default:
return 0, errors.New("尚未实现") return 0, errors.New("尚未实现")
} }

View File

@@ -2,6 +2,7 @@ package controller
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -15,14 +16,17 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/config" "github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/ctxkey" "github.com/songquanpeng/one-api/common/ctxkey"
"github.com/songquanpeng/one-api/common/helper"
"github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/common/logger"
"github.com/songquanpeng/one-api/common/message" "github.com/songquanpeng/one-api/common/message"
"github.com/songquanpeng/one-api/middleware" "github.com/songquanpeng/one-api/middleware"
"github.com/songquanpeng/one-api/model" "github.com/songquanpeng/one-api/model"
"github.com/songquanpeng/one-api/monitor" "github.com/songquanpeng/one-api/monitor"
relay "github.com/songquanpeng/one-api/relay" "github.com/songquanpeng/one-api/relay"
"github.com/songquanpeng/one-api/relay/adaptor/openai"
"github.com/songquanpeng/one-api/relay/channeltype" "github.com/songquanpeng/one-api/relay/channeltype"
"github.com/songquanpeng/one-api/relay/controller" "github.com/songquanpeng/one-api/relay/controller"
"github.com/songquanpeng/one-api/relay/meta" "github.com/songquanpeng/one-api/relay/meta"
@@ -35,18 +39,34 @@ func buildTestRequest(model string) *relaymodel.GeneralOpenAIRequest {
model = "gpt-3.5-turbo" model = "gpt-3.5-turbo"
} }
testRequest := &relaymodel.GeneralOpenAIRequest{ testRequest := &relaymodel.GeneralOpenAIRequest{
MaxTokens: 2, Model: model,
Model: model,
} }
testMessage := relaymodel.Message{ testMessage := relaymodel.Message{
Role: "user", Role: "user",
Content: "hi", Content: config.TestPrompt,
} }
testRequest.Messages = append(testRequest.Messages, testMessage) testRequest.Messages = append(testRequest.Messages, testMessage)
return testRequest return testRequest
} }
func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIRequest) (err error, openaiErr *relaymodel.Error) { func parseTestResponse(resp string) (*openai.TextResponse, string, error) {
var response openai.TextResponse
err := json.Unmarshal([]byte(resp), &response)
if err != nil {
return nil, "", err
}
if len(response.Choices) == 0 {
return nil, "", errors.New("response has no choices")
}
stringContent, ok := response.Choices[0].Content.(string)
if !ok {
return nil, "", errors.New("response content is not string")
}
return &response, stringContent, nil
}
func testChannel(ctx context.Context, channel *model.Channel, request *relaymodel.GeneralOpenAIRequest) (responseMessage string, err error, openaiErr *relaymodel.Error) {
startTime := time.Now()
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)
c.Request = &http.Request{ c.Request = &http.Request{
@@ -66,7 +86,7 @@ func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIReques
apiType := channeltype.ToAPIType(channel.Type) apiType := channeltype.ToAPIType(channel.Type)
adaptor := relay.GetAdaptor(apiType) adaptor := relay.GetAdaptor(apiType)
if adaptor == nil { if adaptor == nil {
return fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), nil return "", fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), nil
} }
adaptor.Init(meta) adaptor.Init(meta)
modelName := request.Model modelName := request.Model
@@ -84,41 +104,69 @@ func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIReques
request.Model = modelName request.Model = modelName
convertedRequest, err := adaptor.ConvertRequest(c, relaymode.ChatCompletions, request) convertedRequest, err := adaptor.ConvertRequest(c, relaymode.ChatCompletions, request)
if err != nil { if err != nil {
return err, nil return "", err, nil
} }
jsonData, err := json.Marshal(convertedRequest) jsonData, err := json.Marshal(convertedRequest)
if err != nil { if err != nil {
return err, nil return "", err, nil
} }
defer func() {
logContent := fmt.Sprintf("渠道 %s 测试成功,响应:%s", channel.Name, responseMessage)
if err != nil || openaiErr != nil {
errorMessage := ""
if err != nil {
errorMessage = err.Error()
} else {
errorMessage = openaiErr.Message
}
logContent = fmt.Sprintf("渠道 %s 测试失败,错误:%s", channel.Name, errorMessage)
}
go model.RecordTestLog(ctx, &model.Log{
ChannelId: channel.Id,
ModelName: modelName,
Content: logContent,
ElapsedTime: helper.CalcElapsedTime(startTime),
})
}()
logger.SysLog(string(jsonData)) logger.SysLog(string(jsonData))
requestBody := bytes.NewBuffer(jsonData) requestBody := bytes.NewBuffer(jsonData)
c.Request.Body = io.NopCloser(requestBody) c.Request.Body = io.NopCloser(requestBody)
resp, err := adaptor.DoRequest(c, meta, requestBody) resp, err := adaptor.DoRequest(c, meta, requestBody)
if err != nil { if err != nil {
return err, nil return "", err, nil
} }
if resp != nil && resp.StatusCode != http.StatusOK { if resp != nil && resp.StatusCode != http.StatusOK {
err := controller.RelayErrorHandler(resp) err := controller.RelayErrorHandler(resp)
return fmt.Errorf("status code %d: %s", resp.StatusCode, err.Error.Message), &err.Error errorMessage := err.Error.Message
if errorMessage != "" {
errorMessage = ", error message: " + errorMessage
}
return "", fmt.Errorf("http status code: %d%s", resp.StatusCode, errorMessage), &err.Error
} }
usage, respErr := adaptor.DoResponse(c, resp, meta) usage, respErr := adaptor.DoResponse(c, resp, meta)
if respErr != nil { if respErr != nil {
return fmt.Errorf("%s", respErr.Error.Message), &respErr.Error return "", fmt.Errorf("%s", respErr.Error.Message), &respErr.Error
} }
if usage == nil { if usage == nil {
return errors.New("usage is nil"), nil return "", errors.New("usage is nil"), nil
}
rawResponse := w.Body.String()
_, responseMessage, err = parseTestResponse(rawResponse)
if err != nil {
return "", err, nil
} }
result := w.Result() result := w.Result()
// print result.Body // print result.Body
respBody, err := io.ReadAll(result.Body) respBody, err := io.ReadAll(result.Body)
if err != nil { if err != nil {
return err, nil return "", err, nil
} }
logger.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody))) logger.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
return nil, nil return responseMessage, nil, nil
} }
func TestChannel(c *gin.Context) { func TestChannel(c *gin.Context) {
ctx := c.Request.Context()
id, err := strconv.Atoi(c.Param("id")) id, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -135,10 +183,10 @@ func TestChannel(c *gin.Context) {
}) })
return return
} }
model := c.Query("model") modelName := c.Query("model")
testRequest := buildTestRequest(model) testRequest := buildTestRequest(modelName)
tik := time.Now() tik := time.Now()
err, _ = testChannel(channel, testRequest) responseMessage, err, _ := testChannel(ctx, channel, testRequest)
tok := time.Now() tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds() milliseconds := tok.Sub(tik).Milliseconds()
if err != nil { if err != nil {
@@ -148,18 +196,18 @@ func TestChannel(c *gin.Context) {
consumedTime := float64(milliseconds) / 1000.0 consumedTime := float64(milliseconds) / 1000.0
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": err.Error(), "message": err.Error(),
"time": consumedTime, "time": consumedTime,
"model": model, "modelName": modelName,
}) })
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": responseMessage,
"time": consumedTime, "time": consumedTime,
"model": model, "modelName": modelName,
}) })
return return
} }
@@ -167,7 +215,7 @@ func TestChannel(c *gin.Context) {
var testAllChannelsLock sync.Mutex var testAllChannelsLock sync.Mutex
var testAllChannelsRunning bool = false var testAllChannelsRunning bool = false
func testChannels(notify bool, scope string) error { func testChannels(ctx context.Context, notify bool, scope string) error {
if config.RootUserEmail == "" { if config.RootUserEmail == "" {
config.RootUserEmail = model.GetRootUserEmail() config.RootUserEmail = model.GetRootUserEmail()
} }
@@ -191,7 +239,7 @@ func testChannels(notify bool, scope string) error {
isChannelEnabled := channel.Status == model.ChannelStatusEnabled isChannelEnabled := channel.Status == model.ChannelStatusEnabled
tik := time.Now() tik := time.Now()
testRequest := buildTestRequest("") testRequest := buildTestRequest("")
err, openaiErr := testChannel(channel, testRequest) _, err, openaiErr := testChannel(ctx, channel, testRequest)
tok := time.Now() tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds() milliseconds := tok.Sub(tik).Milliseconds()
if isChannelEnabled && milliseconds > disableThreshold { if isChannelEnabled && milliseconds > disableThreshold {
@@ -225,11 +273,12 @@ func testChannels(notify bool, scope string) error {
} }
func TestChannels(c *gin.Context) { func TestChannels(c *gin.Context) {
ctx := c.Request.Context()
scope := c.Query("scope") scope := c.Query("scope")
if scope == "" { if scope == "" {
scope = "all" scope = "all"
} }
err := testChannels(true, scope) err := testChannels(ctx, true, scope)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
@@ -245,10 +294,11 @@ func TestChannels(c *gin.Context) {
} }
func AutomaticallyTestChannels(frequency int) { func AutomaticallyTestChannels(frequency int) {
ctx := context.Background()
for { for {
time.Sleep(time.Duration(frequency) * time.Minute) time.Sleep(time.Duration(frequency) * time.Minute)
logger.SysLog("testing all channels") logger.SysLog("testing all channels")
_ = testChannels(false, "all") _ = testChannels(ctx, false, "all")
logger.SysLog("channel test finished") logger.SysLog("channel test finished")
} }
} }

View File

@@ -109,6 +109,7 @@ func Logout(c *gin.Context) {
} }
func Register(c *gin.Context) { func Register(c *gin.Context) {
ctx := c.Request.Context()
if !config.RegisterEnabled { if !config.RegisterEnabled {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "管理员关闭了新用户注册", "message": "管理员关闭了新用户注册",
@@ -166,7 +167,7 @@ func Register(c *gin.Context) {
if config.EmailVerificationEnabled { if config.EmailVerificationEnabled {
cleanUser.Email = user.Email cleanUser.Email = user.Email
} }
if err := cleanUser.Insert(inviterId); err != nil { if err := cleanUser.Insert(ctx, inviterId); err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": err.Error(), "message": err.Error(),
@@ -362,6 +363,7 @@ func GetSelf(c *gin.Context) {
} }
func UpdateUser(c *gin.Context) { func UpdateUser(c *gin.Context) {
ctx := c.Request.Context()
var updatedUser model.User var updatedUser model.User
err := json.NewDecoder(c.Request.Body).Decode(&updatedUser) err := json.NewDecoder(c.Request.Body).Decode(&updatedUser)
if err != nil || updatedUser.Id == 0 { if err != nil || updatedUser.Id == 0 {
@@ -416,7 +418,7 @@ func UpdateUser(c *gin.Context) {
return return
} }
if originUser.Quota != updatedUser.Quota { if originUser.Quota != updatedUser.Quota {
model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", common.LogQuota(originUser.Quota), common.LogQuota(updatedUser.Quota))) model.RecordLog(ctx, originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", common.LogQuota(originUser.Quota), common.LogQuota(updatedUser.Quota)))
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
@@ -535,6 +537,7 @@ func DeleteSelf(c *gin.Context) {
} }
func CreateUser(c *gin.Context) { func CreateUser(c *gin.Context) {
ctx := c.Request.Context()
var user model.User var user model.User
err := json.NewDecoder(c.Request.Body).Decode(&user) err := json.NewDecoder(c.Request.Body).Decode(&user)
if err != nil || user.Username == "" || user.Password == "" { if err != nil || user.Username == "" || user.Password == "" {
@@ -568,7 +571,7 @@ func CreateUser(c *gin.Context) {
Password: user.Password, Password: user.Password,
DisplayName: user.DisplayName, DisplayName: user.DisplayName,
} }
if err := cleanUser.Insert(0); err != nil { if err := cleanUser.Insert(ctx, 0); err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": err.Error(), "message": err.Error(),
@@ -747,6 +750,7 @@ type topUpRequest struct {
} }
func TopUp(c *gin.Context) { func TopUp(c *gin.Context) {
ctx := c.Request.Context()
req := topUpRequest{} req := topUpRequest{}
err := c.ShouldBindJSON(&req) err := c.ShouldBindJSON(&req)
if err != nil { if err != nil {
@@ -757,7 +761,7 @@ func TopUp(c *gin.Context) {
return return
} }
id := c.GetInt("id") id := c.GetInt("id")
quota, err := model.Redeem(req.Key, id) quota, err := model.Redeem(ctx, req.Key, id)
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
@@ -780,6 +784,7 @@ type adminTopUpRequest struct {
} }
func AdminTopUp(c *gin.Context) { func AdminTopUp(c *gin.Context) {
ctx := c.Request.Context()
req := adminTopUpRequest{} req := adminTopUpRequest{}
err := c.ShouldBindJSON(&req) err := c.ShouldBindJSON(&req)
if err != nil { if err != nil {
@@ -800,7 +805,7 @@ func AdminTopUp(c *gin.Context) {
if req.Remark == "" { if req.Remark == "" {
req.Remark = fmt.Sprintf("通过 API 充值 %s", common.LogQuota(int64(req.Quota))) req.Remark = fmt.Sprintf("通过 API 充值 %s", common.LogQuota(int64(req.Quota)))
} }
model.RecordTopupLog(req.UserId, req.Remark, req.Quota) model.RecordTopupLog(ctx, req.UserId, req.Remark, req.Quota)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",

View File

@@ -2,13 +2,15 @@ package middleware
import ( import (
"fmt" "fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/ctxkey" "github.com/songquanpeng/one-api/common/ctxkey"
"github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/common/logger"
"github.com/songquanpeng/one-api/model" "github.com/songquanpeng/one-api/model"
"github.com/songquanpeng/one-api/relay/channeltype" "github.com/songquanpeng/one-api/relay/channeltype"
"net/http"
"strconv"
) )
type ModelRequest struct { type ModelRequest struct {
@@ -17,6 +19,7 @@ type ModelRequest struct {
func Distribute() func(c *gin.Context) { func Distribute() func(c *gin.Context) {
return func(c *gin.Context) { return func(c *gin.Context) {
ctx := c.Request.Context()
userId := c.GetInt(ctxkey.Id) userId := c.GetInt(ctxkey.Id)
userGroup, _ := model.CacheGetUserGroup(userId) userGroup, _ := model.CacheGetUserGroup(userId)
c.Set(ctxkey.Group, userGroup) c.Set(ctxkey.Group, userGroup)
@@ -52,6 +55,7 @@ func Distribute() func(c *gin.Context) {
return return
} }
} }
logger.Debugf(ctx, "user id %d, user group: %s, request model: %s, using channel #%d", userId, userGroup, requestModel, channel.Id)
SetupContextForSelectedChannel(c, channel, requestModel) SetupContextForSelectedChannel(c, channel, requestModel)
c.Next() c.Next()
} }

View File

@@ -1,8 +1,8 @@
package middleware package middleware
import ( import (
"context"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/helper" "github.com/songquanpeng/one-api/common/helper"
) )
@@ -10,7 +10,7 @@ func RequestId() func(c *gin.Context) {
return func(c *gin.Context) { return func(c *gin.Context) {
id := helper.GenRequestID() id := helper.GenRequestID()
c.Set(helper.RequestIdKey, id) c.Set(helper.RequestIdKey, id)
ctx := context.WithValue(c.Request.Context(), helper.RequestIdKey, id) ctx := helper.SetRequestID(c.Request.Context(), id)
c.Request = c.Request.WithContext(ctx) c.Request = c.Request.WithContext(ctx)
c.Header(helper.RequestIdKey, id) c.Header(helper.RequestIdKey, id)
c.Next() c.Next()

View File

@@ -4,26 +4,31 @@ import (
"context" "context"
"fmt" "fmt"
"gorm.io/gorm"
"github.com/songquanpeng/one-api/common" "github.com/songquanpeng/one-api/common"
"github.com/songquanpeng/one-api/common/config" "github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/helper" "github.com/songquanpeng/one-api/common/helper"
"github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/common/logger"
"gorm.io/gorm"
) )
type Log struct { type Log struct {
Id int `json:"id"` Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"` UserId int `json:"user_id" gorm:"index"`
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_type"` CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_type"`
Type int `json:"type" gorm:"index:idx_created_at_type"` Type int `json:"type" gorm:"index:idx_created_at_type"`
Content string `json:"content"` Content string `json:"content"`
Username string `json:"username" gorm:"index:index_username_model_name,priority:2;default:''"` Username string `json:"username" gorm:"index:index_username_model_name,priority:2;default:''"`
TokenName string `json:"token_name" gorm:"index;default:''"` TokenName string `json:"token_name" gorm:"index;default:''"`
ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
Quota int `json:"quota" gorm:"default:0"` Quota int `json:"quota" gorm:"default:0"`
PromptTokens int `json:"prompt_tokens" gorm:"default:0"` PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
CompletionTokens int `json:"completion_tokens" gorm:"default:0"` CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
ChannelId int `json:"channel" gorm:"index"` ChannelId int `json:"channel" gorm:"index"`
RequestId string `json:"request_id" gorm:"default:''"`
ElapsedTime int64 `json:"elapsed_time" gorm:"default:0"` // unit is ms
IsStream bool `json:"is_stream" gorm:"default:false"`
SystemPromptReset bool `json:"system_prompt_reset" gorm:"default:false"`
} }
const ( const (
@@ -32,9 +37,21 @@ const (
LogTypeConsume LogTypeConsume
LogTypeManage LogTypeManage
LogTypeSystem LogTypeSystem
LogTypeTest
) )
func RecordLog(userId int, logType int, content string) { func recordLogHelper(ctx context.Context, log *Log) {
requestId := helper.GetRequestID(ctx)
log.RequestId = requestId
err := LOG_DB.Create(log).Error
if err != nil {
logger.Error(ctx, "failed to record log: "+err.Error())
return
}
logger.Infof(ctx, "record log: %+v", log)
}
func RecordLog(ctx context.Context, userId int, logType int, content string) {
if logType == LogTypeConsume && !config.LogConsumeEnabled { if logType == LogTypeConsume && !config.LogConsumeEnabled {
return return
} }
@@ -45,13 +62,10 @@ func RecordLog(userId int, logType int, content string) {
Type: logType, Type: logType,
Content: content, Content: content,
} }
err := LOG_DB.Create(log).Error recordLogHelper(ctx, log)
if err != nil {
logger.SysError("failed to record log: " + err.Error())
}
} }
func RecordTopupLog(userId int, content string, quota int) { func RecordTopupLog(ctx context.Context, userId int, content string, quota int) {
log := &Log{ log := &Log{
UserId: userId, UserId: userId,
Username: GetUsernameById(userId), Username: GetUsernameById(userId),
@@ -60,34 +74,23 @@ func RecordTopupLog(userId int, content string, quota int) {
Content: content, Content: content,
Quota: quota, Quota: quota,
} }
err := LOG_DB.Create(log).Error recordLogHelper(ctx, log)
if err != nil {
logger.SysError("failed to record log: " + err.Error())
}
} }
func RecordConsumeLog(ctx context.Context, userId int, channelId int, promptTokens int, completionTokens int, modelName string, tokenName string, quota int64, content string) { func RecordConsumeLog(ctx context.Context, log *Log) {
logger.Info(ctx, fmt.Sprintf("record consume log: userId=%d, channelId=%d, promptTokens=%d, completionTokens=%d, modelName=%s, tokenName=%s, quota=%d, content=%s", userId, channelId, promptTokens, completionTokens, modelName, tokenName, quota, content))
if !config.LogConsumeEnabled { if !config.LogConsumeEnabled {
return return
} }
log := &Log{ log.Username = GetUsernameById(log.UserId)
UserId: userId, log.CreatedAt = helper.GetTimestamp()
Username: GetUsernameById(userId), log.Type = LogTypeConsume
CreatedAt: helper.GetTimestamp(), recordLogHelper(ctx, log)
Type: LogTypeConsume, }
Content: content,
PromptTokens: promptTokens, func RecordTestLog(ctx context.Context, log *Log) {
CompletionTokens: completionTokens, log.CreatedAt = helper.GetTimestamp()
TokenName: tokenName, log.Type = LogTypeTest
ModelName: modelName, recordLogHelper(ctx, log)
Quota: int(quota),
ChannelId: channelId,
}
err := LOG_DB.Create(log).Error
if err != nil {
logger.Error(ctx, "failed to record log: "+err.Error())
}
} }
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int) (logs []*Log, err error) { func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int) (logs []*Log, err error) {

View File

@@ -1,11 +1,14 @@
package model package model
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"gorm.io/gorm"
"github.com/songquanpeng/one-api/common" "github.com/songquanpeng/one-api/common"
"github.com/songquanpeng/one-api/common/helper" "github.com/songquanpeng/one-api/common/helper"
"gorm.io/gorm"
) )
const ( const (
@@ -48,7 +51,7 @@ func GetRedemptionById(id int) (*Redemption, error) {
return &redemption, err return &redemption, err
} }
func Redeem(key string, userId int) (quota int64, err error) { func Redeem(ctx context.Context, key string, userId int) (quota int64, err error) {
if key == "" { if key == "" {
return 0, errors.New("未提供兑换码") return 0, errors.New("未提供兑换码")
} }
@@ -82,7 +85,7 @@ func Redeem(key string, userId int) (quota int64, err error) {
if err != nil { if err != nil {
return 0, errors.New("兑换失败," + err.Error()) return 0, errors.New("兑换失败," + err.Error())
} }
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s", common.LogQuota(redemption.Quota))) RecordLog(ctx, userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s", common.LogQuota(redemption.Quota)))
return redemption.Quota, nil return redemption.Quota, nil
} }

View File

@@ -1,16 +1,19 @@
package model package model
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"strings"
"gorm.io/gorm"
"github.com/songquanpeng/one-api/common" "github.com/songquanpeng/one-api/common"
"github.com/songquanpeng/one-api/common/blacklist" "github.com/songquanpeng/one-api/common/blacklist"
"github.com/songquanpeng/one-api/common/config" "github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/helper" "github.com/songquanpeng/one-api/common/helper"
"github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/common/logger"
"github.com/songquanpeng/one-api/common/random" "github.com/songquanpeng/one-api/common/random"
"gorm.io/gorm"
"strings"
) )
const ( const (
@@ -92,7 +95,7 @@ func GetUserById(id int, selectAll bool) (*User, error) {
if selectAll { if selectAll {
err = DB.First(&user, "id = ?", id).Error err = DB.First(&user, "id = ?", id).Error
} else { } else {
err = DB.Omit("password").First(&user, "id = ?", id).Error err = DB.Omit("password", "access_token").First(&user, "id = ?", id).Error
} }
return &user, err return &user, err
} }
@@ -114,7 +117,7 @@ func DeleteUserById(id int) (err error) {
return user.Delete() return user.Delete()
} }
func (user *User) Insert(inviterId int) error { func (user *User) Insert(ctx context.Context, inviterId int) error {
var err error var err error
if user.Password != "" { if user.Password != "" {
user.Password, err = common.Password2Hash(user.Password) user.Password, err = common.Password2Hash(user.Password)
@@ -130,16 +133,16 @@ func (user *User) Insert(inviterId int) error {
return result.Error return result.Error
} }
if config.QuotaForNewUser > 0 { if config.QuotaForNewUser > 0 {
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", common.LogQuota(config.QuotaForNewUser))) RecordLog(ctx, user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", common.LogQuota(config.QuotaForNewUser)))
} }
if inviterId != 0 { if inviterId != 0 {
if config.QuotaForInvitee > 0 { if config.QuotaForInvitee > 0 {
_ = IncreaseUserQuota(user.Id, config.QuotaForInvitee) _ = IncreaseUserQuota(user.Id, config.QuotaForInvitee)
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", common.LogQuota(config.QuotaForInvitee))) RecordLog(ctx, user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", common.LogQuota(config.QuotaForInvitee)))
} }
if config.QuotaForInviter > 0 { if config.QuotaForInviter > 0 {
_ = IncreaseUserQuota(inviterId, config.QuotaForInviter) _ = IncreaseUserQuota(inviterId, config.QuotaForInviter)
RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", common.LogQuota(config.QuotaForInviter))) RecordLog(ctx, inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", common.LogQuota(config.QuotaForInviter)))
} }
} }
// create default token // create default token

View File

@@ -9,5 +9,4 @@ var ModelList = []string{
"claude-3-5-sonnet-20240620", "claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022", "claude-3-5-sonnet-20241022",
"claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest",
"claude-3-5-haiku-20241022",
} }

View File

@@ -7,7 +7,6 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/helper" "github.com/songquanpeng/one-api/common/helper"
channelhelper "github.com/songquanpeng/one-api/relay/adaptor" channelhelper "github.com/songquanpeng/one-api/relay/adaptor"
"github.com/songquanpeng/one-api/relay/adaptor/openai" "github.com/songquanpeng/one-api/relay/adaptor/openai"
@@ -24,8 +23,11 @@ func (a *Adaptor) Init(meta *meta.Meta) {
} }
func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) { func (a *Adaptor) GetRequestURL(meta *meta.Meta) (string, error) {
defaultVersion := config.GeminiVersion var defaultVersion string
if meta.ActualModelName == "gemini-2.0-flash-exp" { switch meta.ActualModelName {
case "gemini-2.0-flash-exp",
"gemini-2.0-flash-thinking-exp",
"gemini-2.0-flash-thinking-exp-01-21":
defaultVersion = "v1beta" defaultVersion = "v1beta"
} }

View File

@@ -7,5 +7,5 @@ var ModelList = []string{
"gemini-1.5-flash", "gemini-1.5-pro", "gemini-1.5-flash", "gemini-1.5-pro",
"text-embedding-004", "aqa", "text-embedding-004", "aqa",
"gemini-2.0-flash-exp", "gemini-2.0-flash-exp",
"gemini-2.0-flash-thinking-exp", "gemini-2.0-flash-thinking-exp", "gemini-2.0-flash-thinking-exp-01-21",
} }

View File

@@ -9,6 +9,7 @@ var ModelList = []string{
"gpt-4-turbo-preview", "gpt-4-turbo", "gpt-4-turbo-2024-04-09", "gpt-4-turbo-preview", "gpt-4-turbo", "gpt-4-turbo-2024-04-09",
"gpt-4o", "gpt-4o-2024-05-13", "gpt-4o", "gpt-4o-2024-05-13",
"gpt-4o-2024-08-06", "gpt-4o-2024-08-06",
"gpt-4o-2024-11-20",
"chatgpt-4o-latest", "chatgpt-4o-latest",
"gpt-4o-mini", "gpt-4o-mini-2024-07-18", "gpt-4o-mini", "gpt-4o-mini-2024-07-18",
"gpt-4-vision-preview", "gpt-4-vision-preview",

View File

@@ -2,16 +2,19 @@ package tencent
import ( import (
"errors" "errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/helper" "github.com/songquanpeng/one-api/common/helper"
"github.com/songquanpeng/one-api/relay/adaptor" "github.com/songquanpeng/one-api/relay/adaptor"
"github.com/songquanpeng/one-api/relay/adaptor/openai" "github.com/songquanpeng/one-api/relay/adaptor/openai"
"github.com/songquanpeng/one-api/relay/meta" "github.com/songquanpeng/one-api/relay/meta"
"github.com/songquanpeng/one-api/relay/model" "github.com/songquanpeng/one-api/relay/model"
"io" "github.com/songquanpeng/one-api/relay/relaymode"
"net/http"
"strconv"
"strings"
) )
// https://cloud.tencent.com/document/api/1729/101837 // https://cloud.tencent.com/document/api/1729/101837
@@ -52,10 +55,18 @@ func (a *Adaptor) ConvertRequest(c *gin.Context, relayMode int, request *model.G
if err != nil { if err != nil {
return nil, err return nil, err
} }
tencentRequest := ConvertRequest(*request) var convertedRequest any
switch relayMode {
case relaymode.Embeddings:
a.Action = "GetEmbedding"
convertedRequest = ConvertEmbeddingRequest(*request)
default:
a.Action = "ChatCompletions"
convertedRequest = ConvertRequest(*request)
}
// we have to calculate the sign here // we have to calculate the sign here
a.Sign = GetSign(*tencentRequest, a, secretId, secretKey) a.Sign = GetSign(convertedRequest, a, secretId, secretKey)
return tencentRequest, nil return convertedRequest, nil
} }
func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) { func (a *Adaptor) ConvertImageRequest(request *model.ImageRequest) (any, error) {
@@ -75,7 +86,12 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, meta *meta.Met
err, responseText = StreamHandler(c, resp) err, responseText = StreamHandler(c, resp)
usage = openai.ResponseText2Usage(responseText, meta.ActualModelName, meta.PromptTokens) usage = openai.ResponseText2Usage(responseText, meta.ActualModelName, meta.PromptTokens)
} else { } else {
err, usage = Handler(c, resp) switch meta.Mode {
case relaymode.Embeddings:
err, usage = EmbeddingHandler(c, resp)
default:
err, usage = Handler(c, resp)
}
} }
return return
} }

View File

@@ -6,4 +6,5 @@ var ModelList = []string{
"hunyuan-standard-256K", "hunyuan-standard-256K",
"hunyuan-pro", "hunyuan-pro",
"hunyuan-vision", "hunyuan-vision",
"hunyuan-embedding",
} }

View File

@@ -8,7 +8,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/songquanpeng/one-api/common/render"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
@@ -16,11 +15,14 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common" "github.com/songquanpeng/one-api/common"
"github.com/songquanpeng/one-api/common/conv" "github.com/songquanpeng/one-api/common/conv"
"github.com/songquanpeng/one-api/common/ctxkey"
"github.com/songquanpeng/one-api/common/helper" "github.com/songquanpeng/one-api/common/helper"
"github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/common/logger"
"github.com/songquanpeng/one-api/common/random" "github.com/songquanpeng/one-api/common/random"
"github.com/songquanpeng/one-api/common/render"
"github.com/songquanpeng/one-api/relay/adaptor/openai" "github.com/songquanpeng/one-api/relay/adaptor/openai"
"github.com/songquanpeng/one-api/relay/constant" "github.com/songquanpeng/one-api/relay/constant"
"github.com/songquanpeng/one-api/relay/model" "github.com/songquanpeng/one-api/relay/model"
@@ -44,8 +46,68 @@ func ConvertRequest(request model.GeneralOpenAIRequest) *ChatRequest {
} }
} }
func ConvertEmbeddingRequest(request model.GeneralOpenAIRequest) *EmbeddingRequest {
return &EmbeddingRequest{
InputList: request.ParseInput(),
}
}
func EmbeddingHandler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *model.Usage) {
var tencentResponseP EmbeddingResponseP
err := json.NewDecoder(resp.Body).Decode(&tencentResponseP)
if err != nil {
return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
}
err = resp.Body.Close()
if err != nil {
return openai.ErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
}
tencentResponse := tencentResponseP.Response
if tencentResponse.Error.Code != "" {
return &model.ErrorWithStatusCode{
Error: model.Error{
Message: tencentResponse.Error.Message,
Code: tencentResponse.Error.Code,
},
StatusCode: resp.StatusCode,
}, nil
}
requestModel := c.GetString(ctxkey.RequestModel)
fullTextResponse := embeddingResponseTencent2OpenAI(&tencentResponse)
fullTextResponse.Model = requestModel
jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil {
return openai.ErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, err = c.Writer.Write(jsonResponse)
return nil, &fullTextResponse.Usage
}
func embeddingResponseTencent2OpenAI(response *EmbeddingResponse) *openai.EmbeddingResponse {
openAIEmbeddingResponse := openai.EmbeddingResponse{
Object: "list",
Data: make([]openai.EmbeddingResponseItem, 0, len(response.Data)),
Model: "hunyuan-embedding",
Usage: model.Usage{TotalTokens: response.EmbeddingUsage.TotalTokens},
}
for _, item := range response.Data {
openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, openai.EmbeddingResponseItem{
Object: item.Object,
Index: item.Index,
Embedding: item.Embedding,
})
}
return &openAIEmbeddingResponse
}
func responseTencent2OpenAI(response *ChatResponse) *openai.TextResponse { func responseTencent2OpenAI(response *ChatResponse) *openai.TextResponse {
fullTextResponse := openai.TextResponse{ fullTextResponse := openai.TextResponse{
Id: response.ReqID,
Object: "chat.completion", Object: "chat.completion",
Created: helper.GetTimestamp(), Created: helper.GetTimestamp(),
Usage: model.Usage{ Usage: model.Usage{
@@ -148,7 +210,7 @@ func Handler(c *gin.Context, resp *http.Response) (*model.ErrorWithStatusCode, *
return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil return openai.ErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
} }
TencentResponse = responseP.Response TencentResponse = responseP.Response
if TencentResponse.Error.Code != 0 { if TencentResponse.Error.Code != "" {
return &model.ErrorWithStatusCode{ return &model.ErrorWithStatusCode{
Error: model.Error{ Error: model.Error{
Message: TencentResponse.Error.Message, Message: TencentResponse.Error.Message,
@@ -195,7 +257,7 @@ func hmacSha256(s, key string) string {
return string(hashed.Sum(nil)) return string(hashed.Sum(nil))
} }
func GetSign(req ChatRequest, adaptor *Adaptor, secId, secKey string) string { func GetSign(req any, adaptor *Adaptor, secId, secKey string) string {
// build canonical request string // build canonical request string
host := "hunyuan.tencentcloudapi.com" host := "hunyuan.tencentcloudapi.com"
httpRequestMethod := "POST" httpRequestMethod := "POST"

View File

@@ -35,16 +35,16 @@ type ChatRequest struct {
// 1. 影响输出文本的多样性,取值越大,生成文本的多样性越强。 // 1. 影响输出文本的多样性,取值越大,生成文本的多样性越强。
// 2. 取值区间为 [0.0, 1.0],未传值时使用各模型推荐值。 // 2. 取值区间为 [0.0, 1.0],未传值时使用各模型推荐值。
// 3. 非必要不建议使用,不合理的取值会影响效果。 // 3. 非必要不建议使用,不合理的取值会影响效果。
TopP *float64 `json:"TopP"` TopP *float64 `json:"TopP,omitempty"`
// 说明: // 说明:
// 1. 较高的数值会使输出更加随机,而较低的数值会使其更加集中和确定。 // 1. 较高的数值会使输出更加随机,而较低的数值会使其更加集中和确定。
// 2. 取值区间为 [0.0, 2.0],未传值时使用各模型推荐值。 // 2. 取值区间为 [0.0, 2.0],未传值时使用各模型推荐值。
// 3. 非必要不建议使用,不合理的取值会影响效果。 // 3. 非必要不建议使用,不合理的取值会影响效果。
Temperature *float64 `json:"Temperature"` Temperature *float64 `json:"Temperature,omitempty"`
} }
type Error struct { type Error struct {
Code int `json:"Code"` Code string `json:"Code"`
Message string `json:"Message"` Message string `json:"Message"`
} }
@@ -61,15 +61,41 @@ type ResponseChoices struct {
} }
type ChatResponse struct { type ChatResponse struct {
Choices []ResponseChoices `json:"Choices,omitempty"` // 结果 Choices []ResponseChoices `json:"Choices,omitempty"` // 结果
Created int64 `json:"Created,omitempty"` // unix 时间戳的字符串 Created int64 `json:"Created,omitempty"` // unix 时间戳的字符串
Id string `json:"Id,omitempty"` // 会话 id Id string `json:"Id,omitempty"` // 会话 id
Usage Usage `json:"Usage,omitempty"` // token 数量 Usage Usage `json:"Usage,omitempty"` // token 数量
Error Error `json:"Error,omitempty"` // 错误信息 注意:此字段可能返回 null表示取不到有效值 Error Error `json:"Error,omitempty"` // 错误信息 注意:此字段可能返回 null表示取不到有效值
Note string `json:"Note,omitempty"` // 注释 Note string `json:"Note,omitempty"` // 注释
ReqID string `json:"Req_id,omitempty"` // 唯一请求 Id每次请求都会返回。用于反馈接口入参 ReqID string `json:"RequestId,omitempty"` // 唯一请求 Id每次请求都会返回。用于反馈接口入参
} }
type ChatResponseP struct { type ChatResponseP struct {
Response ChatResponse `json:"Response,omitempty"` Response ChatResponse `json:"Response,omitempty"`
} }
type EmbeddingRequest struct {
InputList []string `json:"InputList"`
}
type EmbeddingData struct {
Embedding []float64 `json:"Embedding"`
Index int `json:"Index"`
Object string `json:"Object"`
}
type EmbeddingUsage struct {
PromptTokens int `json:"PromptTokens"`
TotalTokens int `json:"TotalTokens"`
}
type EmbeddingResponse struct {
Data []EmbeddingData `json:"Data"`
EmbeddingUsage EmbeddingUsage `json:"Usage,omitempty"`
RequestId string `json:"RequestId,omitempty"`
Error Error `json:"Error,omitempty"`
}
type EmbeddingResponseP struct {
Response EmbeddingResponse `json:"Response,omitempty"`
}

View File

@@ -18,7 +18,8 @@ var ModelList = []string{
"gemini-pro", "gemini-pro-vision", "gemini-pro", "gemini-pro-vision",
"gemini-1.5-pro-001", "gemini-1.5-flash-001", "gemini-1.5-pro-001", "gemini-1.5-flash-001",
"gemini-1.5-pro-002", "gemini-1.5-flash-002", "gemini-1.5-pro-002", "gemini-1.5-flash-002",
"gemini-2.0-flash-exp", "gemini-2.0-flash-thinking-exp", "gemini-2.0-flash-exp",
"gemini-2.0-flash-thinking-exp", "gemini-2.0-flash-thinking-exp-01-21",
} }
type Adaptor struct { type Adaptor struct {

View File

@@ -3,6 +3,7 @@ package billing
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/common/logger"
"github.com/songquanpeng/one-api/model" "github.com/songquanpeng/one-api/model"
) )
@@ -31,8 +32,17 @@ func PostConsumeQuota(ctx context.Context, tokenId int, quotaDelta int64, totalQ
} }
// totalQuota is total quota consumed // totalQuota is total quota consumed
if totalQuota != 0 { if totalQuota != 0 {
logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f", modelRatio, groupRatio) logContent := fmt.Sprintf("倍率%.2f × %.2f", modelRatio, groupRatio)
model.RecordConsumeLog(ctx, userId, channelId, int(totalQuota), 0, modelName, tokenName, totalQuota, logContent) model.RecordConsumeLog(ctx, &model.Log{
UserId: userId,
ChannelId: channelId,
PromptTokens: int(totalQuota),
CompletionTokens: 0,
ModelName: modelName,
TokenName: tokenName,
Quota: int(totalQuota),
Content: logContent,
})
model.UpdateUserUsedQuotaAndRequestCount(userId, totalQuota) model.UpdateUserUsedQuotaAndRequestCount(userId, totalQuota)
model.UpdateChannelUsedQuota(channelId, totalQuota) model.UpdateChannelUsedQuota(channelId, totalQuota)
} }

View File

@@ -9,9 +9,10 @@ import (
) )
const ( const (
USD2RMB = 7 USD2RMB = 7
USD = 500 // $0.002 = 1 -> $1 = 500 USD = 500 // $0.002 = 1 -> $1 = 500
RMB = USD / USD2RMB MILLI_USD = 1.0 / 1000 * USD
RMB = USD / USD2RMB
) )
// ModelRatio // ModelRatio
@@ -37,6 +38,7 @@ var ModelRatio = map[string]float64{
"chatgpt-4o-latest": 2.5, // $0.005 / 1K tokens "chatgpt-4o-latest": 2.5, // $0.005 / 1K tokens
"gpt-4o-2024-05-13": 2.5, // $0.005 / 1K tokens "gpt-4o-2024-05-13": 2.5, // $0.005 / 1K tokens
"gpt-4o-2024-08-06": 1.25, // $0.0025 / 1K tokens "gpt-4o-2024-08-06": 1.25, // $0.0025 / 1K tokens
"gpt-4o-2024-11-20": 1.25, // $0.0025 / 1K tokens
"gpt-4o-mini": 0.075, // $0.00015 / 1K tokens "gpt-4o-mini": 0.075, // $0.00015 / 1K tokens
"gpt-4o-mini-2024-07-18": 0.075, // $0.00015 / 1K tokens "gpt-4o-mini-2024-07-18": 0.075, // $0.00015 / 1K tokens
"gpt-4-vision-preview": 5, // $0.01 / 1K tokens "gpt-4-vision-preview": 5, // $0.01 / 1K tokens
@@ -108,15 +110,16 @@ var ModelRatio = map[string]float64{
"bge-large-en": 0.002 * RMB, "bge-large-en": 0.002 * RMB,
"tao-8k": 0.002 * RMB, "tao-8k": 0.002 * RMB,
// https://ai.google.dev/pricing // https://ai.google.dev/pricing
"gemini-pro": 1, // $0.00025 / 1k characters -> $0.001 / 1k tokens "gemini-pro": 1, // $0.00025 / 1k characters -> $0.001 / 1k tokens
"gemini-1.0-pro": 1, "gemini-1.0-pro": 1,
"gemini-1.5-pro": 1, "gemini-1.5-pro": 1,
"gemini-1.5-pro-001": 1, "gemini-1.5-pro-001": 1,
"gemini-1.5-flash": 1, "gemini-1.5-flash": 1,
"gemini-1.5-flash-001": 1, "gemini-1.5-flash-001": 1,
"gemini-2.0-flash-exp": 1, "gemini-2.0-flash-exp": 1,
"gemini-2.0-flash-thinking-exp": 1, "gemini-2.0-flash-thinking-exp": 1,
"aqa": 1, "gemini-2.0-flash-thinking-exp-01-21": 1,
"aqa": 1,
// https://open.bigmodel.cn/pricing // https://open.bigmodel.cn/pricing
"glm-4": 0.1 * RMB, "glm-4": 0.1 * RMB,
"glm-4v": 0.1 * RMB, "glm-4v": 0.1 * RMB,
@@ -278,8 +281,8 @@ var ModelRatio = map[string]float64{
"command-r": 0.5 / 1000 * USD, "command-r": 0.5 / 1000 * USD,
"command-r-plus": 3.0 / 1000 * USD, "command-r-plus": 3.0 / 1000 * USD,
// https://platform.deepseek.com/api-docs/pricing/ // https://platform.deepseek.com/api-docs/pricing/
"deepseek-chat": 1.0 / 1000 * RMB, "deepseek-chat": 0.14 * MILLI_USD,
"deepseek-coder": 1.0 / 1000 * RMB, "deepseek-reasoner": 0.55 * MILLI_USD,
// https://www.deepl.com/pro?cta=header-prices // https://www.deepl.com/pro?cta=header-prices
"deepl-zh": 25.0 / 1000 * USD, "deepl-zh": 25.0 / 1000 * USD,
"deepl-en": 25.0 / 1000 * USD, "deepl-en": 25.0 / 1000 * USD,
@@ -336,6 +339,11 @@ var CompletionRatio = map[string]float64{
// aws llama3 // aws llama3
"llama3-8b-8192(33)": 0.0006 / 0.0003, "llama3-8b-8192(33)": 0.0006 / 0.0003,
"llama3-70b-8192(33)": 0.0035 / 0.00265, "llama3-70b-8192(33)": 0.0035 / 0.00265,
// whisper
"whisper-1": 0, // only count input tokens
// deepseek
"deepseek-chat": 0.28 / 0.14,
"deepseek-reasoner": 2.19 / 0.55,
} }
var ( var (
@@ -453,11 +461,13 @@ func GetCompletionRatio(name string, channelType int) float64 {
return 4.0 / 3.0 return 4.0 / 3.0
} }
if strings.HasPrefix(name, "gpt-4") { if strings.HasPrefix(name, "gpt-4") {
if strings.HasPrefix(name, "gpt-4o-mini") || name == "gpt-4o-2024-08-06" { if strings.HasPrefix(name, "gpt-4o") {
if name == "gpt-4o-2024-05-13" {
return 3
}
return 4 return 4
} }
if strings.HasPrefix(name, "gpt-4-turbo") || if strings.HasPrefix(name, "gpt-4-turbo") ||
strings.HasPrefix(name, "gpt-4o") ||
strings.HasSuffix(name, "preview") { strings.HasSuffix(name, "preview") {
return 3 return 3
} }

View File

@@ -110,16 +110,9 @@ func RelayAudioHelper(c *gin.Context, relayMode int) *relaymodel.ErrorWithStatus
}() }()
// map model name // map model name
modelMapping := c.GetString(ctxkey.ModelMapping) modelMapping := c.GetStringMapString(ctxkey.ModelMapping)
if modelMapping != "" { if modelMapping != nil && modelMapping[audioModel] != "" {
modelMap := make(map[string]string) audioModel = modelMapping[audioModel]
err := json.Unmarshal([]byte(modelMapping), &modelMap)
if err != nil {
return openai.ErrorWrapper(err, "unmarshal_model_mapping_failed", http.StatusInternalServerError)
}
if modelMap[audioModel] != "" {
audioModel = modelMap[audioModel]
}
} }
baseURL := channeltype.ChannelBaseURLs[channelType] baseURL := channeltype.ChannelBaseURLs[channelType]

View File

@@ -4,12 +4,15 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"github.com/songquanpeng/one-api/relay/constant/role"
"math" "math"
"net/http" "net/http"
"strings" "strings"
"github.com/songquanpeng/one-api/common/helper"
"github.com/songquanpeng/one-api/relay/constant/role"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common" "github.com/songquanpeng/one-api/common"
"github.com/songquanpeng/one-api/common/config" "github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/common/logger"
@@ -119,12 +122,20 @@ func postConsumeQuota(ctx context.Context, usage *relaymodel.Usage, meta *meta.M
if err != nil { if err != nil {
logger.Error(ctx, "error update user quota cache: "+err.Error()) logger.Error(ctx, "error update user quota cache: "+err.Error())
} }
var extraLog string logContent := fmt.Sprintf("倍率:%.2f × %.2f × %.2f", modelRatio, groupRatio, completionRatio)
if systemPromptReset { model.RecordConsumeLog(ctx, &model.Log{
extraLog = " (注意系统提示词已被重置)" UserId: meta.UserId,
} ChannelId: meta.ChannelId,
logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f,补全倍率 %.2f%s", modelRatio, groupRatio, completionRatio, extraLog) PromptTokens: promptTokens,
model.RecordConsumeLog(ctx, meta.UserId, meta.ChannelId, promptTokens, completionTokens, textRequest.Model, meta.TokenName, quota, logContent) CompletionTokens: completionTokens,
ModelName: textRequest.Model,
TokenName: meta.TokenName,
Quota: int(quota),
Content: logContent,
IsStream: meta.IsStream,
ElapsedTime: helper.CalcElapsedTime(meta.StartTime),
SystemPromptReset: systemPromptReset,
})
model.UpdateUserUsedQuotaAndRequestCount(meta.UserId, quota) model.UpdateUserUsedQuotaAndRequestCount(meta.UserId, quota)
model.UpdateChannelUsedQuota(meta.ChannelId, quota) model.UpdateChannelUsedQuota(meta.ChannelId, quota)
} }

View File

@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common" "github.com/songquanpeng/one-api/common"
"github.com/songquanpeng/one-api/common/ctxkey" "github.com/songquanpeng/one-api/common/ctxkey"
"github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/common/logger"
@@ -209,8 +210,17 @@ func RelayImageHelper(c *gin.Context, relayMode int) *relaymodel.ErrorWithStatus
} }
if quota != 0 { if quota != 0 {
tokenName := c.GetString(ctxkey.TokenName) tokenName := c.GetString(ctxkey.TokenName)
logContent := fmt.Sprintf("模型倍率 %.2f,分组倍率 %.2f", modelRatio, groupRatio) logContent := fmt.Sprintf("倍率%.2f × %.2f", modelRatio, groupRatio)
model.RecordConsumeLog(ctx, meta.UserId, meta.ChannelId, 0, 0, imageRequest.Model, tokenName, quota, logContent) model.RecordConsumeLog(ctx, &model.Log{
UserId: meta.UserId,
ChannelId: meta.ChannelId,
PromptTokens: 0,
CompletionTokens: 0,
ModelName: imageRequest.Model,
TokenName: tokenName,
Quota: int(quota),
Content: logContent,
})
model.UpdateUserUsedQuotaAndRequestCount(meta.UserId, quota) model.UpdateUserUsedQuotaAndRequestCount(meta.UserId, quota)
channelId := c.GetInt(ctxkey.ChannelId) channelId := c.GetInt(ctxkey.ChannelId)
model.UpdateChannelUsedQuota(channelId, quota) model.UpdateChannelUsedQuota(channelId, quota)

View File

@@ -4,11 +4,12 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/songquanpeng/one-api/common/config"
"io" "io"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/logger" "github.com/songquanpeng/one-api/common/logger"
"github.com/songquanpeng/one-api/relay" "github.com/songquanpeng/one-api/relay"
"github.com/songquanpeng/one-api/relay/adaptor" "github.com/songquanpeng/one-api/relay/adaptor"

View File

@@ -1,12 +1,15 @@
package meta package meta
import ( import (
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/ctxkey" "github.com/songquanpeng/one-api/common/ctxkey"
"github.com/songquanpeng/one-api/model" "github.com/songquanpeng/one-api/model"
"github.com/songquanpeng/one-api/relay/channeltype" "github.com/songquanpeng/one-api/relay/channeltype"
"github.com/songquanpeng/one-api/relay/relaymode" "github.com/songquanpeng/one-api/relay/relaymode"
"strings"
) )
type Meta struct { type Meta struct {
@@ -31,6 +34,7 @@ type Meta struct {
RequestURLPath string RequestURLPath string
PromptTokens int // only for DoResponse PromptTokens int // only for DoResponse
SystemPrompt string SystemPrompt string
StartTime time.Time
} }
func GetByContext(c *gin.Context) *Meta { func GetByContext(c *gin.Context) *Meta {
@@ -48,6 +52,7 @@ func GetByContext(c *gin.Context) *Meta {
APIKey: strings.TrimPrefix(c.Request.Header.Get("Authorization"), "Bearer "), APIKey: strings.TrimPrefix(c.Request.Header.Get("Authorization"), "Bearer "),
RequestURLPath: c.Request.URL.String(), RequestURLPath: c.Request.URL.String(),
SystemPrompt: c.GetString(ctxkey.SystemPrompt), SystemPrompt: c.GetString(ctxkey.SystemPrompt),
StartTime: time.Now(),
} }
cfg, ok := c.Get(ctxkey.Config) cfg, ok := c.Get(ctxkey.Config)
if ok { if ok {

View File

@@ -28,6 +28,8 @@ function renderType(type) {
return <Tag color="orange" size="large"> 管理 </Tag>; return <Tag color="orange" size="large"> 管理 </Tag>;
case 4: case 4:
return <Tag color="purple" size="large"> 系统 </Tag>; return <Tag color="purple" size="large"> 系统 </Tag>;
case 5:
return <Tag color="violet" size="large"> 测试 </Tag>;
default: default:
return <Tag color="black" size="large"> 未知 </Tag>; return <Tag color="black" size="large"> 未知 </Tag>;
} }

View File

@@ -1,247 +1,260 @@
import { enqueueSnackbar } from 'notistack'; import {enqueueSnackbar} from 'notistack';
import { snackbarConstants } from 'constants/SnackbarConstants'; import {snackbarConstants} from 'constants/SnackbarConstants';
import { API } from './api'; import {API} from './api';
export function getSystemName() { export function getSystemName() {
let system_name = localStorage.getItem('system_name'); let system_name = localStorage.getItem('system_name');
if (!system_name) return 'One API'; if (!system_name) return 'One API';
return system_name; return system_name;
} }
export function isMobile() { export function isMobile() {
return window.innerWidth <= 600; return window.innerWidth <= 600;
} }
// eslint-disable-next-line // eslint-disable-next-line
export function SnackbarHTMLContent({ htmlContent }) { export function SnackbarHTMLContent({htmlContent}) {
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />; return <div dangerouslySetInnerHTML={{__html: htmlContent}}/>;
} }
export function getSnackbarOptions(variant) { export function getSnackbarOptions(variant) {
let options = snackbarConstants.Common[variant]; let options = snackbarConstants.Common[variant];
if (isMobile()) { if (isMobile()) {
// 合并 options 和 snackbarConstants.Mobile // 合并 options 和 snackbarConstants.Mobile
options = { ...options, ...snackbarConstants.Mobile }; options = {...options, ...snackbarConstants.Mobile};
} }
return options; return options;
} }
export function showError(error) { export function showError(error) {
if (error.message) { if (error.message) {
if (error.name === 'AxiosError') { if (error.name === 'AxiosError') {
switch (error.response.status) { switch (error.response.status) {
case 429: case 429:
enqueueSnackbar('错误:请求次数过多,请稍后再试!', getSnackbarOptions('ERROR')); enqueueSnackbar('错误:请求次数过多,请稍后再试!', getSnackbarOptions('ERROR'));
break; break;
case 500: case 500:
enqueueSnackbar('错误:服务器内部错误,请联系管理员!', getSnackbarOptions('ERROR')); enqueueSnackbar('错误:服务器内部错误,请联系管理员!', getSnackbarOptions('ERROR'));
break; break;
case 405: case 405:
enqueueSnackbar('本站仅作演示之用,无服务端!', getSnackbarOptions('INFO')); enqueueSnackbar('本站仅作演示之用,无服务端!', getSnackbarOptions('INFO'));
break; break;
default: default:
enqueueSnackbar('错误:' + error.message, getSnackbarOptions('ERROR')); enqueueSnackbar('错误:' + error.message, getSnackbarOptions('ERROR'));
} }
return; return;
}
} else {
enqueueSnackbar('错误:' + error, getSnackbarOptions('ERROR'));
} }
} else {
enqueueSnackbar('错误:' + error, getSnackbarOptions('ERROR'));
}
} }
export function showNotice(message, isHTML = false) { export function showNotice(message, isHTML = false) {
if (isHTML) { if (isHTML) {
enqueueSnackbar(<SnackbarHTMLContent htmlContent={message} />, getSnackbarOptions('NOTICE')); enqueueSnackbar(<SnackbarHTMLContent htmlContent={message}/>, getSnackbarOptions('NOTICE'));
} else { } else {
enqueueSnackbar(message, getSnackbarOptions('NOTICE')); enqueueSnackbar(message, getSnackbarOptions('NOTICE'));
} }
} }
export function showWarning(message) { export function showWarning(message) {
enqueueSnackbar(message, getSnackbarOptions('WARNING')); enqueueSnackbar(message, getSnackbarOptions('WARNING'));
} }
export function showSuccess(message) { export function showSuccess(message) {
enqueueSnackbar(message, getSnackbarOptions('SUCCESS')); enqueueSnackbar(message, getSnackbarOptions('SUCCESS'));
} }
export function showInfo(message) { export function showInfo(message) {
enqueueSnackbar(message, getSnackbarOptions('INFO')); enqueueSnackbar(message, getSnackbarOptions('INFO'));
} }
export async function getOAuthState() { export async function getOAuthState() {
const res = await API.get('/api/oauth/state'); const res = await API.get('/api/oauth/state');
const { success, message, data } = res.data; const {success, message, data} = res.data;
if (success) { if (success) {
return data; return data;
} else { } else {
showError(message); showError(message);
return ''; return '';
} }
} }
export async function onGitHubOAuthClicked(github_client_id, openInNewTab = false) { export async function onGitHubOAuthClicked(github_client_id, openInNewTab = false) {
const state = await getOAuthState(); const state = await getOAuthState();
if (!state) return; if (!state) return;
let url = `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`; let url = `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`;
if (openInNewTab) { if (openInNewTab) {
window.open(url); window.open(url);
} else { } else {
window.location.href = url; window.location.href = url;
} }
} }
export async function onLarkOAuthClicked(lark_client_id) { export async function onLarkOAuthClicked(lark_client_id) {
const state = await getOAuthState(); const state = await getOAuthState();
if (!state) return; if (!state) return;
let redirect_uri = `${window.location.origin}/oauth/lark`; let redirect_uri = `${window.location.origin}/oauth/lark`;
window.open(`https://accounts.feishu.cn/open-apis/authen/v1/authorize?redirect_uri=${redirect_uri}&client_id=${lark_client_id}&state=${state}`); window.open(`https://accounts.feishu.cn/open-apis/authen/v1/authorize?redirect_uri=${redirect_uri}&client_id=${lark_client_id}&state=${state}`);
} }
export async function onOidcClicked(auth_url, client_id, openInNewTab = false) { export async function onOidcClicked(auth_url, client_id, openInNewTab = false) {
const state = await getOAuthState(); const state = await getOAuthState();
if (!state) return; if (!state) return;
const redirect_uri = `${window.location.origin}/oauth/oidc`; const redirect_uri = `${window.location.origin}/oauth/oidc`;
const response_type = "code"; const response_type = "code";
const scope = "openid profile email"; const scope = "openid profile email";
const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`; const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`;
if (openInNewTab) { if (openInNewTab) {
window.open(url); window.open(url);
} else } else {
{ window.location.href = url;
window.location.href = url; }
}
} }
export function isAdmin() { export function isAdmin() {
let user = localStorage.getItem('user'); let user = localStorage.getItem('user');
if (!user) return false; if (!user) return false;
user = JSON.parse(user); user = JSON.parse(user);
return user.role >= 10; return user.role >= 10;
} }
export function timestamp2string(timestamp) { export function timestamp2string(timestamp) {
let date = new Date(timestamp * 1000); let date = new Date(timestamp * 1000);
let year = date.getFullYear().toString(); let year = date.getFullYear().toString();
let month = (date.getMonth() + 1).toString(); let month = (date.getMonth() + 1).toString();
let day = date.getDate().toString(); let day = date.getDate().toString();
let hour = date.getHours().toString(); let hour = date.getHours().toString();
let minute = date.getMinutes().toString(); let minute = date.getMinutes().toString();
let second = date.getSeconds().toString(); let second = date.getSeconds().toString();
if (month.length === 1) { if (month.length === 1) {
month = '0' + month; month = '0' + month;
} }
if (day.length === 1) { if (day.length === 1) {
day = '0' + day; day = '0' + day;
} }
if (hour.length === 1) { if (hour.length === 1) {
hour = '0' + hour; hour = '0' + hour;
} }
if (minute.length === 1) { if (minute.length === 1) {
minute = '0' + minute; minute = '0' + minute;
} }
if (second.length === 1) { if (second.length === 1) {
second = '0' + second; second = '0' + second;
} }
return year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second; return year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second;
} }
export function calculateQuota(quota, digits = 2) { export function calculateQuota(quota, digits = 2) {
let quotaPerUnit = localStorage.getItem('quota_per_unit'); let quotaPerUnit = localStorage.getItem('quota_per_unit');
quotaPerUnit = parseFloat(quotaPerUnit); quotaPerUnit = parseFloat(quotaPerUnit);
return (quota / quotaPerUnit).toFixed(digits); return (quota / quotaPerUnit).toFixed(digits);
} }
export function renderQuota(quota, digits = 2) { export function renderQuota(quota, digits = 2) {
let displayInCurrency = localStorage.getItem('display_in_currency'); let displayInCurrency = localStorage.getItem('display_in_currency');
displayInCurrency = displayInCurrency === 'true'; displayInCurrency = displayInCurrency === 'true';
if (displayInCurrency) { if (displayInCurrency) {
return '$' + calculateQuota(quota, digits); return '$' + calculateQuota(quota, digits);
} }
return renderNumber(quota); return renderNumber(quota);
} }
export const verifyJSON = (str) => { export const verifyJSON = (str) => {
try { try {
JSON.parse(str); JSON.parse(str);
} catch (e) { } catch (e) {
return false; return false;
} }
return true; return true;
}; };
export function renderNumber(num) { export function renderNumber(num) {
if (num >= 1000000000) { if (num >= 1000000000) {
return (num / 1000000000).toFixed(1) + 'B'; return (num / 1000000000).toFixed(1) + 'B';
} else if (num >= 1000000) { } else if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'; return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 10000) { } else if (num >= 10000) {
return (num / 1000).toFixed(1) + 'k'; return (num / 1000).toFixed(1) + 'k';
} else { } else {
return num; return num;
} }
} }
export function renderQuotaWithPrompt(quota, digits) { export function renderQuotaWithPrompt(quota, digits) {
let displayInCurrency = localStorage.getItem('display_in_currency'); let displayInCurrency = localStorage.getItem('display_in_currency');
displayInCurrency = displayInCurrency === 'true'; displayInCurrency = displayInCurrency === 'true';
if (displayInCurrency) { if (displayInCurrency) {
return `(等价金额:${renderQuota(quota, digits)}`; return `(等价金额:${renderQuota(quota, digits)}`;
} }
return ''; return '';
} }
export function downloadTextAsFile(text, filename) { export function downloadTextAsFile(text, filename) {
let blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); let blob = new Blob([text], {type: 'text/plain;charset=utf-8'});
let url = URL.createObjectURL(blob); let url = URL.createObjectURL(blob);
let a = document.createElement('a'); let a = document.createElement('a');
a.href = url; a.href = url;
a.download = filename; a.download = filename;
a.click(); a.click();
} }
export function removeTrailingSlash(url) { export function removeTrailingSlash(url) {
if (url.endsWith('/')) { if (url.endsWith('/')) {
return url.slice(0, -1); return url.slice(0, -1);
} else { } else {
return url; return url;
} }
} }
let channelModels = undefined; let channelModels = undefined;
export async function loadChannelModels() { export async function loadChannelModels() {
const res = await API.get('/api/models'); const res = await API.get('/api/models');
const { success, data } = res.data; const {success, data} = res.data;
if (!success) { if (!success) {
return; return;
} }
channelModels = data; channelModels = data;
localStorage.setItem('channel_models', JSON.stringify(data)); localStorage.setItem('channel_models', JSON.stringify(data));
} }
export function getChannelModels(type) { export function getChannelModels(type) {
if (channelModels !== undefined && type in channelModels) { if (channelModels !== undefined && type in channelModels) {
return channelModels[type]; return channelModels[type];
} }
let models = localStorage.getItem('channel_models'); let models = localStorage.getItem('channel_models');
if (!models) { if (!models) {
return [];
}
channelModels = JSON.parse(models);
if (type in channelModels) {
return channelModels[type];
}
return []; return [];
}
channelModels = JSON.parse(models);
if (type in channelModels) {
return channelModels[type];
}
return [];
} }
export function copy(text, name = '') { export function copy(text, name = '') {
try { if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text).then(() => {
} catch (error) { showNotice(`复制${name}成功!`, true);
text = `复制${name}失败,请手动复制:<br /><br />${text}`; }, () => {
enqueueSnackbar(<SnackbarHTMLContent htmlContent={text} />, getSnackbarOptions('COPY')); text = `复制${name}失败,请手动复制:<br /><br />${text}`;
return; enqueueSnackbar(<SnackbarHTMLContent htmlContent={text}/>, getSnackbarOptions('COPY'));
} });
showSuccess(`复制${name}成功!`); } else {
const textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
showNotice(`复制${name}成功!`, true);
} catch (err) {
text = `复制${name}失败,请手动复制:<br /><br />${text}`;
enqueueSnackbar(<SnackbarHTMLContent htmlContent={text}/>, getSnackbarOptions('COPY'));
}
document.body.removeChild(textArea);
}
} }

View File

@@ -268,6 +268,8 @@ function renderBalance(type, balance) {
return <span>¥{balance.toFixed(2)}</span>; return <span>¥{balance.toFixed(2)}</span>;
case 13: // AIGC2D case 13: // AIGC2D
return <span>{renderNumber(balance)}</span>; return <span>{renderNumber(balance)}</span>;
case 36: // DeepSeek
return <span>¥{balance.toFixed(2)}</span>;
case 44: // SiliconFlow case 44: // SiliconFlow
return <span>¥{balance.toFixed(2)}</span>; return <span>¥{balance.toFixed(2)}</span>;
default: default:

View File

@@ -3,7 +3,8 @@ const LOG_TYPE = {
1: { value: '1', text: '充值', color: 'primary' }, 1: { value: '1', text: '充值', color: 'primary' },
2: { value: '2', text: '消费', color: 'orange' }, 2: { value: '2', text: '消费', color: 'orange' },
3: { value: '3', text: '管理', color: 'default' }, 3: { value: '3', text: '管理', color: 'default' },
4: { value: '4', text: '系统', color: 'secondary' } 4: { value: '4', text: '系统', color: 'secondary' },
5: { value: '5', text: '测试', color: 'secondary' },
}; };
export default LOG_TYPE; export default LOG_TYPE;

View File

@@ -13,6 +13,7 @@
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"react-toastify": "^9.0.8", "react-toastify": "^9.0.8",
"react-turnstile": "^1.0.5", "react-turnstile": "^1.0.5",
"recharts": "^2.15.1",
"semantic-ui-css": "^2.5.0", "semantic-ui-css": "^2.5.0",
"semantic-ui-react": "^2.1.3" "semantic-ui-react": "^2.1.3"
}, },

View File

@@ -25,6 +25,7 @@ import TopUp from './pages/TopUp';
import Log from './pages/Log'; import Log from './pages/Log';
import Chat from './pages/Chat'; import Chat from './pages/Chat';
import LarkOAuth from './components/LarkOAuth'; import LarkOAuth from './components/LarkOAuth';
import Dashboard from './pages/Dashboard';
const Home = lazy(() => import('./pages/Home')); const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About')); const About = lazy(() => import('./pages/About'));
@@ -261,11 +262,11 @@ function App() {
<Route <Route
path='/topup' path='/topup'
element={ element={
<PrivateRoute> <PrivateRoute>
<Suspense fallback={<Loading></Loading>}> <Suspense fallback={<Loading></Loading>}>
<TopUp /> <TopUp />
</Suspense> </Suspense>
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route <Route
@@ -292,9 +293,15 @@ function App() {
</Suspense> </Suspense>
} }
/> />
<Route path='*' element={ <Route
<NotFound /> path='/dashboard'
} /> element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
<Route path='*' element={<NotFound />} />
</Routes> </Routes>
); );
} }

View File

@@ -1,5 +1,15 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Dropdown, Form, Input, Label, Message, Pagination, Popup, Table } from 'semantic-ui-react'; import {
Button,
Dropdown,
Form,
Input,
Label,
Message,
Pagination,
Popup,
Table,
} from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
API, API,
@@ -9,31 +19,31 @@ import {
showError, showError,
showInfo, showInfo,
showSuccess, showSuccess,
timestamp2string timestamp2string,
} from '../helpers'; } from '../helpers';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants'; import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
import { renderGroup, renderNumber } from '../helpers/render'; import { renderGroup, renderNumber } from '../helpers/render';
function renderTimestamp(timestamp) { function renderTimestamp(timestamp) {
return ( return <>{timestamp2string(timestamp)}</>;
<>
{timestamp2string(timestamp)}
</>
);
} }
let type2label = undefined; let type2label = undefined;
function renderType(type) { function renderType(type) {
if (!type2label) { if (!type2label) {
type2label = new Map; type2label = new Map();
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) { for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i]; type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
} }
type2label[0] = { value: 0, text: '未知类型', color: 'grey' }; type2label[0] = { value: 0, text: '未知类型', color: 'grey' };
} }
return <Label basic color={type2label[type]?.color}>{type2label[type] ? type2label[type].text : type}</Label>; return (
<Label basic color={type2label[type]?.color}>
{type2label[type] ? type2label[type].text : type}
</Label>
);
} }
function renderBalance(type, balance) { function renderBalance(type, balance) {
@@ -52,6 +62,8 @@ function renderBalance(type, balance) {
return <span>¥{balance.toFixed(2)}</span>; return <span>¥{balance.toFixed(2)}</span>;
case 13: // AIGC2D case 13: // AIGC2D
return <span>{renderNumber(balance)}</span>; return <span>{renderNumber(balance)}</span>;
case 36: // DeepSeek
return <span>¥{balance.toFixed(2)}</span>;
case 44: // SiliconFlow case 44: // SiliconFlow
return <span>¥{balance.toFixed(2)}</span>; return <span>¥{balance.toFixed(2)}</span>;
default: default:
@@ -60,10 +72,10 @@ function renderBalance(type, balance) {
} }
function isShowDetail() { function isShowDetail() {
return localStorage.getItem("show_detail") === "true"; return localStorage.getItem('show_detail') === 'true';
} }
const promptID = "detail" const promptID = 'detail';
const ChannelsTable = () => { const ChannelsTable = () => {
const [channels, setChannels] = useState([]); const [channels, setChannels] = useState([]);
@@ -79,33 +91,37 @@ const ChannelsTable = () => {
const res = await API.get(`/api/channel/?p=${startIdx}`); const res = await API.get(`/api/channel/?p=${startIdx}`);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
let localChannels = data.map((channel) => { let localChannels = data.map((channel) => {
if (channel.models === '') { if (channel.models === '') {
channel.models = []; channel.models = [];
channel.test_model = ""; channel.test_model = '';
} else {
channel.models = channel.models.split(',');
if (channel.models.length > 0) {
channel.test_model = channel.models[0];
}
channel.model_options = channel.models.map((model) => {
return {
key: model,
text: model,
value: model,
}
})
console.log('channel', channel)
}
return channel;
});
if (startIdx === 0) {
setChannels(localChannels);
} else { } else {
let newChannels = [...channels]; channel.models = channel.models.split(',');
newChannels.splice(startIdx * ITEMS_PER_PAGE, data.length, ...localChannels); if (channel.models.length > 0) {
setChannels(newChannels); channel.test_model = channel.models[0];
}
channel.model_options = channel.models.map((model) => {
return {
key: model,
text: model,
value: model,
};
});
console.log('channel', channel);
} }
return channel;
});
if (startIdx === 0) {
setChannels(localChannels);
} else {
let newChannels = [...channels];
newChannels.splice(
startIdx * ITEMS_PER_PAGE,
data.length,
...localChannels
);
setChannels(newChannels);
}
} else { } else {
showError(message); showError(message);
} }
@@ -129,8 +145,8 @@ const ChannelsTable = () => {
const toggleShowDetail = () => { const toggleShowDetail = () => {
setShowDetail(!showDetail); setShowDetail(!showDetail);
localStorage.setItem("show_detail", (!showDetail).toString()); localStorage.setItem('show_detail', (!showDetail).toString());
} };
useEffect(() => { useEffect(() => {
loadChannels(0) loadChannels(0)
@@ -194,13 +210,19 @@ const ChannelsTable = () => {
const renderStatus = (status) => { const renderStatus = (status) => {
switch (status) { switch (status) {
case 1: case 1:
return <Label basic color='green'>已启用</Label>; return (
<Label basic color='green'>
已启用
</Label>
);
case 2: case 2:
return ( return (
<Popup <Popup
trigger={<Label basic color='red'> trigger={
已禁用 <Label basic color='red'>
</Label>} 已禁用
</Label>
}
content='本渠道被手动禁用' content='本渠道被手动禁用'
basic basic
/> />
@@ -208,9 +230,11 @@ const ChannelsTable = () => {
case 3: case 3:
return ( return (
<Popup <Popup
trigger={<Label basic color='yellow'> trigger={
已禁用 <Label basic color='yellow'>
</Label>} 已禁用
</Label>
}
content='本渠道被程序自动禁用' content='本渠道被程序自动禁用'
basic basic
/> />
@@ -228,15 +252,35 @@ const ChannelsTable = () => {
let time = responseTime / 1000; let time = responseTime / 1000;
time = time.toFixed(2) + ' 秒'; time = time.toFixed(2) + ' 秒';
if (responseTime === 0) { if (responseTime === 0) {
return <Label basic color='grey'>未测试</Label>; return (
<Label basic color='grey'>
未测试
</Label>
);
} else if (responseTime <= 1000) { } else if (responseTime <= 1000) {
return <Label basic color='green'>{time}</Label>; return (
<Label basic color='green'>
{time}
</Label>
);
} else if (responseTime <= 3000) { } else if (responseTime <= 3000) {
return <Label basic color='olive'>{time}</Label>; return (
<Label basic color='olive'>
{time}
</Label>
);
} else if (responseTime <= 5000) { } else if (responseTime <= 5000) {
return <Label basic color='yellow'>{time}</Label>; return (
<Label basic color='yellow'>
{time}
</Label>
);
} else { } else {
return <Label basic color='red'>{time}</Label>; return (
<Label basic color='red'>
{time}
</Label>
);
} }
}; };
@@ -275,7 +319,11 @@ const ChannelsTable = () => {
newChannels[realIdx].response_time = time * 1000; newChannels[realIdx].response_time = time * 1000;
newChannels[realIdx].test_time = Date.now() / 1000; newChannels[realIdx].test_time = Date.now() / 1000;
setChannels(newChannels); setChannels(newChannels);
showInfo(`渠道 ${name} 测试成功,模型 ${model},耗时 ${time.toFixed(2)} 秒。`); showInfo(
`渠道 ${name} 测试成功,模型 ${model},耗时 ${time.toFixed(
2
)} 秒,模型输出:${message}`
);
} else { } else {
showError(message); showError(message);
} }
@@ -358,7 +406,6 @@ const ChannelsTable = () => {
setLoading(false); setLoading(false);
}; };
return ( return (
<> <>
<Form onSubmit={searchChannels}> <Form onSubmit={searchChannels}>
@@ -372,21 +419,23 @@ const ChannelsTable = () => {
onChange={handleKeywordChange} onChange={handleKeywordChange}
/> />
</Form> </Form>
{ {showPrompt && (
showPrompt && ( <Message
<Message onDismiss={() => { onDismiss={() => {
setShowPrompt(false); setShowPrompt(false);
setPromptShown(promptID); setPromptShown(promptID);
}}> }}
OpenAI 渠道已经不再支持通过 key 获取余额因此余额显示为 0对于支持的渠道类型请点击余额进行刷新 >
<br/> OpenAI 渠道已经不再支持通过 key 获取余额因此余额显示为
渠道测试仅支持 chat 模型优先使用 gpt-3.5-turbo如果该模型不可用则使用你所配置的模型列表中的第一个模型 0对于支持的渠道类型请点击余额进行刷新
<br/> <br />
点击下方详情按钮可以显示余额以及设置额外的测试模型 渠道测试仅支持 chat 模型优先使用
</Message> gpt-3.5-turbo如果该模型不可用则使用你所配置的模型列表中的第一个模型
) <br />
} 点击下方详情按钮可以显示余额以及设置额外的测试模型
<Table basic compact size='small'> </Message>
)}
<Table basic={'very'} compact size='small'>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.HeaderCell <Table.HeaderCell
@@ -476,7 +525,11 @@ const ChannelsTable = () => {
<Table.Cell>{renderStatus(channel.status)}</Table.Cell> <Table.Cell>{renderStatus(channel.status)}</Table.Cell>
<Table.Cell> <Table.Cell>
<Popup <Popup
content={channel.test_time ? renderTimestamp(channel.test_time) : '未测试'} content={
channel.test_time
? renderTimestamp(channel.test_time)
: '未测试'
}
key={channel.id} key={channel.id}
trigger={renderResponseTime(channel.response_time)} trigger={renderResponseTime(channel.response_time)}
basic basic
@@ -484,27 +537,38 @@ const ChannelsTable = () => {
</Table.Cell> </Table.Cell>
<Table.Cell hidden={!showDetail}> <Table.Cell hidden={!showDetail}>
<Popup <Popup
trigger={<span onClick={() => { trigger={
updateChannelBalance(channel.id, channel.name, idx); <span
}} style={{ cursor: 'pointer' }}> onClick={() => {
{renderBalance(channel.type, channel.balance)} updateChannelBalance(channel.id, channel.name, idx);
</span>} }}
style={{ cursor: 'pointer' }}
>
{renderBalance(channel.type, channel.balance)}
</span>
}
content='点击更新' content='点击更新'
basic basic
/> />
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<Popup <Popup
trigger={<Input type='number' defaultValue={channel.priority} onBlur={(event) => { trigger={
manageChannel( <Input
channel.id, type='number'
'priority', defaultValue={channel.priority}
idx, onBlur={(event) => {
event.target.value manageChannel(
); channel.id,
}}> 'priority',
<input style={{ maxWidth: '60px' }} /> idx,
</Input>} event.target.value
);
}}
>
<input style={{ maxWidth: '60px' }} />
</Input>
}
content='渠道选择优先级,越高越优先' content='渠道选择优先级,越高越优先'
basic basic
/> />
@@ -526,7 +590,12 @@ const ChannelsTable = () => {
size={'small'} size={'small'}
positive positive
onClick={() => { onClick={() => {
testChannel(channel.id, channel.name, idx, channel.test_model); testChannel(
channel.id,
channel.name,
idx,
channel.test_model
);
}} }}
> >
测试 测试
@@ -588,14 +657,31 @@ const ChannelsTable = () => {
<Table.Footer> <Table.Footer>
<Table.Row> <Table.Row>
<Table.HeaderCell colSpan={showDetail ? "10" : "8"}> <Table.HeaderCell colSpan={showDetail ? '10' : '8'}>
<Button size='small' as={Link} to='/channel/add' loading={loading}> <Button
size='small'
as={Link}
to='/channel/add'
loading={loading}
>
添加新的渠道 添加新的渠道
</Button> </Button>
<Button size='small' loading={loading} onClick={()=>{testChannels("all")}}> <Button
size='small'
loading={loading}
onClick={() => {
testChannels('all');
}}
>
测试所有渠道 测试所有渠道
</Button> </Button>
<Button size='small' loading={loading} onClick={()=>{testChannels("disabled")}}> <Button
size='small'
loading={loading}
onClick={() => {
testChannels('disabled');
}}
>
测试禁用渠道 测试禁用渠道
</Button> </Button>
{/*<Button size='small' onClick={updateAllChannelsBalance}*/} {/*<Button size='small' onClick={updateAllChannelsBalance}*/}
@@ -610,7 +696,12 @@ const ChannelsTable = () => {
flowing flowing
hoverable hoverable
> >
<Button size='small' loading={loading} negative onClick={deleteAllDisabledChannels}> <Button
size='small'
loading={loading}
negative
onClick={deleteAllDisabledChannels}
>
确认删除 确认删除
</Button> </Button>
</Popup> </Popup>
@@ -625,8 +716,12 @@ const ChannelsTable = () => {
(channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0) (channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
} }
/> />
<Button size='small' onClick={refresh} loading={loading}>刷新</Button> <Button size='small' onClick={refresh} loading={loading}>
<Button size='small' onClick={toggleShowDetail}>{showDetail ? "隐藏详情" : "详情"}</Button> 刷新
</Button>
<Button size='small' onClick={toggleShowDetail}>
{showDetail ? '隐藏详情' : '详情'}
</Button>
</Table.HeaderCell> </Table.HeaderCell>
</Table.Row> </Table.Row>
</Table.Footer> </Table.Footer>

View File

@@ -29,7 +29,7 @@ const Footer = () => {
return ( return (
<Segment vertical> <Segment vertical>
<Container textAlign='center'> <Container textAlign='center' style={{ color: '#666666' }}>
{footer ? ( {footer ? (
<div <div
className='custom-footer' className='custom-footer'
@@ -37,10 +37,7 @@ const Footer = () => {
></div> ></div>
) : ( ) : (
<div className='custom-footer'> <div className='custom-footer'>
<a <a href='https://github.com/songquanpeng/one-api' target='_blank'>
href='https://github.com/songquanpeng/one-api'
target='_blank'
>
{systemName} {process.env.REACT_APP_VERSION}{' '} {systemName} {process.env.REACT_APP_VERSION}{' '}
</a> </a>
{' '} {' '}

View File

@@ -2,8 +2,22 @@ import React, { useContext, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { UserContext } from '../context/User'; import { UserContext } from '../context/User';
import { Button, Container, Dropdown, Icon, Menu, Segment } from 'semantic-ui-react'; import {
import { API, getLogo, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers'; Button,
Container,
Dropdown,
Icon,
Menu,
Segment,
} from 'semantic-ui-react';
import {
API,
getLogo,
getSystemName,
isAdmin,
isMobile,
showSuccess,
} from '../helpers';
import '../index.css'; import '../index.css';
// Header Buttons // Header Buttons
@@ -11,58 +25,63 @@ let headerButtons = [
{ {
name: '首页', name: '首页',
to: '/', to: '/',
icon: 'home' icon: 'home',
}, },
{ {
name: '渠道', name: '渠道',
to: '/channel', to: '/channel',
icon: 'sitemap', icon: 'sitemap',
admin: true admin: true,
}, },
{ {
name: '令牌', name: '令牌',
to: '/token', to: '/token',
icon: 'key' icon: 'key',
}, },
{ {
name: '兑换', name: '兑换',
to: '/redemption', to: '/redemption',
icon: 'dollar sign', icon: 'dollar sign',
admin: true admin: true,
}, },
{ {
name: '充值', name: '充值',
to: '/topup', to: '/topup',
icon: 'cart' icon: 'cart',
}, },
{ {
name: '用户', name: '用户',
to: '/user', to: '/user',
icon: 'user', icon: 'user',
admin: true admin: true,
},
{
name: '总览',
to: '/dashboard',
icon: 'chart bar',
}, },
{ {
name: '日志', name: '日志',
to: '/log', to: '/log',
icon: 'book' icon: 'book',
}, },
{ {
name: '设置', name: '设置',
to: '/setting', to: '/setting',
icon: 'setting' icon: 'setting',
}, },
{ {
name: '关于', name: '关于',
to: '/about', to: '/about',
icon: 'info circle' icon: 'info circle',
} },
]; ];
if (localStorage.getItem('chat_link')) { if (localStorage.getItem('chat_link')) {
headerButtons.splice(1, 0, { headerButtons.splice(1, 0, {
name: '聊天', name: '聊天',
to: '/chat', to: '/chat',
icon: 'comments' icon: 'comments',
}); });
} }
@@ -97,14 +116,24 @@ const Header = () => {
navigate(button.to); navigate(button.to);
setShowSidebar(false); setShowSidebar(false);
}} }}
style={{ fontSize: '15px' }}
> >
{button.name} {button.name}
</Menu.Item> </Menu.Item>
); );
} }
return ( return (
<Menu.Item key={button.name} as={Link} to={button.to}> <Menu.Item
<Icon name={button.icon} /> key={button.name}
as={Link}
to={button.to}
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
>
<Icon name={button.icon} style={{ marginRight: '4px' }} />
{button.name} {button.name}
</Menu.Item> </Menu.Item>
); );
@@ -120,21 +149,17 @@ const Header = () => {
style={ style={
showSidebar showSidebar
? { ? {
borderBottom: 'none', borderBottom: 'none',
marginBottom: '0', marginBottom: '0',
borderTop: 'none', borderTop: 'none',
height: '51px' height: '51px',
} }
: { borderTop: 'none', height: '52px' } : { borderTop: 'none', height: '52px' }
} }
> >
<Container> <Container>
<Menu.Item as={Link} to='/'> <Menu.Item as={Link} to='/'>
<img <img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
src={logo}
alt='logo'
style={{ marginRight: '0.75em' }}
/>
<div style={{ fontSize: '20px' }}> <div style={{ fontSize: '20px' }}>
<b>{systemName}</b> <b>{systemName}</b>
</div> </div>
@@ -152,7 +177,9 @@ const Header = () => {
{renderButtons(true)} {renderButtons(true)}
<Menu.Item> <Menu.Item>
{userState.user ? ( {userState.user ? (
<Button onClick={logout}>注销</Button> <Button onClick={logout} style={{ color: '#666666' }}>
注销
</Button>
) : ( ) : (
<> <>
<Button <Button
@@ -185,12 +212,25 @@ const Header = () => {
return ( return (
<> <>
<Menu borderless style={{ borderTop: 'none' }}> <Menu
borderless
style={{
borderTop: 'none',
boxShadow: 'rgba(0, 0, 0, 0.04) 0px 2px 12px 0px',
border: 'none',
}}
>
<Container> <Container>
<Menu.Item as={Link} to='/' className={'hide-on-mobile'}> <Menu.Item as={Link} to='/' className={'hide-on-mobile'}>
<img src={logo} alt='logo' style={{ marginRight: '0.75em' }} /> <img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
<div style={{ fontSize: '20px' }}> <div
<b>{systemName}</b> style={{
fontSize: '18px',
fontWeight: '500',
color: '#333',
}}
>
{systemName}
</div> </div>
</Menu.Item> </Menu.Item>
{renderButtons(false)} {renderButtons(false)}
@@ -200,9 +240,23 @@ const Header = () => {
text={userState.user.username} text={userState.user.username}
pointing pointing
className='link item' className='link item'
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
> >
<Dropdown.Menu> <Dropdown.Menu>
<Dropdown.Item onClick={logout}>注销</Dropdown.Item> <Dropdown.Item
onClick={logout}
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
>
注销
</Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
) : ( ) : (
@@ -211,6 +265,11 @@ const Header = () => {
as={Link} as={Link}
to='/login' to='/login'
className='btn btn-link' className='btn btn-link'
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
/> />
)} )}
</Menu.Menu> </Menu.Menu>

View File

@@ -1,5 +1,16 @@
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { Button, Divider, Form, Grid, Header, Image, Message, Modal, Segment } from 'semantic-ui-react'; import {
Button,
Divider,
Form,
Grid,
Header,
Image,
Message,
Modal,
Segment,
Card,
} from 'semantic-ui-react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { UserContext } from '../context/User'; import { UserContext } from '../context/User';
import { API, getLogo, showError, showSuccess, showWarning } from '../helpers'; import { API, getLogo, showError, showSuccess, showWarning } from '../helpers';
@@ -10,7 +21,7 @@ const LoginForm = () => {
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
username: '', username: '',
password: '', password: '',
wechat_verification_code: '' wechat_verification_code: '',
}); });
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
@@ -63,7 +74,7 @@ const LoginForm = () => {
if (username && password) { if (username && password) {
const res = await API.post(`/api/user/login`, { const res = await API.post(`/api/user/login`, {
username, username,
password password,
}); });
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
@@ -86,129 +97,149 @@ const LoginForm = () => {
return ( return (
<Grid textAlign='center' style={{ marginTop: '48px' }}> <Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}> <Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'> <Card
<Image src={logo} /> 用户登录 fluid
</Header> className='chart-card'
<Form size='large'> style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
<Segment>
<Form.Input
fluid
icon='user'
iconPosition='left'
placeholder='用户名 / 邮箱地址'
name='username'
value={username}
onChange={handleChange}
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='密码'
name='password'
type='password'
value={password}
onChange={handleChange}
/>
<Button color='green' fluid size='large' onClick={handleSubmit}>
登录
</Button>
</Segment>
</Form>
<Message>
忘记密码
<Link to='/reset' className='btn btn-link'>
点击重置
</Link>
没有账户
<Link to='/register' className='btn btn-link'>
点击注册
</Link>
</Message>
{status.github_oauth || status.wechat_login || status.lark_client_id ? (
<>
<Divider horizontal>Or</Divider>
<div style={{ display: "flex", justifyContent: "center" }}>
{status.github_oauth ? (
<Button
circular
color='black'
icon='github'
onClick={() => onGitHubOAuthClicked(status.github_client_id)}
/>
) : (
<></>
)}
{status.wechat_login ? (
<Button
circular
color='green'
icon='wechat'
onClick={onWeChatLoginClicked}
/>
) : (
<></>
)}
{status.lark_client_id ? (
<div style={{
background: "radial-gradient(circle, #FFFFFF, #FFFFFF, #00D6B9, #2F73FF, #0a3A9C)",
width: "36px",
height: "36px",
borderRadius: "10em",
display: "flex",
cursor: "pointer"
}}
onClick={() => onLarkOAuthClicked(status.lark_client_id)}
>
<Image
src={larkIcon}
avatar
style={{ width: "16px", height: "16px", cursor: "pointer", margin: "auto" }}
onClick={() => onLarkOAuthClicked(status.lark_client_id)}
/>
</div>
) : (
<></>
)}
</div>
</>
) : (
<></>
)}
<Modal
onClose={() => setShowWeChatLoginModal(false)}
onOpen={() => setShowWeChatLoginModal(true)}
open={showWeChatLoginModal}
size={'mini'}
> >
<Modal.Content> <Card.Content>
<Modal.Description> <Card.Header>
<Image src={status.wechat_qrcode} fluid /> <Header
<div style={{ textAlign: 'center' }}> as='h2'
<p> textAlign='center'
微信扫码关注公众号输入验证码获取验证码三分钟内有效 style={{ marginBottom: '1.5em' }}
</p> >
<Image src={logo} style={{ marginBottom: '10px' }} />
<Header.Content>用户登录</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
<Form.Input
fluid
icon='user'
iconPosition='left'
placeholder='用户名 / 邮箱地址'
name='username'
value={username}
onChange={handleChange}
style={{ marginBottom: '1em' }}
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='密码'
name='password'
type='password'
value={password}
onChange={handleChange}
style={{ marginBottom: '1.5em' }}
/>
<Button
fluid
size='large'
style={{
background: '#2F73FF', // 使用更现代的蓝色
color: 'white',
marginBottom: '1.5em',
}}
onClick={handleSubmit}
>
登录
</Button>
</Form>
<Divider />
<Message style={{ background: 'transparent', boxShadow: 'none' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '0.9em',
color: '#666',
}}
>
<div>
忘记密码
<Link to='/reset' style={{ color: '#2185d0' }}>
点击重置
</Link>
</div>
<div>
没有账户
<Link to='/register' style={{ color: '#2185d0' }}>
点击注册
</Link>
</div>
</div> </div>
<Form size='large'> </Message>
<Form.Input
fluid {(status.github_oauth ||
placeholder='验证码' status.wechat_login ||
name='wechat_verification_code' status.lark_client_id) && (
value={inputs.wechat_verification_code} <>
onChange={handleChange} <Divider
/> horizontal
<Button style={{ color: '#666', fontSize: '0.9em' }}
color=''
fluid
size='large'
onClick={onSubmitWeChatVerificationCode}
> >
登录 使用其他方式登录
</Button> </Divider>
</Form> <div
</Modal.Description> style={{
</Modal.Content> display: 'flex',
</Modal> justifyContent: 'center',
gap: '1em',
marginTop: '1em',
}}
>
{status.github_oauth && (
<Button
circular
color='black'
icon='github'
onClick={() =>
onGitHubOAuthClicked(status.github_client_id)
}
/>
)}
{status.wechat_login && (
<Button
circular
color='green'
icon='wechat'
onClick={onWeChatLoginClicked}
/>
)}
{status.lark_client_id && (
<div
style={{
background:
'radial-gradient(circle, #FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF)',
width: '36px',
height: '36px',
borderRadius: '10em',
display: 'flex',
cursor: 'pointer',
}}
onClick={() => onLarkOAuthClicked(status.lark_client_id)}
>
<Image
src={larkIcon}
avatar
style={{
width: '36px',
height: '36px',
cursor: 'pointer',
margin: 'auto',
}}
/>
</div>
)}
</div>
</>
)}
</Card.Content>
</Card>
</Grid.Column> </Grid.Column>
</Grid> </Grid>
); );

View File

@@ -1,21 +1,48 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Label, Pagination, Segment, Select, Table } from 'semantic-ui-react'; import {
import { API, isAdmin, showError, timestamp2string } from '../helpers'; Button,
Form,
Header,
Label,
Pagination,
Segment,
Select,
Table,
} from 'semantic-ui-react';
import {
API,
copy,
isAdmin,
showError,
showSuccess,
showWarning,
timestamp2string,
} from '../helpers';
import { ITEMS_PER_PAGE } from '../constants'; import { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render'; import { renderColorLabel, renderQuota } from '../helpers/render';
import { Link } from 'react-router-dom';
function renderTimestamp(timestamp) { function renderTimestamp(timestamp, request_id) {
return ( return (
<> <code
onClick={async () => {
if (await copy(request_id)) {
showSuccess(`已复制请求 ID${request_id}`);
} else {
showWarning(`请求 ID 复制失败:${request_id}`);
}
}}
style={{ cursor: 'pointer' }}
>
{timestamp2string(timestamp)} {timestamp2string(timestamp)}
</> </code>
); );
} }
const MODE_OPTIONS = [ const MODE_OPTIONS = [
{ key: 'all', text: '全部用户', value: 'all' }, { key: 'all', text: '全部用户', value: 'all' },
{ key: 'self', text: '当前用户', value: 'self' } { key: 'self', text: '当前用户', value: 'self' },
]; ];
const LOG_OPTIONS = [ const LOG_OPTIONS = [
@@ -23,24 +50,92 @@ const LOG_OPTIONS = [
{ key: '1', text: '充值', value: 1 }, { key: '1', text: '充值', value: 1 },
{ key: '2', text: '消费', value: 2 }, { key: '2', text: '消费', value: 2 },
{ key: '3', text: '管理', value: 3 }, { key: '3', text: '管理', value: 3 },
{ key: '4', text: '系统', value: 4 } { key: '4', text: '系统', value: 4 },
{ key: '5', text: '测试', value: 5 },
]; ];
function renderType(type) { function renderType(type) {
switch (type) { switch (type) {
case 1: case 1:
return <Label basic color='green'> 充值 </Label>; return (
<Label basic color='green'>
充值
</Label>
);
case 2: case 2:
return <Label basic color='olive'> 消费 </Label>; return (
<Label basic color='olive'>
消费
</Label>
);
case 3: case 3:
return <Label basic color='orange'> 管理 </Label>; return (
<Label basic color='orange'>
管理
</Label>
);
case 4: case 4:
return <Label basic color='purple'> 系统 </Label>; return (
<Label basic color='purple'>
系统
</Label>
);
case 5:
return (
<Label basic color='violet'>
测试
</Label>
);
default: default:
return <Label basic color='black'> 未知 </Label>; return (
<Label basic color='black'>
未知
</Label>
);
} }
} }
function getColorByElapsedTime(elapsedTime) {
if (elapsedTime === undefined || 0) return 'black';
if (elapsedTime < 1000) return 'green';
if (elapsedTime < 3000) return 'olive';
if (elapsedTime < 5000) return 'yellow';
if (elapsedTime < 10000) return 'orange';
return 'red';
}
function renderDetail(log) {
return (
<>
{log.content}
<br />
{log.elapsed_time && (
<Label
basic
size={'mini'}
color={getColorByElapsedTime(log.elapsed_time)}
>
{log.elapsed_time} ms
</Label>
)}
{log.is_stream && (
<>
<Label size={'mini'} color='pink'>
Stream
</Label>
</>
)}
{log.system_prompt_reset && (
<>
<Label basic size={'mini'} color='red'>
System Prompt Reset
</Label>
</>
)}
</>
);
}
const LogsTable = () => { const LogsTable = () => {
const [logs, setLogs] = useState([]); const [logs, setLogs] = useState([]);
const [showStat, setShowStat] = useState(false); const [showStat, setShowStat] = useState(false);
@@ -57,13 +152,20 @@ const LogsTable = () => {
model_name: '', model_name: '',
start_timestamp: timestamp2string(0), start_timestamp: timestamp2string(0),
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600), end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
channel: '' channel: '',
}); });
const { username, token_name, model_name, start_timestamp, end_timestamp, channel } = inputs; const {
username,
token_name,
model_name,
start_timestamp,
end_timestamp,
channel,
} = inputs;
const [stat, setStat] = useState({ const [stat, setStat] = useState({
quota: 0, quota: 0,
token: 0 token: 0,
}); });
const handleInputChange = (e, { name, value }) => { const handleInputChange = (e, { name, value }) => {
@@ -73,7 +175,9 @@ const LogsTable = () => {
const getLogSelfStat = async () => { const getLogSelfStat = async () => {
let localStartTimestamp = Date.parse(start_timestamp) / 1000; let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000; let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let res = await API.get(`/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`); let res = await API.get(
`/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`
);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
setStat(data); setStat(data);
@@ -85,7 +189,9 @@ const LogsTable = () => {
const getLogStat = async () => { const getLogStat = async () => {
let localStartTimestamp = Date.parse(start_timestamp) / 1000; let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000; let localEndTimestamp = Date.parse(end_timestamp) / 1000;
let res = await API.get(`/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`); let res = await API.get(
`/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`
);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
setStat(data); setStat(data);
@@ -105,6 +211,10 @@ const LogsTable = () => {
setShowStat(!showStat); setShowStat(!showStat);
}; };
const showUserTokenQuota = () => {
return logType !== 5;
};
const loadLogs = async (startIdx) => { const loadLogs = async (startIdx) => {
let url = ''; let url = '';
let localStartTimestamp = Date.parse(start_timestamp) / 1000; let localStartTimestamp = Date.parse(start_timestamp) / 1000;
@@ -197,43 +307,88 @@ const LogsTable = () => {
return ( return (
<> <>
<Segment> <>
<Header as='h3'> <Header as='h3'>
使用明细总消耗额度 使用明细总消耗额度
{showStat && renderQuota(stat.quota)} {showStat && renderQuota(stat.quota)}
{!showStat && <span onClick={handleEyeClick} style={{ cursor: 'pointer', color: 'gray' }}>点击查看</span>} {!showStat && (
<span
onClick={handleEyeClick}
style={{ cursor: 'pointer', color: 'gray' }}
>
点击查看
</span>
)}
</Header> </Header>
<Form> <Form>
<Form.Group> <Form.Group>
<Form.Input fluid label={'令牌名称'} width={3} value={token_name} <Form.Input
placeholder={'可选值'} name='token_name' onChange={handleInputChange} /> fluid
<Form.Input fluid label='模型名称' width={3} value={model_name} placeholder='可选值' label={'令牌名称'}
name='model_name' width={3}
onChange={handleInputChange} /> value={token_name}
<Form.Input fluid label='起始时间' width={4} value={start_timestamp} type='datetime-local' placeholder={'可选值'}
name='start_timestamp' name='token_name'
onChange={handleInputChange} /> onChange={handleInputChange}
<Form.Input fluid label='结束时间' width={4} value={end_timestamp} type='datetime-local' />
name='end_timestamp' <Form.Input
onChange={handleInputChange} /> fluid
<Form.Button fluid label='操作' width={2} onClick={refresh}>查询</Form.Button> label='模型名称'
width={3}
value={model_name}
placeholder='可选值'
name='model_name'
onChange={handleInputChange}
/>
<Form.Input
fluid
label='起始时间'
width={4}
value={start_timestamp}
type='datetime-local'
name='start_timestamp'
onChange={handleInputChange}
/>
<Form.Input
fluid
label='结束时间'
width={4}
value={end_timestamp}
type='datetime-local'
name='end_timestamp'
onChange={handleInputChange}
/>
<Form.Button fluid label='操作' width={2} onClick={refresh}>
查询
</Form.Button>
</Form.Group> </Form.Group>
{ {isAdminUser && (
isAdminUser && <> <>
<Form.Group> <Form.Group>
<Form.Input fluid label={'渠道 ID'} width={3} value={channel} <Form.Input
placeholder='可选值' name='channel' fluid
onChange={handleInputChange} /> label={'渠道 ID'}
<Form.Input fluid label={'用户名称'} width={3} value={username} width={3}
placeholder={'可选值'} name='username' value={channel}
onChange={handleInputChange} /> placeholder='可选值'
name='channel'
onChange={handleInputChange}
/>
<Form.Input
fluid
label={'用户名称'}
width={3}
value={username}
placeholder={'可选值'}
name='username'
onChange={handleInputChange}
/>
</Form.Group> </Form.Group>
</> </>
} )}
</Form> </Form>
<Table basic compact size='small'> <Table basic={'very'} compact size='small'>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.HeaderCell <Table.HeaderCell
@@ -245,8 +400,8 @@ const LogsTable = () => {
> >
时间 时间
</Table.HeaderCell> </Table.HeaderCell>
{ {isAdminUser && (
isAdminUser && <Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => { onClick={() => {
sortLog('channel'); sortLog('channel');
@@ -255,27 +410,7 @@ const LogsTable = () => {
> >
渠道 渠道
</Table.HeaderCell> </Table.HeaderCell>
} )}
{
isAdminUser && <Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('username');
}}
width={1}
>
用户
</Table.HeaderCell>
}
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('token_name');
}}
width={1}
>
令牌
</Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => { onClick={() => {
@@ -294,33 +429,57 @@ const LogsTable = () => {
> >
模型 模型
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell {showUserTokenQuota() && (
style={{ cursor: 'pointer' }} <>
onClick={() => { {isAdminUser && (
sortLog('prompt_tokens'); <Table.HeaderCell
}} style={{ cursor: 'pointer' }}
width={1} onClick={() => {
> sortLog('username');
提示 }}
</Table.HeaderCell> width={1}
<Table.HeaderCell >
style={{ cursor: 'pointer' }} 用户
onClick={() => { </Table.HeaderCell>
sortLog('completion_tokens'); )}
}} <Table.HeaderCell
width={1} style={{ cursor: 'pointer' }}
> onClick={() => {
补全 sortLog('token_name');
</Table.HeaderCell> }}
<Table.HeaderCell width={1}
style={{ cursor: 'pointer' }} >
onClick={() => { 令牌
sortLog('quota'); </Table.HeaderCell>
}} <Table.HeaderCell
width={1} style={{ cursor: 'pointer' }}
> onClick={() => {
额度 sortLog('prompt_tokens');
</Table.HeaderCell> }}
width={1}
>
提示
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('completion_tokens');
}}
width={1}
>
补全
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('quota');
}}
width={1}
>
额度
</Table.HeaderCell>
</>
)}
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => { onClick={() => {
@@ -343,24 +502,64 @@ const LogsTable = () => {
if (log.deleted) return <></>; if (log.deleted) return <></>;
return ( return (
<Table.Row key={log.id}> <Table.Row key={log.id}>
<Table.Cell>{renderTimestamp(log.created_at)}</Table.Cell> <Table.Cell>
{ {renderTimestamp(log.created_at, log.request_id)}
isAdminUser && ( </Table.Cell>
<Table.Cell>{log.channel ? <Label basic>{log.channel}</Label> : ''}</Table.Cell> {isAdminUser && (
) <Table.Cell>
} {log.channel ? (
{ <Label
isAdminUser && ( basic
<Table.Cell>{log.username ? <Label>{log.username}</Label> : ''}</Table.Cell> as={Link}
) to={`/channel/edit/${log.channel}`}
} >
<Table.Cell>{log.token_name ? <Label basic>{log.token_name}</Label> : ''}</Table.Cell> {log.channel}
</Label>
) : (
''
)}
</Table.Cell>
)}
<Table.Cell>{renderType(log.type)}</Table.Cell> <Table.Cell>{renderType(log.type)}</Table.Cell>
<Table.Cell>{log.model_name ? <Label basic>{log.model_name}</Label> : ''}</Table.Cell> <Table.Cell>
<Table.Cell>{log.prompt_tokens ? log.prompt_tokens : ''}</Table.Cell> {log.model_name ? renderColorLabel(log.model_name) : ''}
<Table.Cell>{log.completion_tokens ? log.completion_tokens : ''}</Table.Cell> </Table.Cell>
<Table.Cell>{log.quota ? renderQuota(log.quota, 6) : ''}</Table.Cell> {showUserTokenQuota() && (
<Table.Cell>{log.content}</Table.Cell> <>
{isAdminUser && (
<Table.Cell>
{log.username ? (
<Label
basic
as={Link}
to={`/user/edit/${log.user_id}`}
>
{log.username}
</Label>
) : (
''
)}
</Table.Cell>
)}
<Table.Cell>
{log.token_name
? renderColorLabel(log.token_name)
: ''}
</Table.Cell>
<Table.Cell>
{log.prompt_tokens ? log.prompt_tokens : ''}
</Table.Cell>
<Table.Cell>
{log.completion_tokens ? log.completion_tokens : ''}
</Table.Cell>
<Table.Cell>
{log.quota ? renderQuota(log.quota, 6) : ''}
</Table.Cell>
</>
)}
<Table.Cell>{renderDetail(log)}</Table.Cell>
</Table.Row> </Table.Row>
); );
})} })}
@@ -379,7 +578,9 @@ const LogsTable = () => {
setLogType(value); setLogType(value);
}} }}
/> />
<Button size='small' onClick={refresh} loading={loading}>刷新</Button> <Button size='small' onClick={refresh} loading={loading}>
刷新
</Button>
<Pagination <Pagination
floated='right' floated='right'
activePage={activePage} activePage={activePage}
@@ -395,7 +596,7 @@ const LogsTable = () => {
</Table.Row> </Table.Row>
</Table.Footer> </Table.Footer>
</Table> </Table>
</Segment> </>
</> </>
); );
}; };

View File

@@ -1,6 +1,21 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react'; import {
import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers'; Button,
Form,
Grid,
Header,
Image,
Card,
Message,
} from 'semantic-ui-react';
import {
API,
copy,
showError,
showInfo,
showNotice,
showSuccess,
} from '../helpers';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
const PasswordResetConfirm = () => { const PasswordResetConfirm = () => {
@@ -37,7 +52,7 @@ const PasswordResetConfirm = () => {
setDisableButton(false); setDisableButton(false);
setCountdown(30); setCountdown(30);
} }
return () => clearInterval(countdownInterval); return () => clearInterval(countdownInterval);
}, [disableButton, countdown]); }, [disableButton, countdown]);
async function handleSubmit(e) { async function handleSubmit(e) {
@@ -59,55 +74,86 @@ const PasswordResetConfirm = () => {
} }
setLoading(false); setLoading(false);
} }
return ( return (
<Grid textAlign='center' style={{ marginTop: '48px' }}> <Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}> <Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'> <Card
<Image src='/logo.png' /> 密码重置确认 fluid
</Header> className='chart-card'
<Form size='large'> style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
<Segment> >
<Form.Input <Card.Content>
fluid <Card.Header>
icon='mail' <Header
iconPosition='left' as='h2'
placeholder='邮箱地址' textAlign='center'
name='email' style={{ marginBottom: '1.5em' }}
value={email} >
readOnly <Image src='/logo.png' style={{ marginBottom: '10px' }} />
/> <Header.Content>密码重置确认</Header.Content>
{newPassword && ( </Header>
</Card.Header>
<Form size='large'>
<Form.Input <Form.Input
fluid fluid
icon='lock' icon='mail'
iconPosition='left' iconPosition='left'
placeholder='新密码' placeholder='邮箱地址'
name='newPassword' name='email'
value={newPassword} value={email}
readOnly readOnly
onClick={(e) => { style={{ marginBottom: '1em' }}
e.target.select(); />
navigator.clipboard.writeText(newPassword); {newPassword && (
showNotice(`密码已复制到剪贴板:${newPassword}`); <Form.Input
}} fluid
/> icon='lock'
iconPosition='left'
placeholder='新密码'
name='newPassword'
value={newPassword}
readOnly
style={{
marginBottom: '1em',
cursor: 'pointer',
backgroundColor: '#f8f9fa',
}}
onClick={(e) => {
e.target.select();
navigator.clipboard.writeText(newPassword);
showNotice(`密码已复制到剪贴板:${newPassword}`);
}}
/>
)}
<Button
color='blue'
fluid
size='large'
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
style={{
background: '#2F73FF', // 使用更现代的蓝色
color: 'white',
marginBottom: '1.5em',
}}
>
{disableButton ? '密码重置完成' : '提交'}
</Button>
</Form>
{newPassword && (
<Message style={{ background: 'transparent', boxShadow: 'none' }}>
<p style={{ fontSize: '0.9em', color: '#666' }}>
新密码已生成请点击密码框或上方按钮复制请及时登录并修改密码
</p>
</Message>
)} )}
<Button </Card.Content>
color='green' </Card>
fluid
size='large'
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
>
{disableButton ? `密码重置完成` : '提交'}
</Button>
</Segment>
</Form>
</Grid.Column> </Grid.Column>
</Grid> </Grid>
); );
}; };
export default PasswordResetConfirm; export default PasswordResetConfirm;

View File

@@ -1,11 +1,19 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react'; import {
Button,
Form,
Grid,
Header,
Image,
Card,
Message,
} from 'semantic-ui-react';
import { API, showError, showInfo, showSuccess } from '../helpers'; import { API, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile'; import Turnstile from 'react-turnstile';
const PasswordResetForm = () => { const PasswordResetForm = () => {
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
email: '' email: '',
}); });
const { email } = inputs; const { email } = inputs;
@@ -42,7 +50,7 @@ const PasswordResetForm = () => {
function handleChange(e) { function handleChange(e) {
const { name, value } = e.target; const { name, value } = e.target;
setInputs(inputs => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
} }
async function handleSubmit(e) { async function handleSubmit(e) {
@@ -69,42 +77,72 @@ const PasswordResetForm = () => {
return ( return (
<Grid textAlign='center' style={{ marginTop: '48px' }}> <Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}> <Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'> <Card
<Image src='/logo.png' /> 密码重置 fluid
</Header> className='chart-card'
<Form size='large'> style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
<Segment> >
<Form.Input <Card.Content>
fluid <Card.Header>
icon='mail' <Header
iconPosition='left' as='h2'
placeholder='邮箱地址' textAlign='center'
name='email' style={{ marginBottom: '1.5em' }}
value={email} >
onChange={handleChange} <Image src='/logo.png' style={{ marginBottom: '10px' }} />
/> <Header.Content>密码重置</Header.Content>
{turnstileEnabled ? ( </Header>
<Turnstile </Card.Header>
sitekey={turnstileSiteKey} <Form size='large'>
onVerify={(token) => { <Form.Input
setTurnstileToken(token); fluid
}} icon='mail'
iconPosition='left'
placeholder='邮箱地址'
name='email'
value={email}
onChange={handleChange}
style={{ marginBottom: '1em' }}
/> />
) : ( {turnstileEnabled && (
<></> <div
)} style={{
<Button marginBottom: '1em',
color='green' display: 'flex',
fluid justifyContent: 'center',
size='large' }}
onClick={handleSubmit} >
loading={loading} <Turnstile
disabled={disableButton} sitekey={turnstileSiteKey}
> onVerify={(token) => {
{disableButton ? `重试 (${countdown})` : '提交'} setTurnstileToken(token);
</Button> }}
</Segment> />
</Form> </div>
)}
<Button
color='blue'
fluid
size='large'
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
style={{
background: '#2F73FF', // 使用更现代的蓝色
color: 'white',
marginBottom: '1.5em',
}}
>
{disableButton ? `重试 (${countdown})` : '提交'}
</Button>
</Form>
<Message style={{ background: 'transparent', boxShadow: 'none' }}>
<p style={{ fontSize: '0.9em', color: '#666' }}>
系统将向您的邮箱发送一封包含重置链接的邮件请注意查收
</p>
</Message>
</Card.Content>
</Card>
</Grid.Column> </Grid.Column>
</Grid> </Grid>
); );

View File

@@ -1,29 +1,59 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Label, Popup, Pagination, Table } from 'semantic-ui-react'; import {
Button,
Form,
Label,
Popup,
Pagination,
Table,
} from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { API, copy, showError, showInfo, showSuccess, showWarning, timestamp2string } from '../helpers'; import {
API,
copy,
showError,
showInfo,
showSuccess,
showWarning,
timestamp2string,
} from '../helpers';
import { ITEMS_PER_PAGE } from '../constants'; import { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render'; import { renderQuota } from '../helpers/render';
function renderTimestamp(timestamp) { function renderTimestamp(timestamp) {
return ( return <>{timestamp2string(timestamp)}</>;
<>
{timestamp2string(timestamp)}
</>
);
} }
function renderStatus(status) { function renderStatus(status) {
switch (status) { switch (status) {
case 1: case 1:
return <Label basic color='green'>未使用</Label>; return (
<Label basic color='green'>
未使用
</Label>
);
case 2: case 2:
return <Label basic color='red'> 已禁用 </Label>; return (
<Label basic color='red'>
{' '}
已禁用{' '}
</Label>
);
case 3: case 3:
return <Label basic color='grey'> 已使用 </Label>; return (
<Label basic color='grey'>
{' '}
已使用{' '}
</Label>
);
default: default:
return <Label basic color='black'> 未知状态 </Label>; return (
<Label basic color='black'>
{' '}
未知状态{' '}
</Label>
);
} }
} }
@@ -110,7 +140,9 @@ const RedemptionsTable = () => {
return; return;
} }
setSearching(true); setSearching(true);
const res = await API.get(`/api/redemption/search?keyword=${searchKeyword}`); const res = await API.get(
`/api/redemption/search?keyword=${searchKeyword}`
);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
setRedemptions(data); setRedemptions(data);
@@ -159,7 +191,7 @@ const RedemptionsTable = () => {
/> />
</Form> </Form>
<Table basic compact size='small'> <Table basic={'very'} compact size='small'>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.HeaderCell <Table.HeaderCell
@@ -225,11 +257,19 @@ const RedemptionsTable = () => {
return ( return (
<Table.Row key={redemption.id}> <Table.Row key={redemption.id}>
<Table.Cell>{redemption.id}</Table.Cell> <Table.Cell>{redemption.id}</Table.Cell>
<Table.Cell>{redemption.name ? redemption.name : '无'}</Table.Cell> <Table.Cell>
{redemption.name ? redemption.name : '无'}
</Table.Cell>
<Table.Cell>{renderStatus(redemption.status)}</Table.Cell> <Table.Cell>{renderStatus(redemption.status)}</Table.Cell>
<Table.Cell>{renderQuota(redemption.quota)}</Table.Cell> <Table.Cell>{renderQuota(redemption.quota)}</Table.Cell>
<Table.Cell>{renderTimestamp(redemption.created_time)}</Table.Cell> <Table.Cell>
<Table.Cell>{redemption.redeemed_time ? renderTimestamp(redemption.redeemed_time) : "尚未兑换"} </Table.Cell> {renderTimestamp(redemption.created_time)}
</Table.Cell>
<Table.Cell>
{redemption.redeemed_time
? renderTimestamp(redemption.redeemed_time)
: '尚未兑换'}{' '}
</Table.Cell>
<Table.Cell> <Table.Cell>
<div> <div>
<Button <Button
@@ -239,7 +279,9 @@ const RedemptionsTable = () => {
if (await copy(redemption.key)) { if (await copy(redemption.key)) {
showSuccess('已复制到剪贴板!'); showSuccess('已复制到剪贴板!');
} else { } else {
showWarning('无法复制到剪贴板,请手动复制,已将兑换码填入搜索框。') showWarning(
'无法复制到剪贴板,请手动复制,已将兑换码填入搜索框。'
);
setSearchKeyword(redemption.key); setSearchKeyword(redemption.key);
} }
}} }}
@@ -267,7 +309,7 @@ const RedemptionsTable = () => {
</Popup> </Popup>
<Button <Button
size={'small'} size={'small'}
disabled={redemption.status === 3} // used disabled={redemption.status === 3} // used
onClick={() => { onClick={() => {
manageRedemption( manageRedemption(
redemption.id, redemption.id,
@@ -295,7 +337,12 @@ const RedemptionsTable = () => {
<Table.Footer> <Table.Footer>
<Table.Row> <Table.Row>
<Table.HeaderCell colSpan='8'> <Table.HeaderCell colSpan='8'>
<Button size='small' as={Link} to='/redemption/add' loading={loading}> <Button
size='small'
as={Link}
to='/redemption/add'
loading={loading}
>
添加新的兑换码 添加新的兑换码
</Button> </Button>
<Pagination <Pagination

View File

@@ -1,5 +1,15 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Image, Message, Segment } from 'semantic-ui-react'; import {
Button,
Form,
Grid,
Header,
Image,
Message,
Segment,
Card,
Divider,
} from 'semantic-ui-react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { API, getLogo, showError, showInfo, showSuccess } from '../helpers'; import { API, getLogo, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile'; import Turnstile from 'react-turnstile';
@@ -10,7 +20,7 @@ const RegisterForm = () => {
password: '', password: '',
password2: '', password2: '',
email: '', email: '',
verification_code: '' verification_code: '',
}); });
const { username, password, password2 } = inputs; const { username, password, password2 } = inputs;
const [showEmailVerification, setShowEmailVerification] = useState(false); const [showEmailVerification, setShowEmailVerification] = useState(false);
@@ -100,92 +110,135 @@ const RegisterForm = () => {
return ( return (
<Grid textAlign='center' style={{ marginTop: '48px' }}> <Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}> <Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'> <Card
<Image src={logo} /> 新用户注册 fluid
</Header> className='chart-card'
<Form size='large'> style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
<Segment> >
<Form.Input <Card.Content>
fluid <Card.Header>
icon='user' <Header
iconPosition='left' as='h2'
placeholder='输入用户名,最长 12 位' textAlign='center'
onChange={handleChange} style={{ marginBottom: '1.5em' }}
name='username' >
/> <Image src={logo} style={{ marginBottom: '10px' }} />
<Form.Input <Header.Content>新用户注册</Header.Content>
fluid </Header>
icon='lock' </Card.Header>
iconPosition='left' <Form size='large'>
placeholder='输入密码,最短 8 位,最长 20 位' <Form.Input
onChange={handleChange} fluid
name='password' icon='user'
type='password' iconPosition='left'
/> placeholder='输入用户名,最长 12 位'
<Form.Input onChange={handleChange}
fluid name='username'
icon='lock' style={{ marginBottom: '1em' }}
iconPosition='left'
placeholder='输入密码,最短 8 位,最长 20 位'
onChange={handleChange}
name='password2'
type='password'
/>
{showEmailVerification ? (
<>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder='输入邮箱地址'
onChange={handleChange}
name='email'
type='email'
action={
<Button onClick={sendVerificationCode} disabled={loading}>
获取验证码
</Button>
}
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='输入验证码'
onChange={handleChange}
name='verification_code'
/>
</>
) : (
<></>
)}
{turnstileEnabled ? (
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/> />
) : ( <Form.Input
<></> fluid
)} icon='lock'
<Button iconPosition='left'
color='green' placeholder='输入密码,最短 8 位,最长 20 位'
fluid onChange={handleChange}
size='large' name='password'
onClick={handleSubmit} type='password'
loading={loading} style={{ marginBottom: '1em' }}
> />
注册 <Form.Input
</Button> fluid
</Segment> icon='lock'
</Form> iconPosition='left'
<Message> placeholder='再次输入密码'
已有账户 onChange={handleChange}
<Link to='/login' className='btn btn-link'> name='password2'
点击登录 type='password'
</Link> style={{ marginBottom: '1em' }}
</Message> />
{showEmailVerification && (
<>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder='输入邮箱地址'
onChange={handleChange}
name='email'
type='email'
action={
<Button
onClick={sendVerificationCode}
disabled={loading}
style={{ backgroundColor: '#2185d0', color: 'white' }}
>
获取验证码
</Button>
}
style={{ marginBottom: '1em' }}
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='输入验证码'
onChange={handleChange}
name='verification_code'
style={{ marginBottom: '1em' }}
/>
</>
)}
{turnstileEnabled && (
<div
style={{
marginBottom: '1em',
display: 'flex',
justifyContent: 'center',
}}
>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
<Button
fluid
size='large'
onClick={handleSubmit}
style={{
background: '#2F73FF', // 使用更现代的蓝色
color: 'white',
marginBottom: '1.5em',
}}
loading={loading}
>
注册
</Button>
</Form>
<Divider />
<Message style={{ background: 'transparent', boxShadow: 'none' }}>
<div
style={{
textAlign: 'center',
fontSize: '0.9em',
color: '#666',
}}
>
已有账户
<Link to='/login' style={{ color: '#2185d0' }}>
点击登录
</Link>
</div>
</Message>
</Card.Content>
</Card>
</Grid.Column> </Grid.Column>
</Grid> </Grid>
); );

View File

@@ -1,7 +1,22 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Dropdown, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react'; import {
Button,
Dropdown,
Form,
Label,
Pagination,
Popup,
Table,
} from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { API, copy, showError, showSuccess, showWarning, timestamp2string } from '../helpers'; import {
API,
copy,
showError,
showSuccess,
showWarning,
timestamp2string,
} from '../helpers';
import { ITEMS_PER_PAGE } from '../constants'; import { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render'; import { renderQuota } from '../helpers/render';
@@ -21,25 +36,45 @@ const OPEN_LINK_OPTIONS = [
]; ];
function renderTimestamp(timestamp) { function renderTimestamp(timestamp) {
return ( return <>{timestamp2string(timestamp)}</>;
<>
{timestamp2string(timestamp)}
</>
);
} }
function renderStatus(status) { function renderStatus(status) {
switch (status) { switch (status) {
case 1: case 1:
return <Label basic color='green'>已启用</Label>; return (
<Label basic color='green'>
已启用
</Label>
);
case 2: case 2:
return <Label basic color='red'> 已禁用 </Label>; return (
<Label basic color='red'>
{' '}
已禁用{' '}
</Label>
);
case 3: case 3:
return <Label basic color='yellow'> 已过期 </Label>; return (
<Label basic color='yellow'>
{' '}
已过期{' '}
</Label>
);
case 4: case 4:
return <Label basic color='grey'> 已耗尽 </Label>; return (
<Label basic color='grey'>
{' '}
已耗尽{' '}
</Label>
);
default: default:
return <Label basic color='black'> 未知状态 </Label>; return (
<Label basic color='black'>
{' '}
未知状态{' '}
</Label>
);
} }
} }
@@ -98,9 +133,10 @@ const TokensTable = () => {
let encodedServerAddress = encodeURIComponent(serverAddress); let encodedServerAddress = encodeURIComponent(serverAddress);
const nextLink = localStorage.getItem('chat_link'); const nextLink = localStorage.getItem('chat_link');
let nextUrl; let nextUrl;
if (nextLink) { if (nextLink) {
nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; nextUrl =
nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
} else { } else {
nextUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; nextUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
} }
@@ -117,7 +153,9 @@ const TokensTable = () => {
url = nextUrl; url = nextUrl;
break; break;
case 'lobechat': case 'lobechat':
url = nextLink + `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`; url =
nextLink +
`/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`;
break; break;
default: default:
url = `sk-${key}`; url = `sk-${key}`;
@@ -135,7 +173,7 @@ const TokensTable = () => {
let serverAddress = ''; let serverAddress = '';
if (status) { if (status) {
status = JSON.parse(status); status = JSON.parse(status);
serverAddress = status.server_address; serverAddress = status.server_address;
} }
if (serverAddress === '') { if (serverAddress === '') {
serverAddress = window.location.origin; serverAddress = window.location.origin;
@@ -143,9 +181,10 @@ const TokensTable = () => {
let encodedServerAddress = encodeURIComponent(serverAddress); let encodedServerAddress = encodeURIComponent(serverAddress);
const chatLink = localStorage.getItem('chat_link'); const chatLink = localStorage.getItem('chat_link');
let defaultUrl; let defaultUrl;
if (chatLink) { if (chatLink) {
defaultUrl = chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; defaultUrl =
chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
} else { } else {
defaultUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; defaultUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
} }
@@ -154,21 +193,23 @@ const TokensTable = () => {
case 'ama': case 'ama':
url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`; url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`;
break; break;
case 'opencat': case 'opencat':
url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`; url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
break; break;
case 'lobechat': case 'lobechat':
url = chatLink + `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`; url =
chatLink +
`/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`;
break; break;
default: default:
url = defaultUrl; url = defaultUrl;
} }
window.open(url, '_blank'); window.open(url, '_blank');
} };
useEffect(() => { useEffect(() => {
loadTokens(0, orderBy) loadTokens(0, orderBy)
@@ -274,7 +315,7 @@ const TokensTable = () => {
/> />
</Form> </Form>
<Table basic compact size='small'> <Table basic={'very'} compact size='small'>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.HeaderCell <Table.HeaderCell
@@ -342,12 +383,20 @@ const TokensTable = () => {
<Table.Cell>{token.name ? token.name : '无'}</Table.Cell> <Table.Cell>{token.name ? token.name : '无'}</Table.Cell>
<Table.Cell>{renderStatus(token.status)}</Table.Cell> <Table.Cell>{renderStatus(token.status)}</Table.Cell>
<Table.Cell>{renderQuota(token.used_quota)}</Table.Cell> <Table.Cell>{renderQuota(token.used_quota)}</Table.Cell>
<Table.Cell>{token.unlimited_quota ? '无限制' : renderQuota(token.remain_quota, 2)}</Table.Cell> <Table.Cell>
{token.unlimited_quota
? '无限制'
: renderQuota(token.remain_quota, 2)}
</Table.Cell>
<Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell> <Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell>
<Table.Cell>{token.expired_time === -1 ? '永不过期' : renderTimestamp(token.expired_time)}</Table.Cell> <Table.Cell>
{token.expired_time === -1
? '永不过期'
: renderTimestamp(token.expired_time)}
</Table.Cell>
<Table.Cell> <Table.Cell>
<div> <div>
<Button.Group color='green' size={'small'}> <Button.Group color='green' size={'small'}>
<Button <Button
size={'small'} size={'small'}
positive positive
@@ -360,38 +409,37 @@ const TokensTable = () => {
<Dropdown <Dropdown
className='button icon' className='button icon'
floating floating
options={COPY_OPTIONS.map(option => ({ options={COPY_OPTIONS.map((option) => ({
...option, ...option,
onClick: async () => { onClick: async () => {
await onCopy(option.value, token.key); await onCopy(option.value, token.key);
} },
}))} }))}
trigger={<></>} trigger={<></>}
/> />
</Button.Group> </Button.Group>{' '}
{' '}
<Button.Group color='blue' size={'small'}> <Button.Group color='blue' size={'small'}>
<Button <Button
size={'small'} size={'small'}
positive positive
onClick={() => { onClick={() => {
onOpenLink('', token.key); onOpenLink('', token.key);
}}> }}
聊天 >
</Button> 聊天
<Dropdown </Button>
className="button icon" <Dropdown
floating className='button icon'
options={OPEN_LINK_OPTIONS.map(option => ({ floating
...option, options={OPEN_LINK_OPTIONS.map((option) => ({
onClick: async () => { ...option,
await onOpenLink(option.value, token.key); onClick: async () => {
} await onOpenLink(option.value, token.key);
}))} },
trigger={<></>} }))}
/> trigger={<></>}
</Button.Group> />
{' '} </Button.Group>{' '}
<Popup <Popup
trigger={ trigger={
<Button size='small' negative> <Button size='small' negative>
@@ -443,14 +491,24 @@ const TokensTable = () => {
<Button size='small' as={Link} to='/token/add' loading={loading}> <Button size='small' as={Link} to='/token/add' loading={loading}>
添加新的令牌 添加新的令牌
</Button> </Button>
<Button size='small' onClick={refresh} loading={loading}>刷新</Button> <Button size='small' onClick={refresh} loading={loading}>
刷新
</Button>
<Dropdown <Dropdown
placeholder='排序方式' placeholder='排序方式'
selection selection
options={[ options={[
{ key: '', text: '默认排序', value: '' }, { key: '', text: '默认排序', value: '' },
{ key: 'remain_quota', text: '按剩余额度排序', value: 'remain_quota' }, {
{ key: 'used_quota', text: '按已用额度排序', value: 'used_quota' }, key: 'remain_quota',
text: '按剩余额度排序',
value: 'remain_quota',
},
{
key: 'used_quota',
text: '按已用额度排序',
value: 'used_quota',
},
]} ]}
value={orderBy} value={orderBy}
onChange={handleOrderByChange} onChange={handleOrderByChange}

View File

@@ -1,10 +1,23 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Label, Pagination, Popup, Table, Dropdown } from 'semantic-ui-react'; import {
Button,
Form,
Label,
Pagination,
Popup,
Table,
Dropdown,
} 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';
import { ITEMS_PER_PAGE } from '../constants'; import { ITEMS_PER_PAGE } from '../constants';
import { renderGroup, renderNumber, renderQuota, renderText } from '../helpers/render'; import {
renderGroup,
renderNumber,
renderQuota,
renderText,
} from '../helpers/render';
function renderRole(role) { function renderRole(role) {
switch (role) { switch (role) {
@@ -66,7 +79,7 @@ const UsersTable = () => {
(async () => { (async () => {
const res = await API.post('/api/user/manage', { const res = await API.post('/api/user/manage', {
username, username,
action action,
}); });
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
@@ -169,7 +182,7 @@ const UsersTable = () => {
/> />
</Form> </Form>
<Table basic compact size='small'> <Table basic={'very'} compact size='small'>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.HeaderCell <Table.HeaderCell
@@ -239,7 +252,9 @@ const UsersTable = () => {
<Popup <Popup
content={user.email ? user.email : '未绑定邮箱地址'} content={user.email ? user.email : '未绑定邮箱地址'}
key={user.username} key={user.username}
header={user.display_name ? user.display_name : user.username} header={
user.display_name ? user.display_name : user.username
}
trigger={<span>{renderText(user.username, 15)}</span>} trigger={<span>{renderText(user.username, 15)}</span>}
hoverable hoverable
/> />
@@ -249,9 +264,22 @@ const UsersTable = () => {
{/* {user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : '无'}*/} {/* {user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : '无'}*/}
{/*</Table.Cell>*/} {/*</Table.Cell>*/}
<Table.Cell> <Table.Cell>
<Popup content='剩余额度' trigger={<Label basic>{renderQuota(user.quota)}</Label>} /> <Popup
<Popup content='已用额度' trigger={<Label basic>{renderQuota(user.used_quota)}</Label>} /> content='剩余额度'
<Popup content='请求次数' trigger={<Label basic>{renderNumber(user.request_count)}</Label>} /> trigger={<Label basic>{renderQuota(user.quota)}</Label>}
/>
<Popup
content='已用额度'
trigger={
<Label basic>{renderQuota(user.used_quota)}</Label>
}
/>
<Popup
content='请求次数'
trigger={
<Label basic>{renderNumber(user.request_count)}</Label>
}
/>
</Table.Cell> </Table.Cell>
<Table.Cell>{renderRole(user.role)}</Table.Cell> <Table.Cell>{renderRole(user.role)}</Table.Cell>
<Table.Cell>{renderStatus(user.status)}</Table.Cell> <Table.Cell>{renderStatus(user.status)}</Table.Cell>
@@ -279,7 +307,11 @@ const UsersTable = () => {
</Button> </Button>
<Popup <Popup
trigger={ trigger={
<Button size='small' negative disabled={user.role === 100}> <Button
size='small'
negative
disabled={user.role === 100}
>
删除 删除
</Button> </Button>
} }
@@ -335,8 +367,16 @@ const UsersTable = () => {
options={[ options={[
{ key: '', text: '默认排序', value: '' }, { key: '', text: '默认排序', value: '' },
{ key: 'quota', text: '按剩余额度排序', value: 'quota' }, { key: 'quota', text: '按剩余额度排序', value: 'quota' },
{ key: 'used_quota', text: '按已用额度排序', value: 'used_quota' }, {
{ key: 'request_count', text: '按请求次数排序', value: 'request_count' }, key: 'used_quota',
text: '按已用额度排序',
value: 'used_quota',
},
{
key: 'request_count',
text: '按请求次数排序',
value: 'request_count',
},
]} ]}
value={orderBy} value={orderBy}
onChange={handleOrderByChange} onChange={handleOrderByChange}

View File

@@ -13,16 +13,18 @@ export function renderGroup(group) {
} }
let groups = group.split(','); let groups = group.split(',');
groups.sort(); groups.sort();
return <> return (
{groups.map((group) => { <>
if (group === 'vip' || group === 'pro') { {groups.map((group) => {
return <Label color='yellow'>{group}</Label>; if (group === 'vip' || group === 'pro') {
} else if (group === 'svip' || group === 'premium') { return <Label color='yellow'>{group}</Label>;
return <Label color='red'>{group}</Label>; } else if (group === 'svip' || group === 'premium') {
} return <Label color='red'>{group}</Label>;
return <Label>{group}</Label>; }
})} return <Label>{group}</Label>;
</>; })}
</>
);
} }
export function renderNumber(num) { export function renderNumber(num) {
@@ -55,4 +57,33 @@ export function renderQuotaWithPrompt(quota, digits) {
return `(等价金额:${renderQuota(quota, digits)}`; return `(等价金额:${renderQuota(quota, digits)}`;
} }
return ''; return '';
} }
const colors = [
'red',
'orange',
'yellow',
'olive',
'green',
'teal',
'blue',
'violet',
'purple',
'pink',
'brown',
'grey',
'black',
];
export function renderColorLabel(text) {
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
}
let index = Math.abs(hash % colors.length);
return (
<Label basic color={colors[index]}>
{text}
</Label>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Header, Segment } from 'semantic-ui-react'; import { Card } from 'semantic-ui-react';
import { API, showError } from '../../helpers'; import { API, showError } from '../../helpers';
import { marked } from 'marked'; import { marked } from 'marked';
@@ -7,52 +7,40 @@ const About = () => {
const [about, setAbout] = useState(''); const [about, setAbout] = useState('');
const [aboutLoaded, setAboutLoaded] = useState(false); const [aboutLoaded, setAboutLoaded] = useState(false);
const displayAbout = async () => { // ... 其他函数保持不变 ...
setAbout(localStorage.getItem('about') || '');
const res = await API.get('/api/about');
const { success, message, data } = res.data;
if (success) {
let aboutContent = data;
if (!data.startsWith('https://')) {
aboutContent = marked.parse(data);
}
setAbout(aboutContent);
localStorage.setItem('about', aboutContent);
} else {
showError(message);
setAbout('加载关于内容失败...');
}
setAboutLoaded(true);
};
useEffect(() => {
displayAbout().then();
}, []);
return ( return (
<> <div className='dashboard-container'>
{ <Card fluid className='chart-card'>
aboutLoaded && about === '' ? <> <Card.Content>
<Segment> <Card.Header className='header'>关于系统</Card.Header>
<Header as='h3'>关于</Header> {aboutLoaded && about === '' ? (
<p>可在设置页面设置关于内容支持 HTML & Markdown</p> <>
项目仓库地址 <p>可在设置页面设置关于内容支持 HTML & Markdown</p>
<a href='https://github.com/songquanpeng/one-api'> 项目仓库地址
https://github.com/songquanpeng/one-api <a href='https://github.com/songquanpeng/one-api'>
</a> https://github.com/songquanpeng/one-api
</Segment> </a>
</> : <> </>
{ ) : (
about.startsWith('https://') ? <iframe <>
src={about} {about.startsWith('https://') ? (
style={{ width: '100%', height: '100vh', border: 'none' }} <iframe
/> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div> src={about}
} style={{ width: '100%', height: '100vh', border: 'none' }}
</> />
} ) : (
</> <div
style={{ fontSize: 'larger' }}
dangerouslySetInnerHTML={{ __html: about }}
></div>
)}
</>
)}
</Card.Content>
</Card>
</div>
); );
}; };
export default About; export default About;

View File

@@ -1,13 +1,29 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Input, Message, Segment } from 'semantic-ui-react'; import {
Button,
Form,
Header,
Input,
Message,
Segment,
Card,
} from 'semantic-ui-react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { API, copy, getChannelModels, showError, showInfo, showSuccess, verifyJSON } from '../../helpers'; import {
API,
copy,
getChannelModels,
showError,
showInfo,
showSuccess,
verifyJSON,
} from '../../helpers';
import { CHANNEL_OPTIONS } from '../../constants'; import { CHANNEL_OPTIONS } from '../../constants';
const MODEL_MAPPING_EXAMPLE = { const MODEL_MAPPING_EXAMPLE = {
'gpt-3.5-turbo-0301': 'gpt-3.5-turbo', 'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',
'gpt-4-0314': 'gpt-4', 'gpt-4-0314': 'gpt-4',
'gpt-4-32k-0314': 'gpt-4-32k' 'gpt-4-32k-0314': 'gpt-4-32k',
}; };
function type2secretPrompt(type) { function type2secretPrompt(type) {
@@ -45,7 +61,7 @@ const EditChannel = () => {
model_mapping: '', model_mapping: '',
system_prompt: '', system_prompt: '',
models: [], models: [],
groups: ['default'] groups: ['default'],
}; };
const [batch, setBatch] = useState(false); const [batch, setBatch] = useState(false);
const [inputs, setInputs] = useState(originInputs); const [inputs, setInputs] = useState(originInputs);
@@ -61,7 +77,7 @@ const EditChannel = () => {
ak: '', ak: '',
user_id: '', user_id: '',
vertex_ai_project_id: '', vertex_ai_project_id: '',
vertex_ai_adc: '' vertex_ai_adc: '',
}); });
const handleInputChange = (e, { name, value }) => { const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
@@ -93,7 +109,11 @@ const EditChannel = () => {
data.groups = data.group.split(','); data.groups = data.group.split(',');
} }
if (data.model_mapping !== '') { if (data.model_mapping !== '') {
data.model_mapping = JSON.stringify(JSON.parse(data.model_mapping), null, 2); data.model_mapping = JSON.stringify(
JSON.parse(data.model_mapping),
null,
2
);
} }
setInputs(data); setInputs(data);
if (data.config !== '') { if (data.config !== '') {
@@ -112,7 +132,7 @@ const EditChannel = () => {
let localModelOptions = res.data.data.map((model) => ({ let localModelOptions = res.data.data.map((model) => ({
key: model.id, key: model.id,
text: model.id, text: model.id,
value: model.id value: model.id,
})); }));
setOriginModelOptions(localModelOptions); setOriginModelOptions(localModelOptions);
setFullModels(res.data.data.map((model) => model.id)); setFullModels(res.data.data.map((model) => model.id));
@@ -124,11 +144,13 @@ const EditChannel = () => {
const fetchGroups = async () => { const fetchGroups = async () => {
try { try {
let res = await API.get(`/api/group/`); let res = await API.get(`/api/group/`);
setGroupOptions(res.data.data.map((group) => ({ setGroupOptions(
key: group, res.data.data.map((group) => ({
text: group, key: group,
value: group text: group,
}))); value: group,
}))
);
} catch (error) { } catch (error) {
showError(error.message); showError(error.message);
} }
@@ -141,7 +163,7 @@ const EditChannel = () => {
localModelOptions.push({ localModelOptions.push({
key: model, key: model,
text: model, text: model,
value: model value: model,
}); });
} }
}); });
@@ -163,7 +185,11 @@ const EditChannel = () => {
if (inputs.key === '') { if (inputs.key === '') {
if (config.ak !== '' && config.sk !== '' && config.region !== '') { if (config.ak !== '' && config.sk !== '' && config.region !== '') {
inputs.key = `${config.ak}|${config.sk}|${config.region}`; inputs.key = `${config.ak}|${config.sk}|${config.region}`;
} else if (config.region !== '' && config.vertex_ai_project_id !== '' && config.vertex_ai_adc !== '') { } else if (
config.region !== '' &&
config.vertex_ai_project_id !== '' &&
config.vertex_ai_adc !== ''
) {
inputs.key = `${config.region}|${config.vertex_ai_project_id}|${config.vertex_ai_adc}`; inputs.key = `${config.region}|${config.vertex_ai_project_id}|${config.vertex_ai_adc}`;
} }
} }
@@ -179,9 +205,12 @@ const EditChannel = () => {
showInfo('模型映射必须是合法的 JSON 格式!'); showInfo('模型映射必须是合法的 JSON 格式!');
return; return;
} }
let localInputs = {...inputs}; let localInputs = { ...inputs };
if (localInputs.base_url && localInputs.base_url.endsWith('/')) { if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1); localInputs.base_url = localInputs.base_url.slice(
0,
localInputs.base_url.length - 1
);
} }
if (localInputs.type === 3 && localInputs.other === '') { if (localInputs.type === 3 && localInputs.other === '') {
localInputs.other = '2024-03-01-preview'; localInputs.other = '2024-03-01-preview';
@@ -191,7 +220,10 @@ const EditChannel = () => {
localInputs.group = localInputs.groups.join(','); localInputs.group = localInputs.groups.join(',');
localInputs.config = JSON.stringify(config); localInputs.config = JSON.stringify(config);
if (isEdit) { if (isEdit) {
res = await API.put(`/api/channel/`, { ...localInputs, id: parseInt(channelId) }); res = await API.put(`/api/channel/`, {
...localInputs,
id: parseInt(channelId),
});
} else { } else {
res = await API.post(`/api/channel/`, localInputs); res = await API.post(`/api/channel/`, localInputs);
} }
@@ -217,9 +249,9 @@ const EditChannel = () => {
localModelOptions.push({ localModelOptions.push({
key: customModel, key: customModel,
text: customModel, text: customModel,
value: customModel value: customModel,
}); });
setModelOptions(modelOptions => { setModelOptions((modelOptions) => {
return [...modelOptions, ...localModelOptions]; return [...modelOptions, ...localModelOptions];
}); });
setCustomModel(''); setCustomModel('');
@@ -227,34 +259,45 @@ const EditChannel = () => {
}; };
return ( return (
<> <div className='dashboard-container'>
<Segment loading={loading}> <Card fluid className='chart-card'>
<Header as='h3'>{isEdit ? '更新渠道信息' : '创建新的渠道'}</Header> <Card.Content>
<Form autoComplete='new-password'> <Card.Header className='header'>
<Form.Field> {isEdit ? '更新渠道信息' : '创建新的渠道'}
<Form.Select </Card.Header>
label='类型' <Form loading={loading} autoComplete='new-password'>
name='type' <Form.Field>
required <Form.Select
search label='类型'
options={CHANNEL_OPTIONS} name='type'
value={inputs.type} required
onChange={handleInputChange} search
/> options={CHANNEL_OPTIONS}
</Form.Field> value={inputs.type}
{ onChange={handleInputChange}
inputs.type === 3 && ( />
</Form.Field>
{inputs.type === 3 && (
<> <>
<Message> <Message>
注意<strong>模型部署名称必须和模型名称保持一致</strong> One API model 注意<strong>模型部署名称必须和模型名称保持一致</strong>
参数替换为你的部署名称模型名称中的点会被剔除<a target='_blank' 因为 One API 会把请求体中的 model
href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'>图片演示</a> 参数替换为你的部署名称模型名称中的点会被剔除
<a
target='_blank'
href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'
>
图片演示
</a>
</Message> </Message>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='AZURE_OPENAI_ENDPOINT' label='AZURE_OPENAI_ENDPOINT'
name='base_url' name='base_url'
placeholder={'请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com'} placeholder={
'请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com'
}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.base_url} value={inputs.base_url}
autoComplete='new-password' autoComplete='new-password'
@@ -264,73 +307,72 @@ const EditChannel = () => {
<Form.Input <Form.Input
label='默认 API 版本' label='默认 API 版本'
name='other' name='other'
placeholder={'请输入默认 API 版本例如2024-03-01-preview该配置可以被实际的请求查询参数所覆盖'} placeholder={
'请输入默认 API 版本例如2024-03-01-preview该配置可以被实际的请求查询参数所覆盖'
}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.other} value={inputs.other}
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> </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例如https://openai.justsong.cn'} placeholder={
'请输入自定义渠道的 Base URL例如https://openai.justsong.cn'
}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.base_url} value={inputs.base_url}
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
) )}
} <Form.Field>
<Form.Field> <Form.Input
<Form.Input label='名称'
label='名称' name='name'
required placeholder={'请输入名称'}
name='name' onChange={handleInputChange}
placeholder={'请为渠道命名'} value={inputs.name}
onChange={handleInputChange} required
value={inputs.name} />
autoComplete='new-password' </Form.Field>
/> <Form.Field>
</Form.Field> <Form.Dropdown
<Form.Field> label='分组'
<Form.Dropdown placeholder={'请选择可以使用该渠道的分组'}
label='分组' name='groups'
placeholder={'请选择可以使用该渠道的分组'} required
name='groups' fluid
required multiple
fluid selection
multiple allowAdditions
selection additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
allowAdditions onChange={handleInputChange}
additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'} value={inputs.groups}
onChange={handleInputChange} autoComplete='new-password'
value={inputs.groups} options={groupOptions}
autoComplete='new-password' />
options={groupOptions} </Form.Field>
/> {inputs.type === 18 && (
</Form.Field>
{
inputs.type === 18 && (
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='模型版本' label='模型版本'
name='other' name='other'
placeholder={'请输入星火大模型版本注意是接口地址中的版本号例如v2.1'} placeholder={
'请输入星火大模型版本注意是接口地址中的版本号例如v2.1'
}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.other} value={inputs.other}
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
) )}
} {inputs.type === 21 && (
{
inputs.type === 21 && (
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='知识库 ID' label='知识库 ID'
@@ -341,38 +383,40 @@ const EditChannel = () => {
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
) )}
} {inputs.type === 17 && (
{
inputs.type === 17 && (
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='插件参数' label='插件参数'
name='other' name='other'
placeholder={'请输入插件参数,即 X-DashScope-Plugin 请求头的取值'} placeholder={
'请输入插件参数,即 X-DashScope-Plugin 请求头的取值'
}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.other} value={inputs.other}
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
) )}
} {inputs.type === 34 && (
{
inputs.type === 34 && (
<Message> <Message>
对于 Coze 而言模型名称即 Bot ID你可以添加一个前缀 `bot-`例如`bot-123456` 对于 Coze 而言模型名称即 Bot ID你可以添加一个前缀
`bot-`例如`bot-123456`
</Message> </Message>
) )}
} {inputs.type === 40 && (
{
inputs.type === 40 && (
<Message> <Message>
对于豆包而言需要手动去 <a target="_blank" href="https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint">模型推理页面</a> `ep-20240608051426-tkxvl` 对于豆包而言需要手动去{' '}
<a
target='_blank'
href='https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint'
>
模型推理页面
</a>{' '}
创建推理接入点以接入点名称作为模型名称例如`ep-20240608051426-tkxvl`
</Message> </Message>
) )}
} {inputs.type !== 43 && (
{
inputs.type !== 43 && (
<Form.Field> <Form.Field>
<Form.Dropdown <Form.Dropdown
label='模型' label='模型'
@@ -392,23 +436,44 @@ const EditChannel = () => {
options={modelOptions} options={modelOptions}
/> />
</Form.Field> </Form.Field>
) )}
} {inputs.type !== 43 && (
{
inputs.type !== 43 && (
<div style={{ lineHeight: '40px', marginBottom: '12px' }}> <div style={{ lineHeight: '40px', marginBottom: '12px' }}>
<Button type={'button'} onClick={() => { <Button
handleInputChange(null, { name: 'models', value: basicModels }); type={'button'}
}}>填入相关模型</Button> onClick={() => {
<Button type={'button'} onClick={() => { handleInputChange(null, {
handleInputChange(null, { name: 'models', value: fullModels }); name: 'models',
}}>填入所有模型</Button> value: basicModels,
<Button type={'button'} onClick={() => { });
handleInputChange(null, { name: 'models', value: [] }); }}
}}>清除所有模型</Button> >
填入相关模型
</Button>
<Button
type={'button'}
onClick={() => {
handleInputChange(null, {
name: 'models',
value: fullModels,
});
}}
>
填入所有模型
</Button>
<Button
type={'button'}
onClick={() => {
handleInputChange(null, { name: 'models', value: [] });
}}
>
清除所有模型
</Button>
<Input <Input
action={ action={
<Button type={'button'} onClick={addCustomModel}>填入</Button> <Button type={'button'} onClick={addCustomModel}>
填入
</Button>
} }
placeholder='输入自定义模型名称' placeholder='输入自定义模型名称'
value={customModel} value={customModel}
@@ -423,37 +488,44 @@ const EditChannel = () => {
}} }}
/> />
</div> </div>
) )}
} {inputs.type !== 43 && (
{ <>
inputs.type !== 43 && (<> <Form.Field>
<Form.Field> <Form.TextArea
<Form.TextArea label='模型重定向'
label='模型重定向' placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(
placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`} MODEL_MAPPING_EXAMPLE,
name='model_mapping' null,
onChange={handleInputChange} 2
value={inputs.model_mapping} )}`}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} name='model_mapping'
autoComplete='new-password' onChange={handleInputChange}
/> value={inputs.model_mapping}
</Form.Field> style={{
<Form.Field> minHeight: 150,
<Form.TextArea fontFamily: 'JetBrains Mono, Consolas',
label='系统提示词' }}
placeholder={`此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型`} autoComplete='new-password'
name='system_prompt' />
onChange={handleInputChange} </Form.Field>
value={inputs.system_prompt} <Form.Field>
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} <Form.TextArea
autoComplete='new-password' label='系统提示词'
/> placeholder={`此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型`}
</Form.Field> name='system_prompt'
onChange={handleInputChange}
value={inputs.system_prompt}
style={{
minHeight: 150,
fontFamily: 'JetBrains Mono, Consolas',
}}
autoComplete='new-password'
/>
</Form.Field>
</> </>
) )}
} {inputs.type === 33 && (
{
inputs.type === 33 && (
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='Region' label='Region'
@@ -483,10 +555,8 @@ const EditChannel = () => {
autoComplete='' autoComplete=''
/> />
</Form.Field> </Form.Field>
) )}
} {inputs.type === 42 && (
{
inputs.type === 42 && (
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='Region' label='Region'
@@ -510,16 +580,16 @@ const EditChannel = () => {
label='Google Cloud Application Default Credentials JSON' label='Google Cloud Application Default Credentials JSON'
name='vertex_ai_adc' name='vertex_ai_adc'
required required
placeholder={'Google Cloud Application Default Credentials JSON'} placeholder={
'Google Cloud Application Default Credentials JSON'
}
onChange={handleConfigChange} onChange={handleConfigChange}
value={config.vertex_ai_adc} value={config.vertex_ai_adc}
autoComplete='' autoComplete=''
/> />
</Form.Field> </Form.Field>
) )}
} {inputs.type === 34 && (
{
inputs.type === 34 && (
<Form.Input <Form.Input
label='User ID' label='User ID'
name='user_id' name='user_id'
@@ -528,90 +598,105 @@ const EditChannel = () => {
onChange={handleConfigChange} onChange={handleConfigChange}
value={config.user_id} value={config.user_id}
autoComplete='' autoComplete=''
/>)
}
{
inputs.type !== 33 && inputs.type !== 42 && (batch ? <Form.Field>
<Form.TextArea
label='密钥'
name='key'
required
placeholder={'请输入密钥,一行一个'}
onChange={handleInputChange}
value={inputs.key}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
/> />
</Form.Field> : <Form.Field> )}
<Form.Input {inputs.type !== 33 &&
label='密钥' inputs.type !== 42 &&
name='key' (batch ? (
required <Form.Field>
placeholder={type2secretPrompt(inputs.type)} <Form.TextArea
onChange={handleInputChange} label='密钥'
value={inputs.key} name='key'
autoComplete='new-password' required
/> placeholder={'请输入密钥,一行一个'}
</Form.Field>) onChange={handleInputChange}
} value={inputs.key}
{ style={{
inputs.type === 37 && ( minHeight: 150,
fontFamily: 'JetBrains Mono, Consolas',
}}
autoComplete='new-password'
/>
</Form.Field>
) : (
<Form.Field>
<Form.Input
label='密钥'
name='key'
required
placeholder={type2secretPrompt(inputs.type)}
onChange={handleInputChange}
value={inputs.key}
autoComplete='new-password'
/>
</Form.Field>
))}
{inputs.type === 37 && (
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='Account ID' label='Account ID'
name='user_id' name='user_id'
required required
placeholder={'请输入 Account ID例如d8d7c61dbc334c32d3ced580e4bf42b4'} placeholder={
'请输入 Account ID例如d8d7c61dbc334c32d3ced580e4bf42b4'
}
onChange={handleConfigChange} onChange={handleConfigChange}
value={config.user_id} value={config.user_id}
autoComplete='' autoComplete=''
/> />
</Form.Field> </Form.Field>
) )}
} {inputs.type !== 33 && !isEdit && (
{
inputs.type !== 33 && !isEdit && (
<Form.Checkbox <Form.Checkbox
checked={batch} checked={batch}
label='批量创建' label='批量创建'
name='batch' name='batch'
onChange={() => setBatch(!batch)} onChange={() => setBatch(!batch)}
/> />
) )}
} {inputs.type !== 3 &&
{ inputs.type !== 33 &&
inputs.type !== 3 && inputs.type !== 33 && inputs.type !== 8 && inputs.type !== 22 && ( inputs.type !== 8 &&
<Form.Field> inputs.type !== 22 && (
<Form.Input <Form.Field>
label='代理' <Form.Input
name='base_url' label='代理'
placeholder={'此项可选,用于通过代理站来进行 API 调用请输入代理站地址格式为https://domain.com'} name='base_url'
onChange={handleInputChange} placeholder={
value={inputs.base_url} '此项可选,用于通过代理站来进行 API 调用请输入代理站地址格式为https://domain.com'
autoComplete='new-password' }
/> onChange={handleInputChange}
</Form.Field> value={inputs.base_url}
) autoComplete='new-password'
} />
{ </Form.Field>
inputs.type === 22 && ( )}
{inputs.type === 22 && (
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='私有部署地址' label='私有部署地址'
name='base_url' name='base_url'
placeholder={'请输入私有部署地址格式为https://fastgpt.run/api/openapi'} placeholder={
'请输入私有部署地址格式为https://fastgpt.run/api/openapi'
}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.base_url} value={inputs.base_url}
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
) )}
} <Button onClick={handleCancel}>取消</Button>
<Button onClick={handleCancel}>取消</Button> <Button
<Button type={isEdit ? 'button' : 'submit'} positive onClick={submit}>提交</Button> type={isEdit ? 'button' : 'submit'}
</Form> positive
</Segment> onClick={submit}
</> >
提交
</Button>
</Form>
</Card.Content>
</Card>
</div>
); );
}; };

View File

@@ -1,14 +1,16 @@
import React from 'react'; import React from 'react';
import { Header, Segment } from 'semantic-ui-react'; import { Card } from 'semantic-ui-react';
import ChannelsTable from '../../components/ChannelsTable'; import ChannelsTable from '../../components/ChannelsTable';
const Channel = () => ( const Channel = () => (
<> <div className='dashboard-container'>
<Segment> <Card fluid className='chart-card'>
<Header as='h3'>管理渠道</Header> <Card.Content>
<ChannelsTable /> <Card.Header className='header'>管理渠道</Card.Header>
</Segment> <ChannelsTable />
</> </Card.Content>
</Card>
</div>
); );
export default Channel; export default Channel;

View File

@@ -0,0 +1,109 @@
.dashboard-container {
padding: 20px 24px 40px;
background-color: #ffffff;
margin-top: -15px; /* 减小与导航栏的间距 */
max-width: 1600px; /* 设置最大宽度 */
margin-left: auto; /* 水平居中 */
margin-right: auto;
}
.stat-card {
background: linear-gradient(135deg, #2185d0 0%, #1678c2 100%) !important;
color: white !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
transition: transform 0.2s ease !important;
margin-bottom: 1rem !important;
}
.stat-card:hover {
transform: translateY(-5px);
}
.stat-card .statistic {
color: white !important;
}
.charts-grid {
margin-bottom: 1rem !important;
}
.charts-grid .column {
padding: 0.5rem !important;
}
.chart-card {
height: 100%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04) !important;
border: none !important;
border-radius: 16px !important;
padding-top: 8px!important;
}
.chart-container {
margin-top: 2px;
padding: 16px;
background-color: white;
border-radius: 12px;
}
.ui.card > .content > .header {
color: #2B3674;
font-size: 1.2em;
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
gap: 12px; /* 增加标题和数值之间的间距 */
}
.stat-value {
color: #4318FF;
font-weight: bold;
font-size: 1.1em;
background: rgba(67, 24, 255, 0.1);
padding: 4px 12px;
border-radius: 8px;
white-space: nowrap; /* 防止数值换行 */
margin-left: 16px;
}
/* 优化图表响应式布局 */
@media (max-width: 768px) {
.dashboard-container {
padding: 10px 16px; /* 移动端也相应减小内边距 */
max-width: 100%; /* 移动端占满全宽 */
}
.chart-container {
padding: 12px;
}
.charts-grid .column {
padding: 0.25rem !important;
}
}
/* 设置页面的 Tab 样式 */
.settings-tab {
margin-top: 1rem !important;
border-bottom: none !important;
}
.settings-tab .item {
color: #2B3674 !important;
font-weight: 500 !important;
padding: 0.8rem 1.2rem !important;
}
.settings-tab .active.item {
color: #4318FF !important;
font-weight: 600 !important;
border-color: #4318FF !important;
}
.ui.tab.segment {
border: none !important;
box-shadow: none !important;
padding: 1rem 0 !important;
}

View File

@@ -0,0 +1,378 @@
import React, { useEffect, useState } from 'react';
import { Card, Grid, Statistic } from 'semantic-ui-react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
Legend,
} from 'recharts';
import axios from 'axios';
import './Dashboard.css';
// 在 Dashboard 组件内添加自定义配置
const chartConfig = {
lineChart: {
style: {
background: '#fff',
borderRadius: '8px',
},
line: {
strokeWidth: 2,
dot: false,
activeDot: { r: 4 },
},
grid: {
vertical: false,
horizontal: true,
opacity: 0.1,
},
},
colors: {
requests: '#4318FF',
quota: '#00B5D8',
tokens: '#6C63FF',
},
barColors: [
'#4318FF', // 深紫色
'#00B5D8', // 青色
'#6C63FF', // 紫色
'#05CD99', // 绿色
'#FFB547', // 橙色
'#FF5E7D', // 粉色
'#41B883', // 翠绿
'#7983FF', // 淡紫
'#FF8F6B', // 珊瑚色
'#49BEFF', // 天蓝
],
};
const Dashboard = () => {
const [data, setData] = useState([]);
const [summaryData, setSummaryData] = useState({
todayRequests: 0,
todayQuota: 0,
todayTokens: 0,
});
useEffect(() => {
fetchDashboardData();
}, []);
const fetchDashboardData = async () => {
try {
const response = await axios.get('/api/user/dashboard');
if (response.data.success) {
const dashboardData = response.data.data;
setData(dashboardData);
calculateSummary(dashboardData);
}
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
}
};
const calculateSummary = (dashboardData) => {
const today = new Date().toISOString().split('T')[0];
const todayData = dashboardData.filter((item) => item.Day === today);
const summary = {
todayRequests: todayData.reduce(
(sum, item) => sum + item.RequestCount,
0
),
todayQuota:
todayData.reduce((sum, item) => sum + item.Quota, 0) / 1000000, // 转换为美元
todayTokens: todayData.reduce(
(sum, item) => sum + item.PromptTokens + item.CompletionTokens,
0
),
};
setSummaryData(summary);
};
// 处理数据以供折线图使用,补充缺失的日期
const processTimeSeriesData = () => {
const dailyData = {};
// 获取日期范围
const dates = data.map((item) => item.Day);
const minDate = new Date(Math.min(...dates.map((d) => new Date(d))));
const maxDate = new Date(Math.max(...dates.map((d) => new Date(d))));
// 生成所有日期
for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
dailyData[dateStr] = {
date: dateStr,
requests: 0,
quota: 0,
tokens: 0,
};
}
// 填充实际数据
data.forEach((item) => {
dailyData[item.Day].requests += item.RequestCount;
dailyData[item.Day].quota += item.Quota / 1000000;
dailyData[item.Day].tokens += item.PromptTokens + item.CompletionTokens;
});
return Object.values(dailyData).sort((a, b) =>
a.date.localeCompare(b.date)
);
};
// 处理数据以供堆叠柱状图使用
const processModelData = () => {
const timeData = {};
// 获取日期范围
const dates = data.map((item) => item.Day);
const minDate = new Date(Math.min(...dates.map((d) => new Date(d))));
const maxDate = new Date(Math.max(...dates.map((d) => new Date(d))));
// 生成所有日期
for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
timeData[dateStr] = {
date: dateStr,
};
// 初始化所有模型的数据为0
const models = [...new Set(data.map((item) => item.ModelName))];
models.forEach((model) => {
timeData[dateStr][model] = 0;
});
}
// 填充实际数据
data.forEach((item) => {
timeData[item.Day][item.ModelName] =
item.PromptTokens + item.CompletionTokens;
});
return Object.values(timeData).sort((a, b) => a.date.localeCompare(b.date));
};
// 获取所有唯一的模型名称
const getUniqueModels = () => {
return [...new Set(data.map((item) => item.ModelName))];
};
const timeSeriesData = processTimeSeriesData();
const modelData = processModelData();
const models = getUniqueModels();
// 生成随机颜色
const getRandomColor = (index) => {
return chartConfig.barColors[index % chartConfig.barColors.length];
};
return (
<div className='dashboard-container'>
{/* 三个并排的折线图 */}
<Grid columns={3} stackable className='charts-grid'>
<Grid.Column>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
模型请求趋势
<span className='stat-value'>{summaryData.todayRequests}</span>
</Card.Header>
<div className='chart-container'>
<ResponsiveContainer width='100%' height={120}>
<LineChart data={timeSeriesData}>
<CartesianGrid
strokeDasharray='3 3'
vertical={chartConfig.lineChart.grid.vertical}
horizontal={chartConfig.lineChart.grid.horizontal}
opacity={chartConfig.lineChart.grid.opacity}
/>
<XAxis
dataKey='date'
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#A3AED0' }}
/>
<YAxis hide={true} />
<Tooltip
contentStyle={{
background: '#fff',
border: 'none',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
/>
<Line
type='monotone'
dataKey='requests'
stroke={chartConfig.colors.requests}
strokeWidth={chartConfig.lineChart.line.strokeWidth}
dot={chartConfig.lineChart.line.dot}
activeDot={chartConfig.lineChart.line.activeDot}
/>
</LineChart>
</ResponsiveContainer>
</div>
</Card.Content>
</Card>
</Grid.Column>
<Grid.Column>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
额度消费趋势
<span className='stat-value'>
${summaryData.todayQuota.toFixed(3)}
</span>
</Card.Header>
<div className='chart-container'>
<ResponsiveContainer width='100%' height={120}>
<LineChart data={timeSeriesData}>
<CartesianGrid
strokeDasharray='3 3'
vertical={chartConfig.lineChart.grid.vertical}
horizontal={chartConfig.lineChart.grid.horizontal}
opacity={chartConfig.lineChart.grid.opacity}
/>
<XAxis
dataKey='date'
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#A3AED0' }}
/>
<YAxis hide={true} />
<Tooltip
contentStyle={{
background: '#fff',
border: 'none',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
/>
<Line
type='monotone'
dataKey='quota'
stroke={chartConfig.colors.quota}
strokeWidth={chartConfig.lineChart.line.strokeWidth}
dot={chartConfig.lineChart.line.dot}
activeDot={chartConfig.lineChart.line.activeDot}
/>
</LineChart>
</ResponsiveContainer>
</div>
</Card.Content>
</Card>
</Grid.Column>
<Grid.Column>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
Token 消费趋势
<span className='stat-value'>{summaryData.todayTokens}</span>
</Card.Header>
<div className='chart-container'>
<ResponsiveContainer width='100%' height={120}>
<LineChart data={timeSeriesData}>
<CartesianGrid
strokeDasharray='3 3'
vertical={chartConfig.lineChart.grid.vertical}
horizontal={chartConfig.lineChart.grid.horizontal}
opacity={chartConfig.lineChart.grid.opacity}
/>
<XAxis
dataKey='date'
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#A3AED0' }}
/>
<YAxis hide={true} />
<Tooltip
contentStyle={{
background: '#fff',
border: 'none',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
/>
<Line
type='monotone'
dataKey='tokens'
stroke={chartConfig.colors.tokens}
strokeWidth={chartConfig.lineChart.line.strokeWidth}
dot={chartConfig.lineChart.line.dot}
activeDot={chartConfig.lineChart.line.activeDot}
/>
</LineChart>
</ResponsiveContainer>
</div>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
{/* 模型使用统计 */}
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>统计</Card.Header>
<div className='chart-container'>
<ResponsiveContainer width='100%' height={300}>
<BarChart data={modelData}>
<CartesianGrid
strokeDasharray='3 3'
vertical={false}
opacity={0.1}
/>
<XAxis
dataKey='date'
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#A3AED0' }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#A3AED0' }}
/>
<Tooltip
contentStyle={{
background: '#fff',
border: 'none',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
/>
<Legend
wrapperStyle={{
paddingTop: '20px',
}}
/>
{models.map((model, index) => (
<Bar
key={model}
dataKey={model}
stackId='a'
fill={getRandomColor(index)}
name={model}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</Card.Content>
</Card>
</div>
);
};
export default Dashboard;

View File

@@ -3,22 +3,25 @@ import { Card, Grid, Header, Segment } from 'semantic-ui-react';
import { API, showError, showNotice, timestamp2string } from '../../helpers'; import { API, showError, showNotice, timestamp2string } from '../../helpers';
import { StatusContext } from '../../context/Status'; import { StatusContext } from '../../context/Status';
import { marked } from 'marked'; import { marked } from 'marked';
import { UserContext } from '../../context/User';
import { Link } from 'react-router-dom';
const Home = () => { const Home = () => {
const [statusState, statusDispatch] = useContext(StatusContext); const [statusState, statusDispatch] = useContext(StatusContext);
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false); const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
const [homePageContent, setHomePageContent] = useState(''); const [homePageContent, setHomePageContent] = useState('');
const [userState] = useContext(UserContext);
const displayNotice = async () => { const displayNotice = async () => {
const res = await API.get('/api/notice'); const res = await API.get('/api/notice');
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
let oldNotice = localStorage.getItem('notice'); let oldNotice = localStorage.getItem('notice');
if (data !== oldNotice && data !== '') { if (data !== oldNotice && data !== '') {
const htmlNotice = marked(data); const htmlNotice = marked(data);
showNotice(htmlNotice, true); showNotice(htmlNotice, true);
localStorage.setItem('notice', data); localStorage.setItem('notice', data);
} }
} else { } else {
showError(message); showError(message);
} }
@@ -51,82 +54,236 @@ const Home = () => {
displayNotice().then(); displayNotice().then();
displayHomePageContent().then(); displayHomePageContent().then();
}, []); }, []);
return ( return (
<> <div className='dashboard-container'>
{ <Card fluid className='chart-card'>
homePageContentLoaded && homePageContent === '' ? <> <Card.Content>
<Segment> <Card.Header className='header'>欢迎使用 One API</Card.Header>
<Header as='h3'>系统状况</Header> <Card.Description style={{ lineHeight: '1.6' }}>
<p>
One API 是一个 LLM API
接口管理和分发系统可以帮助您更好地管理和使用各大厂商的 LLM API
</p>
{!userState.user && (
<p>
如需使用请先<Link to='/login'>登录</Link>
<Link to='/register'>注册</Link>
</p>
)}
</Card.Description>
</Card.Content>
</Card>
{homePageContentLoaded && homePageContent === '' ? (
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
<Header as='h3'>系统状况</Header>
</Card.Header>
<Grid columns={2} stackable> <Grid columns={2} stackable>
<Grid.Column> <Grid.Column>
<Card fluid> <Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
<Card.Content> <Card.Content>
<Card.Header>系统信息</Card.Header> <Card.Header>
<Card.Meta>系统信息总览</Card.Meta> <Header as='h3' style={{ color: '#444' }}>
<Card.Description> 系统信息
<p>名称{statusState?.status?.system_name}</p> </Header>
<p>版本{statusState?.status?.version ? statusState?.status?.version : "unknown"}</p> </Card.Header>
<p> <Card.Description
源码 style={{ lineHeight: '2', marginTop: '1em' }}
>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='info circle icon'></i>
<span style={{ fontWeight: 'bold' }}>名称</span>
<span>{statusState?.status?.system_name}</span>
</p>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='code branch icon'></i>
<span style={{ fontWeight: 'bold' }}>版本</span>
<span>{statusState?.status?.version || 'unknown'}</span>
</p>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='github icon'></i>
<span style={{ fontWeight: 'bold' }}>源码</span>
<a <a
href='https://github.com/songquanpeng/one-api' href='https://github.com/songquanpeng/one-api'
target='_blank' target='_blank'
style={{ color: '#2185d0' }}
> >
https://github.com/songquanpeng/one-api GitHub 仓库
</a> </a>
</p> </p>
<p>启动时间{getStartTimeString()}</p> <p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='clock outline icon'></i>
<span style={{ fontWeight: 'bold' }}>启动时间</span>
<span>{getStartTimeString()}</span>
</p>
</Card.Description> </Card.Description>
</Card.Content> </Card.Content>
</Card> </Card>
</Grid.Column> </Grid.Column>
<Grid.Column> <Grid.Column>
<Card fluid> <Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
<Card.Content> <Card.Content>
<Card.Header>系统配置</Card.Header> <Card.Header>
<Card.Meta>系统配置总览</Card.Meta> <Header as='h3' style={{ color: '#444' }}>
<Card.Description> 系统配置
<p> </Header>
邮箱验证 </Card.Header>
{statusState?.status?.email_verification === true <Card.Description
? '已启用' style={{ lineHeight: '2', marginTop: '1em' }}
: '未启用'} >
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='envelope icon'></i>
<span style={{ fontWeight: 'bold' }}>邮箱验证</span>
<span
style={{
color: statusState?.status?.email_verification
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.email_verification
? '已启用'
: '未启用'}
</span>
</p> </p>
<p> <p
GitHub 身份验证 style={{
{statusState?.status?.github_oauth === true display: 'flex',
? '已启用' alignItems: 'center',
: '未启用'} gap: '0.5em',
}}
>
<i className='github icon'></i>
<span style={{ fontWeight: 'bold' }}>
GitHub 身份验证
</span>
<span
style={{
color: statusState?.status?.github_oauth
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.github_oauth
? '已启用'
: '未启用'}
</span>
</p> </p>
<p> <p
微信身份验证 style={{
{statusState?.status?.wechat_login === true display: 'flex',
? '已启用' alignItems: 'center',
: '未启用'} gap: '0.5em',
}}
>
<i className='wechat icon'></i>
<span style={{ fontWeight: 'bold' }}>
微信身份验证
</span>
<span
style={{
color: statusState?.status?.wechat_login
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.wechat_login
? '已启用'
: '未启用'}
</span>
</p> </p>
<p> <p
Turnstile 用户校验 style={{
{statusState?.status?.turnstile_check === true display: 'flex',
? '已启用' alignItems: 'center',
: '未启用'} gap: '0.5em',
}}
>
<i className='shield alternate icon'></i>
<span style={{ fontWeight: 'bold' }}>
Turnstile 校验
</span>
<span
style={{
color: statusState?.status?.turnstile_check
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.turnstile_check
? '已启用'
: '未启用'}
</span>
</p> </p>
</Card.Description> </Card.Description>
</Card.Content> </Card.Content>
</Card> </Card>
</Grid.Column> </Grid.Column>
</Grid> </Grid>
</Segment> </Card.Content>
</> : <> </Card>
{ ) : (
homePageContent.startsWith('https://') ? <iframe <>
{homePageContent.startsWith('https://') ? (
<iframe
src={homePageContent} src={homePageContent}
style={{ width: '100%', height: '100vh', border: 'none' }} style={{ width: '100%', height: '100vh', border: 'none' }}
/> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: homePageContent }}></div> />
} ) : (
<div
style={{ fontSize: 'larger' }}
dangerouslySetInnerHTML={{ __html: homePageContent }}
></div>
)}
</> </>
} )}
</div>
</>
); );
}; };

View File

@@ -1,11 +1,16 @@
import React from 'react'; import React from 'react';
import { Header, Segment } from 'semantic-ui-react'; import { Card } from 'semantic-ui-react';
import LogsTable from '../../components/LogsTable'; import LogsTable from '../../components/LogsTable';
const Token = () => ( const Log = () => (
<> <div className='dashboard-container'>
<LogsTable /> <Card fluid className='chart-card'>
</> <Card.Content>
{/*<Card.Header className='header'>操作日志</Card.Header>*/}
<LogsTable />
</Card.Content>
</Card>
</div>
); );
export default Token; export default Log;

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, Card } from 'semantic-ui-react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { API, downloadTextAsFile, showError, showSuccess } from '../../helpers'; import { API, downloadTextAsFile, showError, showSuccess } from '../../helpers';
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render'; import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
@@ -13,7 +13,7 @@ const EditRedemption = () => {
const originInputs = { const originInputs = {
name: '', name: '',
quota: 100000, quota: 100000,
count: 1 count: 1,
}; };
const [inputs, setInputs] = useState(originInputs); const [inputs, setInputs] = useState(originInputs);
const { name, quota, count } = inputs; const { name, quota, count } = inputs;
@@ -21,7 +21,7 @@ const EditRedemption = () => {
const handleCancel = () => { const handleCancel = () => {
navigate('/redemption'); navigate('/redemption');
}; };
const handleInputChange = (e, { name, value }) => { const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
}; };
@@ -49,10 +49,13 @@ const EditRedemption = () => {
localInputs.quota = parseInt(localInputs.quota); localInputs.quota = parseInt(localInputs.quota);
let res; let res;
if (isEdit) { if (isEdit) {
res = await API.put(`/api/redemption/`, { ...localInputs, id: parseInt(redemptionId) }); res = await API.put(`/api/redemption/`, {
...localInputs,
id: parseInt(redemptionId),
});
} else { } else {
res = await API.post(`/api/redemption/`, { res = await API.post(`/api/redemption/`, {
...localInputs ...localInputs,
}); });
} }
const { success, message, data } = res.data; const { success, message, data } = res.data;
@@ -67,61 +70,67 @@ const EditRedemption = () => {
showError(message); showError(message);
} }
if (!isEdit && data) { if (!isEdit && data) {
let text = ""; let text = '';
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
text += data[i] + "\n"; text += data[i] + '\n';
} }
downloadTextAsFile(text, `${inputs.name}.txt`); downloadTextAsFile(text, `${inputs.name}.txt`);
} }
}; };
return ( return (
<> <div className='dashboard-container'>
<Segment loading={loading}> <Card fluid className='chart-card'>
<Header as='h3'>{isEdit ? '更新兑换码信息' : '创建新的兑换码'}</Header> <Card.Content>
<Form autoComplete='new-password'> <Card.Header className='header'>
<Form.Field> {isEdit ? '更新兑换码信息' : '创建新的兑换码'}
<Form.Input </Card.Header>
label='名称' <Form loading={loading} autoComplete='new-password'>
name='name' <Form.Field>
placeholder={'请输入名称'} <Form.Input
onChange={handleInputChange} label='名称'
value={name} name='name'
autoComplete='new-password' placeholder={'请输入名称'}
required={!isEdit} onChange={handleInputChange}
/> value={name}
</Form.Field> autoComplete='new-password'
<Form.Field> required={!isEdit}
<Form.Input />
label={`额度${renderQuotaWithPrompt(quota)}`} </Form.Field>
name='quota' <Form.Field>
placeholder={'请输入单个兑换码中包含的额度'} <Form.Input
onChange={handleInputChange} label={`额度${renderQuotaWithPrompt(quota)}`}
value={quota} name='quota'
autoComplete='new-password' placeholder={'请输入单个兑换码中包含的额度'}
type='number' onChange={handleInputChange}
/> value={quota}
</Form.Field> autoComplete='new-password'
{ type='number'
!isEdit && <> />
<Form.Field> </Form.Field>
<Form.Input {!isEdit && (
label='生成数量' <>
name='count' <Form.Field>
placeholder={'请输入生成数量'} <Form.Input
onChange={handleInputChange} label='生成数量'
value={count} name='count'
autoComplete='new-password' placeholder={'请输入生成数量'}
type='number' onChange={handleInputChange}
/> value={count}
</Form.Field> autoComplete='new-password'
</> type='number'
} />
<Button positive onClick={submit}>提交</Button> </Form.Field>
<Button onClick={handleCancel}>取消</Button> </>
</Form> )}
</Segment> <Button positive onClick={submit}>
</> 提交
</Button>
<Button onClick={handleCancel}>取消</Button>
</Form>
</Card.Content>
</Card>
</div>
); );
}; };

View File

@@ -1,14 +1,16 @@
import React from 'react'; import React from 'react';
import { Segment, Header } from 'semantic-ui-react'; import { Card } from 'semantic-ui-react';
import RedemptionsTable from '../../components/RedemptionsTable'; import RedemptionsTable from '../../components/RedemptionsTable';
const Redemption = () => ( const Redemption = () => (
<> <div className='dashboard-container'>
<Segment> <Card fluid className='chart-card'>
<Header as='h3'>管理兑换码</Header> <Card.Content>
<RedemptionsTable/> <Card.Header className='header'>兑换管理</Card.Header>
</Segment> <RedemptionsTable />
</> </Card.Content>
</Card>
</div>
); );
export default Redemption; export default Redemption;

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Segment, Tab } from 'semantic-ui-react'; import { Card, Tab } from 'semantic-ui-react';
import SystemSetting from '../../components/SystemSetting'; import SystemSetting from '../../components/SystemSetting';
import { isRoot } from '../../helpers'; import { isRoot } from '../../helpers';
import OtherSetting from '../../components/OtherSetting'; import OtherSetting from '../../components/OtherSetting';
@@ -14,8 +14,8 @@ const Setting = () => {
<Tab.Pane attached={false}> <Tab.Pane attached={false}>
<PersonalSetting /> <PersonalSetting />
</Tab.Pane> </Tab.Pane>
) ),
} },
]; ];
if (isRoot()) { if (isRoot()) {
@@ -25,7 +25,7 @@ const Setting = () => {
<Tab.Pane attached={false}> <Tab.Pane attached={false}>
<OperationSetting /> <OperationSetting />
</Tab.Pane> </Tab.Pane>
) ),
}); });
panes.push({ panes.push({
menuItem: '系统设置', menuItem: '系统设置',
@@ -33,7 +33,7 @@ const Setting = () => {
<Tab.Pane attached={false}> <Tab.Pane attached={false}>
<SystemSetting /> <SystemSetting />
</Tab.Pane> </Tab.Pane>
) ),
}); });
panes.push({ panes.push({
menuItem: '其他设置', menuItem: '其他设置',
@@ -41,14 +41,26 @@ const Setting = () => {
<Tab.Pane attached={false}> <Tab.Pane attached={false}>
<OtherSetting /> <OtherSetting />
</Tab.Pane> </Tab.Pane>
) ),
}); });
} }
return ( return (
<Segment> <div className='dashboard-container'>
<Tab menu={{ secondary: true, pointing: true }} panes={panes} /> <Card fluid className='chart-card'>
</Segment> <Card.Content>
<Card.Header className='header'>系统设置</Card.Header>
<Tab
menu={{
secondary: true,
pointing: true,
className: 'settings-tab', // 添加自定义类名以便样式化
}}
panes={panes}
/>
</Card.Content>
</Card>
</div>
); );
}; };

View File

@@ -1,7 +1,20 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Message, Segment } from 'semantic-ui-react'; import {
Button,
Form,
Header,
Message,
Segment,
Card,
} from 'semantic-ui-react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { API, copy, showError, showSuccess, timestamp2string } from '../../helpers'; import {
API,
copy,
showError,
showSuccess,
timestamp2string,
} from '../../helpers';
import { renderQuotaWithPrompt } from '../../helpers/render'; import { renderQuotaWithPrompt } from '../../helpers/render';
const EditToken = () => { const EditToken = () => {
@@ -16,7 +29,7 @@ const EditToken = () => {
expired_time: -1, expired_time: -1,
unlimited_quota: false, unlimited_quota: false,
models: [], models: [],
subnet: "", subnet: '',
}; };
const [inputs, setInputs] = useState(originInputs); const [inputs, setInputs] = useState(originInputs);
const { name, remain_quota, expired_time, unlimited_quota } = inputs; const { name, remain_quota, expired_time, unlimited_quota } = inputs;
@@ -79,7 +92,7 @@ const EditToken = () => {
return { return {
key: model, key: model,
text: model, text: model,
value: model value: model,
}; };
}); });
setModelOptions(options); setModelOptions(options);
@@ -103,7 +116,10 @@ const EditToken = () => {
localInputs.models = localInputs.models.join(','); localInputs.models = localInputs.models.join(',');
let res; let res;
if (isEdit) { if (isEdit) {
res = await API.put(`/api/token/`, { ...localInputs, id: parseInt(tokenId) }); res = await API.put(`/api/token/`, {
...localInputs,
id: parseInt(tokenId),
});
} else { } else {
res = await API.post(`/api/token/`, localInputs); res = await API.post(`/api/token/`, localInputs);
} }
@@ -121,98 +137,142 @@ const EditToken = () => {
}; };
return ( return (
<> <div className='dashboard-container'>
<Segment loading={loading}> <Card fluid className='chart-card'>
<Header as='h3'>{isEdit ? '更新令牌信息' : '创建新的令牌'}</Header> <Card.Content>
<Form autoComplete='new-password'> <Card.Header className='header'>
<Form.Field> {isEdit ? '更新令牌信息' : '创建新的令牌'}
<Form.Input </Card.Header>
label='名称' <Form loading={loading} autoComplete='new-password'>
name='name' <Form.Field>
placeholder={'请输入名称'} <Form.Input
onChange={handleInputChange} label='名称'
value={name} name='name'
autoComplete='new-password' placeholder={'请输入名称'}
required={!isEdit} onChange={handleInputChange}
/> value={name}
</Form.Field> autoComplete='new-password'
<Form.Field> required={!isEdit}
<Form.Dropdown />
label='模型范围' </Form.Field>
placeholder={'请选择允许使用的模型,留空则不进行限制'} <Form.Field>
name='models' <Form.Dropdown
fluid label='模型范围'
multiple placeholder={'请选择允许使用的模型,留空则不进行限制'}
search name='models'
onLabelClick={(e, { value }) => { fluid
copy(value).then(); multiple
search
onLabelClick={(e, { value }) => {
copy(value).then();
}}
selection
onChange={handleInputChange}
value={inputs.models}
autoComplete='new-password'
options={modelOptions}
/>
</Form.Field>
<Form.Field>
<Form.Input
label='IP 限制'
name='subnet'
placeholder={
'请输入允许访问的网段例如192.168.0.0/24请使用英文逗号分隔多个网段'
}
onChange={handleInputChange}
value={inputs.subnet}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.Input
label='过期时间'
name='expired_time'
placeholder={
'请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss-1 表示无限制'
}
onChange={handleInputChange}
value={expired_time}
autoComplete='new-password'
type='datetime-local'
/>
</Form.Field>
<div style={{ lineHeight: '40px' }}>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 0, 0, 0);
}}
>
永不过期
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(1, 0, 0, 0);
}}
>
一个月后过期
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 1, 0, 0);
}}
>
一天后过期
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 0, 1, 0);
}}
>
一小时后过期
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 0, 0, 1);
}}
>
一分钟后过期
</Button>
</div>
<Message>
注意令牌的额度仅用于限制令牌本身的最大额度使用量实际的使用受到账户的剩余额度限制
</Message>
<Form.Field>
<Form.Input
label={`额度${renderQuotaWithPrompt(remain_quota)}`}
name='remain_quota'
placeholder={'请输入额度'}
onChange={handleInputChange}
value={remain_quota}
autoComplete='new-password'
type='number'
disabled={unlimited_quota}
/>
</Form.Field>
<Button
type={'button'}
onClick={() => {
setUnlimitedQuota();
}} }}
selection >
onChange={handleInputChange} {unlimited_quota ? '取消无限额度' : '设为无限额度'}
value={inputs.models} </Button>
autoComplete='new-password' <Button floated='right' positive onClick={submit}>
options={modelOptions} 提交
/> </Button>
</Form.Field> <Button floated='right' onClick={handleCancel}>
<Form.Field> 取消
<Form.Input </Button>
label='IP 限制' </Form>
name='subnet' </Card.Content>
placeholder={'请输入允许访问的网段例如192.168.0.0/24请使用英文逗号分隔多个网段'} </Card>
onChange={handleInputChange} </div>
value={inputs.subnet}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.Input
label='过期时间'
name='expired_time'
placeholder={'请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss-1 表示无限制'}
onChange={handleInputChange}
value={expired_time}
autoComplete='new-password'
type='datetime-local'
/>
</Form.Field>
<div style={{ lineHeight: '40px' }}>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 0, 0, 0);
}}>永不过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(1, 0, 0, 0);
}}>一个月后过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 1, 0, 0);
}}>一天后过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 0, 1, 0);
}}>一小时后过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 0, 0, 1);
}}>一分钟后过期</Button>
</div>
<Message>注意令牌的额度仅用于限制令牌本身的最大额度使用量实际的使用受到账户的剩余额度限制</Message>
<Form.Field>
<Form.Input
label={`额度${renderQuotaWithPrompt(remain_quota)}`}
name='remain_quota'
placeholder={'请输入额度'}
onChange={handleInputChange}
value={remain_quota}
autoComplete='new-password'
type='number'
disabled={unlimited_quota}
/>
</Form.Field>
<Button type={'button'} onClick={() => {
setUnlimitedQuota();
}}>{unlimited_quota ? '取消无限额度' : '设为无限额度'}</Button>
<Button floated='right' positive onClick={submit}>提交</Button>
<Button floated='right' onClick={handleCancel}>取消</Button>
</Form>
</Segment>
</>
); );
}; };

View File

@@ -1,14 +1,16 @@
import React from 'react'; import React from 'react';
import { Segment, Header } from 'semantic-ui-react'; import { Card } from 'semantic-ui-react';
import TokensTable from '../../components/TokensTable'; import TokensTable from '../../components/TokensTable';
const Token = () => ( const Token = () => (
<> <div className='dashboard-container'>
<Segment> <Card fluid className='chart-card'>
<Header as='h3'>我的令牌</Header> <Card.Content>
<TokensTable/> <Card.Header className='header'>令牌管理</Card.Header>
</Segment> <TokensTable />
</> </Card.Content>
</Card>
</div>
); );
export default Token; export default Token;

View File

@@ -1,5 +1,13 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Segment, Statistic } from 'semantic-ui-react'; import {
Button,
Form,
Grid,
Header,
Card,
Statistic,
Divider,
} from 'semantic-ui-react';
import { API, showError, showInfo, showSuccess } from '../../helpers'; import { API, showError, showInfo, showSuccess } from '../../helpers';
import { renderQuota } from '../../helpers/render'; import { renderQuota } from '../../helpers/render';
@@ -12,13 +20,13 @@ const TopUp = () => {
const topUp = async () => { const topUp = async () => {
if (redemptionCode === '') { if (redemptionCode === '') {
showInfo('请输入充值码!') showInfo('请输入兑换码!');
return; return;
} }
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const res = await API.post('/api/user/topup', { const res = await API.post('/api/user/topup', {
key: redemptionCode key: redemptionCode,
}); });
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
@@ -33,7 +41,7 @@ const TopUp = () => {
} catch (err) { } catch (err) {
showError('请求失败'); showError('请求失败');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
@@ -45,23 +53,23 @@ const TopUp = () => {
let url = new URL(topUpLink); let url = new URL(topUpLink);
let username = user.username; let username = user.username;
let user_id = user.id; let user_id = user.id;
// add username and user_id to the topup link // add username and user_id to the topup link
url.searchParams.append('username', username); url.searchParams.append('username', username);
url.searchParams.append('user_id', user_id); url.searchParams.append('user_id', user_id);
url.searchParams.append('transaction_id', crypto.randomUUID()); url.searchParams.append('transaction_id', crypto.randomUUID());
window.open(url.toString(), '_blank'); window.open(url.toString(), '_blank');
}; };
const getUserQuota = async ()=>{ const getUserQuota = async () => {
let res = await API.get(`/api/user/self`); let res = await API.get(`/api/user/self`);
const {success, message, data} = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
setUserQuota(data.quota); setUserQuota(data.quota);
setUser(data); setUser(data);
} else { } else {
showError(message); showError(message);
} }
} };
useEffect(() => { useEffect(() => {
let status = localStorage.getItem('status'); let status = localStorage.getItem('status');
@@ -75,38 +83,166 @@ const TopUp = () => {
}, []); }, []);
return ( return (
<Segment> <div className='dashboard-container'>
<Header as='h3'>充值额度</Header> <Card fluid className='chart-card'>
<Grid columns={2} stackable> <Card.Content>
<Grid.Column> <Card.Header>
<Form> <Header as='h2'>充值中心</Header>
<Form.Input </Card.Header>
placeholder='兑换码'
name='redemptionCode' <Grid columns={2} stackable>
value={redemptionCode} <Grid.Column>
onChange={(e) => { <Card
setRedemptionCode(e.target.value); fluid
}} style={{
/> height: '100%',
<Button color='green' onClick={openTopUpLink}> boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
充值 }}
</Button> >
<Button color='yellow' onClick={topUp} disabled={isSubmitting}> <Card.Content
{isSubmitting ? '兑换中...' : '兑换'} style={{
</Button> height: '100%',
</Form> display: 'flex',
</Grid.Column> flexDirection: 'column',
<Grid.Column> }}
<Statistic.Group widths='one'> >
<Statistic> <Card.Header>
<Statistic.Value>{renderQuota(userQuota)}</Statistic.Value> <Header as='h3' style={{ color: '#2185d0', margin: '1em' }}>
<Statistic.Label>剩余额度</Statistic.Label> <i className='credit card icon'></i>
</Statistic> 获取兑换码
</Statistic.Group> </Header>
</Grid.Column> </Card.Header>
</Grid> <Card.Description
</Segment> style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}}
>
<div style={{ textAlign: 'center', paddingTop: '1em' }}>
<Statistic>
<Statistic.Value style={{ color: '#2185d0' }}>
{renderQuota(userQuota)}
</Statistic.Value>
<Statistic.Label>当前可用额度</Statistic.Label>
</Statistic>
</div>
<div
style={{ textAlign: 'center', paddingBottom: '1em' }}
>
<Button
primary
size='large'
onClick={openTopUpLink}
style={{ width: '80%' }}
>
立即获取兑换码
</Button>
</div>
</div>
</Card.Description>
</Card.Content>
</Card>
</Grid.Column>
<Grid.Column>
<Card
fluid
style={{
height: '100%',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
<Card.Content
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<Card.Header>
<Header as='h3' style={{ color: '#21ba45', margin: '1em' }}>
<i className='ticket alternate icon'></i>
兑换码充值
</Header>
</Card.Header>
<Card.Description
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}}
>
<Form.Input
fluid
icon='key'
iconPosition='left'
placeholder='请输入兑换码'
value={redemptionCode}
onChange={(e) => {
setRedemptionCode(e.target.value);
}}
onPaste={(e) => {
e.preventDefault();
const pastedText = e.clipboardData.getData('text');
setRedemptionCode(pastedText.trim());
}}
action={
<Button
icon='paste'
content='粘贴'
onClick={async () => {
try {
const text =
await navigator.clipboard.readText();
setRedemptionCode(text.trim());
} catch (err) {
showError('无法访问剪贴板,请手动粘贴');
}
}}
/>
}
/>
<div style={{ paddingBottom: '1em' }}>
<Button
color='green'
fluid
size='large'
onClick={topUp}
loading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting ? '兑换中...' : '立即兑换'}
</Button>
</div>
</div>
</Card.Description>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
</Card.Content>
</Card>
</div>
); );
}; };
export default TopUp; export default TopUp;

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, Card } from 'semantic-ui-react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { API, showError, showSuccess } from '../../helpers'; import { API, showError, showSuccess } from '../../helpers';
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render'; import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
@@ -16,30 +16,40 @@ const EditUser = () => {
wechat_id: '', wechat_id: '',
email: '', email: '',
quota: 0, quota: 0,
group: 'default' group: 'default',
}); });
const [groupOptions, setGroupOptions] = useState([]); const [groupOptions, setGroupOptions] = useState([]);
const { username, display_name, password, github_id, wechat_id, email, quota, group } = const {
inputs; username,
display_name,
password,
github_id,
wechat_id,
email,
quota,
group,
} = inputs;
const handleInputChange = (e, { name, value }) => { const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
}; };
const fetchGroups = async () => { const fetchGroups = async () => {
try { try {
let res = await API.get(`/api/group/`); let res = await API.get(`/api/group/`);
setGroupOptions(res.data.data.map((group) => ({ setGroupOptions(
key: group, res.data.data.map((group) => ({
text: group, key: group,
value: group, text: group,
}))); value: group,
}))
);
} catch (error) { } catch (error) {
showError(error.message); showError(error.message);
} }
}; };
const navigate = useNavigate(); const navigate = useNavigate();
const handleCancel = () => { const handleCancel = () => {
navigate("/setting"); navigate('/setting');
} };
const loadUser = async () => { const loadUser = async () => {
let res = undefined; let res = undefined;
if (userId) { if (userId) {
@@ -83,107 +93,113 @@ const EditUser = () => {
}; };
return ( return (
<> <div className='dashboard-container'>
<Segment loading={loading}> <Card fluid className='chart-card'>
<Header as='h3'>更新用户信息</Header> <Card.Content>
<Form autoComplete='new-password'> <Card.Header className='header'>更新用户信息</Card.Header>
<Form.Field> <Form loading={loading} autoComplete='new-password'>
<Form.Input <Form.Field>
label='用户名' <Form.Input
name='username' label='用户名'
placeholder={'请输入新的用户名'} name='username'
onChange={handleInputChange} placeholder={'请输入新的用户名'}
value={username} onChange={handleInputChange}
autoComplete='new-password' value={username}
/> autoComplete='new-password'
</Form.Field> />
<Form.Field> </Form.Field>
<Form.Input <Form.Field>
label='密码' <Form.Input
name='password' label='密码'
type={'password'} name='password'
placeholder={'请输入新的密码,最短 8 位'} type={'password'}
onChange={handleInputChange} placeholder={'请输入新的密码,最短 8 位'}
value={password} onChange={handleInputChange}
autoComplete='new-password' value={password}
/> autoComplete='new-password'
</Form.Field> />
<Form.Field> </Form.Field>
<Form.Input <Form.Field>
label='显示名称' <Form.Input
name='display_name' label='显示名称'
placeholder={'请输入新的显示名称'} name='display_name'
onChange={handleInputChange} placeholder={'请输入新的显示名称'}
value={display_name} onChange={handleInputChange}
autoComplete='new-password' value={display_name}
/> autoComplete='new-password'
</Form.Field> />
{ </Form.Field>
userId && <> {userId && (
<Form.Field> <>
<Form.Dropdown <Form.Field>
label='分组' <Form.Dropdown
placeholder={'请选择分组'} label='分组'
name='group' placeholder={'请选择分组'}
fluid name='group'
search fluid
selection search
allowAdditions selection
additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'} allowAdditions
onChange={handleInputChange} additionLabel={
value={inputs.group} '请在系统设置页面编辑分组倍率以添加新的分组:'
autoComplete='new-password' }
options={groupOptions} onChange={handleInputChange}
/> value={inputs.group}
</Form.Field> autoComplete='new-password'
<Form.Field> options={groupOptions}
<Form.Input />
label={`剩余额度${renderQuotaWithPrompt(quota)}`} </Form.Field>
name='quota' <Form.Field>
placeholder={'请输入新的剩余额度'} <Form.Input
onChange={handleInputChange} label={`剩余额度${renderQuotaWithPrompt(quota)}`}
value={quota} name='quota'
type={'number'} placeholder={'请输入新的剩余额度'}
autoComplete='new-password' onChange={handleInputChange}
/> value={quota}
</Form.Field> type={'number'}
</> autoComplete='new-password'
} />
<Form.Field> </Form.Field>
<Form.Input </>
label='已绑定的 GitHub 账户' )}
name='github_id' <Form.Field>
value={github_id} <Form.Input
autoComplete='new-password' label='已绑定的 GitHub 账户'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' name='github_id'
readOnly value={github_id}
/> autoComplete='new-password'
</Form.Field> placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
<Form.Field> readOnly
<Form.Input />
label='已绑定的微信账户' </Form.Field>
name='wechat_id' <Form.Field>
value={wechat_id} <Form.Input
autoComplete='new-password' label='已绑定的微信账户'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' name='wechat_id'
readOnly value={wechat_id}
/> autoComplete='new-password'
</Form.Field> placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
<Form.Field> readOnly
<Form.Input />
label='已绑定的邮箱账户' </Form.Field>
name='email' <Form.Field>
value={email} <Form.Input
autoComplete='new-password' label='已绑定的邮箱账户'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' name='email'
readOnly value={email}
/> autoComplete='new-password'
</Form.Field> placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
<Button onClick={handleCancel}>取消</Button> readOnly
<Button positive onClick={submit}>提交</Button> />
</Form> </Form.Field>
</Segment> <Button onClick={handleCancel}>取消</Button>
</> <Button positive onClick={submit}>
提交
</Button>
</Form>
</Card.Content>
</Card>
</div>
); );
}; };

View File

@@ -1,14 +1,16 @@
import React from 'react'; import React from 'react';
import { Segment, Header } from 'semantic-ui-react'; import { Card } from 'semantic-ui-react';
import UsersTable from '../../components/UsersTable'; import UsersTable from '../../components/UsersTable';
const User = () => ( const User = () => (
<> <div className='dashboard-container'>
<Segment> <Card fluid className='chart-card'>
<Header as='h3'>管理用户</Header> <Card.Content>
<UsersTable/> <Card.Header className='header'>用户管理</Card.Header>
</Segment> <UsersTable />
</> </Card.Content>
</Card>
</div>
); );
export default User; export default User;