Merge branch 'upstream/main'

This commit is contained in:
Laisky.Cai 2025-02-01 13:17:28 +00:00
commit acd9cc0db5
49 changed files with 3875 additions and 1600 deletions

69
.github/workflows/docker-image.yml vendored Normal file
View File

@ -0,0 +1,69 @@
name: Publish Docker image
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
name:
description: 'reason'
required: false
jobs:
push_to_registries:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Check repository URL
run: |
REPO_URL=$(git config --get remote.origin.url)
if [[ $REPO_URL == *"pro" ]]; then
exit 1
fi
- name: Save version info
run: |
git describe --tags > VERSION
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
justsong/one-api
ghcr.io/${{ github.repository }}
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
context: .
# platforms: linux/amd64,linux/arm64
platforms: linux/amd64 # TODO disable arm64 for now, because it cause error
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@ -4,44 +4,48 @@ WORKDIR /web
COPY ./VERSION .
COPY ./web .
WORKDIR /web/default
RUN npm install
RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat ../VERSION) npm run build
RUN npm install --prefix /web/default & \
npm install --prefix /web/berry & \
npm install --prefix /web/air & \
wait
WORKDIR /web/berry
RUN npm install
RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat ../VERSION) npm run build
WORKDIR /web/air
RUN npm install
RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat ../VERSION) npm run build
RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat /web/default/VERSION) npm run build --prefix /web/default & \
DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat /web/berry/VERSION) npm run build --prefix /web/berry & \
DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat /web/air/VERSION) npm run build --prefix /web/air & \
wait
FROM golang:1.23.5-bullseye AS builder2
RUN apt-get update
RUN apt-get install -y --no-install-recommends g++ make gcc git build-essential ca-certificates \
&& update-ca-certificates 2>/dev/null || true \
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
sqlite3 libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
ENV GO111MODULE=on \
CGO_ENABLED=1 \
GOOS=linux
GOOS=linux \
CGO_CFLAGS="-I/usr/include" \
CGO_LDFLAGS="-L/usr/lib"
WORKDIR /build
ADD go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=builder /web/build ./web/build
RUN go build -trimpath -ldflags "-s -w -X 'github.com/songquanpeng/one-api/common.Version=$(cat VERSION)' -extldflags '-static'" -o one-api
RUN go build -trimpath -ldflags "-s -w -X 'github.com/songquanpeng/one-api/common.Version=$(cat VERSION)'" -o one-api
# Final runtime image
FROM debian:bullseye
RUN apt-get update
RUN apt-get install -y --no-install-recommends ca-certificates haveged tzdata ffmpeg \
&& update-ca-certificates 2>/dev/null || true \
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata bash haveged ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder2 /build/one-api /
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/one-api"]

View File

@ -178,3 +178,4 @@ var UserContentRequestTimeout = env.Int("USER_CONTENT_REQUEST_TIMEOUT", 30)
// EnforceIncludeUsage is used to determine whether to include usage in the response
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

@ -14,3 +14,8 @@ func GetTimeString() string {
now := time.Now()
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

@ -2,23 +2,9 @@ package controller
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
"github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/ctxkey"
"github.com/songquanpeng/one-api/common/logger"
"github.com/songquanpeng/one-api/common/message"
"github.com/songquanpeng/one-api/middleware"
"github.com/songquanpeng/one-api/model"
"github.com/songquanpeng/one-api/monitor"
relay "github.com/songquanpeng/one-api/relay"
"github.com/songquanpeng/one-api/relay/channeltype"
"github.com/songquanpeng/one-api/relay/controller"
"github.com/songquanpeng/one-api/relay/meta"
relaymodel "github.com/songquanpeng/one-api/relay/model"
"github.com/songquanpeng/one-api/relay/relaymode"
"io"
"net/http"
"net/http/httptest"
@ -27,6 +13,24 @@ import (
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
"github.com/songquanpeng/one-api/common/config"
"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/message"
"github.com/songquanpeng/one-api/middleware"
"github.com/songquanpeng/one-api/model"
"github.com/songquanpeng/one-api/monitor"
"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/controller"
"github.com/songquanpeng/one-api/relay/meta"
relaymodel "github.com/songquanpeng/one-api/relay/model"
"github.com/songquanpeng/one-api/relay/relaymode"
)
func buildTestRequest(model string) *relaymodel.GeneralOpenAIRequest {
@ -34,18 +38,34 @@ func buildTestRequest(model string) *relaymodel.GeneralOpenAIRequest {
model = "gpt-3.5-turbo"
}
testRequest := &relaymodel.GeneralOpenAIRequest{
MaxTokens: 2,
Model: model,
}
testMessage := relaymodel.Message{
Role: "user",
Content: "hi",
Content: config.TestPrompt,
}
testRequest.Messages = append(testRequest.Messages, testMessage)
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()
c, _ := gin.CreateTestContext(w)
c.Request = &http.Request{
@ -65,7 +85,7 @@ func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIReques
apiType := channeltype.ToAPIType(channel.Type)
adaptor := relay.GetAdaptor(apiType)
if adaptor == nil {
return errors.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)
modelName := request.Model
@ -83,41 +103,69 @@ func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIReques
request.Model = modelName
convertedRequest, err := adaptor.ConvertRequest(c, relaymode.ChatCompletions, request)
if err != nil {
return err, nil
return "", err, nil
}
jsonData, err := json.Marshal(convertedRequest)
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))
requestBody := bytes.NewBuffer(jsonData)
c.Request.Body = io.NopCloser(requestBody)
resp, err := adaptor.DoRequest(c, meta, requestBody)
if err != nil {
return err, nil
return "", err, nil
}
if resp != nil && resp.StatusCode != http.StatusOK {
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)
if respErr != nil {
return errors.Errorf("%s", respErr.Error.Message), &respErr.Error
return "", fmt.Errorf("%s", respErr.Error.Message), &respErr.Error
}
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()
// print result.Body
respBody, err := io.ReadAll(result.Body)
if err != nil {
return err, nil
return "", err, nil
}
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) {
ctx := c.Request.Context()
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
@ -134,10 +182,10 @@ func TestChannel(c *gin.Context) {
})
return
}
model := c.Query("model")
testRequest := buildTestRequest(model)
modelName := c.Query("model")
testRequest := buildTestRequest(modelName)
tik := time.Now()
err, _ = testChannel(channel, testRequest)
responseMessage, err, _ := testChannel(ctx, channel, testRequest)
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
if err != nil {
@ -150,15 +198,15 @@ func TestChannel(c *gin.Context) {
"success": false,
"message": err.Error(),
"time": consumedTime,
"model": model,
"modelName": modelName,
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"message": responseMessage,
"time": consumedTime,
"model": model,
"modelName": modelName,
})
return
}
@ -166,7 +214,7 @@ func TestChannel(c *gin.Context) {
var testAllChannelsLock sync.Mutex
var testAllChannelsRunning bool = false
func testChannels(notify bool, scope string) error {
func testChannels(ctx context.Context, notify bool, scope string) error {
if config.RootUserEmail == "" {
config.RootUserEmail = model.GetRootUserEmail()
}
@ -190,7 +238,7 @@ func testChannels(notify bool, scope string) error {
isChannelEnabled := channel.Status == model.ChannelStatusEnabled
tik := time.Now()
testRequest := buildTestRequest("")
err, openaiErr := testChannel(channel, testRequest)
_, err, openaiErr := testChannel(ctx, channel, testRequest)
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
if isChannelEnabled && milliseconds > disableThreshold {
@ -224,11 +272,12 @@ func testChannels(notify bool, scope string) error {
}
func TestChannels(c *gin.Context) {
ctx := c.Request.Context()
scope := c.Query("scope")
if scope == "" {
scope = "all"
}
err := testChannels(true, scope)
err := testChannels(ctx, true, scope)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@ -244,10 +293,11 @@ func TestChannels(c *gin.Context) {
}
func AutomaticallyTestChannels(frequency int) {
ctx := context.Background()
for {
time.Sleep(time.Duration(frequency) * time.Minute)
logger.SysLog("testing all channels")
_ = testChannels(false, "all")
_ = testChannels(ctx, false, "all")
logger.SysLog("channel test finished")
}
}

View File

@ -287,11 +287,14 @@ func ConsumeToken(c *gin.Context) {
return
}
model.RecordConsumeLog(c.Request.Context(),
userID, 0, 0, 0, tokenPatch.AddReason, cleanToken.Name,
int64(tokenPatch.AddUsedQuota),
fmt.Sprintf("External (%s) consumed %s",
tokenPatch.AddReason, common.LogQuota(int64(tokenPatch.AddUsedQuota))))
model.RecordConsumeLog(c.Request.Context(), &model.Log{
UserId: userID,
ModelName: tokenPatch.AddReason,
TokenName: cleanToken.Name,
Quota: int(tokenPatch.AddUsedQuota),
Content: fmt.Sprintf("External (%s) consumed %s",
tokenPatch.AddReason, common.LogQuota(int64(tokenPatch.AddUsedQuota))),
})
err = cleanToken.Update()
if err != nil {

3
go.mod
View File

@ -1,6 +1,5 @@
module github.com/songquanpeng/one-api
// +heroku goVersion go1.18
go 1.23
toolchain go1.23.0
@ -93,7 +92,7 @@ require (
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect

4
go.sum
View File

@ -167,8 +167,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=

View File

@ -25,7 +25,10 @@ type Log struct {
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
ChannelId int `json:"channel" gorm:"index"`
RequestId string `json:"request_id"`
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 (
@ -34,6 +37,7 @@ const (
LogTypeConsume
LogTypeManage
LogTypeSystem
LogTypeTest
)
func recordLogHelper(ctx context.Context, log *Log) {
@ -73,23 +77,19 @@ func RecordTopupLog(ctx context.Context, userId int, content string, quota int)
recordLogHelper(ctx, log)
}
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) {
if !config.LogConsumeEnabled {
return
}
log := &Log{
UserId: userId,
Username: GetUsernameById(userId),
CreatedAt: helper.GetTimestamp(),
Type: LogTypeConsume,
Content: content,
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TokenName: tokenName,
ModelName: modelName,
Quota: int(quota),
ChannelId: channelId,
log.Username = GetUsernameById(log.UserId)
log.CreatedAt = helper.GetTimestamp()
log.Type = LogTypeConsume
recordLogHelper(ctx, log)
}
func RecordTestLog(ctx context.Context, log *Log) {
log.CreatedAt = helper.GetTimestamp()
log.Type = LogTypeTest
recordLogHelper(ctx, log)
}

View File

@ -94,7 +94,7 @@ func GetUserById(id int, selectAll bool) (*User, error) {
if selectAll {
err = DB.First(&user, "id = ?", id).Error
} else {
err = DB.Omit("password").First(&user, "id = ?", id).Error
err = DB.Omit("password", "access_token").First(&user, "id = ?", id).Error
}
return &user, err
}

View File

@ -33,7 +33,16 @@ func PostConsumeQuota(ctx context.Context, tokenId int, quotaDelta int64, totalQ
// totalQuota is total quota consumed
if totalQuota != 0 {
logContent := fmt.Sprintf("model rate %.2f, group rate %.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.UpdateChannelUsedQuota(channelId, totalQuota)
}

View File

@ -12,6 +12,7 @@ import (
"github.com/songquanpeng/one-api/common"
"github.com/songquanpeng/one-api/common/config"
"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/model"
"github.com/songquanpeng/one-api/relay/adaptor/openai"
@ -123,12 +124,20 @@ func postConsumeQuota(ctx context.Context, usage *relaymodel.Usage, meta *meta.M
if err != nil {
logger.Error(ctx, "error update user quota cache: "+err.Error())
}
var extraLog string
if systemPromptReset {
extraLog = " (Note: System prompt has been reset)"
}
logContent := fmt.Sprintf("model rate %.2f, group rate %.2f, completion rate %.2f%s", modelRatio, groupRatio, completionRatio, extraLog)
model.RecordConsumeLog(ctx, meta.UserId, meta.ChannelId, promptTokens, completionTokens, textRequest.Model, meta.TokenName, quota, logContent)
logContent := fmt.Sprintf("model rate %.2f, group rate %.2f, completion rate %.2f", modelRatio, groupRatio, completionRatio)
model.RecordConsumeLog(ctx, &model.Log{
UserId: meta.UserId,
ChannelId: meta.ChannelId,
PromptTokens: promptTokens,
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.UpdateChannelUsedQuota(meta.ChannelId, quota)

View File

@ -227,7 +227,16 @@ func RelayImageHelper(c *gin.Context, relayMode int) *relaymodel.ErrorWithStatus
if quota >= 0 {
tokenName := c.GetString(ctxkey.TokenName)
logContent := fmt.Sprintf("model rate %.2f, group rate %.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)
channelId := c.GetInt(ctxkey.ChannelId)
model.UpdateChannelUsedQuota(channelId, quota)

View File

@ -2,6 +2,7 @@ package meta
import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common/ctxkey"
@ -33,6 +34,7 @@ type Meta struct {
PromptTokens int // only for DoResponse
ChannelRatio float64
SystemPrompt string
StartTime time.Time
}
// GetMappedModelName returns the mapped model name and a bool indicating if the model name is mapped
@ -70,6 +72,7 @@ func GetByContext(c *gin.Context) *Meta {
RequestURLPath: c.Request.URL.String(),
ChannelRatio: c.GetFloat64(ctxkey.ChannelRatio), // add by Laisky
SystemPrompt: c.GetString(ctxkey.SystemPrompt),
StartTime: time.Now(),
}
cfg, ok := c.Get(ctxkey.Config)
if ok {

View File

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

View File

@ -3,7 +3,8 @@ const LOG_TYPE = {
1: { value: '1', text: '充值', color: 'primary' },
2: { value: '2', text: '消费', color: 'orange' },
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;

View File

@ -5,16 +5,22 @@
"dependencies": {
"axios": "^0.27.2",
"history": "^5.3.0",
"i18next": "23.2.3",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.2",
"marked": "^4.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-i18next": "^13.0.0",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"react-toastify": "^9.0.8",
"react-turnstile": "^1.0.5",
"recharts": "^2.15.1",
"semantic-ui-css": "^2.5.0",
"semantic-ui-react": "^2.1.3"
"semantic-ui-react": "^2.1.3",
"typescript": "4.9.5"
},
"scripts": {
"start": "react-scripts start",

View File

@ -0,0 +1,156 @@
{
"header": {
"home": "Home",
"channel": "Channel",
"token": "Token",
"redemption": "Redemption",
"topup": "Top Up",
"user": "User",
"dashboard": "Dashboard",
"log": "Log",
"setting": "Settings",
"about": "About",
"chat": "Chat",
"login": "Login",
"logout": "Logout",
"register": "Register"
},
"topup": {
"title": "Top Up Center",
"get_code": {
"title": "Get Redemption Code",
"current_quota": "Current Available Quota",
"button": "Get Code Now"
},
"redeem_code": {
"title": "Redeem Code",
"placeholder": "Please enter redemption code",
"paste": "Paste",
"paste_error": "Cannot access clipboard, please paste manually",
"submit": "Redeem Now",
"submitting": "Redeeming...",
"empty_code": "Please enter the redemption code!",
"success": "Top up successful!",
"request_failed": "Request failed",
"no_link": "Admin has not set up the top-up link!"
}
},
"channel": {
"title": "Channel Management",
"search": "Search channels by ID, name and key...",
"balance_notice": "OpenAI channels no longer support getting balance via key, so balance shows as 0. For supported channel types, click balance to refresh.",
"test_notice": "Channel testing only supports chat models, preferring gpt-3.5-turbo. If unavailable, uses the first model in your configured list.",
"detail_notice": "Click the detail button below to show balance and set additional test models.",
"table": {
"id": "ID",
"name": "Name",
"group": "Group",
"type": "Type",
"status": "Status",
"response_time": "Response Time",
"balance": "Balance",
"priority": "Priority",
"test_model": "Test Model",
"actions": "Actions",
"no_name": "None",
"status_enabled": "Enabled",
"status_disabled": "Disabled",
"status_auto_disabled": "Disabled",
"status_disabled_tip": "This channel is manually disabled",
"status_auto_disabled_tip": "This channel is automatically disabled",
"status_unknown": "Unknown Status",
"not_tested": "Not Tested",
"priority_tip": "Channel selection priority, higher is preferred",
"select_test_model": "Please select test model",
"click_to_update": "Click to update"
},
"buttons": {
"test": "Test",
"delete": "Delete",
"confirm_delete": "Delete Channel",
"enable": "Enable",
"disable": "Disable",
"edit": "Edit",
"add": "Add New Channel",
"test_all": "Test All Channels",
"test_disabled": "Test Disabled Channels",
"delete_disabled": "Delete Disabled Channels",
"confirm_delete_disabled": "Confirm Delete",
"refresh": "Refresh",
"show_detail": "Details",
"hide_detail": "Hide Details"
},
"messages": {
"test_success": "Channel ${name} test successful, model ${model}, time ${time}s, output: ${message}",
"test_all_started": "Channel testing started successfully, please refresh page to see results.",
"delete_disabled_success": "Deleted all disabled channels, total: ${count}",
"balance_update_success": "Channel ${name} balance updated successfully!",
"all_balance_updated": "All enabled channel balances have been updated!"
},
"edit": {
"title_edit": "Update Channel Information",
"title_create": "Create New Channel",
"type": "Type",
"name": "Name",
"name_placeholder": "Please enter name",
"group": "Group",
"group_placeholder": "Please select groups that can use this channel",
"group_addition": "Please edit group multipliers in system settings to add new group:",
"models": "Models",
"models_placeholder": "Please select models supported by this channel",
"model_mapping": "Model Mapping",
"model_mapping_placeholder": "Optional, used to modify model names in request body. A JSON string where keys are request model names and values are target model names",
"system_prompt": "System Prompt",
"system_prompt_placeholder": "Optional, used to force set system prompt. Use with custom model & model mapping. First create a unique custom model name above, then map it to a natively supported model",
"base_url": "Proxy",
"base_url_placeholder": "Optional, used for API calls through proxy. Enter proxy address in format: https://domain.com",
"key": "Key",
"key_placeholder": "Please enter key",
"batch": "Batch Create",
"batch_placeholder": "Please enter keys, one per line",
"buttons": {
"cancel": "Cancel",
"submit": "Submit",
"fill_models": "Fill Related Models",
"fill_all": "Fill All Models",
"clear": "Clear All Models",
"add_custom": "Add",
"custom_placeholder": "Enter custom model name"
},
"messages": {
"name_required": "Please enter channel name and key!",
"models_required": "Please select at least one model!",
"model_mapping_invalid": "Model mapping must be valid JSON format!",
"update_success": "Channel updated successfully!",
"create_success": "Channel created successfully!"
},
"spark_version": "Model Version",
"spark_version_placeholder": "Please enter Spark model version from API URL, e.g.: v2.1",
"knowledge_id": "Knowledge Base ID",
"knowledge_id_placeholder": "Please enter knowledge base ID, e.g.: 123456",
"plugin_param": "Plugin Parameter",
"plugin_param_placeholder": "Please enter plugin parameter (X-DashScope-Plugin header value)",
"coze_notice": "For Coze, model name is the Bot ID. You can add prefix `bot-`, e.g.: `bot-123456`.",
"douban_notice": "For Douban, you need to go to",
"douban_notice_link": "Model Inference Page",
"douban_notice_2": "to create an inference endpoint, and use the endpoint name as model name, e.g.: `ep-20240608051426-tkxvl`.",
"aws_region_placeholder": "region, e.g.: us-west-2",
"aws_ak_placeholder": "AWS IAM Access Key",
"aws_sk_placeholder": "AWS IAM Secret Key",
"vertex_region_placeholder": "Vertex AI Region, e.g.: us-east5",
"vertex_project_id": "Vertex AI Project ID",
"vertex_project_id_placeholder": "Vertex AI Project ID",
"vertex_credentials": "Google Cloud Application Default Credentials JSON",
"vertex_credentials_placeholder": "Google Cloud Application Default Credentials JSON",
"user_id": "User ID",
"user_id_placeholder": "User ID who generated this key",
"key_prompts": {
"default": "Please enter the authentication key for this channel",
"zhipu": "Enter in format: APIKey|SecretKey",
"spark": "Enter in format: APPID|APISecret|APIKey",
"fastgpt": "Enter in format: APIKey-AppId, e.g.: fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041",
"tencent": "Enter in format: AppId|SecretId|SecretKey"
}
}
}
}

View File

@ -0,0 +1,156 @@
{
"header": {
"home": "首页",
"channel": "渠道",
"token": "令牌",
"redemption": "兑换",
"topup": "充值",
"user": "用户",
"dashboard": "总览",
"log": "日志",
"setting": "设置",
"about": "关于",
"chat": "聊天",
"login": "登录",
"logout": "注销",
"register": "注册"
},
"topup": {
"title": "充值中心",
"get_code": {
"title": "获取兑换码",
"current_quota": "当前可用额度",
"button": "立即获取兑换码"
},
"redeem_code": {
"title": "兑换码充值",
"placeholder": "请输入兑换码",
"paste": "粘贴",
"paste_error": "无法访问剪贴板,请手动粘贴",
"submit": "立即兑换",
"submitting": "兑换中...",
"empty_code": "请输入兑换码!",
"success": "充值成功!",
"request_failed": "请求失败",
"no_link": "超级管理员未设置充值链接!"
}
},
"channel": {
"title": "管理渠道",
"search": "搜索渠道的 ID名称和密钥 ...",
"balance_notice": "OpenAI 渠道已经不再支持通过 key 获取余额,因此余额显示为 0。对于支持的渠道类型请点击余额进行刷新。",
"test_notice": "渠道测试仅支持 chat 模型,优先使用 gpt-3.5-turbo如果该模型不可用则使用你所配置的模型列表中的第一个模型。",
"detail_notice": "点击下方详情按钮可以显示余额以及设置额外的测试模型。",
"table": {
"id": "ID",
"name": "名称",
"group": "分组",
"type": "类型",
"status": "状态",
"response_time": "响应时间",
"balance": "余额",
"priority": "优先级",
"test_model": "测试模型",
"actions": "操作",
"no_name": "无",
"status_enabled": "已启用",
"status_disabled": "已禁用",
"status_auto_disabled": "已禁用",
"status_disabled_tip": "本渠道被手动禁用",
"status_auto_disabled_tip": "本渠道被程序自动禁用",
"status_unknown": "未知状态",
"not_tested": "未测试",
"priority_tip": "渠道选择优先级,越高越优先",
"select_test_model": "请选择测试模型",
"click_to_update": "点击更新"
},
"buttons": {
"test": "测试",
"delete": "删除",
"confirm_delete": "删除渠道",
"enable": "启用",
"disable": "禁用",
"edit": "编辑",
"add": "添加新的渠道",
"test_all": "测试所有渠道",
"test_disabled": "测试禁用渠道",
"delete_disabled": "删除禁用渠道",
"confirm_delete_disabled": "确认删除",
"refresh": "刷新",
"show_detail": "详情",
"hide_detail": "隐藏详情"
},
"messages": {
"test_success": "渠道 ${name} 测试成功,模型 ${model},耗时 ${time} 秒,模型输出:${message}",
"test_all_started": "已成功开始测试渠道,请刷新页面查看结果。",
"delete_disabled_success": "已删除所有禁用渠道,共计 ${count} 个",
"balance_update_success": "渠道 ${name} 余额更新成功!",
"all_balance_updated": "已更新完毕所有已启用渠道余额!"
},
"edit": {
"title_edit": "更新渠道信息",
"title_create": "创建新的渠道",
"type": "类型",
"name": "名称",
"name_placeholder": "请输入名称",
"group": "分组",
"group_placeholder": "请选择可以使用该渠道的分组",
"group_addition": "请在系统设置页面编辑分组倍率以添加新的分组:",
"models": "模型",
"models_placeholder": "请选择该渠道所支持的模型",
"model_mapping": "模型重定向",
"model_mapping_placeholder": "此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称",
"system_prompt": "系统提示词",
"system_prompt_placeholder": "此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型",
"base_url": "代理",
"base_url_placeholder": "此项可选,用于通过代理站来进行 API 调用请输入代理站地址格式为https://domain.com",
"key": "密钥",
"key_placeholder": "请输入密钥",
"batch": "批量创建",
"batch_placeholder": "请输入密钥,一行一个",
"buttons": {
"cancel": "取消",
"submit": "提交",
"fill_models": "填入相关模型",
"fill_all": "填入所有模型",
"clear": "清除所有模型",
"add_custom": "填入",
"custom_placeholder": "输入自定义模型名称"
},
"messages": {
"name_required": "请填写渠道名称和渠道密钥!",
"models_required": "请至少选择一个模型!",
"model_mapping_invalid": "模型映射必须是合法的 JSON 格式!",
"update_success": "渠道更新成功!",
"create_success": "渠道创建成功!"
},
"spark_version": "模型版本",
"spark_version_placeholder": "请输入星火大模型版本注意是接口地址中的版本号例如v2.1",
"knowledge_id": "知识库 ID",
"knowledge_id_placeholder": "请输入知识库 ID例如123456",
"plugin_param": "插件参数",
"plugin_param_placeholder": "请输入插件参数,即 X-DashScope-Plugin 请求头的取值",
"coze_notice": "对于 Coze 而言,模型名称即 Bot ID你可以添加一个前缀 `bot-`,例如:`bot-123456`。",
"douban_notice": "对于豆包而言,需要手动去",
"douban_notice_link": "模型推理页面",
"douban_notice_2": "创建推理接入点,以接入点名称作为模型名称,例如:`ep-20240608051426-tkxvl`。",
"aws_region_placeholder": "region例如us-west-2",
"aws_ak_placeholder": "AWS IAM Access Key",
"aws_sk_placeholder": "AWS IAM Secret Key",
"vertex_region_placeholder": "Vertex AI Region例如us-east5",
"vertex_project_id": "Vertex AI Project ID",
"vertex_project_id_placeholder": "Vertex AI Project ID",
"vertex_credentials": "Google Cloud Application Default Credentials JSON",
"vertex_credentials_placeholder": "Google Cloud Application Default Credentials JSON",
"user_id": "User ID",
"user_id_placeholder": "生成该密钥的用户 ID",
"key_prompts": {
"default": "请输入渠道对应的鉴权密钥",
"zhipu": "按照如下格式输入APIKey|SecretKey",
"spark": "按照如下格式输入APPID|APISecret|APIKey",
"fastgpt": "按照如下格式输入APIKey-AppId例如fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041",
"tencent": "按照如下格式输入AppId|SecretId|SecretKey"
}
}
}
}

View File

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

View File

@ -1,5 +1,16 @@
import React, { useEffect, useState } from 'react';
import { Button, Dropdown, Form, Input, Label, Message, Pagination, Popup, Table } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import {
Button,
Dropdown,
Form,
Input,
Label,
Message,
Pagination,
Popup,
Table,
} from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import {
API,
@ -9,34 +20,38 @@ import {
showError,
showInfo,
showSuccess,
timestamp2string
timestamp2string,
} from '../helpers';
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
import { renderGroup, renderNumber } from '../helpers/render';
function renderTimestamp(timestamp) {
return (
<>
{timestamp2string(timestamp)}
</>
);
return <>{timestamp2string(timestamp)}</>;
}
let type2label = undefined;
function renderType(type) {
function renderType(type, t) {
if (!type2label) {
type2label = new Map;
type2label = new Map();
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
}
type2label[0] = { value: 0, text: 'Unknown type', color: 'grey' };
type2label[0] = {
value: 0,
text: t('channel.table.status_unknown'),
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, t) {
switch (type) {
case 1: // OpenAI
return <span>${balance.toFixed(2)}</span>;
@ -57,17 +72,18 @@ function renderBalance(type, balance) {
case 44: // SiliconFlow
return <span>¥{balance.toFixed(2)}</span>;
default:
return <span>Not supported</span>;
return <span>{t('channel.table.balance_not_supported')}</span>;
}
}
function isShowDetail() {
return localStorage.getItem("show_detail") === "true";
return localStorage.getItem('show_detail') === 'true';
}
const promptID = "detail"
const promptID = 'detail';
const ChannelsTable = () => {
const { t } = useTranslation();
const [channels, setChannels] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
@ -84,7 +100,7 @@ const ChannelsTable = () => {
let localChannels = data.map((channel) => {
if (channel.models === '') {
channel.models = [];
channel.test_model = "";
channel.test_model = '';
} else {
channel.models = channel.models.split(',');
if (channel.models.length > 0) {
@ -95,9 +111,9 @@ const ChannelsTable = () => {
key: model,
text: model,
value: model,
}
})
console.log('channel', channel)
};
});
console.log('channel', channel);
}
return channel;
});
@ -105,7 +121,11 @@ const ChannelsTable = () => {
setChannels(localChannels);
} else {
let newChannels = [...channels];
newChannels.splice(startIdx * ITEMS_PER_PAGE, data.length, ...localChannels);
newChannels.splice(
startIdx * ITEMS_PER_PAGE,
data.length,
...localChannels
);
setChannels(newChannels);
}
} else {
@ -131,8 +151,8 @@ const ChannelsTable = () => {
const toggleShowDetail = () => {
setShowDetail(!showDetail);
localStorage.setItem("show_detail", (!showDetail).toString());
}
localStorage.setItem('show_detail', (!showDetail).toString());
};
useEffect(() => {
loadChannels(0)
@ -193,52 +213,80 @@ const ChannelsTable = () => {
}
};
const renderStatus = (status) => {
const renderStatus = (status, t) => {
switch (status) {
case 1:
return <Label basic color='green'>Enabled</Label>;
return (
<Label basic color='green'>
{t('channel.table.status_enabled')}
</Label>
);
case 2:
return (
<Popup
trigger={<Label basic color='red'>
Disabled
</Label>}
content='This channel has been manually disabled'
trigger={
<Label basic color='red'>
{t('channel.table.status_disabled')}
</Label>
}
content={t('channel.table.status_disabled_tip')}
basic
/>
);
case 3:
return (
<Popup
trigger={<Label basic color='yellow'>
Disabled
</Label>}
content='This channel has been automatically disabled by the program'
trigger={
<Label basic color='yellow'>
{t('channel.table.status_auto_disabled')}
</Label>
}
content={t('channel.table.status_auto_disabled_tip')}
basic
/>
);
default:
return (
<Label basic color='grey'>
Unknown status
{t('channel.table.status_unknown')}
</Label>
);
}
};
const renderResponseTime = (responseTime) => {
const renderResponseTime = (responseTime, t) => {
let time = responseTime / 1000;
time = time.toFixed(2) + 's';
if (responseTime === 0) {
return <Label basic color='grey'>Not tested</Label>;
return (
<Label basic color='grey'>
{t('channel.table.not_tested')}
</Label>
);
} else if (responseTime <= 1000) {
return <Label basic color='green'>{time}</Label>;
return (
<Label basic color='green'>
{time}
</Label>
);
} else if (responseTime <= 3000) {
return <Label basic color='olive'>{time}</Label>;
return (
<Label basic color='olive'>
{time}
</Label>
);
} else if (responseTime <= 5000) {
return <Label basic color='yellow'>{time}</Label>;
return (
<Label basic color='yellow'>
{time}
</Label>
);
} else {
return <Label basic color='red'>{time}</Label>;
return (
<Label basic color='red'>
{time}
</Label>
);
}
};
@ -277,7 +325,14 @@ const ChannelsTable = () => {
newChannels[realIdx].response_time = time * 1000;
newChannels[realIdx].test_time = Date.now() / 1000;
setChannels(newChannels);
showInfo(`Channel ${name} tested successfully with model ${model}, taking ${time.toFixed(2)} seconds.`);
showInfo(
t('channel.messages.test_success', {
name: name,
model: model,
time: time.toFixed(2),
message: message,
})
);
} else {
showError(message);
}
@ -292,7 +347,7 @@ const ChannelsTable = () => {
const res = await API.get(`/api/channel/test?scope=${scope}`);
const { success, message } = res.data;
if (success) {
showInfo('Successfully started testing channels, please refresh the page to see the results.');
showInfo(t('channel.messages.test_all_started'));
} else {
showError(message);
}
@ -302,7 +357,9 @@ const ChannelsTable = () => {
const res = await API.delete(`/api/channel/disabled`);
const { success, message, data } = res.data;
if (success) {
showSuccess(`Successfully deleted all disabled channels, total ${data} channels`);
showSuccess(
t('channel.messages.delete_disabled_success', { count: data })
);
await refresh();
} else {
showError(message);
@ -318,7 +375,7 @@ const ChannelsTable = () => {
newChannels[realIdx].balance = balance;
newChannels[realIdx].balance_updated_time = Date.now() / 1000;
setChannels(newChannels);
showInfo(`Channel ${name} balance updated successfully!`);
showInfo(t('channel.messages.balance_update_success', { name: name }));
} else {
showError(message);
}
@ -329,7 +386,7 @@ const ChannelsTable = () => {
const res = await API.get(`/api/channel/update_balance`);
const { success, message } = res.data;
if (success) {
showInfo('The balance of all enabled channels has been updated!');
showInfo(t('channel.messages.all_balance_updated'));
} else {
showError(message);
}
@ -360,7 +417,6 @@ const ChannelsTable = () => {
setLoading(false);
};
return (
<>
<Form onSubmit={searchChannels}>
@ -368,27 +424,27 @@ const ChannelsTable = () => {
icon='search'
fluid
iconPosition='left'
placeholder='Search for channel ID, name and key ...'
placeholder={t('channel.search')}
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
/>
</Form>
{
showPrompt && (
<Message onDismiss={() => {
{showPrompt && (
<Message
onDismiss={() => {
setShowPrompt(false);
setPromptShown(promptID);
}}>
OpenAI Channel no longer supports getting Balance via key, so Balance is shown as 0. For supported ChannelTypes, please click Balance to Refresh.
}}
>
{t('channel.balance_notice')}
<br />
ChannelTest only supports chat Models, preferring gpt-3.5-turbo. If this Model is not available, it will use the first Model in your configured Model list.
{t('channel.test_notice')}
<br />
Click the Details button below to display Balance and additional TestModel Settings.
{t('channel.detail_notice')}
</Message>
)
}
<Table basic compact size='small'>
)}
<Table basic={'very'} compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
@ -397,7 +453,7 @@ const ChannelsTable = () => {
sortChannel('id');
}}
>
ID
{t('channel.table.id')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@ -405,7 +461,7 @@ const ChannelsTable = () => {
sortChannel('name');
}}
>
Name
{t('channel.table.name')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@ -413,7 +469,7 @@ const ChannelsTable = () => {
sortChannel('group');
}}
>
Group
{t('channel.table.group')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@ -421,7 +477,7 @@ const ChannelsTable = () => {
sortChannel('type');
}}
>
Type
{t('channel.table.type')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@ -429,7 +485,7 @@ const ChannelsTable = () => {
sortChannel('status');
}}
>
Status
{t('channel.table.status')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@ -437,7 +493,7 @@ const ChannelsTable = () => {
sortChannel('response_time');
}}
>
Response time
{t('channel.table.response_time')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@ -446,7 +502,7 @@ const ChannelsTable = () => {
}}
hidden={!showDetail}
>
Balance
{t('channel.table.balance')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@ -454,10 +510,12 @@ const ChannelsTable = () => {
sortChannel('priority');
}}
>
Priority
{t('channel.table.priority')}
</Table.HeaderCell>
<Table.HeaderCell hidden={!showDetail}>TestModel</Table.HeaderCell>
<Table.HeaderCell>Operation</Table.HeaderCell>
<Table.HeaderCell hidden={!showDetail}>
{t('channel.table.test_model')}
</Table.HeaderCell>
<Table.HeaderCell>{t('channel.table.actions')}</Table.HeaderCell>
</Table.Row>
</Table.Header>
@ -472,47 +530,65 @@ const ChannelsTable = () => {
return (
<Table.Row key={channel.id}>
<Table.Cell>{channel.id}</Table.Cell>
<Table.Cell>{channel.name ? channel.name : 'None'}</Table.Cell>
<Table.Cell>
{channel.name ? channel.name : t('channel.table.no_name')}
</Table.Cell>
<Table.Cell>{renderGroup(channel.group)}</Table.Cell>
<Table.Cell>{renderType(channel.type)}</Table.Cell>
<Table.Cell>{renderStatus(channel.status)}</Table.Cell>
<Table.Cell>{renderType(channel.type, t)}</Table.Cell>
<Table.Cell>{renderStatus(channel.status, t)}</Table.Cell>
<Table.Cell>
<Popup
content={channel.test_time ? renderTimestamp(channel.test_time) : 'Not tested'}
content={
channel.test_time
? renderTimestamp(channel.test_time)
: t('channel.table.not_tested')
}
key={channel.id}
trigger={renderResponseTime(channel.response_time)}
trigger={renderResponseTime(channel.response_time, t)}
basic
/>
</Table.Cell>
<Table.Cell hidden={!showDetail}>
<Popup
trigger={<span onClick={() => {
trigger={
<span
onClick={() => {
updateChannelBalance(channel.id, channel.name, idx);
}} style={{ cursor: 'pointer' }}>
{renderBalance(channel.type, channel.balance)}
</span>}
content='Click to refresh'
}}
style={{ cursor: 'pointer' }}
>
{renderBalance(channel.type, channel.balance, t)}
</span>
}
content={t('channel.table.click_to_update')}
basic
/>
</Table.Cell>
<Table.Cell>
<Popup
trigger={<Input type='number' defaultValue={channel.priority} onBlur={(event) => {
trigger={
<Input
type='number'
defaultValue={channel.priority}
onBlur={(event) => {
manageChannel(
channel.id,
'priority',
idx,
event.target.value
);
}}>
}}
>
<input style={{ maxWidth: '60px' }} />
</Input>}
content='Channel priority - higher value means higher priority'
</Input>
}
content={t('channel.table.priority_tip')}
basic
/>
</Table.Cell>
<Table.Cell hidden={!showDetail}>
<Dropdown
placeholder='Please select TestModel'
placeholder={t('channel.table.select_test_model')}
selection
options={channel.model_options}
defaultValue={channel.test_model}
@ -527,25 +603,20 @@ const ChannelsTable = () => {
size={'small'}
positive
onClick={() => {
testChannel(channel.id, channel.name, idx, channel.test_model);
testChannel(
channel.id,
channel.name,
idx,
channel.test_model
);
}}
>
Test
{t('channel.buttons.test')}
</Button>
{/*<Button*/}
{/* size={'small'}*/}
{/* positive*/}
{/* loading={updatingBalance}*/}
{/* onClick={() => {*/}
{/* updateChannelBalance(channel.id, channel.name, idx);*/}
{/* }}*/}
{/*>*/}
{/* Update balance*/}
{/*</Button>*/}
<Popup
trigger={
<Button size='small' negative>
Delete
{t('channel.buttons.delete')}
</Button>
}
on='click'
@ -558,7 +629,7 @@ const ChannelsTable = () => {
manageChannel(channel.id, 'delete', idx);
}}
>
Delete channel {channel.name}
{t('channel.buttons.confirm_delete')} {channel.name}
</Button>
</Popup>
<Button
@ -571,14 +642,16 @@ const ChannelsTable = () => {
);
}}
>
{channel.status === 1 ? 'Disable' : 'Enable'}
{channel.status === 1
? t('channel.buttons.disable')
: t('channel.buttons.enable')}
</Button>
<Button
size={'small'}
as={Link}
to={'/channel/edit/' + channel.id}
>
Edit
{t('channel.buttons.edit')}
</Button>
</div>
</Table.Cell>
@ -589,30 +662,50 @@ const ChannelsTable = () => {
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan={showDetail ? "10" : "8"}>
<Button size='small' as={Link} to='/channel/add' loading={loading}>
Add a new channel
<Table.HeaderCell colSpan={showDetail ? '10' : '8'}>
<Button
size='small'
as={Link}
to='/channel/add'
loading={loading}
>
{t('channel.buttons.add')}
</Button>
<Button size='small' loading={loading} onClick={()=>{testChannels("all")}}>
Test all channels
<Button
size='small'
loading={loading}
onClick={() => {
testChannels('all');
}}
>
{t('channel.buttons.test_all')}
</Button>
<Button size='small' loading={loading} onClick={()=>{testChannels("disabled")}}>
Test disabled channels
<Button
size='small'
loading={loading}
onClick={() => {
testChannels('disabled');
}}
>
{t('channel.buttons.test_disabled')}
</Button>
{/*<Button size='small' onClick={updateAllChannelsBalance}*/}
{/* loading={loading || updatingBalance}>Update the balance of enabled channels</Button>*/}
<Popup
trigger={
<Button size='small' loading={loading}>
Delete disabled channels
{t('channel.buttons.delete_disabled')}
</Button>
}
on='click'
flowing
hoverable
>
<Button size='small' loading={loading} negative onClick={deleteAllDisabledChannels}>
Confirm deletion
<Button
size='small'
loading={loading}
negative
onClick={deleteAllDisabledChannels}
>
{t('channel.buttons.confirm_delete_disabled')}
</Button>
</Popup>
<Pagination
@ -626,8 +719,14 @@ const ChannelsTable = () => {
(channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
}
/>
<Button size='small' onClick={refresh} loading={loading}>Refresh</Button>
<Button size='small' onClick={toggleShowDetail}>{showDetail ? "Hide Details" : "Details"}</Button>
<Button size='small' onClick={refresh} loading={loading}>
{t('channel.buttons.refresh')}
</Button>
<Button size='small' onClick={toggleShowDetail}>
{showDetail
? t('channel.buttons.hide_detail')
: t('channel.buttons.show_detail')}
</Button>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>

View File

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

View File

@ -1,72 +1,93 @@
import React, { useContext, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { UserContext } from '../context/User';
import { useTranslation } from 'react-i18next';
import { Button, Container, Dropdown, Icon, Menu, Segment } from 'semantic-ui-react';
import { API, getLogo, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers';
import {
Button,
Container,
Dropdown,
Icon,
Menu,
Segment,
} from 'semantic-ui-react';
import {
API,
getLogo,
getSystemName,
isAdmin,
isMobile,
showSuccess,
} from '../helpers';
import '../index.css';
// Header Buttons
let headerButtons = [
{
name: 'Home',
name: 'header.home',
to: '/',
icon: 'home'
icon: 'home',
},
{
name: 'Channel',
name: 'header.channel',
to: '/channel',
icon: 'sitemap',
admin: true
admin: true,
},
{
name: 'API Keys',
name: 'header.token',
to: '/token',
icon: 'key'
icon: 'key',
},
{
name: 'Redeem',
name: 'header.redemption',
to: '/redemption',
icon: 'dollar sign',
admin: true
admin: true,
},
{
name: 'Recharge',
name: 'header.topup',
to: '/topup',
icon: 'cart'
icon: 'cart',
},
{
name: 'Users',
name: 'header.user',
to: '/user',
icon: 'user',
admin: true
admin: true,
},
{
name: 'Logs',
name: 'header.dashboard',
to: '/dashboard',
icon: 'chart bar',
},
{
name: 'header.log',
to: '/log',
icon: 'book'
icon: 'book',
},
{
name: 'Settings',
name: 'header.setting',
to: '/setting',
icon: 'setting'
icon: 'setting',
},
{
name: 'About',
name: 'header.about',
to: '/about',
icon: 'info circle'
}
icon: 'info circle',
},
];
if (localStorage.getItem('chat_link')) {
headerButtons.splice(1, 0, {
name: 'Chat',
name: 'header.chat',
to: '/chat',
icon: 'comments'
icon: 'comments',
});
}
const Header = () => {
const { t, i18n } = useTranslation();
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
@ -93,24 +114,45 @@ const Header = () => {
if (isMobile) {
return (
<Menu.Item
key={button.name}
onClick={() => {
navigate(button.to);
setShowSidebar(false);
}}
style={{ fontSize: '15px' }}
>
{button.name}
{t(button.name)}
</Menu.Item>
);
}
return (
<Menu.Item key={button.name} as={Link} to={button.to}>
<Icon name={button.icon} />
{button.name}
<Menu.Item
key={button.name}
as={Link}
to={button.to}
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
>
<Icon name={button.icon} style={{ marginRight: '4px' }} />
{t(button.name)}
</Menu.Item>
);
});
};
// Add language switcher dropdown
const languageOptions = [
{ key: 'zh', text: '中文', value: 'zh' },
{ key: 'en', text: 'English', value: 'en' },
];
const changeLanguage = (language) => {
i18n.changeLanguage(language);
};
if (isMobile()) {
return (
<>
@ -123,18 +165,14 @@ const Header = () => {
borderBottom: 'none',
marginBottom: '0',
borderTop: 'none',
height: '51px'
height: '51px',
}
: { borderTop: 'none', height: '52px' }
}
>
<Container>
<Menu.Item as={Link} to='/'>
<img
src={logo}
alt='logo'
style={{ marginRight: '0.75em' }}
/>
<img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
<div style={{ fontSize: '20px' }}>
<b>{systemName}</b>
</div>
@ -150,9 +188,19 @@ const Header = () => {
<Segment style={{ marginTop: 0, borderTop: '0' }}>
<Menu secondary vertical style={{ width: '100%', margin: 0 }}>
{renderButtons(true)}
<Menu.Item>
<Dropdown
selection
options={languageOptions}
value={i18n.language}
onChange={(_, { value }) => changeLanguage(value)}
/>
</Menu.Item>
<Menu.Item>
{userState.user ? (
<Button onClick={logout}>Log out</Button>
<Button onClick={logout} style={{ color: '#666666' }}>
{t('header.logout')}
</Button>
) : (
<>
<Button
@ -161,7 +209,7 @@ const Header = () => {
navigate('/login');
}}
>
Log in
{t('header.login')}
</Button>
<Button
onClick={() => {
@ -169,7 +217,7 @@ const Header = () => {
navigate('/register');
}}
>
Sign up
{t('header.register')}
</Button>
</>
)}
@ -185,32 +233,75 @@ const Header = () => {
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>
<Menu.Item as={Link} to='/' className={'hide-on-mobile'}>
<img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
<div style={{ fontSize: '20px' }}>
<b>{systemName}</b>
<div
style={{
fontSize: '18px',
fontWeight: '500',
color: '#333',
}}
>
{systemName}
</div>
</Menu.Item>
{renderButtons(false)}
<Menu.Menu position='right'>
<Dropdown
item
options={languageOptions}
value={i18n.language}
onChange={(_, { value }) => changeLanguage(value)}
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
/>
{userState.user ? (
<Dropdown
text={userState.user.username}
pointing
className='link item'
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
>
<Dropdown.Menu>
<Dropdown.Item onClick={logout}>Log out</Dropdown.Item>
<Dropdown.Item
onClick={logout}
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
>
{t('header.logout')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
) : (
<Menu.Item
name='Log in'
name={t('header.login')}
as={Link}
to='/login'
className='btn btn-link'
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
/>
)}
</Menu.Menu>

View File

@ -1,5 +1,16 @@
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 { UserContext } from '../context/User';
import { API, getLogo, showError, showSuccess, showWarning } from '../helpers';
@ -10,7 +21,7 @@ const LoginForm = () => {
const [inputs, setInputs] = useState({
username: '',
password: '',
wechat_verification_code: ''
wechat_verification_code: '',
});
const [searchParams, setSearchParams] = useSearchParams();
const [submitted, setSubmitted] = useState(false);
@ -63,7 +74,7 @@ const LoginForm = () => {
if (username && password) {
const res = await API.post(`/api/user/login`, {
username,
password
password,
});
const { success, message, data } = res.data;
if (success) {
@ -86,19 +97,32 @@ const LoginForm = () => {
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'>
<Image src={logo} /> User login
<Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
<Card.Content>
<Card.Header>
<Header
as='h2'
textAlign='center'
style={{ marginBottom: '1.5em' }}
>
<Image src={logo} style={{ marginBottom: '10px' }} />
<Header.Content>User Login</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
<Segment>
<Form.Input
fluid
icon='user'
iconPosition='left'
placeholder='Username / Email address'
placeholder='Username / Email Address'
name='username'
value={username}
onChange={handleChange}
style={{ marginBottom: '1em' }}
/>
<Form.Input
fluid
@ -109,72 +133,113 @@ const LoginForm = () => {
type='password'
value={password}
onChange={handleChange}
style={{ marginBottom: '1.5em' }}
/>
<Button color='green' fluid size='large' onClick={handleSubmit}>
Log in
<Button
fluid
size='large'
style={{
background: '#2F73FF', // Use a more modern blue
color: 'white',
marginBottom: '1.5em',
}}
onClick={handleSubmit}
>
Log In
</Button>
</Segment>
</Form>
<Message>
Forget password?
<Link to='/reset' className='btn btn-link'>
<Divider />
<Message style={{ background: 'transparent', boxShadow: 'none' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '0.9em',
color: '#666',
}}
>
<div>
Forgot password?
<Link to='/reset' style={{ color: '#2185d0' }}>
Click to reset
</Link>
; No account?
<Link to='/register' className='btn btn-link'>
</div>
<div>
No account?
<Link to='/register' style={{ color: '#2185d0' }}>
Click to register
</Link>
</div>
</div>
</Message>
{status.github_oauth || status.wechat_login || status.lark_client_id ? (
{(status.github_oauth ||
status.wechat_login ||
status.lark_client_id) && (
<>
<Divider horizontal>Or</Divider>
<div style={{ display: "flex", justifyContent: "center" }}>
{status.github_oauth ? (
<Divider
horizontal
style={{ color: '#666', fontSize: '0.9em' }}
>
Log in with other methods
</Divider>
<div
style={{
display: 'flex',
justifyContent: 'center',
gap: '1em',
marginTop: '1em',
}}
>
{status.github_oauth && (
<Button
circular
color='black'
icon='github'
onClick={() => onGitHubOAuthClicked(status.github_client_id)}
onClick={() =>
onGitHubOAuthClicked(status.github_client_id)
}
/>
) : (
<></>
)}
{status.wechat_login ? (
{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"
{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: "16px", height: "16px", cursor: "pointer", margin: "auto" }}
onClick={() => onLarkOAuthClicked(status.lark_client_id)}
style={{
width: '36px',
height: '36px',
cursor: 'pointer',
margin: 'auto',
}}
/>
</div>
) : (
<></>
)}
</div>
</>
) : (
<></>
)}
</Card.Content>
</Card>
<Modal
onClose={() => setShowWeChatLoginModal(false)}
onOpen={() => setShowWeChatLoginModal(true)}
@ -198,9 +263,13 @@ const LoginForm = () => {
onChange={handleChange}
/>
<Button
color=''
fluid
size='large'
style={{
background: '#2F73FF', // Use a more modern blue
color: 'white',
marginBottom: '1.5em',
}}
onClick={onSubmitWeChatVerificationCode}
>
Log in

View File

@ -1,21 +1,48 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Label, Pagination, Segment, Select, Table } from 'semantic-ui-react';
import { API, isAdmin, showError, timestamp2string } from '../helpers';
import {
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 { 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 (
<>
<code
onClick={async () => {
if (await copy(request_id)) {
showSuccess(`Request ID copied: ${request_id}`);
} else {
showWarning(`Failed to copy request ID: ${request_id}`);
}
}}
style={{ cursor: 'pointer' }}
>
{timestamp2string(timestamp)}
</>
</code>
);
}
const MODE_OPTIONS = [
{ key: 'all', text: 'All users', value: 'all' },
{ key: 'self', text: 'Current user', value: 'self' }
{ key: 'all', text: 'All Users', value: 'all' },
{ key: 'self', text: 'Current User', value: 'self' },
];
const LOG_OPTIONS = [
@ -23,24 +50,92 @@ const LOG_OPTIONS = [
{ key: '1', text: 'Recharge', value: 1 },
{ key: '2', text: 'Consumption', value: 2 },
{ key: '3', text: 'Management', value: 3 },
{ key: '4', text: 'System', value: 4 }
{ key: '4', text: 'System', value: 4 },
{ key: '5', text: 'Test', value: 5 },
];
function renderType(type) {
switch (type) {
case 1:
return <Label basic color='green'> Recharge </Label>;
return (
<Label basic color='green'>
Recharge
</Label>
);
case 2:
return <Label basic color='olive'> Consumption </Label>;
return (
<Label basic color='olive'>
Consumption
</Label>
);
case 3:
return <Label basic color='orange'> Management </Label>;
return (
<Label basic color='orange'>
Management
</Label>
);
case 4:
return <Label basic color='purple'> System </Label>;
return (
<Label basic color='purple'>
System
</Label>
);
case 5:
return (
<Label basic color='violet'>
Test
</Label>
);
default:
return <Label basic color='black'> Unknown </Label>;
return (
<Label basic color='black'>
Unknown
</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 [logs, setLogs] = useState([]);
const [showStat, setShowStat] = useState(false);
@ -57,13 +152,20 @@ const LogsTable = () => {
model_name: '',
start_timestamp: timestamp2string(0),
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({
quota: 0,
token: 0
token: 0,
});
const handleInputChange = (e, { name, value }) => {
@ -73,7 +175,9 @@ const LogsTable = () => {
const getLogSelfStat = async () => {
let localStartTimestamp = Date.parse(start_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;
if (success) {
setStat(data);
@ -85,7 +189,9 @@ const LogsTable = () => {
const getLogStat = async () => {
let localStartTimestamp = Date.parse(start_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;
if (success) {
setStat(data);
@ -105,6 +211,10 @@ const LogsTable = () => {
setShowStat(!showStat);
};
const showUserTokenQuota = () => {
return logType !== 5;
};
const loadLogs = async (startIdx) => {
let url = '';
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
@ -197,43 +307,88 @@ const LogsTable = () => {
return (
<>
<Segment>
<>
<Header as='h3'>
UsagesTotal consumption limit
Usages (Total consumption limit:
{showStat && renderQuota(stat.quota)}
{!showStat && <span onClick={handleEyeClick} style={{ cursor: 'pointer', color: 'gray' }}>click to view</span>}
{!showStat && (
<span
onClick={handleEyeClick}
style={{ cursor: 'pointer', color: 'gray' }}
>
Click to view
</span>
)}
)
</Header>
<Form>
<Form.Group>
<Form.Input fluid label={'Key name'} width={3} value={token_name}
placeholder={'Optional values'} name='token_name' onChange={handleInputChange} />
<Form.Input fluid label='Model name' width={3} value={model_name} placeholder='Optional values'
<Form.Input
fluid
label={'Token Name'}
width={3}
value={token_name}
placeholder={'Optional'}
name='token_name'
onChange={handleInputChange}
/>
<Form.Input
fluid
label='Model Name'
width={3}
value={model_name}
placeholder='Optional'
name='model_name'
onChange={handleInputChange} />
<Form.Input fluid label='Start time' width={4} value={start_timestamp} type='datetime-local'
onChange={handleInputChange}
/>
<Form.Input
fluid
label='Start Time'
width={4}
value={start_timestamp}
type='datetime-local'
name='start_timestamp'
onChange={handleInputChange} />
<Form.Input fluid label='End time' width={4} value={end_timestamp} type='datetime-local'
onChange={handleInputChange}
/>
<Form.Input
fluid
label='End Time'
width={4}
value={end_timestamp}
type='datetime-local'
name='end_timestamp'
onChange={handleInputChange} />
<Form.Button fluid label='Operation' width={2} onClick={refresh}>Query</Form.Button>
onChange={handleInputChange}
/>
<Form.Button fluid label='Action' width={2} onClick={refresh}>
Search
</Form.Button>
</Form.Group>
{
isAdminUser && <>
{isAdminUser && (
<>
<Form.Group>
<Form.Input fluid label={'Channel ID'} width={3} value={channel}
placeholder='Optional values' name='channel'
onChange={handleInputChange} />
<Form.Input fluid label={'User name'} width={3} value={username}
placeholder={'Optional values'} name='username'
onChange={handleInputChange} />
<Form.Input
fluid
label={'Channel ID'}
width={3}
value={channel}
placeholder='Optional'
name='channel'
onChange={handleInputChange}
/>
<Form.Input
fluid
label={'Username'}
width={3}
value={username}
placeholder={'Optional'}
name='username'
onChange={handleInputChange}
/>
</Form.Group>
</>
}
)}
</Form>
<Table basic compact size='small'>
<Table basic={'very'} compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
@ -245,8 +400,8 @@ const LogsTable = () => {
>
Time
</Table.HeaderCell>
{
isAdminUser && <Table.HeaderCell
{isAdminUser && (
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('channel');
@ -255,27 +410,7 @@ const LogsTable = () => {
>
Channel
</Table.HeaderCell>
}
{
isAdminUser && <Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('username');
}}
width={1}
>
Users
</Table.HeaderCell>
}
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('token_name');
}}
width={1}
>
API Keys
</Table.HeaderCell>
)}
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
@ -294,6 +429,28 @@ const LogsTable = () => {
>
Model
</Table.HeaderCell>
{showUserTokenQuota() && (
<>
{isAdminUser && (
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('username');
}}
width={1}
>
User
</Table.HeaderCell>
)}
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('token_name');
}}
width={1}
>
Token
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
@ -319,8 +476,10 @@ const LogsTable = () => {
}}
width={1}
>
Cost
Quota
</Table.HeaderCell>
</>
)}
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
@ -343,24 +502,64 @@ const LogsTable = () => {
if (log.deleted) return <></>;
return (
<Table.Row key={log.id}>
<Table.Cell>{renderTimestamp(log.created_at)}</Table.Cell>
{
isAdminUser && (
<Table.Cell>{log.channel ? <Label basic>{log.channel}</Label> : ''}</Table.Cell>
)
}
{
isAdminUser && (
<Table.Cell>{log.username ? <Label>{log.username}</Label> : ''}</Table.Cell>
)
}
<Table.Cell>{log.token_name ? <Label basic>{log.token_name}</Label> : ''}</Table.Cell>
<Table.Cell>
{renderTimestamp(log.created_at, log.request_id)}
</Table.Cell>
{isAdminUser && (
<Table.Cell>
{log.channel ? (
<Label
basic
as={Link}
to={`/channel/edit/${log.channel}`}
>
{log.channel}
</Label>
) : (
''
)}
</Table.Cell>
)}
<Table.Cell>{renderType(log.type)}</Table.Cell>
<Table.Cell>{log.model_name ? <Label basic>{log.model_name}</Label> : ''}</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) : 'free'}</Table.Cell>
<Table.Cell>{log.content}</Table.Cell>
<Table.Cell>
{log.model_name ? renderColorLabel(log.model_name) : ''}
</Table.Cell>
{showUserTokenQuota() && (
<>
{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>
);
})}
@ -379,7 +578,9 @@ const LogsTable = () => {
setLogType(value);
}}
/>
<Button size='small' onClick={refresh} loading={loading}>Refresh</Button>
<Button size='small' onClick={refresh} loading={loading}>
Refresh
</Button>
<Pagination
floated='right'
activePage={activePage}
@ -395,7 +596,7 @@ const LogsTable = () => {
</Table.Row>
</Table.Footer>
</Table>
</Segment>
</>
</>
);
};

View File

@ -1,6 +1,21 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers';
import {
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';
const PasswordResetConfirm = () => {
@ -63,29 +78,47 @@ const PasswordResetConfirm = () => {
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'>
<Image src='/logo.png' /> Password reset confirmation
<Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
<Card.Content>
<Card.Header>
<Header
as='h2'
textAlign='center'
style={{ marginBottom: '1.5em' }}
>
<Image src='/logo.png' style={{ marginBottom: '10px' }} />
<Header.Content>Password Reset Confirmation</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
<Segment>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder='Email address'
placeholder='Email Address'
name='email'
value={email}
readOnly
style={{ marginBottom: '1em' }}
/>
{newPassword && (
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='New password'
placeholder='New Password'
name='newPassword'
value={newPassword}
readOnly
style={{
marginBottom: '1em',
cursor: 'pointer',
backgroundColor: '#f8f9fa',
}}
onClick={(e) => {
e.target.select();
navigator.clipboard.writeText(newPassword);
@ -94,17 +127,30 @@ const PasswordResetConfirm = () => {
/>
)}
<Button
color='green'
color='blue'
fluid
size='large'
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
style={{
background: '#2F73FF', // Use a more modern blue
color: 'white',
marginBottom: '1.5em',
}}
>
{disableButton ? `Password reset complete` : 'Submit'}
{disableButton ? 'Password Reset Complete' : 'Submit'}
</Button>
</Segment>
</Form>
{newPassword && (
<Message style={{ background: 'transparent', boxShadow: 'none' }}>
<p style={{ fontSize: '0.9em', color: '#666' }}>
A new password has been generated. Please click the password box or the button above to copy it. Please log in and change your password promptly!
</p>
</Message>
)}
</Card.Content>
</Card>
</Grid.Column>
</Grid>
);

View File

@ -1,11 +1,19 @@
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 Turnstile from 'react-turnstile';
const PasswordResetForm = () => {
const [inputs, setInputs] = useState({
email: ''
email: '',
});
const { email } = inputs;
@ -42,7 +50,7 @@ const PasswordResetForm = () => {
function handleChange(e) {
const { name, value } = e.target;
setInputs(inputs => ({ ...inputs, [name]: value }));
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
async function handleSubmit(e) {
@ -69,42 +77,72 @@ const PasswordResetForm = () => {
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'>
<Image src='/logo.png' /> Password reset
<Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
<Card.Content>
<Card.Header>
<Header
as='h2'
textAlign='center'
style={{ marginBottom: '1.5em' }}
>
<Image src='/logo.png' style={{ marginBottom: '10px' }} />
<Header.Content>Password Reset</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
<Segment>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder='Email address'
placeholder='Email Address'
name='email'
value={email}
onChange={handleChange}
style={{ marginBottom: '1em' }}
/>
{turnstileEnabled ? (
{turnstileEnabled && (
<div
style={{
marginBottom: '1em',
display: 'flex',
justifyContent: 'center',
}}
>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
) : (
<></>
</div>
)}
<Button
color='green'
color='blue'
fluid
size='large'
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
style={{
background: '#2F73FF', // Use a more modern blue
color: 'white',
marginBottom: '1.5em',
}}
>
{disableButton ? `Retry (${countdown})` : 'Submit'}
</Button>
</Segment>
</Form>
<Message style={{ background: 'transparent', boxShadow: 'none' }}>
<p style={{ fontSize: '0.9em', color: '#666' }}>
The system will send an email with a reset link to your email address. Please check your inbox.
</p>
</Message>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
);

View File

@ -1,29 +1,56 @@
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 { 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 { renderQuota } from '../helpers/render';
function renderTimestamp(timestamp) {
return (
<>
{timestamp2string(timestamp)}
</>
);
return <>{timestamp2string(timestamp)}</>;
}
function renderStatus(status) {
switch (status) {
case 1:
return <Label basic color='green'>Not used</Label>;
return (
<Label basic color='green'>
Unused
</Label>
);
case 2:
return <Label basic color='red'> Disabled </Label>;
return (
<Label basic color='red'>
Disabled
</Label>
);
case 3:
return <Label basic color='grey'> Used </Label>;
return (
<Label basic color='grey'>
Used
</Label>
);
default:
return <Label basic color='black'> Unknown status </Label>;
return (
<Label basic color='black'>
Unknown status
</Label>
);
}
}
@ -110,7 +137,9 @@ const RedemptionsTable = () => {
return;
}
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;
if (success) {
setRedemptions(data);
@ -159,7 +188,7 @@ const RedemptionsTable = () => {
/>
</Form>
<Table basic compact size='small'>
<Table basic={'very'} compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
@ -225,11 +254,19 @@ const RedemptionsTable = () => {
return (
<Table.Row key={redemption.id}>
<Table.Cell>{redemption.id}</Table.Cell>
<Table.Cell>{redemption.name ? redemption.name : 'None'}</Table.Cell>
<Table.Cell>
{redemption.name ? redemption.name : 'None'}
</Table.Cell>
<Table.Cell>{renderStatus(redemption.status)}</Table.Cell>
<Table.Cell>{renderQuota(redemption.quota)}</Table.Cell>
<Table.Cell>{renderTimestamp(redemption.created_time)}</Table.Cell>
<Table.Cell>{redemption.redeemed_time ? renderTimestamp(redemption.redeemed_time) : "Not yet redeemed"} </Table.Cell>
<Table.Cell>
{renderTimestamp(redemption.created_time)}
</Table.Cell>
<Table.Cell>
{redemption.redeemed_time
? renderTimestamp(redemption.redeemed_time)
: 'Not redeemed yet'}{' '}
</Table.Cell>
<Table.Cell>
<div>
<Button
@ -239,7 +276,9 @@ const RedemptionsTable = () => {
if (await copy(redemption.key)) {
showSuccess('Copied to clipboard!');
} else {
showWarning('Unable to copy to clipboard, please copy manually. The redemption code has been filled in the search box.')
showWarning(
'Unable to copy to clipboard, please copy manually. The redemption code has been filled into the search box.'
);
setSearchKeyword(redemption.key);
}
}}
@ -295,7 +334,12 @@ const RedemptionsTable = () => {
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan='8'>
<Button size='small' as={Link} to='/redemption/add' loading={loading}>
<Button
size='small'
as={Link}
to='/redemption/add'
loading={loading}
>
Add new redemption code
</Button>
<Pagination

View File

@ -1,5 +1,15 @@
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 { API, getLogo, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile';
@ -10,7 +20,7 @@ const RegisterForm = () => {
password: '',
password2: '',
email: '',
verification_code: ''
verification_code: '',
});
const { username, password, password2 } = inputs;
const [showEmailVerification, setShowEmailVerification] = useState(false);
@ -100,11 +110,23 @@ const RegisterForm = () => {
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'>
<Image src={logo} /> New User Registration
<Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
<Card.Content>
<Card.Header>
<Header
as='h2'
textAlign='center'
style={{ marginBottom: '1.5em' }}
>
<Image src={logo} style={{ marginBottom: '10px' }} />
<Header.Content>New User Registration</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
<Segment>
<Form.Input
fluid
icon='user'
@ -112,26 +134,30 @@ const RegisterForm = () => {
placeholder='Enter username, up to 12 characters'
onChange={handleChange}
name='username'
style={{ marginBottom: '1em' }}
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='Enter password, at least 8 characters and up to 20 characters'
placeholder='Enter password, minimum 8 characters, maximum 20 characters'
onChange={handleChange}
name='password'
type='password'
style={{ marginBottom: '1em' }}
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='Enter password, at least 8 characters and up to 20 characters'
placeholder='Re-enter password'
onChange={handleChange}
name='password2'
type='password'
style={{ marginBottom: '1em' }}
/>
{showEmailVerification ? (
{showEmailVerification && (
<>
<Form.Input
fluid
@ -142,50 +168,76 @@ const RegisterForm = () => {
name='email'
type='email'
action={
<Button onClick={sendVerificationCode} disabled={loading}>
Get verification code
<Button
onClick={sendVerificationCode}
disabled={loading}
>
Get Verification Code
</Button>
}
style={{ marginBottom: '1em' }}
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='Enter Verification Code'
placeholder='Enter verification code'
onChange={handleChange}
name='verification_code'
style={{ marginBottom: '1em' }}
/>
</>
) : (
<></>
)}
{turnstileEnabled ? (
{turnstileEnabled && (
<div
style={{
marginBottom: '1em',
display: 'flex',
justifyContent: 'center',
}}
>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
) : (
<></>
</div>
)}
<Button
color='green'
fluid
size='large'
onClick={handleSubmit}
style={{
background: '#2F73FF', // Use a more modern blue
color: 'white',
marginBottom: '1.5em',
}}
loading={loading}
>
Sign up
Register
</Button>
</Segment>
</Form>
<Message>
<Divider />
<Message style={{ background: 'transparent', boxShadow: 'none' }}>
<div
style={{
textAlign: 'center',
fontSize: '0.9em',
color: '#666',
}}
>
Already have an account?
<Link to='/login' className='btn btn-link'>
<Link to='/login' style={{ color: '#2185d0' }}>
Click to login
</Link>
</div>
</Message>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
);

View File

@ -1,7 +1,22 @@
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 { API, copy, showError, showSuccess, showWarning, timestamp2string } from '../helpers';
import {
API,
copy,
showError,
showSuccess,
showWarning,
timestamp2string,
} from '../helpers';
import { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render';
@ -20,25 +35,41 @@ const OPEN_LINK_OPTIONS = [
];
function renderTimestamp(timestamp) {
return (
<>
{timestamp2string(timestamp)}
</>
);
return <>{timestamp2string(timestamp)}</>;
}
function renderStatus(status) {
switch (status) {
case 1:
return <Label basic color='green'>Enabled</Label>;
return (
<Label basic color='green'>
Enabled
</Label>
);
case 2:
return <Label basic color='red'> Disabled </Label>;
return (
<Label basic color='red'>
Disabled
</Label>
);
case 3:
return <Label basic color='yellow'> Expired </Label>;
return (
<Label basic color='yellow'>
Expired
</Label>
);
case 4:
return <Label basic color='grey'> Exhausted </Label>;
return (
<Label basic color='grey'>
Exhausted
</Label>
);
default:
return <Label basic color='black'> Unknown status </Label>;
return (
<Label basic color='black'>
Unknown Status
</Label>
);
}
}
@ -95,14 +126,15 @@ const TokensTable = () => {
serverAddress = window.location.origin;
}
let encodedServerAddress = encodeURIComponent(serverAddress);
// const nextLink = localStorage.getItem('chat_link');
// let nextUrl;
const nextLink = localStorage.getItem('chat_link');
let nextUrl;
// if (nextLink) {
// nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
// } else {
// nextUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
// }
if (nextLink) {
nextUrl =
nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
} else {
nextUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
}
let url;
switch (type) {
@ -116,7 +148,9 @@ const TokensTable = () => {
url = `https://chat.laisky.com?apikey=sk-${key}`;
break;
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;
default:
url = `sk-${key}`;
@ -144,7 +178,8 @@ const TokensTable = () => {
let defaultUrl;
if (chatLink) {
defaultUrl = chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
defaultUrl =
chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
} else {
defaultUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
}
@ -159,7 +194,9 @@ const TokensTable = () => {
break;
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;
default:
@ -167,7 +204,7 @@ const TokensTable = () => {
}
window.open(url, '_blank');
}
};
useEffect(() => {
loadTokens(0, orderBy)
@ -273,7 +310,7 @@ const TokensTable = () => {
/>
</Form>
<Table basic compact size='small'>
<Table basic={'very'} compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
@ -341,9 +378,17 @@ const TokensTable = () => {
<Table.Cell>{token.name ? token.name : 'None'}</Table.Cell>
<Table.Cell>{renderStatus(token.status)}</Table.Cell>
<Table.Cell>{renderQuota(token.used_quota)}</Table.Cell>
<Table.Cell>{token.unlimited_quota ? 'Unlimited' : renderQuota(token.remain_quota, 2)}</Table.Cell>
<Table.Cell>
{token.unlimited_quota
? 'Unlimited'
: renderQuota(token.remain_quota, 2)}
</Table.Cell>
<Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell>
<Table.Cell>{token.expired_time === -1 ? 'Never expires' : renderTimestamp(token.expired_time)}</Table.Cell>
<Table.Cell>
{token.expired_time === -1
? 'Never Expires'
: renderTimestamp(token.expired_time)}
</Table.Cell>
<Table.Cell>
<div>
<Button.Group color='green' size={'small'}>
@ -359,16 +404,37 @@ const TokensTable = () => {
<Dropdown
className='button icon'
floating
options={COPY_OPTIONS.map(option => ({
options={COPY_OPTIONS.map((option) => ({
...option,
onClick: async () => {
await onCopy(option.value, token.key);
}
},
}))}
trigger={<></>}
/>
</Button.Group>
{' '}
</Button.Group>{' '}
<Button.Group color='blue' size={'small'}>
<Button
size={'small'}
positive
onClick={() => {
onOpenLink('', token.key);
}}
>
Chat
</Button>
<Dropdown
className='button icon'
floating
options={OPEN_LINK_OPTIONS.map((option) => ({
...option,
onClick: async () => {
await onOpenLink(option.value, token.key);
},
}))}
trigger={<></>}
/>
</Button.Group>{' '}
<Popup
trigger={
<Button size='small' negative>
@ -420,14 +486,24 @@ const TokensTable = () => {
<Button size='small' as={Link} to='/token/add' loading={loading}>
Add New Token
</Button>
<Button size='small' onClick={refresh} loading={loading}>Refresh</Button>
<Button size='small' onClick={refresh} loading={loading}>
Refresh
</Button>
<Dropdown
placeholder='Sort By'
selection
options={[
{ key: '', text: 'Default Order', value: '' },
{ key: 'remain_quota', text: 'Sort by Remaining Quota', value: 'remain_quota' },
{ key: 'used_quota', text: 'Sort by Used Quota', value: 'used_quota' },
{
key: 'remain_quota',
text: 'Sort by Remaining Quota',
value: 'remain_quota',
},
{
key: 'used_quota',
text: 'Sort by Used Quota',
value: 'used_quota',
},
]}
value={orderBy}
onChange={handleOrderByChange}

View File

@ -1,10 +1,23 @@
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 { API, showError, showSuccess } from '../helpers';
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) {
switch (role) {
@ -66,7 +79,7 @@ const UsersTable = () => {
(async () => {
const res = await API.post('/api/user/manage', {
username,
action
action,
});
const { success, message } = res.data;
if (success) {
@ -169,7 +182,7 @@ const UsersTable = () => {
/>
</Form>
<Table basic compact size='small'>
<Table basic={'very'} compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
@ -239,7 +252,9 @@ const UsersTable = () => {
<Popup
content={user.email ? user.email : 'Email not bound'}
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>}
hoverable
/>
@ -249,9 +264,22 @@ const UsersTable = () => {
{/* {user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : 'None'}*/}
{/*</Table.Cell>*/}
<Table.Cell>
<Popup content='Remaining quota' trigger={<Label basic>{renderQuota(user.quota)}</Label>} />
<Popup content='Used quota' trigger={<Label basic>{renderQuota(user.used_quota)}</Label>} />
<Popup content='Number of Requests' trigger={<Label basic>{renderNumber(user.request_count)}</Label>} />
<Popup
content='Remaining Quota'
trigger={<Label basic>{renderQuota(user.quota)}</Label>}
/>
<Popup
content='Used Quota'
trigger={
<Label basic>{renderQuota(user.used_quota)}</Label>
}
/>
<Popup
content='Request Count'
trigger={
<Label basic>{renderNumber(user.request_count)}</Label>
}
/>
</Table.Cell>
<Table.Cell>{renderRole(user.role)}</Table.Cell>
<Table.Cell>{renderStatus(user.status)}</Table.Cell>
@ -279,7 +307,11 @@ const UsersTable = () => {
</Button>
<Popup
trigger={
<Button size='small' negative disabled={user.role === 100}>
<Button
size='small'
negative
disabled={user.role === 100}
>
Delete
</Button>
}
@ -335,8 +367,16 @@ const UsersTable = () => {
options={[
{ key: '', text: 'Default Order', value: '' },
{ key: 'quota', text: 'Sort by Remaining Quota', value: 'quota' },
{ key: 'used_quota', text: 'Sort by Used Quota', value: 'used_quota' },
{ key: 'request_count', text: 'Sort by Number of Requests', value: 'request_count' },
{
key: 'used_quota',
text: 'Sort by Used Quota',
value: 'used_quota',
},
{
key: 'request_count',
text: 'Sort by Request Count',
value: 'request_count',
},
]}
value={orderBy}
onChange={handleOrderByChange}

View File

@ -13,7 +13,8 @@ export function renderGroup(group) {
}
let groups = group.split(',');
groups.sort();
return <>
return (
<>
{groups.map((group) => {
if (group === 'vip' || group === 'pro') {
return <Label color='yellow'>{group}</Label>;
@ -22,7 +23,8 @@ export function renderGroup(group) {
}
return <Label>{group}</Label>;
})}
</>;
</>
);
}
export function renderNumber(num) {
@ -56,3 +58,32 @@ export function renderQuotaWithPrompt(quota, digits) {
}
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>
);
}

23
web/default/src/i18n.js Normal file
View File

@ -0,0 +1,23 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'zh',
debug: process.env.NODE_ENV === 'development',
interpolation: {
escapeValue: false,
},
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
});
export default i18n;

View File

@ -11,6 +11,7 @@ import { UserProvider } from './context/User';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { StatusProvider } from './context/Status';
import './i18n';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Header, Segment } from 'semantic-ui-react';
import { Card, Header, Segment } from 'semantic-ui-react';
import { API, showError } from '../../helpers';
import { marked } from 'marked';
@ -28,31 +28,38 @@ const About = () => {
useEffect(() => {
displayAbout().then();
}, []);
return (
<>
{
aboutLoaded && about === '' ? <>
<Segment>
<Header as='h3'>About</Header>
<p>You can set the content about in the settings page, support HTML & Markdown</p>
Project Repository Address
{aboutLoaded && about === '' ? (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>About the System</Card.Header>
<p>You can set the about content on the settings page, supporting HTML & Markdown</p>
Project repository address:
<a href='https://github.com/Laisky/one-api'>
https://github.com/Laisky/one-api
</a>
</Segment>
</> : <>
{
about.startsWith('https://') ? <iframe
</Card.Content>
</Card>
</div>
) : (
<>
{about.startsWith('https://') ? (
<iframe
src={about}
style={{ width: '100%', height: '100vh', border: 'none' }}
/> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div>
}
/>
) : (
<div
style={{ fontSize: 'larger' }}
dangerouslySetInnerHTML={{ __html: about }}
></div>
)}
</>
}
)}
</>
);
};
export default About;

View File

@ -1,32 +1,49 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Input, Message, Segment } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import {
Button,
Form,
Header,
Input,
Message,
Segment,
Card,
} from 'semantic-ui-react';
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';
const MODEL_MAPPING_EXAMPLE = {
'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',
'gpt-4-0314': 'gpt-4',
'gpt-4-32k-0314': 'gpt-4-32k'
'gpt-4-32k-0314': 'gpt-4-32k',
};
function type2secretPrompt(type) {
// inputs.type === 15 ? 'Enter in the following format:APIKey|SecretKey' : (inputs.type === 18 ? 'Enter in the following format:APPID|APISecret|APIKey' : 'Please enter the authentication key corresponding to the channel')
function type2secretPrompt(type, t) {
switch (type) {
case 15:
return 'Enter in the following format:APIKey|SecretKey';
return t('channel.edit.key_prompts.zhipu');
case 18:
return 'Enter in the following format:APPID|APISecret|APIKey';
return t('channel.edit.key_prompts.spark');
case 22:
return 'Enter in the following format:APIKey-AppIdFor examplefastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041';
return t('channel.edit.key_prompts.fastgpt');
case 23:
return 'Enter in the following format:AppId|SecretId|SecretKey';
return t('channel.edit.key_prompts.tencent');
default:
return 'Please enter the authentication key corresponding to the channel';
return t('channel.edit.key_prompts.default');
}
}
const EditChannel = () => {
const { t } = useTranslation();
const params = useParams();
const navigate = useNavigate();
const channelId = params.id;
@ -45,7 +62,7 @@ const EditChannel = () => {
model_mapping: '',
system_prompt: '',
models: [],
groups: ['default']
groups: ['default'],
};
const [batch, setBatch] = useState(false);
const [inputs, setInputs] = useState(originInputs);
@ -61,7 +78,7 @@ const EditChannel = () => {
ak: '',
user_id: '',
vertex_ai_project_id: '',
vertex_ai_adc: ''
vertex_ai_adc: '',
});
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
@ -93,7 +110,11 @@ const EditChannel = () => {
data.groups = data.group.split(',');
}
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);
if (data.config !== '') {
@ -112,7 +133,7 @@ const EditChannel = () => {
let localModelOptions = res.data.data.map((model) => ({
key: model.id,
text: model.id,
value: model.id
value: model.id,
}));
setOriginModelOptions(localModelOptions);
setFullModels(res.data.data.map((model) => model.id));
@ -124,11 +145,13 @@ const EditChannel = () => {
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
setGroupOptions(res.data.data.map((group) => ({
setGroupOptions(
res.data.data.map((group) => ({
key: group,
text: group,
value: group
})));
value: group,
}))
);
} catch (error) {
showError(error.message);
}
@ -141,7 +164,7 @@ const EditChannel = () => {
localModelOptions.push({
key: model,
text: model,
value: model
value: model,
});
}
});
@ -163,25 +186,32 @@ const EditChannel = () => {
if (inputs.key === '') {
if (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}`;
}
}
if (!isEdit && (inputs.name === '' || inputs.key === '')) {
showInfo('Please fill in the ChannelName and ChannelKey!');
showInfo(t('channel.edit.messages.name_required'));
return;
}
if (inputs.type !== 43 && inputs.models.length === 0) {
showInfo('Please select at least one Model!');
showInfo(t('channel.edit.messages.models_required'));
return;
}
if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {
showInfo('Model mapping must be in valid JSON format!');
showInfo(t('channel.edit.messages.model_mapping_invalid'));
return;
}
let localInputs = { ...inputs };
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 === '') {
localInputs.other = '2024-03-01-preview';
@ -191,16 +221,19 @@ const EditChannel = () => {
localInputs.group = localInputs.groups.join(',');
localInputs.config = JSON.stringify(config);
if (isEdit) {
res = await API.put(`/api/channel/`, { ...localInputs, id: parseInt(channelId) });
res = await API.put(`/api/channel/`, {
...localInputs,
id: parseInt(channelId),
});
} else {
res = await API.post(`/api/channel/`, localInputs);
}
const { success, message } = res.data;
if (success) {
if (isEdit) {
showSuccess('Channel updated successfully!');
showSuccess(t('channel.edit.messages.update_success'));
} else {
showSuccess('Channel created successfully!');
showSuccess(t('channel.edit.messages.create_success'));
setInputs(originInputs);
}
} else {
@ -217,9 +250,9 @@ const EditChannel = () => {
localModelOptions.push({
key: customModel,
text: customModel,
value: customModel
value: customModel,
});
setModelOptions(modelOptions => {
setModelOptions((modelOptions) => {
return [...modelOptions, ...localModelOptions];
});
setCustomModel('');
@ -227,13 +260,18 @@ const EditChannel = () => {
};
return (
<>
<Segment loading={loading}>
<Header as='h3'>{isEdit ? 'Update Channel Information' : 'Create New Channel'}</Header>
<Form autoComplete='new-password'>
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>
{isEdit
? t('channel.edit.title_edit')
: t('channel.edit.title_create')}
</Card.Header>
<Form loading={loading} autoComplete='new-password'>
<Form.Field>
<Form.Select
label='Type'
label={t('channel.edit.type')}
name='type'
required
search
@ -242,19 +280,53 @@ const EditChannel = () => {
onChange={handleInputChange}
/>
</Form.Field>
{
inputs.type === 3 && (
<Form.Field>
<Form.Input
label={t('channel.edit.name')}
name='name'
placeholder={t('channel.edit.name_placeholder')}
onChange={handleInputChange}
value={inputs.name}
required
/>
</Form.Field>
<Form.Field>
<Form.Dropdown
label={t('channel.edit.group')}
placeholder={t('channel.edit.group_placeholder')}
name='groups'
required
fluid
multiple
selection
allowAdditions
additionLabel={t('channel.edit.group_addition')}
onChange={handleInputChange}
value={inputs.groups}
autoComplete='new-password'
options={groupOptions}
/>
</Form.Field>
{/* Azure OpenAI specific fields */}
{inputs.type === 3 && (
<>
<Message>
Note that, <strong>The model deployment name must be consistent with the model name</strong>, because One API will take the model in the request body
Replace the parameter with your deployment name (dots in the model name will be removed)<a target='_blank'
href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'>Image demo</a>
Note: <strong>The model deployment name must match the model name</strong>
, because One API will replace the model parameter in the request body
with your deployment name (dots in the model name will be removed).
<a
target='_blank'
href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'
>
Image Demo
</a>
</Message>
<Form.Field>
<Form.Input
label='AZURE_OPENAI_ENDPOINT'
name='base_url'
placeholder={'Please enter AZURE_OPENAI_ENDPOINTFor examplehttps://docs-test-001.openai.azure.com'}
placeholder='Please enter AZURE_OPENAI_ENDPOINT, for example: https://docs-test-001.openai.azure.com'
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
@ -264,119 +336,85 @@ const EditChannel = () => {
<Form.Input
label='Default API Version'
name='other'
placeholder={'Please enter default API version, for example: 2024-03-01-preview. This configuration can be overridden by actual request query parameters'}
placeholder='Please enter default API version, for example: 2024-03-01-preview. This configuration can be overridden by actual request query parameters'
onChange={handleInputChange}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
</>
)
}
{
inputs.type === 8 && (
)}
{/* Custom base URL field */}
{inputs.type === 8 && (
<Form.Field>
<Form.Input
label='Base URL'
label={t('channel.edit.base_url')}
name='base_url'
placeholder={'Please enter the Base URL of the custom channelFor examplehttps://openai.justsong.cn'}
placeholder={t('channel.edit.base_url_placeholder')}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)
}
)}
{inputs.type === 18 && (
<Form.Field>
<Form.Input
label='Name'
required
name='name'
placeholder={'Please name the channel'}
label={t('channel.edit.spark_version')}
name='other'
placeholder={t('channel.edit.spark_version_placeholder')}
onChange={handleInputChange}
value={inputs.name}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
)}
{inputs.type === 21 && (
<Form.Field>
<Form.Input
label={t('channel.edit.knowledge_id')}
name='other'
placeholder={t('channel.edit.knowledge_id_placeholder')}
onChange={handleInputChange}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
)}
{inputs.type === 17 && (
<Form.Field>
<Form.Input
label={t('channel.edit.plugin_param')}
name='other'
placeholder={t('channel.edit.plugin_param_placeholder')}
onChange={handleInputChange}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
)}
{inputs.type === 34 && (
<Message>{t('channel.edit.coze_notice')}</Message>
)}
{inputs.type === 40 && (
<Message>
{t('channel.edit.douban_notice')}
<a
target='_blank'
href='https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint'
>
{t('channel.edit.douban_notice_link')}
</a>
{t('channel.edit.douban_notice_2')}
</Message>
)}
{inputs.type !== 43 && (
<Form.Field>
<Form.Dropdown
label='Group'
placeholder={'Please select the Group that can use this Channel'}
name='groups'
required
fluid
multiple
selection
allowAdditions
additionLabel={'Please edit the group rate on the system settings page to add a new group:'}
onChange={handleInputChange}
value={inputs.groups}
autoComplete='new-password'
options={groupOptions}
/>
</Form.Field>
{
inputs.type === 18 && (
<Form.Field>
<Form.Input
label='Model version'
name='other'
placeholder={'Please enter the version of the Starfire model, note that it is the version number in the interface address, for example: v2.1'}
onChange={handleInputChange}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
)
}
{
inputs.type === 21 && (
<Form.Field>
<Form.Input
label='Knowledge Base ID'
name='other'
placeholder={'Please enter Knowledge Base ID, for example: 123456'}
onChange={handleInputChange}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
)
}
{
inputs.type === 17 && (
<Form.Field>
<Form.Input
label='Plugin Parameters'
name='other'
placeholder={'Please enter plugin parameters, i.e., the value of the X-DashScope-Plugin request header'}
onChange={handleInputChange}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
)
}
{
inputs.type === 34 && (
<Message>
For Coze, the Model name is the Bot ID. You can add a prefix `bot-`, for example: `bot-123456`.
</Message>
)
}
{
inputs.type === 40 && (
<Message>
For Doubao, you need to manually create an inference endpoint on the <a target="_blank" href="https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint">Model Inference Page</a>. Use the endpoint Name as the Model name, for example: `ep-20240608051426-tkxvl`.
</Message>
)
}
{
inputs.type !== 43 && (
<Form.Field>
<Form.Dropdown
label='Model'
placeholder={'Please select the model supported by the channel'}
label={t('channel.edit.models')}
placeholder={t('channel.edit.models_placeholder')}
name='models'
required
fluid
@ -392,25 +430,46 @@ const EditChannel = () => {
options={modelOptions}
/>
</Form.Field>
)
}
{
inputs.type !== 43 && (
)}
{inputs.type !== 43 && (
<div style={{ lineHeight: '40px', marginBottom: '12px' }}>
<Button type={'button'} onClick={() => {
handleInputChange(null, { name: 'models', value: basicModels });
}}>Fill in Related Models</Button>
<Button type={'button'} onClick={() => {
handleInputChange(null, { name: 'models', value: fullModels });
}}>Fill in all models</Button>
<Button type={'button'} onClick={() => {
<Button
type={'button'}
onClick={() => {
handleInputChange(null, {
name: 'models',
value: basicModels,
});
}}
>
{t('channel.edit.buttons.fill_models')}
</Button>
<Button
type={'button'}
onClick={() => {
handleInputChange(null, {
name: 'models',
value: fullModels,
});
}}
>
{t('channel.edit.buttons.fill_all')}
</Button>
<Button
type={'button'}
onClick={() => {
handleInputChange(null, { name: 'models', value: [] });
}}>Clear all models</Button>
}}
>
{t('channel.edit.buttons.clear')}
</Button>
<Input
action={
<Button type={'button'} onClick={addCustomModel}>Fill in</Button>
<Button type={'button'} onClick={addCustomModel}>
{t('channel.edit.buttons.add_custom')}
</Button>
}
placeholder='EnterCustomModel name'
placeholder={t('channel.edit.buttons.custom_placeholder')}
value={customModel}
onChange={(e, { value }) => {
setCustomModel(value);
@ -423,43 +482,48 @@ const EditChannel = () => {
}}
/>
</div>
)
}
{
inputs.type !== 43 && (<>
)}
{inputs.type !== 43 && (
<>
<Form.Field>
<Form.TextArea
label='Model redirection'
placeholder={`This is optional, used to modify the model name in the request body, it's a JSON string, the key is the model name in the request, and the value is the model name to be replaced, for example:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
label={t('channel.edit.model_mapping')}
placeholder={`${t(
'channel.edit.model_mapping_placeholder'
)}\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
name='model_mapping'
onChange={handleInputChange}
value={inputs.model_mapping}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
style={{
minHeight: 150,
fontFamily: 'JetBrains Mono, Consolas',
}}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.TextArea
label='System Prompt'
placeholder={`Optional: Used to force system prompt words specified in Settings. Use with CustomModel & Model redirection - first create a unique CustomModel name and fill it above, then map that CustomModel redirection to a natively supported Model on this Channel`}
label={t('channel.edit.system_prompt')}
placeholder={t('channel.edit.system_prompt_placeholder')}
name='system_prompt'
onChange={handleInputChange}
value={inputs.system_prompt}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
style={{
minHeight: 150,
fontFamily: 'JetBrains Mono, Consolas',
}}
autoComplete='new-password'
/>
</Form.Field>
</>
)
}
{
inputs.type === 33 && (
)}
{inputs.type === 33 && (
<Form.Field>
<Form.Input
label='Region'
name='region'
required
placeholder={'regione.g. us-west-2'}
placeholder={t('channel.edit.aws_region_placeholder')}
onChange={handleConfigChange}
value={config.region}
autoComplete=''
@ -468,7 +532,7 @@ const EditChannel = () => {
label='AK'
name='ak'
required
placeholder={'AWS IAM Access Key'}
placeholder={t('channel.edit.aws_ak_placeholder')}
onChange={handleConfigChange}
value={config.ak}
autoComplete=''
@ -477,141 +541,137 @@ const EditChannel = () => {
label='SK'
name='sk'
required
placeholder={'AWS IAM Secret Key'}
placeholder={t('channel.edit.aws_sk_placeholder')}
onChange={handleConfigChange}
value={config.sk}
autoComplete=''
/>
</Form.Field>
)
}
{
inputs.type === 42 && (
)}
{inputs.type === 42 && (
<Form.Field>
<Form.Input
label='Region'
name='region'
required
placeholder={'Vertex AI Region.g. us-east5'}
placeholder={t('channel.edit.vertex_region_placeholder')}
onChange={handleConfigChange}
value={config.region}
autoComplete=''
/>
<Form.Input
label='Vertex AI Project ID'
label={t('channel.edit.vertex_project_id')}
name='vertex_ai_project_id'
required
placeholder={'Vertex AI Project ID'}
placeholder={t('channel.edit.vertex_project_id_placeholder')}
onChange={handleConfigChange}
value={config.vertex_ai_project_id}
autoComplete=''
/>
<Form.Input
label='Google Cloud Application Default Credentials JSON'
label={t('channel.edit.vertex_credentials')}
name='vertex_ai_adc'
required
placeholder={'Google Cloud Application Default Credentials JSON'}
placeholder={t('channel.edit.vertex_credentials_placeholder')}
onChange={handleConfigChange}
value={config.vertex_ai_adc}
autoComplete=''
/>
</Form.Field>
)
}
{
inputs.type === 34 && (
)}
{inputs.type === 34 && (
<Form.Input
label='User ID'
label={t('channel.edit.user_id')}
name='user_id'
required
placeholder={'User ID that generated this Key'}
placeholder={t('channel.edit.user_id_placeholder')}
onChange={handleConfigChange}
value={config.user_id}
autoComplete=''
/>)
}
{
inputs.type !== 33 && inputs.type !== 42 && (batch ? <Form.Field>
<Form.TextArea
label='Key'
name='key'
required
placeholder={'Please enter the key, one per line'}
onChange={handleInputChange}
value={inputs.key}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
/>
</Form.Field> : <Form.Field>
<Form.Input
label='Key'
name='key'
required
placeholder={type2secretPrompt(inputs.type)}
onChange={handleInputChange}
value={inputs.key}
autoComplete='new-password'
/>
</Form.Field>)
}
{
inputs.type === 37 && (
)}
{inputs.type !== 33 &&
inputs.type !== 42 &&
(batch ? (
<Form.Field>
<Form.Input
label='Account ID'
name='user_id'
<Form.TextArea
label={t('channel.edit.key')}
name='key'
required
placeholder={'Enter Account IDFor exampled8d7c61dbc334c32d3ced580e4bf42b4'}
onChange={handleConfigChange}
value={config.user_id}
autoComplete=''
placeholder={t('channel.edit.batch_placeholder')}
onChange={handleInputChange}
value={inputs.key}
style={{
minHeight: 150,
fontFamily: 'JetBrains Mono, Consolas',
}}
autoComplete='new-password'
/>
</Form.Field>
)
}
{
inputs.type !== 33 && !isEdit && (
) : (
<Form.Field>
<Form.Input
label={t('channel.edit.key')}
name='key'
required
placeholder={type2secretPrompt(inputs.type, t)}
onChange={handleInputChange}
value={inputs.key}
autoComplete='new-password'
/>
</Form.Field>
))}
{inputs.type !== 33 && !isEdit && (
<Form.Checkbox
checked={batch}
label='Batch Create'
label={t('channel.edit.batch')}
name='batch'
onChange={() => setBatch(!batch)}
/>
)
}
{
inputs.type !== 3 && inputs.type !== 33 && inputs.type !== 8 && inputs.type !== 22 && (
)}
{inputs.type !== 3 &&
inputs.type !== 33 &&
inputs.type !== 8 &&
inputs.type !== 22 && (
<Form.Field>
<Form.Input
label='Proxy'
label={t('channel.edit.base_url')}
name='base_url'
placeholder={'This is optional, used to make API calls through the proxy site, please enter the proxy site address, the format is: https://domain.com'}
placeholder={t('channel.edit.base_url_placeholder')}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)
}
{
inputs.type === 22 && (
)}
{inputs.type === 22 && (
<Form.Field>
<Form.Input
label='Private Deployment URL'
name='base_url'
placeholder={'Please enter the private deployment URL, format: https://fastgpt.run/api/openapi'}
placeholder={
'Please enter the private deployment URL, format: https://fastgpt.run/api/openapi'
}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)
}
<Button onClick={handleCancel}>Cancel</Button>
<Button type={isEdit ? 'button' : 'submit'} positive onClick={submit}>Submit</Button>
)}
<Button onClick={handleCancel}>
{t('channel.edit.buttons.cancel')}
</Button>
<Button
type={isEdit ? 'button' : 'submit'}
positive
onClick={submit}
>
{t('channel.edit.buttons.submit')}
</Button>
</Form>
</Segment>
</>
</Card.Content>
</Card>
</div>
);
};

View File

@ -1,14 +1,21 @@
import React from 'react';
import { Header, Segment } from 'semantic-ui-react';
import { Card } from 'semantic-ui-react';
import ChannelsTable from '../../components/ChannelsTable';
import { useTranslation } from 'react-i18next';
const Channel = () => (
<>
<Segment>
<Header as='h3'>Manage Channels</Header>
const Channel = () => {
const { t } = useTranslation();
return (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>{t('channel.title')}</Card.Header>
<ChannelsTable />
</Segment>
</>
</Card.Content>
</Card>
</div>
);
};
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,389 @@
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);
setData([]);
calculateSummary([]);
}
};
const calculateSummary = (dashboardData) => {
if (!Array.isArray(dashboardData) || dashboardData.length === 0) {
setSummaryData({
todayRequests: 0,
todayQuota: 0,
todayTokens: 0
});
return;
}
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,11 +3,14 @@ import { Card, Grid, Header, Segment } from 'semantic-ui-react';
import { API, showError, showNotice, timestamp2string } from '../../helpers';
import { StatusContext } from '../../context/Status';
import { marked } from 'marked';
import { UserContext } from '../../context/User';
import { Link } from 'react-router-dom';
const Home = () => {
const [statusState, statusDispatch] = useContext(StatusContext);
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
const [homePageContent, setHomePageContent] = useState('');
const [userState] = useContext(UserContext);
const displayNotice = async () => {
const res = await API.get('/api/notice');
@ -51,81 +54,236 @@ const Home = () => {
displayNotice().then();
displayHomePageContent().then();
}, []);
return (
<>
{
homePageContentLoaded && homePageContent === '' ? <>
<Segment>
<Header as='h3'>System status</Header>
{homePageContentLoaded && homePageContent === '' ? (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>Welcome to One API</Card.Header>
<Card.Description style={{ lineHeight: '1.6' }}>
<p>
One API is an LLM API interface management and distribution system that helps you better manage and use LLM APIs from various vendors.
</p>
{!userState.user && (
<p>
To use, please <Link to='/login'>log in</Link> or <Link to='/register'>register</Link>.
</p>
)}
</Card.Description>
</Card.Content>
</Card>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
<Header as='h3'>System Status</Header>
</Card.Header>
<Grid columns={2} stackable>
<Grid.Column>
<Card fluid>
<Card.Content>
<Card.Header>System information</Card.Header>
<Card.Meta>System information overview</Card.Meta>
<Card.Description>
<p>Name:{statusState?.status?.system_name}</p>
<p>Version:{statusState?.status?.version ? statusState?.status?.version : "unknown"}</p>
<p>
Source code:
<a
href='https://github.com/Laisky/one-api'
target='_blank'
<Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
https://github.com/Laisky/one-api
<Card.Content>
<Card.Header>
<Header as='h3' style={{ color: '#444' }}>
System Information
</Header>
</Card.Header>
<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' }}>Name:</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' }}>Version:</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' }}>Source Code:</span>
<a
href='https://github.com/songquanpeng/one-api'
target='_blank'
style={{ color: '#2185d0' }}
>
GitHub Repository
</a>
</p>
<p>Startup time:{getStartTimeString()}</p>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='clock outline icon'></i>
<span style={{ fontWeight: 'bold' }}>Start Time:</span>
<span>{getStartTimeString()}</span>
</p>
</Card.Description>
</Card.Content>
</Card>
</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.Header>System configuration</Card.Header>
<Card.Meta>System configuration overview</Card.Meta>
<Card.Description>
<p>
Email verification:
{statusState?.status?.email_verification === true
<Card.Header>
<Header as='h3' style={{ color: '#444' }}>
System Configuration
</Header>
</Card.Header>
<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' }}>Email Verification:</span>
<span
style={{
color: statusState?.status?.email_verification
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.email_verification
? 'Enabled'
: 'Not enabled'}
: 'Disabled'}
</span>
</p>
<p>
GitHub Authentication
{statusState?.status?.github_oauth === true
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='github icon'></i>
<span style={{ fontWeight: 'bold' }}>
GitHub Authentication:
</span>
<span
style={{
color: statusState?.status?.github_oauth
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.github_oauth
? 'Enabled'
: 'Not enabled'}
: 'Disabled'}
</span>
</p>
<p>
WeChat Authentication
{statusState?.status?.wechat_login === true
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='wechat icon'></i>
<span style={{ fontWeight: 'bold' }}>
WeChat Authentication:
</span>
<span
style={{
color: statusState?.status?.wechat_login
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.wechat_login
? 'Enabled'
: 'Not enabled'}
: 'Disabled'}
</span>
</p>
<p>
Turnstile user verification:
{statusState?.status?.turnstile_check === true
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='shield alternate icon'></i>
<span style={{ fontWeight: 'bold' }}>
Turnstile Check:
</span>
<span
style={{
color: statusState?.status?.turnstile_check
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.turnstile_check
? 'Enabled'
: 'Not enabled'}
: 'Disabled'}
</span>
</p>
</Card.Description>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
</Segment>
</> : <>
{
homePageContent.startsWith('https://') ? <iframe
</Card.Content>
</Card>{' '}
</div>
) : (
<>
{homePageContent.startsWith('https://') ? (
<iframe
src={homePageContent}
style={{ width: '100%', height: '100vh', border: 'none' }}
/> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: homePageContent }}></div>
}
/>
) : (
<div
style={{ fontSize: 'larger' }}
dangerouslySetInnerHTML={{ __html: homePageContent }}
></div>
)}
</>
}
)}
</>
);
};

View File

@ -1,11 +1,16 @@
import React from 'react';
import { Header, Segment } from 'semantic-ui-react';
import { Card } from 'semantic-ui-react';
import LogsTable from '../../components/LogsTable';
const Token = () => (
<>
const Log = () => (
<div className='dashboard-container'>
<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 { Button, Form, Header, Segment } from 'semantic-ui-react';
import { Button, Form, Card } from 'semantic-ui-react';
import { useParams, useNavigate } from 'react-router-dom';
import { API, downloadTextAsFile, showError, showSuccess } from '../../helpers';
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
@ -13,7 +13,7 @@ const EditRedemption = () => {
const originInputs = {
name: '',
quota: 100000,
count: 1
count: 1,
};
const [inputs, setInputs] = useState(originInputs);
const { name, quota, count } = inputs;
@ -49,10 +49,13 @@ const EditRedemption = () => {
localInputs.quota = parseInt(localInputs.quota);
let res;
if (isEdit) {
res = await API.put(`/api/redemption/`, { ...localInputs, id: parseInt(redemptionId) });
res = await API.put(`/api/redemption/`, {
...localInputs,
id: parseInt(redemptionId),
});
} else {
res = await API.post(`/api/redemption/`, {
...localInputs
...localInputs,
});
}
const { success, message, data } = res.data;
@ -67,24 +70,27 @@ const EditRedemption = () => {
showError(message);
}
if (!isEdit && data) {
let text = "";
let text = '';
for (let i = 0; i < data.length; i++) {
text += data[i] + "\n";
text += data[i] + '\n';
}
downloadTextAsFile(text, `${inputs.name}.txt`);
}
};
return (
<>
<Segment loading={loading}>
<Header as='h3'>{isEdit ? 'Update redemption code information' : 'Create a new redemption code'}</Header>
<Form autoComplete='new-password'>
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>
{isEdit ? 'Update Redemption Code Information' : 'Create New Redemption Code'}
</Card.Header>
<Form loading={loading} autoComplete='new-password'>
<Form.Field>
<Form.Input
label='Name'
name='name'
placeholder={'Please enter a name'}
placeholder={'Please enter name'}
onChange={handleInputChange}
value={name}
autoComplete='new-password'
@ -95,18 +101,18 @@ const EditRedemption = () => {
<Form.Input
label={`Quota ${renderQuotaWithPrompt(quota)}`}
name='quota'
placeholder={'Please enter the quota included in a single redemption code'}
placeholder={'Please enter the quota included in each redemption code'}
onChange={handleInputChange}
value={quota}
autoComplete='new-password'
type='number'
/>
</Form.Field>
{
!isEdit && <>
{!isEdit && (
<>
<Form.Field>
<Form.Input
label='Generate quantity'
label='Quantity'
name='count'
placeholder={'Please enter the quantity to generate'}
onChange={handleInputChange}
@ -116,12 +122,15 @@ const EditRedemption = () => {
/>
</Form.Field>
</>
}
<Button positive onClick={submit}>Submit</Button>
)}
<Button positive onClick={submit}>
Submit
</Button>
<Button onClick={handleCancel}>Cancel</Button>
</Form>
</Segment>
</>
</Card.Content>
</Card>
</div>
);
};

View File

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

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Segment, Tab } from 'semantic-ui-react';
import { Card, Tab } from 'semantic-ui-react';
import SystemSetting from '../../components/SystemSetting';
import { isRoot } from '../../helpers';
import OtherSetting from '../../components/OtherSetting';
@ -14,8 +14,8 @@ const Setting = () => {
<Tab.Pane attached={false}>
<PersonalSetting />
</Tab.Pane>
)
}
),
},
];
if (isRoot()) {
@ -25,7 +25,7 @@ const Setting = () => {
<Tab.Pane attached={false}>
<OperationSetting />
</Tab.Pane>
)
),
});
panes.push({
menuItem: 'System settings',
@ -33,7 +33,7 @@ const Setting = () => {
<Tab.Pane attached={false}>
<SystemSetting />
</Tab.Pane>
)
),
});
panes.push({
menuItem: 'Other settings',
@ -41,14 +41,26 @@ const Setting = () => {
<Tab.Pane attached={false}>
<OtherSetting />
</Tab.Pane>
)
),
});
}
return (
<Segment>
<Tab menu={{ secondary: true, pointing: true }} panes={panes} />
</Segment>
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<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 { 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 { API, copy, showError, showSuccess, timestamp2string } from '../../helpers';
import {
API,
copy,
showError,
showSuccess,
timestamp2string,
} from '../../helpers';
import { renderQuotaWithPrompt } from '../../helpers/render';
const EditToken = () => {
@ -16,7 +29,7 @@ const EditToken = () => {
expired_time: -1,
unlimited_quota: false,
models: [],
subnet: "",
subnet: '',
};
const [inputs, setInputs] = useState(originInputs);
const { name, remain_quota, expired_time, unlimited_quota } = inputs;
@ -79,7 +92,7 @@ const EditToken = () => {
return {
key: model,
text: model,
value: model
value: model,
};
});
setModelOptions(options);
@ -103,7 +116,10 @@ const EditToken = () => {
localInputs.models = localInputs.models.join(',');
let res;
if (isEdit) {
res = await API.put(`/api/token/`, { ...localInputs, id: parseInt(tokenId) });
res = await API.put(`/api/token/`, {
...localInputs,
id: parseInt(tokenId),
});
} else {
res = await API.post(`/api/token/`, localInputs);
}
@ -121,15 +137,18 @@ const EditToken = () => {
};
return (
<>
<Segment loading={loading}>
<Header as='h3'>{isEdit ? 'Update key information' : 'Create a new key'}</Header>
<Form autoComplete='new-password'>
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>
{isEdit ? 'Update Token Information' : 'Create New Token'}
</Card.Header>
<Form loading={loading} autoComplete='new-password'>
<Form.Field>
<Form.Input
label='Name'
name='name'
placeholder={'Please enter a name'}
placeholder={'Please enter name'}
onChange={handleInputChange}
value={name}
autoComplete='new-password'
@ -138,8 +157,8 @@ const EditToken = () => {
</Form.Field>
<Form.Field>
<Form.Dropdown
label='Model Range'
placeholder={'Please select the allowed models, leave blank for no restriction'}
label='Model Scope'
placeholder={'Please select allowed models, leave blank for no restriction'}
name='models'
fluid
multiple
@ -158,7 +177,9 @@ const EditToken = () => {
<Form.Input
label='IP Restriction'
name='subnet'
placeholder={'Please enter the allowed subnet, e.g., 192.168.0.0/24, use commas to separate multiple subnets'}
placeholder={
'Please enter allowed subnets, e.g., 192.168.0.0/24, use commas to separate multiple subnets'
}
onChange={handleInputChange}
value={inputs.subnet}
autoComplete='new-password'
@ -168,7 +189,9 @@ const EditToken = () => {
<Form.Input
label='Expiration Time'
name='expired_time'
placeholder={'Please enter the expiration time, format: yyyy-MM-dd HH:mm:ss, -1 means unlimited'}
placeholder={
'Please enter expiration time, format: yyyy-MM-dd HH:mm:ss, -1 means no restriction'
}
onChange={handleInputChange}
value={expired_time}
autoComplete='new-password'
@ -176,28 +199,55 @@ const EditToken = () => {
/>
</Form.Field>
<div style={{ lineHeight: '40px' }}>
<Button type={'button'} onClick={() => {
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 0, 0, 0);
}}>Never expires</Button>
<Button type={'button'} onClick={() => {
}}
>
Never Expires
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(1, 0, 0, 0);
}}>Expires after one month</Button>
<Button type={'button'} onClick={() => {
}}
>
Expires in One Month
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 1, 0, 0);
}}>Expires after one day</Button>
<Button type={'button'} onClick={() => {
}}
>
Expires in One Day
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 0, 1, 0);
}}>Expires after one hour</Button>
<Button type={'button'} onClick={() => {
}}
>
Expires in One Hour
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 0, 0, 1);
}}>Expires after one minute</Button>
}}
>
Expires in One Minute
</Button>
</div>
<Message>Note that the token's quota is only used to limit the maximum usage of the token itself, and the actual usage is limited by the remaining quota of the account.</Message>
<Message>
Note, the token quota is only used to limit the maximum usage of the token itself, actual usage is subject to the account's remaining quota.
</Message>
<Form.Field>
<Form.Input
label={`Quota ${renderQuotaWithPrompt(remain_quota)}`}
name='remain_quota'
placeholder={'Please enter the quota'}
placeholder={'Please enter quota'}
onChange={handleInputChange}
value={remain_quota}
autoComplete='new-password'
@ -205,15 +255,24 @@ const EditToken = () => {
disabled={unlimited_quota}
/>
</Form.Field>
<Button type={'button'} onClick={() => {
<Button
type={'button'}
onClick={() => {
setUnlimitedQuota();
}}>{unlimited_quota ? 'Cancel unlimited quota' : 'Set to unlimited quota'}</Button>
<Button floated='right' positive onClick={submit}>Submit</Button>
<Button floated='right' onClick={handleCancel}>Cancel</Button>
}}
>
{unlimited_quota ? 'Cancel Unlimited Quota' : 'Set as Unlimited Quota'}
</Button>
<Button floated='right' positive onClick={submit}>
Submit
</Button>
<Button floated='right' onClick={handleCancel}>
Cancel
</Button>
</Form>
</Segment>
</>
</Card.Content>
</Card>
</div>
);
};
export default EditToken;

View File

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

View File

@ -1,9 +1,19 @@
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 { renderQuota } from '../../helpers/render';
import { useTranslation } from 'react-i18next';
const TopUp = () => {
const { t } = useTranslation();
const [redemptionCode, setRedemptionCode] = useState('');
const [topUpLink, setTopUpLink] = useState('');
const [userQuota, setUserQuota] = useState(0);
@ -12,17 +22,17 @@ const TopUp = () => {
const topUp = async () => {
if (redemptionCode === '') {
showInfo('Please enter the recharge code!')
showInfo(t('topup.redeem_code.empty_code'));
return;
}
setIsSubmitting(true);
try {
const res = await API.post('/api/user/topup', {
key: redemptionCode
key: redemptionCode,
});
const { success, message, data } = res.data;
if (success) {
showSuccess('Recharge successful!');
showSuccess(t('topup.redeem_code.success'));
setUserQuota((quota) => {
return quota + data;
});
@ -31,7 +41,7 @@ const TopUp = () => {
showError(message);
}
} catch (err) {
showError('Request failed');
showError(t('topup.redeem_code.request_failed'));
} finally {
setIsSubmitting(false);
}
@ -39,13 +49,12 @@ const TopUp = () => {
const openTopUpLink = () => {
if (!topUpLink) {
showError('The super administrator did not set a recharge link!');
showError(t('topup.redeem_code.no_link'));
return;
}
let url = new URL(topUpLink);
let username = user.username;
let user_id = user.id;
// add username and user_id to the topup link
url.searchParams.append('username', username);
url.searchParams.append('user_id', user_id);
url.searchParams.append('transaction_id', crypto.randomUUID());
@ -61,7 +70,7 @@ const TopUp = () => {
} else {
showError(message);
}
}
};
useEffect(() => {
let status = localStorage.getItem('status');
@ -75,37 +84,169 @@ const TopUp = () => {
}, []);
return (
<Segment>
<Header as='h3'>Recharge quota</Header>
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
<Header as='h2'>{t('topup.title')}</Header>
</Card.Header>
<Grid columns={2} stackable>
<Grid.Column>
<Form>
<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: '#2185d0', margin: '1em' }}>
<i className='credit card icon'></i>
{t('topup.get_code.title')}
</Header>
</Card.Header>
<Card.Description
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>
{t('topup.get_code.current_quota')}
</Statistic.Label>
</Statistic>
</div>
<div
style={{ textAlign: 'center', paddingBottom: '1em' }}
>
<Button
primary
size='large'
onClick={openTopUpLink}
style={{ width: '80%' }}
>
{t('topup.get_code.button')}
</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>
{t('topup.redeem_code.title')}
</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
placeholder='Redeem Code'
name='redemptionCode'
fluid
icon='key'
iconPosition='left'
placeholder={t('topup.redeem_code.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={t('topup.redeem_code.paste')}
onClick={async () => {
try {
const text =
await navigator.clipboard.readText();
setRedemptionCode(text.trim());
} catch (err) {
showError(t('topup.redeem_code.paste_error'));
}
}}
/>
<Button color='green' onClick={openTopUpLink}>
Recharge
}
/>
<div style={{ paddingBottom: '1em' }}>
<Button
color='green'
fluid
size='large'
onClick={topUp}
loading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting
? t('topup.redeem_code.submitting')
: t('topup.redeem_code.submit')}
</Button>
<Button color='yellow' onClick={topUp} disabled={isSubmitting}>
{isSubmitting ? 'Redeeming...' : 'Redeem'}
</Button>
</Form>
</Grid.Column>
<Grid.Column>
<Statistic.Group widths='one'>
<Statistic>
<Statistic.Value>{renderQuota(userQuota)}</Statistic.Value>
<Statistic.Label>Remaining quota</Statistic.Label>
</Statistic>
</Statistic.Group>
</div>
</div>
</Card.Description>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
</Segment>
</Card.Content>
</Card>
</div>
);
};

View File

@ -1,5 +1,5 @@
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 { API, showError, showSuccess } from '../../helpers';
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
@ -16,30 +16,40 @@ const EditUser = () => {
wechat_id: '',
email: '',
quota: 0,
group: 'default'
group: 'default',
});
const [groupOptions, setGroupOptions] = useState([]);
const { username, display_name, password, github_id, wechat_id, email, quota, group } =
inputs;
const {
username,
display_name,
password,
github_id,
wechat_id,
email,
quota,
group,
} = inputs;
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
setGroupOptions(res.data.data.map((group) => ({
setGroupOptions(
res.data.data.map((group) => ({
key: group,
text: group,
value: group,
})));
}))
);
} catch (error) {
showError(error.message);
}
};
const navigate = useNavigate();
const handleCancel = () => {
navigate("/setting");
}
navigate('/setting');
};
const loadUser = async () => {
let res = undefined;
if (userId) {
@ -83,15 +93,16 @@ const EditUser = () => {
};
return (
<>
<Segment loading={loading}>
<Header as='h3'>Update user information</Header>
<Form autoComplete='new-password'>
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>Update User Information</Card.Header>
<Form loading={loading} autoComplete='new-password'>
<Form.Field>
<Form.Input
label='Username'
name='username'
placeholder={'Please enter a new username'}
placeholder={'Please enter new username'}
onChange={handleInputChange}
value={username}
autoComplete='new-password'
@ -102,7 +113,7 @@ const EditUser = () => {
label='Password'
name='password'
type={'password'}
placeholder={'Please enter a new password, at least 8 characters'}
placeholder={'Please enter new password, minimum 8 characters'}
onChange={handleInputChange}
value={password}
autoComplete='new-password'
@ -110,26 +121,28 @@ const EditUser = () => {
</Form.Field>
<Form.Field>
<Form.Input
label='Display name'
label='Display Name'
name='display_name'
placeholder={'Please enter a new display name'}
placeholder={'Please enter new display name'}
onChange={handleInputChange}
value={display_name}
autoComplete='new-password'
/>
</Form.Field>
{
userId && <>
{userId && (
<>
<Form.Field>
<Form.Dropdown
label='Group'
placeholder={'Please select a group'}
placeholder={'Please select group'}
name='group'
fluid
search
selection
allowAdditions
additionLabel={'Please edit the group rate on the system settings page to add a new group:'}
additionLabel={
'Please edit group ratios in system settings to add new groups:'
}
onChange={handleInputChange}
value={inputs.group}
autoComplete='new-password'
@ -138,9 +151,9 @@ const EditUser = () => {
</Form.Field>
<Form.Field>
<Form.Input
label={`Remaining quota${renderQuotaWithPrompt(quota)}`}
label={`Remaining Quota ${renderQuotaWithPrompt(quota)}`}
name='quota'
placeholder={'Please enter a new remaining quota'}
placeholder={'Please enter new remaining quota'}
onChange={handleInputChange}
value={quota}
type={'number'}
@ -148,42 +161,45 @@ const EditUser = () => {
/>
</Form.Field>
</>
}
)}
<Form.Field>
<Form.Input
label='Bound GitHub account'
label='Bound GitHub Account'
name='github_id'
value={github_id}
autoComplete='new-password'
placeholder='This item is read-only, users need to bind through the relevant binding button on the personal settings page, cannot be directly modified'
placeholder='This field is read-only. Users need to bind through the relevant button on the personal settings page, cannot be modified directly'
readOnly
/>
</Form.Field>
<Form.Field>
<Form.Input
label='Bound WeChat account'
label='Bound WeChat Account'
name='wechat_id'
value={wechat_id}
autoComplete='new-password'
placeholder='This item is read-only, users need to bind through the relevant binding button on the personal settings page, cannot be directly modified'
placeholder='This field is read-only. Users need to bind through the relevant button on the personal settings page, cannot be modified directly'
readOnly
/>
</Form.Field>
<Form.Field>
<Form.Input
label='Bound email account'
label='Bound Email Account'
name='email'
value={email}
autoComplete='new-password'
placeholder='This item is read-only, users need to bind through the relevant binding button on the personal settings page, cannot be directly modified'
placeholder='This field is read-only. Users need to bind through the relevant button on the personal settings page, cannot be modified directly'
readOnly
/>
</Form.Field>
<Button onClick={handleCancel}>Cancel</Button>
<Button positive onClick={submit}>Submit</Button>
<Button positive onClick={submit}>
Submit
</Button>
</Form>
</Segment>
</>
</Card.Content>
</Card>
</div>
);
};

View File

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