mirror of
https://github.com/songquanpeng/one-api.git
synced 2025-09-17 09:16:36 +08:00
Merge branch 'upstream/main'
This commit is contained in:
commit
acd9cc0db5
69
.github/workflows/docker-image.yml
vendored
Normal file
69
.github/workflows/docker-image.yml
vendored
Normal 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 }}
|
40
Dockerfile
40
Dockerfile
@ -4,44 +4,48 @@ WORKDIR /web
|
|||||||
COPY ./VERSION .
|
COPY ./VERSION .
|
||||||
COPY ./web .
|
COPY ./web .
|
||||||
|
|
||||||
WORKDIR /web/default
|
RUN npm install --prefix /web/default & \
|
||||||
RUN npm install
|
npm install --prefix /web/berry & \
|
||||||
RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat ../VERSION) npm run build
|
npm install --prefix /web/air & \
|
||||||
|
wait
|
||||||
|
|
||||||
WORKDIR /web/berry
|
RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat /web/default/VERSION) npm run build --prefix /web/default & \
|
||||||
RUN npm install
|
DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat /web/berry/VERSION) npm run build --prefix /web/berry & \
|
||||||
RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat ../VERSION) npm run build
|
DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat /web/air/VERSION) npm run build --prefix /web/air & \
|
||||||
|
wait
|
||||||
WORKDIR /web/air
|
|
||||||
RUN npm install
|
|
||||||
RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat ../VERSION) npm run build
|
|
||||||
|
|
||||||
FROM golang:1.23.5-bullseye AS builder2
|
FROM golang:1.23.5-bullseye AS builder2
|
||||||
|
|
||||||
RUN apt-get update
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
RUN apt-get install -y --no-install-recommends g++ make gcc git build-essential ca-certificates \
|
build-essential \
|
||||||
&& update-ca-certificates 2>/dev/null || true \
|
sqlite3 libsqlite3-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV GO111MODULE=on \
|
ENV GO111MODULE=on \
|
||||||
CGO_ENABLED=1 \
|
CGO_ENABLED=1 \
|
||||||
GOOS=linux
|
GOOS=linux \
|
||||||
|
CGO_CFLAGS="-I/usr/include" \
|
||||||
|
CGO_LDFLAGS="-L/usr/lib"
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
ADD go.mod go.sum ./
|
ADD go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=builder /web/build ./web/build
|
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
|
FROM debian:bullseye
|
||||||
|
|
||||||
RUN apt-get update
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
RUN apt-get install -y --no-install-recommends ca-certificates haveged tzdata ffmpeg \
|
ca-certificates tzdata bash haveged ffmpeg \
|
||||||
&& update-ca-certificates 2>/dev/null || true \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder2 /build/one-api /
|
COPY --from=builder2 /build/one-api /
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
ENTRYPOINT ["/one-api"]
|
ENTRYPOINT ["/one-api"]
|
||||||
|
@ -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
|
// EnforceIncludeUsage is used to determine whether to include usage in the response
|
||||||
var EnforceIncludeUsage = env.Bool("ENFORCE_INCLUDE_USAGE", false)
|
var EnforceIncludeUsage = env.Bool("ENFORCE_INCLUDE_USAGE", false)
|
||||||
|
var TestPrompt = env.String("TEST_PROMPT", "Print your model name exactly and do not output without any other text.")
|
||||||
|
@ -14,3 +14,8 @@ func GetTimeString() string {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
return fmt.Sprintf("%s%d", now.Format("20060102150405"), now.UnixNano()%1e9)
|
return fmt.Sprintf("%s%d", now.Format("20060102150405"), now.UnixNano()%1e9)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CalcElapsedTime return the elapsed time in milliseconds (ms)
|
||||||
|
func CalcElapsedTime(start time.Time) int64 {
|
||||||
|
return time.Now().Sub(start).Milliseconds()
|
||||||
|
}
|
||||||
|
@ -2,23 +2,9 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"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"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@ -27,6 +13,24 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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 {
|
func buildTestRequest(model string) *relaymodel.GeneralOpenAIRequest {
|
||||||
@ -34,18 +38,34 @@ func buildTestRequest(model string) *relaymodel.GeneralOpenAIRequest {
|
|||||||
model = "gpt-3.5-turbo"
|
model = "gpt-3.5-turbo"
|
||||||
}
|
}
|
||||||
testRequest := &relaymodel.GeneralOpenAIRequest{
|
testRequest := &relaymodel.GeneralOpenAIRequest{
|
||||||
MaxTokens: 2,
|
Model: model,
|
||||||
Model: model,
|
|
||||||
}
|
}
|
||||||
testMessage := relaymodel.Message{
|
testMessage := relaymodel.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: "hi",
|
Content: config.TestPrompt,
|
||||||
}
|
}
|
||||||
testRequest.Messages = append(testRequest.Messages, testMessage)
|
testRequest.Messages = append(testRequest.Messages, testMessage)
|
||||||
return testRequest
|
return testRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIRequest) (err error, openaiErr *relaymodel.Error) {
|
func parseTestResponse(resp string) (*openai.TextResponse, string, error) {
|
||||||
|
var response openai.TextResponse
|
||||||
|
err := json.Unmarshal([]byte(resp), &response)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if len(response.Choices) == 0 {
|
||||||
|
return nil, "", errors.New("response has no choices")
|
||||||
|
}
|
||||||
|
stringContent, ok := response.Choices[0].Content.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, "", errors.New("response content is not string")
|
||||||
|
}
|
||||||
|
return &response, stringContent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChannel(ctx context.Context, channel *model.Channel, request *relaymodel.GeneralOpenAIRequest) (responseMessage string, err error, openaiErr *relaymodel.Error) {
|
||||||
|
startTime := time.Now()
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
c.Request = &http.Request{
|
c.Request = &http.Request{
|
||||||
@ -65,7 +85,7 @@ func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIReques
|
|||||||
apiType := channeltype.ToAPIType(channel.Type)
|
apiType := channeltype.ToAPIType(channel.Type)
|
||||||
adaptor := relay.GetAdaptor(apiType)
|
adaptor := relay.GetAdaptor(apiType)
|
||||||
if adaptor == nil {
|
if adaptor == nil {
|
||||||
return 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)
|
adaptor.Init(meta)
|
||||||
modelName := request.Model
|
modelName := request.Model
|
||||||
@ -83,41 +103,69 @@ func testChannel(channel *model.Channel, request *relaymodel.GeneralOpenAIReques
|
|||||||
request.Model = modelName
|
request.Model = modelName
|
||||||
convertedRequest, err := adaptor.ConvertRequest(c, relaymode.ChatCompletions, request)
|
convertedRequest, err := adaptor.ConvertRequest(c, relaymode.ChatCompletions, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err, nil
|
return "", err, nil
|
||||||
}
|
}
|
||||||
jsonData, err := json.Marshal(convertedRequest)
|
jsonData, err := json.Marshal(convertedRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err, nil
|
return "", err, nil
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
logContent := fmt.Sprintf("渠道 %s 测试成功,响应:%s", channel.Name, responseMessage)
|
||||||
|
if err != nil || openaiErr != nil {
|
||||||
|
errorMessage := ""
|
||||||
|
if err != nil {
|
||||||
|
errorMessage = err.Error()
|
||||||
|
} else {
|
||||||
|
errorMessage = openaiErr.Message
|
||||||
|
}
|
||||||
|
logContent = fmt.Sprintf("渠道 %s 测试失败,错误:%s", channel.Name, errorMessage)
|
||||||
|
}
|
||||||
|
go model.RecordTestLog(ctx, &model.Log{
|
||||||
|
ChannelId: channel.Id,
|
||||||
|
ModelName: modelName,
|
||||||
|
Content: logContent,
|
||||||
|
ElapsedTime: helper.CalcElapsedTime(startTime),
|
||||||
|
})
|
||||||
|
}()
|
||||||
logger.SysLog(string(jsonData))
|
logger.SysLog(string(jsonData))
|
||||||
requestBody := bytes.NewBuffer(jsonData)
|
requestBody := bytes.NewBuffer(jsonData)
|
||||||
c.Request.Body = io.NopCloser(requestBody)
|
c.Request.Body = io.NopCloser(requestBody)
|
||||||
resp, err := adaptor.DoRequest(c, meta, requestBody)
|
resp, err := adaptor.DoRequest(c, meta, requestBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err, nil
|
return "", err, nil
|
||||||
}
|
}
|
||||||
if resp != nil && resp.StatusCode != http.StatusOK {
|
if resp != nil && resp.StatusCode != http.StatusOK {
|
||||||
err := controller.RelayErrorHandler(resp)
|
err := controller.RelayErrorHandler(resp)
|
||||||
return fmt.Errorf("status code %d: %s", resp.StatusCode, err.Error.Message), &err.Error
|
errorMessage := err.Error.Message
|
||||||
|
if errorMessage != "" {
|
||||||
|
errorMessage = ", error message: " + errorMessage
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("http status code: %d%s", resp.StatusCode, errorMessage), &err.Error
|
||||||
}
|
}
|
||||||
usage, respErr := adaptor.DoResponse(c, resp, meta)
|
usage, respErr := adaptor.DoResponse(c, resp, meta)
|
||||||
if respErr != nil {
|
if respErr != nil {
|
||||||
return errors.Errorf("%s", respErr.Error.Message), &respErr.Error
|
return "", fmt.Errorf("%s", respErr.Error.Message), &respErr.Error
|
||||||
}
|
}
|
||||||
if usage == nil {
|
if usage == nil {
|
||||||
return errors.New("usage is nil"), nil
|
return "", errors.New("usage is nil"), nil
|
||||||
|
}
|
||||||
|
rawResponse := w.Body.String()
|
||||||
|
_, responseMessage, err = parseTestResponse(rawResponse)
|
||||||
|
if err != nil {
|
||||||
|
return "", err, nil
|
||||||
}
|
}
|
||||||
result := w.Result()
|
result := w.Result()
|
||||||
// print result.Body
|
// print result.Body
|
||||||
respBody, err := io.ReadAll(result.Body)
|
respBody, err := io.ReadAll(result.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err, nil
|
return "", err, nil
|
||||||
}
|
}
|
||||||
logger.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
|
logger.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
|
||||||
return nil, nil
|
return responseMessage, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestChannel(c *gin.Context) {
|
func TestChannel(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@ -134,10 +182,10 @@ func TestChannel(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
model := c.Query("model")
|
modelName := c.Query("model")
|
||||||
testRequest := buildTestRequest(model)
|
testRequest := buildTestRequest(modelName)
|
||||||
tik := time.Now()
|
tik := time.Now()
|
||||||
err, _ = testChannel(channel, testRequest)
|
responseMessage, err, _ := testChannel(ctx, channel, testRequest)
|
||||||
tok := time.Now()
|
tok := time.Now()
|
||||||
milliseconds := tok.Sub(tik).Milliseconds()
|
milliseconds := tok.Sub(tik).Milliseconds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -147,18 +195,18 @@ func TestChannel(c *gin.Context) {
|
|||||||
consumedTime := float64(milliseconds) / 1000.0
|
consumedTime := float64(milliseconds) / 1000.0
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": err.Error(),
|
"message": err.Error(),
|
||||||
"time": consumedTime,
|
"time": consumedTime,
|
||||||
"model": model,
|
"modelName": modelName,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": responseMessage,
|
||||||
"time": consumedTime,
|
"time": consumedTime,
|
||||||
"model": model,
|
"modelName": modelName,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -166,7 +214,7 @@ func TestChannel(c *gin.Context) {
|
|||||||
var testAllChannelsLock sync.Mutex
|
var testAllChannelsLock sync.Mutex
|
||||||
var testAllChannelsRunning bool = false
|
var testAllChannelsRunning bool = false
|
||||||
|
|
||||||
func testChannels(notify bool, scope string) error {
|
func testChannels(ctx context.Context, notify bool, scope string) error {
|
||||||
if config.RootUserEmail == "" {
|
if config.RootUserEmail == "" {
|
||||||
config.RootUserEmail = model.GetRootUserEmail()
|
config.RootUserEmail = model.GetRootUserEmail()
|
||||||
}
|
}
|
||||||
@ -190,7 +238,7 @@ func testChannels(notify bool, scope string) error {
|
|||||||
isChannelEnabled := channel.Status == model.ChannelStatusEnabled
|
isChannelEnabled := channel.Status == model.ChannelStatusEnabled
|
||||||
tik := time.Now()
|
tik := time.Now()
|
||||||
testRequest := buildTestRequest("")
|
testRequest := buildTestRequest("")
|
||||||
err, openaiErr := testChannel(channel, testRequest)
|
_, err, openaiErr := testChannel(ctx, channel, testRequest)
|
||||||
tok := time.Now()
|
tok := time.Now()
|
||||||
milliseconds := tok.Sub(tik).Milliseconds()
|
milliseconds := tok.Sub(tik).Milliseconds()
|
||||||
if isChannelEnabled && milliseconds > disableThreshold {
|
if isChannelEnabled && milliseconds > disableThreshold {
|
||||||
@ -224,11 +272,12 @@ func testChannels(notify bool, scope string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestChannels(c *gin.Context) {
|
func TestChannels(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
scope := c.Query("scope")
|
scope := c.Query("scope")
|
||||||
if scope == "" {
|
if scope == "" {
|
||||||
scope = "all"
|
scope = "all"
|
||||||
}
|
}
|
||||||
err := testChannels(true, scope)
|
err := testChannels(ctx, true, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
@ -244,10 +293,11 @@ func TestChannels(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func AutomaticallyTestChannels(frequency int) {
|
func AutomaticallyTestChannels(frequency int) {
|
||||||
|
ctx := context.Background()
|
||||||
for {
|
for {
|
||||||
time.Sleep(time.Duration(frequency) * time.Minute)
|
time.Sleep(time.Duration(frequency) * time.Minute)
|
||||||
logger.SysLog("testing all channels")
|
logger.SysLog("testing all channels")
|
||||||
_ = testChannels(false, "all")
|
_ = testChannels(ctx, false, "all")
|
||||||
logger.SysLog("channel test finished")
|
logger.SysLog("channel test finished")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -287,11 +287,14 @@ func ConsumeToken(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
model.RecordConsumeLog(c.Request.Context(),
|
model.RecordConsumeLog(c.Request.Context(), &model.Log{
|
||||||
userID, 0, 0, 0, tokenPatch.AddReason, cleanToken.Name,
|
UserId: userID,
|
||||||
int64(tokenPatch.AddUsedQuota),
|
ModelName: tokenPatch.AddReason,
|
||||||
fmt.Sprintf("External (%s) consumed %s",
|
TokenName: cleanToken.Name,
|
||||||
tokenPatch.AddReason, common.LogQuota(int64(tokenPatch.AddUsedQuota))))
|
Quota: int(tokenPatch.AddUsedQuota),
|
||||||
|
Content: fmt.Sprintf("External (%s) consumed %s",
|
||||||
|
tokenPatch.AddReason, common.LogQuota(int64(tokenPatch.AddUsedQuota))),
|
||||||
|
})
|
||||||
|
|
||||||
err = cleanToken.Update()
|
err = cleanToken.Update()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
3
go.mod
3
go.mod
@ -1,6 +1,5 @@
|
|||||||
module github.com/songquanpeng/one-api
|
module github.com/songquanpeng/one-api
|
||||||
|
|
||||||
// +heroku goVersion go1.18
|
|
||||||
go 1.23
|
go 1.23
|
||||||
|
|
||||||
toolchain go1.23.0
|
toolchain go1.23.0
|
||||||
@ -93,7 +92,7 @@ require (
|
|||||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
|
4
go.sum
4
go.sum
@ -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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
54
model/log.go
54
model/log.go
@ -13,19 +13,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Log struct {
|
type Log struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
UserId int `json:"user_id" gorm:"index"`
|
UserId int `json:"user_id" gorm:"index"`
|
||||||
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_type"`
|
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_type"`
|
||||||
Type int `json:"type" gorm:"index:idx_created_at_type"`
|
Type int `json:"type" gorm:"index:idx_created_at_type"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Username string `json:"username" gorm:"index:index_username_model_name,priority:2;default:''"`
|
Username string `json:"username" gorm:"index:index_username_model_name,priority:2;default:''"`
|
||||||
TokenName string `json:"token_name" gorm:"index;default:''"`
|
TokenName string `json:"token_name" gorm:"index;default:''"`
|
||||||
ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
|
ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
|
||||||
Quota int `json:"quota" gorm:"default:0"`
|
Quota int `json:"quota" gorm:"default:0"`
|
||||||
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
|
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
|
||||||
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
|
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
|
||||||
ChannelId int `json:"channel" gorm:"index"`
|
ChannelId int `json:"channel" gorm:"index"`
|
||||||
RequestId string `json:"request_id"`
|
RequestId string `json:"request_id" gorm:"default:''"`
|
||||||
|
ElapsedTime int64 `json:"elapsed_time" gorm:"default:0"` // unit is ms
|
||||||
|
IsStream bool `json:"is_stream" gorm:"default:false"`
|
||||||
|
SystemPromptReset bool `json:"system_prompt_reset" gorm:"default:false"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -34,6 +37,7 @@ const (
|
|||||||
LogTypeConsume
|
LogTypeConsume
|
||||||
LogTypeManage
|
LogTypeManage
|
||||||
LogTypeSystem
|
LogTypeSystem
|
||||||
|
LogTypeTest
|
||||||
)
|
)
|
||||||
|
|
||||||
func recordLogHelper(ctx context.Context, log *Log) {
|
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)
|
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 {
|
if !config.LogConsumeEnabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log := &Log{
|
log.Username = GetUsernameById(log.UserId)
|
||||||
UserId: userId,
|
log.CreatedAt = helper.GetTimestamp()
|
||||||
Username: GetUsernameById(userId),
|
log.Type = LogTypeConsume
|
||||||
CreatedAt: helper.GetTimestamp(),
|
recordLogHelper(ctx, log)
|
||||||
Type: LogTypeConsume,
|
}
|
||||||
Content: content,
|
|
||||||
PromptTokens: promptTokens,
|
func RecordTestLog(ctx context.Context, log *Log) {
|
||||||
CompletionTokens: completionTokens,
|
log.CreatedAt = helper.GetTimestamp()
|
||||||
TokenName: tokenName,
|
log.Type = LogTypeTest
|
||||||
ModelName: modelName,
|
|
||||||
Quota: int(quota),
|
|
||||||
ChannelId: channelId,
|
|
||||||
}
|
|
||||||
recordLogHelper(ctx, log)
|
recordLogHelper(ctx, log)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,7 +94,7 @@ func GetUserById(id int, selectAll bool) (*User, error) {
|
|||||||
if selectAll {
|
if selectAll {
|
||||||
err = DB.First(&user, "id = ?", id).Error
|
err = DB.First(&user, "id = ?", id).Error
|
||||||
} else {
|
} else {
|
||||||
err = DB.Omit("password").First(&user, "id = ?", id).Error
|
err = DB.Omit("password", "access_token").First(&user, "id = ?", id).Error
|
||||||
}
|
}
|
||||||
return &user, err
|
return &user, err
|
||||||
}
|
}
|
||||||
|
@ -33,7 +33,16 @@ func PostConsumeQuota(ctx context.Context, tokenId int, quotaDelta int64, totalQ
|
|||||||
// totalQuota is total quota consumed
|
// totalQuota is total quota consumed
|
||||||
if totalQuota != 0 {
|
if totalQuota != 0 {
|
||||||
logContent := fmt.Sprintf("model rate %.2f, group rate %.2f", modelRatio, groupRatio)
|
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.UpdateUserUsedQuotaAndRequestCount(userId, totalQuota)
|
||||||
model.UpdateChannelUsedQuota(channelId, totalQuota)
|
model.UpdateChannelUsedQuota(channelId, totalQuota)
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/songquanpeng/one-api/common"
|
"github.com/songquanpeng/one-api/common"
|
||||||
"github.com/songquanpeng/one-api/common/config"
|
"github.com/songquanpeng/one-api/common/config"
|
||||||
"github.com/songquanpeng/one-api/common/ctxkey"
|
"github.com/songquanpeng/one-api/common/ctxkey"
|
||||||
|
"github.com/songquanpeng/one-api/common/helper"
|
||||||
"github.com/songquanpeng/one-api/common/logger"
|
"github.com/songquanpeng/one-api/common/logger"
|
||||||
"github.com/songquanpeng/one-api/model"
|
"github.com/songquanpeng/one-api/model"
|
||||||
"github.com/songquanpeng/one-api/relay/adaptor/openai"
|
"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 {
|
if err != nil {
|
||||||
logger.Error(ctx, "error update user quota cache: "+err.Error())
|
logger.Error(ctx, "error update user quota cache: "+err.Error())
|
||||||
}
|
}
|
||||||
var extraLog string
|
logContent := fmt.Sprintf("model rate %.2f, group rate %.2f, completion rate %.2f", modelRatio, groupRatio, completionRatio)
|
||||||
if systemPromptReset {
|
model.RecordConsumeLog(ctx, &model.Log{
|
||||||
extraLog = " (Note: System prompt has been reset)"
|
UserId: meta.UserId,
|
||||||
}
|
ChannelId: meta.ChannelId,
|
||||||
logContent := fmt.Sprintf("model rate %.2f, group rate %.2f, completion rate %.2f%s", modelRatio, groupRatio, completionRatio, extraLog)
|
PromptTokens: promptTokens,
|
||||||
model.RecordConsumeLog(ctx, meta.UserId, meta.ChannelId, promptTokens, completionTokens, textRequest.Model, meta.TokenName, quota, logContent)
|
CompletionTokens: completionTokens,
|
||||||
|
ModelName: textRequest.Model,
|
||||||
|
TokenName: meta.TokenName,
|
||||||
|
Quota: int(quota),
|
||||||
|
Content: logContent,
|
||||||
|
IsStream: meta.IsStream,
|
||||||
|
ElapsedTime: helper.CalcElapsedTime(meta.StartTime),
|
||||||
|
SystemPromptReset: systemPromptReset,
|
||||||
|
})
|
||||||
model.UpdateUserUsedQuotaAndRequestCount(meta.UserId, quota)
|
model.UpdateUserUsedQuotaAndRequestCount(meta.UserId, quota)
|
||||||
model.UpdateChannelUsedQuota(meta.ChannelId, quota)
|
model.UpdateChannelUsedQuota(meta.ChannelId, quota)
|
||||||
|
|
||||||
|
@ -227,7 +227,16 @@ func RelayImageHelper(c *gin.Context, relayMode int) *relaymodel.ErrorWithStatus
|
|||||||
if quota >= 0 {
|
if quota >= 0 {
|
||||||
tokenName := c.GetString(ctxkey.TokenName)
|
tokenName := c.GetString(ctxkey.TokenName)
|
||||||
logContent := fmt.Sprintf("model rate %.2f, group rate %.2f", modelRatio, groupRatio)
|
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)
|
model.UpdateUserUsedQuotaAndRequestCount(meta.UserId, quota)
|
||||||
channelId := c.GetInt(ctxkey.ChannelId)
|
channelId := c.GetInt(ctxkey.ChannelId)
|
||||||
model.UpdateChannelUsedQuota(channelId, quota)
|
model.UpdateChannelUsedQuota(channelId, quota)
|
||||||
|
@ -2,6 +2,7 @@ package meta
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/songquanpeng/one-api/common/ctxkey"
|
"github.com/songquanpeng/one-api/common/ctxkey"
|
||||||
@ -33,6 +34,7 @@ type Meta struct {
|
|||||||
PromptTokens int // only for DoResponse
|
PromptTokens int // only for DoResponse
|
||||||
ChannelRatio float64
|
ChannelRatio float64
|
||||||
SystemPrompt string
|
SystemPrompt string
|
||||||
|
StartTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMappedModelName returns the mapped model name and a bool indicating if the model name is mapped
|
// 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(),
|
RequestURLPath: c.Request.URL.String(),
|
||||||
ChannelRatio: c.GetFloat64(ctxkey.ChannelRatio), // add by Laisky
|
ChannelRatio: c.GetFloat64(ctxkey.ChannelRatio), // add by Laisky
|
||||||
SystemPrompt: c.GetString(ctxkey.SystemPrompt),
|
SystemPrompt: c.GetString(ctxkey.SystemPrompt),
|
||||||
|
StartTime: time.Now(),
|
||||||
}
|
}
|
||||||
cfg, ok := c.Get(ctxkey.Config)
|
cfg, ok := c.Get(ctxkey.Config)
|
||||||
if ok {
|
if ok {
|
||||||
|
@ -28,6 +28,8 @@ function renderType(type) {
|
|||||||
return <Tag color="orange" size="large"> 管理 </Tag>;
|
return <Tag color="orange" size="large"> 管理 </Tag>;
|
||||||
case 4:
|
case 4:
|
||||||
return <Tag color="purple" size="large"> 系统 </Tag>;
|
return <Tag color="purple" size="large"> 系统 </Tag>;
|
||||||
|
case 5:
|
||||||
|
return <Tag color="violet" size="large"> 测试 </Tag>;
|
||||||
default:
|
default:
|
||||||
return <Tag color="black" size="large"> 未知 </Tag>;
|
return <Tag color="black" size="large"> 未知 </Tag>;
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,8 @@ const LOG_TYPE = {
|
|||||||
1: { value: '1', text: '充值', color: 'primary' },
|
1: { value: '1', text: '充值', color: 'primary' },
|
||||||
2: { value: '2', text: '消费', color: 'orange' },
|
2: { value: '2', text: '消费', color: 'orange' },
|
||||||
3: { value: '3', text: '管理', color: 'default' },
|
3: { value: '3', text: '管理', color: 'default' },
|
||||||
4: { value: '4', text: '系统', color: 'secondary' }
|
4: { value: '4', text: '系统', color: 'secondary' },
|
||||||
|
5: { value: '5', text: '测试', color: 'secondary' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LOG_TYPE;
|
export default LOG_TYPE;
|
||||||
|
@ -5,16 +5,22 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
|
"i18next": "23.2.3",
|
||||||
|
"i18next-browser-languagedetector": "^8.0.2",
|
||||||
|
"i18next-http-backend": "^3.0.2",
|
||||||
"marked": "^4.1.1",
|
"marked": "^4.1.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
|
"react-i18next": "^13.0.0",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"react-toastify": "^9.0.8",
|
"react-toastify": "^9.0.8",
|
||||||
"react-turnstile": "^1.0.5",
|
"react-turnstile": "^1.0.5",
|
||||||
|
"recharts": "^2.15.1",
|
||||||
"semantic-ui-css": "^2.5.0",
|
"semantic-ui-css": "^2.5.0",
|
||||||
"semantic-ui-react": "^2.1.3"
|
"semantic-ui-react": "^2.1.3",
|
||||||
|
"typescript": "4.9.5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
|
156
web/default/public/locales/en/translation.json
Normal file
156
web/default/public/locales/en/translation.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
156
web/default/public/locales/zh/translation.json
Normal file
156
web/default/public/locales/zh/translation.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -25,6 +25,7 @@ import TopUp from './pages/TopUp';
|
|||||||
import Log from './pages/Log';
|
import Log from './pages/Log';
|
||||||
import Chat from './pages/Chat';
|
import Chat from './pages/Chat';
|
||||||
import LarkOAuth from './components/LarkOAuth';
|
import LarkOAuth from './components/LarkOAuth';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
|
||||||
const Home = lazy(() => import('./pages/Home'));
|
const Home = lazy(() => import('./pages/Home'));
|
||||||
const About = lazy(() => import('./pages/About'));
|
const About = lazy(() => import('./pages/About'));
|
||||||
@ -261,11 +262,11 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
path='/topup'
|
path='/topup'
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<Suspense fallback={<Loading></Loading>}>
|
<Suspense fallback={<Loading></Loading>}>
|
||||||
<TopUp />
|
<TopUp />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
@ -292,9 +293,15 @@ function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path='*' element={
|
<Route
|
||||||
<NotFound />
|
path='/dashboard'
|
||||||
} />
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path='*' element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,16 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Dropdown, Form, Input, Label, Message, Pagination, Popup, Table } from 'semantic-ui-react';
|
import { 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 { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
API,
|
API,
|
||||||
@ -9,34 +20,38 @@ import {
|
|||||||
showError,
|
showError,
|
||||||
showInfo,
|
showInfo,
|
||||||
showSuccess,
|
showSuccess,
|
||||||
timestamp2string
|
timestamp2string,
|
||||||
} from '../helpers';
|
} from '../helpers';
|
||||||
|
|
||||||
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
|
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
|
||||||
import { renderGroup, renderNumber } from '../helpers/render';
|
import { renderGroup, renderNumber } from '../helpers/render';
|
||||||
|
|
||||||
function renderTimestamp(timestamp) {
|
function renderTimestamp(timestamp) {
|
||||||
return (
|
return <>{timestamp2string(timestamp)}</>;
|
||||||
<>
|
|
||||||
{timestamp2string(timestamp)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let type2label = undefined;
|
let type2label = undefined;
|
||||||
|
|
||||||
function renderType(type) {
|
function renderType(type, t) {
|
||||||
if (!type2label) {
|
if (!type2label) {
|
||||||
type2label = new Map;
|
type2label = new Map();
|
||||||
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
|
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
|
||||||
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
|
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
|
||||||
}
|
}
|
||||||
type2label[0] = { value: 0, text: '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) {
|
switch (type) {
|
||||||
case 1: // OpenAI
|
case 1: // OpenAI
|
||||||
return <span>${balance.toFixed(2)}</span>;
|
return <span>${balance.toFixed(2)}</span>;
|
||||||
@ -57,17 +72,18 @@ function renderBalance(type, balance) {
|
|||||||
case 44: // SiliconFlow
|
case 44: // SiliconFlow
|
||||||
return <span>¥{balance.toFixed(2)}</span>;
|
return <span>¥{balance.toFixed(2)}</span>;
|
||||||
default:
|
default:
|
||||||
return <span>Not supported</span>;
|
return <span>{t('channel.table.balance_not_supported')}</span>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isShowDetail() {
|
function isShowDetail() {
|
||||||
return localStorage.getItem("show_detail") === "true";
|
return localStorage.getItem('show_detail') === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
const promptID = "detail"
|
const promptID = 'detail';
|
||||||
|
|
||||||
const ChannelsTable = () => {
|
const ChannelsTable = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [channels, setChannels] = useState([]);
|
const [channels, setChannels] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activePage, setActivePage] = useState(1);
|
const [activePage, setActivePage] = useState(1);
|
||||||
@ -81,33 +97,37 @@ const ChannelsTable = () => {
|
|||||||
const res = await API.get(`/api/channel/?p=${startIdx}`);
|
const res = await API.get(`/api/channel/?p=${startIdx}`);
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
let localChannels = data.map((channel) => {
|
let localChannels = data.map((channel) => {
|
||||||
if (channel.models === '') {
|
if (channel.models === '') {
|
||||||
channel.models = [];
|
channel.models = [];
|
||||||
channel.test_model = "";
|
channel.test_model = '';
|
||||||
} else {
|
|
||||||
channel.models = channel.models.split(',');
|
|
||||||
if (channel.models.length > 0) {
|
|
||||||
channel.test_model = channel.models[0];
|
|
||||||
}
|
|
||||||
channel.model_options = channel.models.map((model) => {
|
|
||||||
return {
|
|
||||||
key: model,
|
|
||||||
text: model,
|
|
||||||
value: model,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
console.log('channel', channel)
|
|
||||||
}
|
|
||||||
return channel;
|
|
||||||
});
|
|
||||||
if (startIdx === 0) {
|
|
||||||
setChannels(localChannels);
|
|
||||||
} else {
|
} else {
|
||||||
let newChannels = [...channels];
|
channel.models = channel.models.split(',');
|
||||||
newChannels.splice(startIdx * ITEMS_PER_PAGE, data.length, ...localChannels);
|
if (channel.models.length > 0) {
|
||||||
setChannels(newChannels);
|
channel.test_model = channel.models[0];
|
||||||
|
}
|
||||||
|
channel.model_options = channel.models.map((model) => {
|
||||||
|
return {
|
||||||
|
key: model,
|
||||||
|
text: model,
|
||||||
|
value: model,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log('channel', channel);
|
||||||
}
|
}
|
||||||
|
return channel;
|
||||||
|
});
|
||||||
|
if (startIdx === 0) {
|
||||||
|
setChannels(localChannels);
|
||||||
|
} else {
|
||||||
|
let newChannels = [...channels];
|
||||||
|
newChannels.splice(
|
||||||
|
startIdx * ITEMS_PER_PAGE,
|
||||||
|
data.length,
|
||||||
|
...localChannels
|
||||||
|
);
|
||||||
|
setChannels(newChannels);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
@ -131,8 +151,8 @@ const ChannelsTable = () => {
|
|||||||
|
|
||||||
const toggleShowDetail = () => {
|
const toggleShowDetail = () => {
|
||||||
setShowDetail(!showDetail);
|
setShowDetail(!showDetail);
|
||||||
localStorage.setItem("show_detail", (!showDetail).toString());
|
localStorage.setItem('show_detail', (!showDetail).toString());
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadChannels(0)
|
loadChannels(0)
|
||||||
@ -193,52 +213,80 @@ const ChannelsTable = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderStatus = (status) => {
|
const renderStatus = (status, t) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 1:
|
case 1:
|
||||||
return <Label basic color='green'>Enabled</Label>;
|
return (
|
||||||
|
<Label basic color='green'>
|
||||||
|
{t('channel.table.status_enabled')}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
case 2:
|
case 2:
|
||||||
return (
|
return (
|
||||||
<Popup
|
<Popup
|
||||||
trigger={<Label basic color='red'>
|
trigger={
|
||||||
Disabled
|
<Label basic color='red'>
|
||||||
</Label>}
|
{t('channel.table.status_disabled')}
|
||||||
content='This channel has been manually disabled'
|
</Label>
|
||||||
|
}
|
||||||
|
content={t('channel.table.status_disabled_tip')}
|
||||||
basic
|
basic
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 3:
|
case 3:
|
||||||
return (
|
return (
|
||||||
<Popup
|
<Popup
|
||||||
trigger={<Label basic color='yellow'>
|
trigger={
|
||||||
Disabled
|
<Label basic color='yellow'>
|
||||||
</Label>}
|
{t('channel.table.status_auto_disabled')}
|
||||||
content='This channel has been automatically disabled by the program'
|
</Label>
|
||||||
|
}
|
||||||
|
content={t('channel.table.status_auto_disabled_tip')}
|
||||||
basic
|
basic
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Label basic color='grey'>
|
<Label basic color='grey'>
|
||||||
Unknown status
|
{t('channel.table.status_unknown')}
|
||||||
</Label>
|
</Label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderResponseTime = (responseTime) => {
|
const renderResponseTime = (responseTime, t) => {
|
||||||
let time = responseTime / 1000;
|
let time = responseTime / 1000;
|
||||||
time = time.toFixed(2) + 's';
|
time = time.toFixed(2) + 's';
|
||||||
if (responseTime === 0) {
|
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) {
|
} else if (responseTime <= 1000) {
|
||||||
return <Label basic color='green'>{time}</Label>;
|
return (
|
||||||
|
<Label basic color='green'>
|
||||||
|
{time}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
} else if (responseTime <= 3000) {
|
} else if (responseTime <= 3000) {
|
||||||
return <Label basic color='olive'>{time}</Label>;
|
return (
|
||||||
|
<Label basic color='olive'>
|
||||||
|
{time}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
} else if (responseTime <= 5000) {
|
} else if (responseTime <= 5000) {
|
||||||
return <Label basic color='yellow'>{time}</Label>;
|
return (
|
||||||
|
<Label basic color='yellow'>
|
||||||
|
{time}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return <Label basic color='red'>{time}</Label>;
|
return (
|
||||||
|
<Label basic color='red'>
|
||||||
|
{time}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -277,7 +325,14 @@ const ChannelsTable = () => {
|
|||||||
newChannels[realIdx].response_time = time * 1000;
|
newChannels[realIdx].response_time = time * 1000;
|
||||||
newChannels[realIdx].test_time = Date.now() / 1000;
|
newChannels[realIdx].test_time = Date.now() / 1000;
|
||||||
setChannels(newChannels);
|
setChannels(newChannels);
|
||||||
showInfo(`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 {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
@ -292,7 +347,7 @@ const ChannelsTable = () => {
|
|||||||
const res = await API.get(`/api/channel/test?scope=${scope}`);
|
const res = await API.get(`/api/channel/test?scope=${scope}`);
|
||||||
const { success, message } = res.data;
|
const { success, message } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
showInfo('Successfully started testing channels, please refresh the page to see the results.');
|
showInfo(t('channel.messages.test_all_started'));
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
@ -302,7 +357,9 @@ const ChannelsTable = () => {
|
|||||||
const res = await API.delete(`/api/channel/disabled`);
|
const res = await API.delete(`/api/channel/disabled`);
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
showSuccess(`Successfully deleted all disabled channels, total ${data} channels`);
|
showSuccess(
|
||||||
|
t('channel.messages.delete_disabled_success', { count: data })
|
||||||
|
);
|
||||||
await refresh();
|
await refresh();
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
@ -318,7 +375,7 @@ const ChannelsTable = () => {
|
|||||||
newChannels[realIdx].balance = balance;
|
newChannels[realIdx].balance = balance;
|
||||||
newChannels[realIdx].balance_updated_time = Date.now() / 1000;
|
newChannels[realIdx].balance_updated_time = Date.now() / 1000;
|
||||||
setChannels(newChannels);
|
setChannels(newChannels);
|
||||||
showInfo(`Channel ${name} balance updated successfully!`);
|
showInfo(t('channel.messages.balance_update_success', { name: name }));
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
@ -329,7 +386,7 @@ const ChannelsTable = () => {
|
|||||||
const res = await API.get(`/api/channel/update_balance`);
|
const res = await API.get(`/api/channel/update_balance`);
|
||||||
const { success, message } = res.data;
|
const { success, message } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
showInfo('The balance of all enabled channels has been updated!');
|
showInfo(t('channel.messages.all_balance_updated'));
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
@ -360,7 +417,6 @@ const ChannelsTable = () => {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form onSubmit={searchChannels}>
|
<Form onSubmit={searchChannels}>
|
||||||
@ -368,27 +424,27 @@ const ChannelsTable = () => {
|
|||||||
icon='search'
|
icon='search'
|
||||||
fluid
|
fluid
|
||||||
iconPosition='left'
|
iconPosition='left'
|
||||||
placeholder='Search for channel ID, name and key ...'
|
placeholder={t('channel.search')}
|
||||||
value={searchKeyword}
|
value={searchKeyword}
|
||||||
loading={searching}
|
loading={searching}
|
||||||
onChange={handleKeywordChange}
|
onChange={handleKeywordChange}
|
||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
{
|
{showPrompt && (
|
||||||
showPrompt && (
|
<Message
|
||||||
<Message onDismiss={() => {
|
onDismiss={() => {
|
||||||
setShowPrompt(false);
|
setShowPrompt(false);
|
||||||
setPromptShown(promptID);
|
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.
|
>
|
||||||
<br/>
|
{t('channel.balance_notice')}
|
||||||
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.
|
<br />
|
||||||
<br/>
|
{t('channel.test_notice')}
|
||||||
Click the Details button below to display Balance and additional TestModel Settings.
|
<br />
|
||||||
</Message>
|
{t('channel.detail_notice')}
|
||||||
)
|
</Message>
|
||||||
}
|
)}
|
||||||
<Table basic compact size='small'>
|
<Table basic={'very'} compact size='small'>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.HeaderCell
|
<Table.HeaderCell
|
||||||
@ -397,7 +453,7 @@ const ChannelsTable = () => {
|
|||||||
sortChannel('id');
|
sortChannel('id');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ID
|
{t('channel.table.id')}
|
||||||
</Table.HeaderCell>
|
</Table.HeaderCell>
|
||||||
<Table.HeaderCell
|
<Table.HeaderCell
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
@ -405,7 +461,7 @@ const ChannelsTable = () => {
|
|||||||
sortChannel('name');
|
sortChannel('name');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Name
|
{t('channel.table.name')}
|
||||||
</Table.HeaderCell>
|
</Table.HeaderCell>
|
||||||
<Table.HeaderCell
|
<Table.HeaderCell
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
@ -413,7 +469,7 @@ const ChannelsTable = () => {
|
|||||||
sortChannel('group');
|
sortChannel('group');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Group
|
{t('channel.table.group')}
|
||||||
</Table.HeaderCell>
|
</Table.HeaderCell>
|
||||||
<Table.HeaderCell
|
<Table.HeaderCell
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
@ -421,7 +477,7 @@ const ChannelsTable = () => {
|
|||||||
sortChannel('type');
|
sortChannel('type');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Type
|
{t('channel.table.type')}
|
||||||
</Table.HeaderCell>
|
</Table.HeaderCell>
|
||||||
<Table.HeaderCell
|
<Table.HeaderCell
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
@ -429,7 +485,7 @@ const ChannelsTable = () => {
|
|||||||
sortChannel('status');
|
sortChannel('status');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Status
|
{t('channel.table.status')}
|
||||||
</Table.HeaderCell>
|
</Table.HeaderCell>
|
||||||
<Table.HeaderCell
|
<Table.HeaderCell
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
@ -437,7 +493,7 @@ const ChannelsTable = () => {
|
|||||||
sortChannel('response_time');
|
sortChannel('response_time');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Response time
|
{t('channel.table.response_time')}
|
||||||
</Table.HeaderCell>
|
</Table.HeaderCell>
|
||||||
<Table.HeaderCell
|
<Table.HeaderCell
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
@ -446,7 +502,7 @@ const ChannelsTable = () => {
|
|||||||
}}
|
}}
|
||||||
hidden={!showDetail}
|
hidden={!showDetail}
|
||||||
>
|
>
|
||||||
Balance
|
{t('channel.table.balance')}
|
||||||
</Table.HeaderCell>
|
</Table.HeaderCell>
|
||||||
<Table.HeaderCell
|
<Table.HeaderCell
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
@ -454,10 +510,12 @@ const ChannelsTable = () => {
|
|||||||
sortChannel('priority');
|
sortChannel('priority');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Priority
|
{t('channel.table.priority')}
|
||||||
</Table.HeaderCell>
|
</Table.HeaderCell>
|
||||||
<Table.HeaderCell hidden={!showDetail}>TestModel</Table.HeaderCell>
|
<Table.HeaderCell hidden={!showDetail}>
|
||||||
<Table.HeaderCell>Operation</Table.HeaderCell>
|
{t('channel.table.test_model')}
|
||||||
|
</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>{t('channel.table.actions')}</Table.HeaderCell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
|
|
||||||
@ -472,51 +530,69 @@ const ChannelsTable = () => {
|
|||||||
return (
|
return (
|
||||||
<Table.Row key={channel.id}>
|
<Table.Row key={channel.id}>
|
||||||
<Table.Cell>{channel.id}</Table.Cell>
|
<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>{renderGroup(channel.group)}</Table.Cell>
|
||||||
<Table.Cell>{renderType(channel.type)}</Table.Cell>
|
<Table.Cell>{renderType(channel.type, t)}</Table.Cell>
|
||||||
<Table.Cell>{renderStatus(channel.status)}</Table.Cell>
|
<Table.Cell>{renderStatus(channel.status, t)}</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Popup
|
<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}
|
key={channel.id}
|
||||||
trigger={renderResponseTime(channel.response_time)}
|
trigger={renderResponseTime(channel.response_time, t)}
|
||||||
basic
|
basic
|
||||||
/>
|
/>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell hidden={!showDetail}>
|
<Table.Cell hidden={!showDetail}>
|
||||||
<Popup
|
<Popup
|
||||||
trigger={<span onClick={() => {
|
trigger={
|
||||||
updateChannelBalance(channel.id, channel.name, idx);
|
<span
|
||||||
}} style={{ cursor: 'pointer' }}>
|
onClick={() => {
|
||||||
{renderBalance(channel.type, channel.balance)}
|
updateChannelBalance(channel.id, channel.name, idx);
|
||||||
</span>}
|
}}
|
||||||
content='Click to refresh'
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{renderBalance(channel.type, channel.balance, t)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
content={t('channel.table.click_to_update')}
|
||||||
basic
|
basic
|
||||||
/>
|
/>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Popup
|
<Popup
|
||||||
trigger={<Input type='number' defaultValue={channel.priority} onBlur={(event) => {
|
trigger={
|
||||||
manageChannel(
|
<Input
|
||||||
channel.id,
|
type='number'
|
||||||
'priority',
|
defaultValue={channel.priority}
|
||||||
idx,
|
onBlur={(event) => {
|
||||||
event.target.value
|
manageChannel(
|
||||||
);
|
channel.id,
|
||||||
}}>
|
'priority',
|
||||||
<input style={{ maxWidth: '60px' }} />
|
idx,
|
||||||
</Input>}
|
event.target.value
|
||||||
content='Channel priority - higher value means higher priority'
|
);
|
||||||
/>
|
}}
|
||||||
</Table.Cell>
|
>
|
||||||
<Table.Cell hidden={!showDetail}>
|
<input style={{ maxWidth: '60px' }} />
|
||||||
<Dropdown
|
</Input>
|
||||||
placeholder='Please select TestModel'
|
}
|
||||||
selection
|
content={t('channel.table.priority_tip')}
|
||||||
options={channel.model_options}
|
basic
|
||||||
defaultValue={channel.test_model}
|
/>
|
||||||
onChange={(event, data) => {
|
</Table.Cell>
|
||||||
|
<Table.Cell hidden={!showDetail}>
|
||||||
|
<Dropdown
|
||||||
|
placeholder={t('channel.table.select_test_model')}
|
||||||
|
selection
|
||||||
|
options={channel.model_options}
|
||||||
|
defaultValue={channel.test_model}
|
||||||
|
onChange={(event, data) => {
|
||||||
switchTestModel(idx, data.value);
|
switchTestModel(idx, data.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -527,25 +603,20 @@ const ChannelsTable = () => {
|
|||||||
size={'small'}
|
size={'small'}
|
||||||
positive
|
positive
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
testChannel(channel.id, channel.name, idx, channel.test_model);
|
testChannel(
|
||||||
|
channel.id,
|
||||||
|
channel.name,
|
||||||
|
idx,
|
||||||
|
channel.test_model
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Test
|
{t('channel.buttons.test')}
|
||||||
</Button>
|
</Button>
|
||||||
{/*<Button*/}
|
|
||||||
{/* size={'small'}*/}
|
|
||||||
{/* positive*/}
|
|
||||||
{/* loading={updatingBalance}*/}
|
|
||||||
{/* onClick={() => {*/}
|
|
||||||
{/* updateChannelBalance(channel.id, channel.name, idx);*/}
|
|
||||||
{/* }}*/}
|
|
||||||
{/*>*/}
|
|
||||||
{/* Update balance*/}
|
|
||||||
{/*</Button>*/}
|
|
||||||
<Popup
|
<Popup
|
||||||
trigger={
|
trigger={
|
||||||
<Button size='small' negative>
|
<Button size='small' negative>
|
||||||
Delete
|
{t('channel.buttons.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
on='click'
|
on='click'
|
||||||
@ -558,7 +629,7 @@ const ChannelsTable = () => {
|
|||||||
manageChannel(channel.id, 'delete', idx);
|
manageChannel(channel.id, 'delete', idx);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete channel {channel.name}
|
{t('channel.buttons.confirm_delete')} {channel.name}
|
||||||
</Button>
|
</Button>
|
||||||
</Popup>
|
</Popup>
|
||||||
<Button
|
<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>
|
||||||
<Button
|
<Button
|
||||||
size={'small'}
|
size={'small'}
|
||||||
as={Link}
|
as={Link}
|
||||||
to={'/channel/edit/' + channel.id}
|
to={'/channel/edit/' + channel.id}
|
||||||
>
|
>
|
||||||
Edit
|
{t('channel.buttons.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
@ -589,30 +662,50 @@ const ChannelsTable = () => {
|
|||||||
|
|
||||||
<Table.Footer>
|
<Table.Footer>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.HeaderCell colSpan={showDetail ? "10" : "8"}>
|
<Table.HeaderCell colSpan={showDetail ? '10' : '8'}>
|
||||||
<Button size='small' as={Link} to='/channel/add' loading={loading}>
|
<Button
|
||||||
Add a new channel
|
size='small'
|
||||||
|
as={Link}
|
||||||
|
to='/channel/add'
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{t('channel.buttons.add')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size='small' loading={loading} onClick={()=>{testChannels("all")}}>
|
<Button
|
||||||
Test all channels
|
size='small'
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => {
|
||||||
|
testChannels('all');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('channel.buttons.test_all')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size='small' loading={loading} onClick={()=>{testChannels("disabled")}}>
|
<Button
|
||||||
Test disabled channels
|
size='small'
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => {
|
||||||
|
testChannels('disabled');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('channel.buttons.test_disabled')}
|
||||||
</Button>
|
</Button>
|
||||||
{/*<Button size='small' onClick={updateAllChannelsBalance}*/}
|
|
||||||
{/* loading={loading || updatingBalance}>Update the balance of enabled channels</Button>*/}
|
|
||||||
<Popup
|
<Popup
|
||||||
trigger={
|
trigger={
|
||||||
<Button size='small' loading={loading}>
|
<Button size='small' loading={loading}>
|
||||||
Delete disabled channels
|
{t('channel.buttons.delete_disabled')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
on='click'
|
on='click'
|
||||||
flowing
|
flowing
|
||||||
hoverable
|
hoverable
|
||||||
>
|
>
|
||||||
<Button size='small' loading={loading} negative onClick={deleteAllDisabledChannels}>
|
<Button
|
||||||
Confirm deletion
|
size='small'
|
||||||
|
loading={loading}
|
||||||
|
negative
|
||||||
|
onClick={deleteAllDisabledChannels}
|
||||||
|
>
|
||||||
|
{t('channel.buttons.confirm_delete_disabled')}
|
||||||
</Button>
|
</Button>
|
||||||
</Popup>
|
</Popup>
|
||||||
<Pagination
|
<Pagination
|
||||||
@ -626,8 +719,14 @@ const ChannelsTable = () => {
|
|||||||
(channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
|
(channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Button size='small' onClick={refresh} loading={loading}>Refresh</Button>
|
<Button size='small' onClick={refresh} loading={loading}>
|
||||||
<Button size='small' onClick={toggleShowDetail}>{showDetail ? "Hide Details" : "Details"}</Button>
|
{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.HeaderCell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Footer>
|
</Table.Footer>
|
||||||
|
@ -29,7 +29,7 @@ const Footer = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Segment vertical>
|
<Segment vertical>
|
||||||
<Container textAlign='center'>
|
<Container textAlign='center' style={{ color: '#666666' }}>
|
||||||
{footer ? (
|
{footer ? (
|
||||||
<div
|
<div
|
||||||
className='custom-footer'
|
className='custom-footer'
|
||||||
@ -37,10 +37,7 @@ const Footer = () => {
|
|||||||
></div>
|
></div>
|
||||||
) : (
|
) : (
|
||||||
<div className='custom-footer'>
|
<div className='custom-footer'>
|
||||||
<a
|
<a href='https://github.com/Laisky/one-api' target='_blank'>
|
||||||
href='https://github.com/Laisky/one-api'
|
|
||||||
target='_blank'
|
|
||||||
>
|
|
||||||
{systemName} {process.env.REACT_APP_VERSION}{' '}
|
{systemName} {process.env.REACT_APP_VERSION}{' '}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,72 +1,93 @@
|
|||||||
import React, { useContext, useState } from 'react';
|
import React, { useContext, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { UserContext } from '../context/User';
|
import { UserContext } from '../context/User';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button, Container, Dropdown, Icon, Menu, Segment } from 'semantic-ui-react';
|
import {
|
||||||
import { API, getLogo, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers';
|
Button,
|
||||||
|
Container,
|
||||||
|
Dropdown,
|
||||||
|
Icon,
|
||||||
|
Menu,
|
||||||
|
Segment,
|
||||||
|
} from 'semantic-ui-react';
|
||||||
|
import {
|
||||||
|
API,
|
||||||
|
getLogo,
|
||||||
|
getSystemName,
|
||||||
|
isAdmin,
|
||||||
|
isMobile,
|
||||||
|
showSuccess,
|
||||||
|
} from '../helpers';
|
||||||
import '../index.css';
|
import '../index.css';
|
||||||
|
|
||||||
// Header Buttons
|
// Header Buttons
|
||||||
let headerButtons = [
|
let headerButtons = [
|
||||||
{
|
{
|
||||||
name: 'Home',
|
name: 'header.home',
|
||||||
to: '/',
|
to: '/',
|
||||||
icon: 'home'
|
icon: 'home',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Channel',
|
name: 'header.channel',
|
||||||
to: '/channel',
|
to: '/channel',
|
||||||
icon: 'sitemap',
|
icon: 'sitemap',
|
||||||
admin: true
|
admin: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'API Keys',
|
name: 'header.token',
|
||||||
to: '/token',
|
to: '/token',
|
||||||
icon: 'key'
|
icon: 'key',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Redeem',
|
name: 'header.redemption',
|
||||||
to: '/redemption',
|
to: '/redemption',
|
||||||
icon: 'dollar sign',
|
icon: 'dollar sign',
|
||||||
admin: true
|
admin: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Recharge',
|
name: 'header.topup',
|
||||||
to: '/topup',
|
to: '/topup',
|
||||||
icon: 'cart'
|
icon: 'cart',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Users',
|
name: 'header.user',
|
||||||
to: '/user',
|
to: '/user',
|
||||||
icon: 'user',
|
icon: 'user',
|
||||||
admin: true
|
admin: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Logs',
|
name: 'header.dashboard',
|
||||||
|
to: '/dashboard',
|
||||||
|
icon: 'chart bar',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'header.log',
|
||||||
to: '/log',
|
to: '/log',
|
||||||
icon: 'book'
|
icon: 'book',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Settings',
|
name: 'header.setting',
|
||||||
to: '/setting',
|
to: '/setting',
|
||||||
icon: 'setting'
|
icon: 'setting',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'About',
|
name: 'header.about',
|
||||||
to: '/about',
|
to: '/about',
|
||||||
icon: 'info circle'
|
icon: 'info circle',
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (localStorage.getItem('chat_link')) {
|
if (localStorage.getItem('chat_link')) {
|
||||||
headerButtons.splice(1, 0, {
|
headerButtons.splice(1, 0, {
|
||||||
name: 'Chat',
|
name: 'header.chat',
|
||||||
to: '/chat',
|
to: '/chat',
|
||||||
icon: 'comments'
|
icon: 'comments',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const [userState, userDispatch] = useContext(UserContext);
|
const [userState, userDispatch] = useContext(UserContext);
|
||||||
let navigate = useNavigate();
|
let navigate = useNavigate();
|
||||||
|
|
||||||
@ -93,24 +114,45 @@ const Header = () => {
|
|||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
|
key={button.name}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(button.to);
|
navigate(button.to);
|
||||||
setShowSidebar(false);
|
setShowSidebar(false);
|
||||||
}}
|
}}
|
||||||
|
style={{ fontSize: '15px' }}
|
||||||
>
|
>
|
||||||
{button.name}
|
{t(button.name)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Menu.Item key={button.name} as={Link} to={button.to}>
|
<Menu.Item
|
||||||
<Icon name={button.icon} />
|
key={button.name}
|
||||||
{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>
|
</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()) {
|
if (isMobile()) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -120,21 +162,17 @@ const Header = () => {
|
|||||||
style={
|
style={
|
||||||
showSidebar
|
showSidebar
|
||||||
? {
|
? {
|
||||||
borderBottom: 'none',
|
borderBottom: 'none',
|
||||||
marginBottom: '0',
|
marginBottom: '0',
|
||||||
borderTop: 'none',
|
borderTop: 'none',
|
||||||
height: '51px'
|
height: '51px',
|
||||||
}
|
}
|
||||||
: { borderTop: 'none', height: '52px' }
|
: { borderTop: 'none', height: '52px' }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Container>
|
<Container>
|
||||||
<Menu.Item as={Link} to='/'>
|
<Menu.Item as={Link} to='/'>
|
||||||
<img
|
<img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
|
||||||
src={logo}
|
|
||||||
alt='logo'
|
|
||||||
style={{ marginRight: '0.75em' }}
|
|
||||||
/>
|
|
||||||
<div style={{ fontSize: '20px' }}>
|
<div style={{ fontSize: '20px' }}>
|
||||||
<b>{systemName}</b>
|
<b>{systemName}</b>
|
||||||
</div>
|
</div>
|
||||||
@ -150,9 +188,19 @@ const Header = () => {
|
|||||||
<Segment style={{ marginTop: 0, borderTop: '0' }}>
|
<Segment style={{ marginTop: 0, borderTop: '0' }}>
|
||||||
<Menu secondary vertical style={{ width: '100%', margin: 0 }}>
|
<Menu secondary vertical style={{ width: '100%', margin: 0 }}>
|
||||||
{renderButtons(true)}
|
{renderButtons(true)}
|
||||||
|
<Menu.Item>
|
||||||
|
<Dropdown
|
||||||
|
selection
|
||||||
|
options={languageOptions}
|
||||||
|
value={i18n.language}
|
||||||
|
onChange={(_, { value }) => changeLanguage(value)}
|
||||||
|
/>
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
{userState.user ? (
|
{userState.user ? (
|
||||||
<Button onClick={logout}>Log out</Button>
|
<Button onClick={logout} style={{ color: '#666666' }}>
|
||||||
|
{t('header.logout')}
|
||||||
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@ -161,7 +209,7 @@ const Header = () => {
|
|||||||
navigate('/login');
|
navigate('/login');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Log in
|
{t('header.login')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -169,7 +217,7 @@ const Header = () => {
|
|||||||
navigate('/register');
|
navigate('/register');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign up
|
{t('header.register')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -185,32 +233,75 @@ const Header = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu borderless style={{ borderTop: 'none' }}>
|
<Menu
|
||||||
|
borderless
|
||||||
|
style={{
|
||||||
|
borderTop: 'none',
|
||||||
|
boxShadow: 'rgba(0, 0, 0, 0.04) 0px 2px 12px 0px',
|
||||||
|
border: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Container>
|
<Container>
|
||||||
<Menu.Item as={Link} to='/' className={'hide-on-mobile'}>
|
<Menu.Item as={Link} to='/' className={'hide-on-mobile'}>
|
||||||
<img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
|
<img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
|
||||||
<div style={{ fontSize: '20px' }}>
|
<div
|
||||||
<b>{systemName}</b>
|
style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#333',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{systemName}
|
||||||
</div>
|
</div>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{renderButtons(false)}
|
{renderButtons(false)}
|
||||||
<Menu.Menu position='right'>
|
<Menu.Menu position='right'>
|
||||||
|
<Dropdown
|
||||||
|
item
|
||||||
|
options={languageOptions}
|
||||||
|
value={i18n.language}
|
||||||
|
onChange={(_, { value }) => changeLanguage(value)}
|
||||||
|
style={{
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: '400',
|
||||||
|
color: '#666',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{userState.user ? (
|
{userState.user ? (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
text={userState.user.username}
|
text={userState.user.username}
|
||||||
pointing
|
pointing
|
||||||
className='link item'
|
className='link item'
|
||||||
|
style={{
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: '400',
|
||||||
|
color: '#666',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Dropdown.Menu>
|
<Dropdown.Menu>
|
||||||
<Dropdown.Item onClick={logout}>Log out</Dropdown.Item>
|
<Dropdown.Item
|
||||||
|
onClick={logout}
|
||||||
|
style={{
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: '400',
|
||||||
|
color: '#666',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('header.logout')}
|
||||||
|
</Dropdown.Item>
|
||||||
</Dropdown.Menu>
|
</Dropdown.Menu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
) : (
|
) : (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
name='Log in'
|
name={t('header.login')}
|
||||||
as={Link}
|
as={Link}
|
||||||
to='/login'
|
to='/login'
|
||||||
className='btn btn-link'
|
className='btn btn-link'
|
||||||
|
style={{
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: '400',
|
||||||
|
color: '#666',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Menu.Menu>
|
</Menu.Menu>
|
||||||
|
@ -1,5 +1,16 @@
|
|||||||
import React, { useContext, useEffect, useState } from 'react';
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
import { Button, Divider, Form, Grid, Header, Image, Message, Modal, Segment } from 'semantic-ui-react';
|
import {
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Form,
|
||||||
|
Grid,
|
||||||
|
Header,
|
||||||
|
Image,
|
||||||
|
Message,
|
||||||
|
Modal,
|
||||||
|
Segment,
|
||||||
|
Card,
|
||||||
|
} from 'semantic-ui-react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { UserContext } from '../context/User';
|
import { UserContext } from '../context/User';
|
||||||
import { API, getLogo, showError, showSuccess, showWarning } from '../helpers';
|
import { API, getLogo, showError, showSuccess, showWarning } from '../helpers';
|
||||||
@ -10,7 +21,7 @@ const LoginForm = () => {
|
|||||||
const [inputs, setInputs] = useState({
|
const [inputs, setInputs] = useState({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
wechat_verification_code: ''
|
wechat_verification_code: '',
|
||||||
});
|
});
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
@ -63,7 +74,7 @@ const LoginForm = () => {
|
|||||||
if (username && password) {
|
if (username && password) {
|
||||||
const res = await API.post(`/api/user/login`, {
|
const res = await API.post(`/api/user/login`, {
|
||||||
username,
|
username,
|
||||||
password
|
password,
|
||||||
});
|
});
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
@ -86,95 +97,149 @@ const LoginForm = () => {
|
|||||||
return (
|
return (
|
||||||
<Grid textAlign='center' style={{ marginTop: '48px' }}>
|
<Grid textAlign='center' style={{ marginTop: '48px' }}>
|
||||||
<Grid.Column style={{ maxWidth: 450 }}>
|
<Grid.Column style={{ maxWidth: 450 }}>
|
||||||
<Header as='h2' color='' textAlign='center'>
|
<Card
|
||||||
<Image src={logo} /> User login
|
fluid
|
||||||
</Header>
|
className='chart-card'
|
||||||
<Form size='large'>
|
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
|
||||||
<Segment>
|
>
|
||||||
<Form.Input
|
<Card.Content>
|
||||||
fluid
|
<Card.Header>
|
||||||
icon='user'
|
<Header
|
||||||
iconPosition='left'
|
as='h2'
|
||||||
placeholder='Username / Email address'
|
textAlign='center'
|
||||||
name='username'
|
style={{ marginBottom: '1.5em' }}
|
||||||
value={username}
|
>
|
||||||
onChange={handleChange}
|
<Image src={logo} style={{ marginBottom: '10px' }} />
|
||||||
/>
|
<Header.Content>User Login</Header.Content>
|
||||||
<Form.Input
|
</Header>
|
||||||
fluid
|
</Card.Header>
|
||||||
icon='lock'
|
<Form size='large'>
|
||||||
iconPosition='left'
|
<Form.Input
|
||||||
placeholder='Password'
|
fluid
|
||||||
name='password'
|
icon='user'
|
||||||
type='password'
|
iconPosition='left'
|
||||||
value={password}
|
placeholder='Username / Email Address'
|
||||||
onChange={handleChange}
|
name='username'
|
||||||
/>
|
value={username}
|
||||||
<Button color='green' fluid size='large' onClick={handleSubmit}>
|
onChange={handleChange}
|
||||||
Log in
|
style={{ marginBottom: '1em' }}
|
||||||
</Button>
|
/>
|
||||||
</Segment>
|
<Form.Input
|
||||||
</Form>
|
fluid
|
||||||
<Message>
|
icon='lock'
|
||||||
Forget password?
|
iconPosition='left'
|
||||||
<Link to='/reset' className='btn btn-link'>
|
placeholder='Password'
|
||||||
Click to reset
|
name='password'
|
||||||
</Link>
|
type='password'
|
||||||
; No account?
|
value={password}
|
||||||
<Link to='/register' className='btn btn-link'>
|
onChange={handleChange}
|
||||||
Click to register
|
style={{ marginBottom: '1.5em' }}
|
||||||
</Link>
|
/>
|
||||||
</Message>
|
<Button
|
||||||
{status.github_oauth || status.wechat_login || status.lark_client_id ? (
|
fluid
|
||||||
<>
|
size='large'
|
||||||
<Divider horizontal>Or</Divider>
|
style={{
|
||||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
background: '#2F73FF', // Use a more modern blue
|
||||||
{status.github_oauth ? (
|
color: 'white',
|
||||||
<Button
|
marginBottom: '1.5em',
|
||||||
circular
|
|
||||||
color='black'
|
|
||||||
icon='github'
|
|
||||||
onClick={() => onGitHubOAuthClicked(status.github_client_id)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
{status.wechat_login ? (
|
|
||||||
<Button
|
|
||||||
circular
|
|
||||||
color='green'
|
|
||||||
icon='wechat'
|
|
||||||
onClick={onWeChatLoginClicked}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
{status.lark_client_id ? (
|
|
||||||
<div style={{
|
|
||||||
background: "radial-gradient(circle, #FFFFFF, #FFFFFF, #00D6B9, #2F73FF, #0a3A9C)",
|
|
||||||
width: "36px",
|
|
||||||
height: "36px",
|
|
||||||
borderRadius: "10em",
|
|
||||||
display: "flex",
|
|
||||||
cursor: "pointer"
|
|
||||||
}}
|
}}
|
||||||
onClick={() => onLarkOAuthClicked(status.lark_client_id)}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
<Image
|
Log In
|
||||||
src={larkIcon}
|
</Button>
|
||||||
avatar
|
</Form>
|
||||||
style={{ width: "16px", height: "16px", cursor: "pointer", margin: "auto" }}
|
|
||||||
onClick={() => onLarkOAuthClicked(status.lark_client_id)}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div>
|
||||||
<></>
|
No account?
|
||||||
)}
|
<Link to='/register' style={{ color: '#2185d0' }}>
|
||||||
</div>
|
Click to register
|
||||||
</>
|
</Link>
|
||||||
) : (
|
</div>
|
||||||
<></>
|
</div>
|
||||||
)}
|
</Message>
|
||||||
|
|
||||||
|
{(status.github_oauth ||
|
||||||
|
status.wechat_login ||
|
||||||
|
status.lark_client_id) && (
|
||||||
|
<>
|
||||||
|
<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)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{status.wechat_login && (
|
||||||
|
<Button
|
||||||
|
circular
|
||||||
|
color='green'
|
||||||
|
icon='wechat'
|
||||||
|
onClick={onWeChatLoginClicked}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{status.lark_client_id && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle, #FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF)',
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '10em',
|
||||||
|
display: 'flex',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => onLarkOAuthClicked(status.lark_client_id)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={larkIcon}
|
||||||
|
avatar
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
margin: 'auto',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
<Modal
|
<Modal
|
||||||
onClose={() => setShowWeChatLoginModal(false)}
|
onClose={() => setShowWeChatLoginModal(false)}
|
||||||
onOpen={() => setShowWeChatLoginModal(true)}
|
onOpen={() => setShowWeChatLoginModal(true)}
|
||||||
@ -198,9 +263,13 @@ const LoginForm = () => {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
color=''
|
|
||||||
fluid
|
fluid
|
||||||
size='large'
|
size='large'
|
||||||
|
style={{
|
||||||
|
background: '#2F73FF', // Use a more modern blue
|
||||||
|
color: 'white',
|
||||||
|
marginBottom: '1.5em',
|
||||||
|
}}
|
||||||
onClick={onSubmitWeChatVerificationCode}
|
onClick={onSubmitWeChatVerificationCode}
|
||||||
>
|
>
|
||||||
Log in
|
Log in
|
||||||
|
@ -1,21 +1,48 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Form, Header, Label, Pagination, Segment, Select, Table } from 'semantic-ui-react';
|
import {
|
||||||
import { API, isAdmin, showError, timestamp2string } from '../helpers';
|
Button,
|
||||||
|
Form,
|
||||||
|
Header,
|
||||||
|
Label,
|
||||||
|
Pagination,
|
||||||
|
Segment,
|
||||||
|
Select,
|
||||||
|
Table,
|
||||||
|
} from 'semantic-ui-react';
|
||||||
|
import {
|
||||||
|
API,
|
||||||
|
copy,
|
||||||
|
isAdmin,
|
||||||
|
showError,
|
||||||
|
showSuccess,
|
||||||
|
showWarning,
|
||||||
|
timestamp2string,
|
||||||
|
} from '../helpers';
|
||||||
|
|
||||||
import { ITEMS_PER_PAGE } from '../constants';
|
import { ITEMS_PER_PAGE } from '../constants';
|
||||||
import { renderQuota } from '../helpers/render';
|
import { renderColorLabel, renderQuota } from '../helpers/render';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
function renderTimestamp(timestamp) {
|
function renderTimestamp(timestamp, request_id) {
|
||||||
return (
|
return (
|
||||||
<>
|
<code
|
||||||
|
onClick={async () => {
|
||||||
|
if (await copy(request_id)) {
|
||||||
|
showSuccess(`Request ID copied: ${request_id}`);
|
||||||
|
} else {
|
||||||
|
showWarning(`Failed to copy request ID: ${request_id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
{timestamp2string(timestamp)}
|
{timestamp2string(timestamp)}
|
||||||
</>
|
</code>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODE_OPTIONS = [
|
const MODE_OPTIONS = [
|
||||||
{ key: 'all', text: 'All users', value: 'all' },
|
{ key: 'all', text: 'All Users', value: 'all' },
|
||||||
{ key: 'self', text: 'Current user', value: 'self' }
|
{ key: 'self', text: 'Current User', value: 'self' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const LOG_OPTIONS = [
|
const LOG_OPTIONS = [
|
||||||
@ -23,24 +50,92 @@ const LOG_OPTIONS = [
|
|||||||
{ key: '1', text: 'Recharge', value: 1 },
|
{ key: '1', text: 'Recharge', value: 1 },
|
||||||
{ key: '2', text: 'Consumption', value: 2 },
|
{ key: '2', text: 'Consumption', value: 2 },
|
||||||
{ key: '3', text: 'Management', value: 3 },
|
{ 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) {
|
function renderType(type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 1:
|
case 1:
|
||||||
return <Label basic color='green'> Recharge </Label>;
|
return (
|
||||||
|
<Label basic color='green'>
|
||||||
|
Recharge
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
case 2:
|
case 2:
|
||||||
return <Label basic color='olive'> Consumption </Label>;
|
return (
|
||||||
|
<Label basic color='olive'>
|
||||||
|
Consumption
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
case 3:
|
case 3:
|
||||||
return <Label basic color='orange'> Management </Label>;
|
return (
|
||||||
|
<Label basic color='orange'>
|
||||||
|
Management
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
case 4:
|
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:
|
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 LogsTable = () => {
|
||||||
const [logs, setLogs] = useState([]);
|
const [logs, setLogs] = useState([]);
|
||||||
const [showStat, setShowStat] = useState(false);
|
const [showStat, setShowStat] = useState(false);
|
||||||
@ -57,13 +152,20 @@ const LogsTable = () => {
|
|||||||
model_name: '',
|
model_name: '',
|
||||||
start_timestamp: timestamp2string(0),
|
start_timestamp: timestamp2string(0),
|
||||||
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
|
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
|
||||||
channel: ''
|
channel: '',
|
||||||
});
|
});
|
||||||
const { username, token_name, model_name, start_timestamp, end_timestamp, channel } = inputs;
|
const {
|
||||||
|
username,
|
||||||
|
token_name,
|
||||||
|
model_name,
|
||||||
|
start_timestamp,
|
||||||
|
end_timestamp,
|
||||||
|
channel,
|
||||||
|
} = inputs;
|
||||||
|
|
||||||
const [stat, setStat] = useState({
|
const [stat, setStat] = useState({
|
||||||
quota: 0,
|
quota: 0,
|
||||||
token: 0
|
token: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleInputChange = (e, { name, value }) => {
|
const handleInputChange = (e, { name, value }) => {
|
||||||
@ -73,7 +175,9 @@ const LogsTable = () => {
|
|||||||
const getLogSelfStat = async () => {
|
const getLogSelfStat = async () => {
|
||||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||||
let res = await API.get(`/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`);
|
let res = await API.get(
|
||||||
|
`/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`
|
||||||
|
);
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
setStat(data);
|
setStat(data);
|
||||||
@ -85,7 +189,9 @@ const LogsTable = () => {
|
|||||||
const getLogStat = async () => {
|
const getLogStat = async () => {
|
||||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||||
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||||
let res = await API.get(`/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`);
|
let res = await API.get(
|
||||||
|
`/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`
|
||||||
|
);
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
setStat(data);
|
setStat(data);
|
||||||
@ -105,6 +211,10 @@ const LogsTable = () => {
|
|||||||
setShowStat(!showStat);
|
setShowStat(!showStat);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showUserTokenQuota = () => {
|
||||||
|
return logType !== 5;
|
||||||
|
};
|
||||||
|
|
||||||
const loadLogs = async (startIdx) => {
|
const loadLogs = async (startIdx) => {
|
||||||
let url = '';
|
let url = '';
|
||||||
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||||
@ -197,43 +307,88 @@ const LogsTable = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Segment>
|
<>
|
||||||
<Header as='h3'>
|
<Header as='h3'>
|
||||||
Usages(Total consumption limit:
|
Usages (Total consumption limit:
|
||||||
{showStat && renderQuota(stat.quota)}
|
{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>
|
</Header>
|
||||||
<Form>
|
<Form>
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<Form.Input fluid label={'Key name'} width={3} value={token_name}
|
<Form.Input
|
||||||
placeholder={'Optional values'} name='token_name' onChange={handleInputChange} />
|
fluid
|
||||||
<Form.Input fluid label='Model name' width={3} value={model_name} placeholder='Optional values'
|
label={'Token Name'}
|
||||||
name='model_name'
|
width={3}
|
||||||
onChange={handleInputChange} />
|
value={token_name}
|
||||||
<Form.Input fluid label='Start time' width={4} value={start_timestamp} type='datetime-local'
|
placeholder={'Optional'}
|
||||||
name='start_timestamp'
|
name='token_name'
|
||||||
onChange={handleInputChange} />
|
onChange={handleInputChange}
|
||||||
<Form.Input fluid label='End time' width={4} value={end_timestamp} type='datetime-local'
|
/>
|
||||||
name='end_timestamp'
|
<Form.Input
|
||||||
onChange={handleInputChange} />
|
fluid
|
||||||
<Form.Button fluid label='Operation' width={2} onClick={refresh}>Query</Form.Button>
|
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'
|
||||||
|
name='start_timestamp'
|
||||||
|
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='Action' width={2} onClick={refresh}>
|
||||||
|
Search
|
||||||
|
</Form.Button>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
{
|
{isAdminUser && (
|
||||||
isAdminUser && <>
|
<>
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<Form.Input fluid label={'Channel ID'} width={3} value={channel}
|
<Form.Input
|
||||||
placeholder='Optional values' name='channel'
|
fluid
|
||||||
onChange={handleInputChange} />
|
label={'Channel ID'}
|
||||||
<Form.Input fluid label={'User name'} width={3} value={username}
|
width={3}
|
||||||
placeholder={'Optional values'} name='username'
|
value={channel}
|
||||||
onChange={handleInputChange} />
|
placeholder='Optional'
|
||||||
|
name='channel'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
fluid
|
||||||
|
label={'Username'}
|
||||||
|
width={3}
|
||||||
|
value={username}
|
||||||
|
placeholder={'Optional'}
|
||||||
|
name='username'
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
</>
|
</>
|
||||||
}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
<Table basic compact size='small'>
|
<Table basic={'very'} compact size='small'>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.HeaderCell
|
<Table.HeaderCell
|
||||||
@ -245,8 +400,8 @@ const LogsTable = () => {
|
|||||||
>
|
>
|
||||||
Time
|
Time
|
||||||
</Table.HeaderCell>
|
</Table.HeaderCell>
|
||||||
{
|
{isAdminUser && (
|
||||||
isAdminUser && <Table.HeaderCell
|
<Table.HeaderCell
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
sortLog('channel');
|
sortLog('channel');
|
||||||
@ -255,27 +410,7 @@ const LogsTable = () => {
|
|||||||
>
|
>
|
||||||
Channel
|
Channel
|
||||||
</Table.HeaderCell>
|
</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
|
<Table.HeaderCell
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -294,33 +429,57 @@ const LogsTable = () => {
|
|||||||
>
|
>
|
||||||
Model
|
Model
|
||||||
</Table.HeaderCell>
|
</Table.HeaderCell>
|
||||||
<Table.HeaderCell
|
{showUserTokenQuota() && (
|
||||||
style={{ cursor: 'pointer' }}
|
<>
|
||||||
onClick={() => {
|
{isAdminUser && (
|
||||||
sortLog('prompt_tokens');
|
<Table.HeaderCell
|
||||||
}}
|
style={{ cursor: 'pointer' }}
|
||||||
width={1}
|
onClick={() => {
|
||||||
>
|
sortLog('username');
|
||||||
Prompt
|
}}
|
||||||
</Table.HeaderCell>
|
width={1}
|
||||||
<Table.HeaderCell
|
>
|
||||||
style={{ cursor: 'pointer' }}
|
User
|
||||||
onClick={() => {
|
</Table.HeaderCell>
|
||||||
sortLog('completion_tokens');
|
)}
|
||||||
}}
|
<Table.HeaderCell
|
||||||
width={1}
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
onClick={() => {
|
||||||
Completion
|
sortLog('token_name');
|
||||||
</Table.HeaderCell>
|
}}
|
||||||
<Table.HeaderCell
|
width={1}
|
||||||
style={{ cursor: 'pointer' }}
|
>
|
||||||
onClick={() => {
|
Token
|
||||||
sortLog('quota');
|
</Table.HeaderCell>
|
||||||
}}
|
<Table.HeaderCell
|
||||||
width={1}
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
onClick={() => {
|
||||||
Cost
|
sortLog('prompt_tokens');
|
||||||
</Table.HeaderCell>
|
}}
|
||||||
|
width={1}
|
||||||
|
>
|
||||||
|
Prompt
|
||||||
|
</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => {
|
||||||
|
sortLog('completion_tokens');
|
||||||
|
}}
|
||||||
|
width={1}
|
||||||
|
>
|
||||||
|
Completion
|
||||||
|
</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => {
|
||||||
|
sortLog('quota');
|
||||||
|
}}
|
||||||
|
width={1}
|
||||||
|
>
|
||||||
|
Quota
|
||||||
|
</Table.HeaderCell>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Table.HeaderCell
|
<Table.HeaderCell
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -343,24 +502,64 @@ const LogsTable = () => {
|
|||||||
if (log.deleted) return <></>;
|
if (log.deleted) return <></>;
|
||||||
return (
|
return (
|
||||||
<Table.Row key={log.id}>
|
<Table.Row key={log.id}>
|
||||||
<Table.Cell>{renderTimestamp(log.created_at)}</Table.Cell>
|
<Table.Cell>
|
||||||
{
|
{renderTimestamp(log.created_at, log.request_id)}
|
||||||
isAdminUser && (
|
</Table.Cell>
|
||||||
<Table.Cell>{log.channel ? <Label basic>{log.channel}</Label> : ''}</Table.Cell>
|
{isAdminUser && (
|
||||||
)
|
<Table.Cell>
|
||||||
}
|
{log.channel ? (
|
||||||
{
|
<Label
|
||||||
isAdminUser && (
|
basic
|
||||||
<Table.Cell>{log.username ? <Label>{log.username}</Label> : ''}</Table.Cell>
|
as={Link}
|
||||||
)
|
to={`/channel/edit/${log.channel}`}
|
||||||
}
|
>
|
||||||
<Table.Cell>{log.token_name ? <Label basic>{log.token_name}</Label> : ''}</Table.Cell>
|
{log.channel}
|
||||||
|
</Label>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
)}
|
||||||
<Table.Cell>{renderType(log.type)}</Table.Cell>
|
<Table.Cell>{renderType(log.type)}</Table.Cell>
|
||||||
<Table.Cell>{log.model_name ? <Label basic>{log.model_name}</Label> : ''}</Table.Cell>
|
<Table.Cell>
|
||||||
<Table.Cell>{log.prompt_tokens ? log.prompt_tokens : ''}</Table.Cell>
|
{log.model_name ? renderColorLabel(log.model_name) : ''}
|
||||||
<Table.Cell>{log.completion_tokens ? log.completion_tokens : ''}</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>{log.quota ? renderQuota(log.quota, 6) : 'free'}</Table.Cell>
|
{showUserTokenQuota() && (
|
||||||
<Table.Cell>{log.content}</Table.Cell>
|
<>
|
||||||
|
{isAdminUser && (
|
||||||
|
<Table.Cell>
|
||||||
|
{log.username ? (
|
||||||
|
<Label
|
||||||
|
basic
|
||||||
|
as={Link}
|
||||||
|
to={`/user/edit/${log.user_id}`}
|
||||||
|
>
|
||||||
|
{log.username}
|
||||||
|
</Label>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
<Table.Cell>
|
||||||
|
{log.token_name
|
||||||
|
? renderColorLabel(log.token_name)
|
||||||
|
: ''}
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
<Table.Cell>
|
||||||
|
{log.prompt_tokens ? log.prompt_tokens : ''}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{log.completion_tokens ? log.completion_tokens : ''}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{log.quota ? renderQuota(log.quota, 6) : ''}
|
||||||
|
</Table.Cell>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table.Cell>{renderDetail(log)}</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -379,7 +578,9 @@ const LogsTable = () => {
|
|||||||
setLogType(value);
|
setLogType(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button size='small' onClick={refresh} loading={loading}>Refresh</Button>
|
<Button size='small' onClick={refresh} loading={loading}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
<Pagination
|
<Pagination
|
||||||
floated='right'
|
floated='right'
|
||||||
activePage={activePage}
|
activePage={activePage}
|
||||||
@ -395,7 +596,7 @@ const LogsTable = () => {
|
|||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Footer>
|
</Table.Footer>
|
||||||
</Table>
|
</Table>
|
||||||
</Segment>
|
</>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,21 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
|
import {
|
||||||
import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers';
|
Button,
|
||||||
|
Form,
|
||||||
|
Grid,
|
||||||
|
Header,
|
||||||
|
Image,
|
||||||
|
Card,
|
||||||
|
Message,
|
||||||
|
} from 'semantic-ui-react';
|
||||||
|
import {
|
||||||
|
API,
|
||||||
|
copy,
|
||||||
|
showError,
|
||||||
|
showInfo,
|
||||||
|
showNotice,
|
||||||
|
showSuccess,
|
||||||
|
} from '../helpers';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
const PasswordResetConfirm = () => {
|
const PasswordResetConfirm = () => {
|
||||||
@ -37,7 +52,7 @@ const PasswordResetConfirm = () => {
|
|||||||
setDisableButton(false);
|
setDisableButton(false);
|
||||||
setCountdown(30);
|
setCountdown(30);
|
||||||
}
|
}
|
||||||
return () => clearInterval(countdownInterval);
|
return () => clearInterval(countdownInterval);
|
||||||
}, [disableButton, countdown]);
|
}, [disableButton, countdown]);
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
@ -59,55 +74,86 @@ const PasswordResetConfirm = () => {
|
|||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid textAlign='center' style={{ marginTop: '48px' }}>
|
<Grid textAlign='center' style={{ marginTop: '48px' }}>
|
||||||
<Grid.Column style={{ maxWidth: 450 }}>
|
<Grid.Column style={{ maxWidth: 450 }}>
|
||||||
<Header as='h2' color='' textAlign='center'>
|
<Card
|
||||||
<Image src='/logo.png' /> Password reset confirmation
|
fluid
|
||||||
</Header>
|
className='chart-card'
|
||||||
<Form size='large'>
|
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
|
||||||
<Segment>
|
>
|
||||||
<Form.Input
|
<Card.Content>
|
||||||
fluid
|
<Card.Header>
|
||||||
icon='mail'
|
<Header
|
||||||
iconPosition='left'
|
as='h2'
|
||||||
placeholder='Email address'
|
textAlign='center'
|
||||||
name='email'
|
style={{ marginBottom: '1.5em' }}
|
||||||
value={email}
|
>
|
||||||
readOnly
|
<Image src='/logo.png' style={{ marginBottom: '10px' }} />
|
||||||
/>
|
<Header.Content>Password Reset Confirmation</Header.Content>
|
||||||
{newPassword && (
|
</Header>
|
||||||
|
</Card.Header>
|
||||||
|
<Form size='large'>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
fluid
|
fluid
|
||||||
icon='lock'
|
icon='mail'
|
||||||
iconPosition='left'
|
iconPosition='left'
|
||||||
placeholder='New password'
|
placeholder='Email Address'
|
||||||
name='newPassword'
|
name='email'
|
||||||
value={newPassword}
|
value={email}
|
||||||
readOnly
|
readOnly
|
||||||
onClick={(e) => {
|
style={{ marginBottom: '1em' }}
|
||||||
e.target.select();
|
/>
|
||||||
navigator.clipboard.writeText(newPassword);
|
{newPassword && (
|
||||||
showNotice(`Password has been copied to the clipboard:${newPassword}`);
|
<Form.Input
|
||||||
}}
|
fluid
|
||||||
/>
|
icon='lock'
|
||||||
|
iconPosition='left'
|
||||||
|
placeholder='New Password'
|
||||||
|
name='newPassword'
|
||||||
|
value={newPassword}
|
||||||
|
readOnly
|
||||||
|
style={{
|
||||||
|
marginBottom: '1em',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.target.select();
|
||||||
|
navigator.clipboard.writeText(newPassword);
|
||||||
|
showNotice(`Password has been copied to the clipboard: ${newPassword}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
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'}
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
<Button
|
</Card.Content>
|
||||||
color='green'
|
</Card>
|
||||||
fluid
|
|
||||||
size='large'
|
|
||||||
onClick={handleSubmit}
|
|
||||||
loading={loading}
|
|
||||||
disabled={disableButton}
|
|
||||||
>
|
|
||||||
{disableButton ? `Password reset complete` : 'Submit'}
|
|
||||||
</Button>
|
|
||||||
</Segment>
|
|
||||||
</Form>
|
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PasswordResetConfirm;
|
export default PasswordResetConfirm;
|
||||||
|
@ -1,11 +1,19 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Grid,
|
||||||
|
Header,
|
||||||
|
Image,
|
||||||
|
Card,
|
||||||
|
Message,
|
||||||
|
} from 'semantic-ui-react';
|
||||||
import { API, showError, showInfo, showSuccess } from '../helpers';
|
import { API, showError, showInfo, showSuccess } from '../helpers';
|
||||||
import Turnstile from 'react-turnstile';
|
import Turnstile from 'react-turnstile';
|
||||||
|
|
||||||
const PasswordResetForm = () => {
|
const PasswordResetForm = () => {
|
||||||
const [inputs, setInputs] = useState({
|
const [inputs, setInputs] = useState({
|
||||||
email: ''
|
email: '',
|
||||||
});
|
});
|
||||||
const { email } = inputs;
|
const { email } = inputs;
|
||||||
|
|
||||||
@ -42,7 +50,7 @@ const PasswordResetForm = () => {
|
|||||||
|
|
||||||
function handleChange(e) {
|
function handleChange(e) {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setInputs(inputs => ({ ...inputs, [name]: value }));
|
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
@ -69,42 +77,72 @@ const PasswordResetForm = () => {
|
|||||||
return (
|
return (
|
||||||
<Grid textAlign='center' style={{ marginTop: '48px' }}>
|
<Grid textAlign='center' style={{ marginTop: '48px' }}>
|
||||||
<Grid.Column style={{ maxWidth: 450 }}>
|
<Grid.Column style={{ maxWidth: 450 }}>
|
||||||
<Header as='h2' color='' textAlign='center'>
|
<Card
|
||||||
<Image src='/logo.png' /> Password reset
|
fluid
|
||||||
</Header>
|
className='chart-card'
|
||||||
<Form size='large'>
|
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
|
||||||
<Segment>
|
>
|
||||||
<Form.Input
|
<Card.Content>
|
||||||
fluid
|
<Card.Header>
|
||||||
icon='mail'
|
<Header
|
||||||
iconPosition='left'
|
as='h2'
|
||||||
placeholder='Email address'
|
textAlign='center'
|
||||||
name='email'
|
style={{ marginBottom: '1.5em' }}
|
||||||
value={email}
|
>
|
||||||
onChange={handleChange}
|
<Image src='/logo.png' style={{ marginBottom: '10px' }} />
|
||||||
/>
|
<Header.Content>Password Reset</Header.Content>
|
||||||
{turnstileEnabled ? (
|
</Header>
|
||||||
<Turnstile
|
</Card.Header>
|
||||||
sitekey={turnstileSiteKey}
|
<Form size='large'>
|
||||||
onVerify={(token) => {
|
<Form.Input
|
||||||
setTurnstileToken(token);
|
fluid
|
||||||
}}
|
icon='mail'
|
||||||
|
iconPosition='left'
|
||||||
|
placeholder='Email Address'
|
||||||
|
name='email'
|
||||||
|
value={email}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={{ marginBottom: '1em' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
{turnstileEnabled && (
|
||||||
<></>
|
<div
|
||||||
)}
|
style={{
|
||||||
<Button
|
marginBottom: '1em',
|
||||||
color='green'
|
display: 'flex',
|
||||||
fluid
|
justifyContent: 'center',
|
||||||
size='large'
|
}}
|
||||||
onClick={handleSubmit}
|
>
|
||||||
loading={loading}
|
<Turnstile
|
||||||
disabled={disableButton}
|
sitekey={turnstileSiteKey}
|
||||||
>
|
onVerify={(token) => {
|
||||||
{disableButton ? `Retry (${countdown})` : 'Submit'}
|
setTurnstileToken(token);
|
||||||
</Button>
|
}}
|
||||||
</Segment>
|
/>
|
||||||
</Form>
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
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>
|
||||||
|
</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.Column>
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
|
@ -1,29 +1,56 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Form, Label, Popup, Pagination, Table } from 'semantic-ui-react';
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Label,
|
||||||
|
Popup,
|
||||||
|
Pagination,
|
||||||
|
Table,
|
||||||
|
} from 'semantic-ui-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { API, copy, showError, showInfo, showSuccess, showWarning, timestamp2string } from '../helpers';
|
import {
|
||||||
|
API,
|
||||||
|
copy,
|
||||||
|
showError,
|
||||||
|
showInfo,
|
||||||
|
showSuccess,
|
||||||
|
showWarning,
|
||||||
|
timestamp2string,
|
||||||
|
} from '../helpers';
|
||||||
|
|
||||||
import { ITEMS_PER_PAGE } from '../constants';
|
import { ITEMS_PER_PAGE } from '../constants';
|
||||||
import { renderQuota } from '../helpers/render';
|
import { renderQuota } from '../helpers/render';
|
||||||
|
|
||||||
function renderTimestamp(timestamp) {
|
function renderTimestamp(timestamp) {
|
||||||
return (
|
return <>{timestamp2string(timestamp)}</>;
|
||||||
<>
|
|
||||||
{timestamp2string(timestamp)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStatus(status) {
|
function renderStatus(status) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 1:
|
case 1:
|
||||||
return <Label basic color='green'>Not used</Label>;
|
return (
|
||||||
|
<Label basic color='green'>
|
||||||
|
Unused
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
case 2:
|
case 2:
|
||||||
return <Label basic color='red'> Disabled </Label>;
|
return (
|
||||||
|
<Label basic color='red'>
|
||||||
|
Disabled
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
case 3:
|
case 3:
|
||||||
return <Label basic color='grey'> Used </Label>;
|
return (
|
||||||
|
<Label basic color='grey'>
|
||||||
|
Used
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return <Label basic color='black'> Unknown status </Label>;
|
return (
|
||||||
|
<Label basic color='black'>
|
||||||
|
Unknown status
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,7 +137,9 @@ const RedemptionsTable = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
const res = await API.get(`/api/redemption/search?keyword=${searchKeyword}`);
|
const res = await API.get(
|
||||||
|
`/api/redemption/search?keyword=${searchKeyword}`
|
||||||
|
);
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
setRedemptions(data);
|
setRedemptions(data);
|
||||||
@ -159,7 +188,7 @@ const RedemptionsTable = () => {
|
|||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table basic compact size='small'>
|
<Table basic={'very'} compact size='small'>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.HeaderCell
|
<Table.HeaderCell
|
||||||
@ -222,80 +251,95 @@ const RedemptionsTable = () => {
|
|||||||
)
|
)
|
||||||
.map((redemption, idx) => {
|
.map((redemption, idx) => {
|
||||||
if (redemption.deleted) return <></>;
|
if (redemption.deleted) return <></>;
|
||||||
return (
|
return (
|
||||||
<Table.Row key={redemption.id}>
|
<Table.Row key={redemption.id}>
|
||||||
<Table.Cell>{redemption.id}</Table.Cell>
|
<Table.Cell>{redemption.id}</Table.Cell>
|
||||||
<Table.Cell>{redemption.name ? redemption.name : 'None'}</Table.Cell>
|
<Table.Cell>
|
||||||
|
{redemption.name ? redemption.name : 'None'}
|
||||||
|
</Table.Cell>
|
||||||
<Table.Cell>{renderStatus(redemption.status)}</Table.Cell>
|
<Table.Cell>{renderStatus(redemption.status)}</Table.Cell>
|
||||||
<Table.Cell>{renderQuota(redemption.quota)}</Table.Cell>
|
<Table.Cell>{renderQuota(redemption.quota)}</Table.Cell>
|
||||||
<Table.Cell>{renderTimestamp(redemption.created_time)}</Table.Cell>
|
|
||||||
<Table.Cell>{redemption.redeemed_time ? renderTimestamp(redemption.redeemed_time) : "Not yet redeemed"} </Table.Cell>
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div>
|
{renderTimestamp(redemption.created_time)}
|
||||||
<Button
|
</Table.Cell>
|
||||||
size={'small'}
|
<Table.Cell>
|
||||||
positive
|
{redemption.redeemed_time
|
||||||
onClick={async () => {
|
? renderTimestamp(redemption.redeemed_time)
|
||||||
if (await copy(redemption.key)) {
|
: 'Not redeemed yet'}{' '}
|
||||||
showSuccess('Copied to clipboard!');
|
</Table.Cell>
|
||||||
} else {
|
<Table.Cell>
|
||||||
showWarning('Unable to copy to clipboard, please copy manually. The redemption code has been filled in the search box.')
|
<div>
|
||||||
setSearchKeyword(redemption.key);
|
<Button
|
||||||
}
|
size={'small'}
|
||||||
}}
|
positive
|
||||||
>
|
onClick={async () => {
|
||||||
Copy
|
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 into the search box.'
|
||||||
|
);
|
||||||
|
setSearchKeyword(redemption.key);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
<Popup
|
||||||
|
trigger={
|
||||||
|
<Button size='small' negative>
|
||||||
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
<Popup
|
}
|
||||||
trigger={
|
on='click'
|
||||||
<Button size='small' negative>
|
flowing
|
||||||
Delete
|
hoverable
|
||||||
</Button>
|
>
|
||||||
}
|
<Button
|
||||||
on='click'
|
negative
|
||||||
flowing
|
onClick={() => {
|
||||||
hoverable
|
manageRedemption(redemption.id, 'delete', idx);
|
||||||
>
|
}}
|
||||||
<Button
|
>
|
||||||
negative
|
Confirm deletion
|
||||||
onClick={() => {
|
</Button>
|
||||||
manageRedemption(redemption.id, 'delete', idx);
|
</Popup>
|
||||||
}}
|
<Button
|
||||||
>
|
size={'small'}
|
||||||
Confirm deletion
|
disabled={redemption.status === 3} // used
|
||||||
</Button>
|
onClick={() => {
|
||||||
</Popup>
|
manageRedemption(
|
||||||
<Button
|
redemption.id,
|
||||||
size={'small'}
|
redemption.status === 1 ? 'disable' : 'enable',
|
||||||
disabled={redemption.status === 3} // used
|
idx
|
||||||
onClick={() => {
|
);
|
||||||
manageRedemption(
|
}}
|
||||||
redemption.id,
|
>
|
||||||
redemption.status === 1 ? 'disable' : 'enable',
|
{redemption.status === 1 ? 'Disable' : 'Enable'}
|
||||||
idx
|
</Button>
|
||||||
);
|
<Button
|
||||||
}}
|
size={'small'}
|
||||||
>
|
as={Link}
|
||||||
{redemption.status === 1 ? 'Disable' : 'Enable'}
|
to={'/redemption/edit/' + redemption.id}
|
||||||
</Button>
|
>
|
||||||
<Button
|
Edit
|
||||||
size={'small'}
|
</Button>
|
||||||
as={Link}
|
</div>
|
||||||
to={'/redemption/edit/' + redemption.id}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
|
|
||||||
<Table.Footer>
|
<Table.Footer>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.HeaderCell colSpan='8'>
|
<Table.HeaderCell colSpan='8'>
|
||||||
<Button size='small' as={Link} to='/redemption/add' loading={loading}>
|
<Button
|
||||||
|
size='small'
|
||||||
|
as={Link}
|
||||||
|
to='/redemption/add'
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
Add new redemption code
|
Add new redemption code
|
||||||
</Button>
|
</Button>
|
||||||
<Pagination
|
<Pagination
|
||||||
|
@ -1,5 +1,15 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Form, Grid, Header, Image, Message, Segment } from 'semantic-ui-react';
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Grid,
|
||||||
|
Header,
|
||||||
|
Image,
|
||||||
|
Message,
|
||||||
|
Segment,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
} from 'semantic-ui-react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { API, getLogo, showError, showInfo, showSuccess } from '../helpers';
|
import { API, getLogo, showError, showInfo, showSuccess } from '../helpers';
|
||||||
import Turnstile from 'react-turnstile';
|
import Turnstile from 'react-turnstile';
|
||||||
@ -10,7 +20,7 @@ const RegisterForm = () => {
|
|||||||
password: '',
|
password: '',
|
||||||
password2: '',
|
password2: '',
|
||||||
email: '',
|
email: '',
|
||||||
verification_code: ''
|
verification_code: '',
|
||||||
});
|
});
|
||||||
const { username, password, password2 } = inputs;
|
const { username, password, password2 } = inputs;
|
||||||
const [showEmailVerification, setShowEmailVerification] = useState(false);
|
const [showEmailVerification, setShowEmailVerification] = useState(false);
|
||||||
@ -100,92 +110,134 @@ const RegisterForm = () => {
|
|||||||
return (
|
return (
|
||||||
<Grid textAlign='center' style={{ marginTop: '48px' }}>
|
<Grid textAlign='center' style={{ marginTop: '48px' }}>
|
||||||
<Grid.Column style={{ maxWidth: 450 }}>
|
<Grid.Column style={{ maxWidth: 450 }}>
|
||||||
<Header as='h2' color='' textAlign='center'>
|
<Card
|
||||||
<Image src={logo} /> New User Registration
|
fluid
|
||||||
</Header>
|
className='chart-card'
|
||||||
<Form size='large'>
|
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
|
||||||
<Segment>
|
>
|
||||||
<Form.Input
|
<Card.Content>
|
||||||
fluid
|
<Card.Header>
|
||||||
icon='user'
|
<Header
|
||||||
iconPosition='left'
|
as='h2'
|
||||||
placeholder='Enter username, up to 12 characters'
|
textAlign='center'
|
||||||
onChange={handleChange}
|
style={{ marginBottom: '1.5em' }}
|
||||||
name='username'
|
>
|
||||||
/>
|
<Image src={logo} style={{ marginBottom: '10px' }} />
|
||||||
<Form.Input
|
<Header.Content>New User Registration</Header.Content>
|
||||||
fluid
|
</Header>
|
||||||
icon='lock'
|
</Card.Header>
|
||||||
iconPosition='left'
|
<Form size='large'>
|
||||||
placeholder='Enter password, at least 8 characters and up to 20 characters'
|
<Form.Input
|
||||||
onChange={handleChange}
|
fluid
|
||||||
name='password'
|
icon='user'
|
||||||
type='password'
|
iconPosition='left'
|
||||||
/>
|
placeholder='Enter username, up to 12 characters'
|
||||||
<Form.Input
|
onChange={handleChange}
|
||||||
fluid
|
name='username'
|
||||||
icon='lock'
|
style={{ marginBottom: '1em' }}
|
||||||
iconPosition='left'
|
|
||||||
placeholder='Enter password, at least 8 characters and up to 20 characters'
|
|
||||||
onChange={handleChange}
|
|
||||||
name='password2'
|
|
||||||
type='password'
|
|
||||||
/>
|
|
||||||
{showEmailVerification ? (
|
|
||||||
<>
|
|
||||||
<Form.Input
|
|
||||||
fluid
|
|
||||||
icon='mail'
|
|
||||||
iconPosition='left'
|
|
||||||
placeholder='Enter email address'
|
|
||||||
onChange={handleChange}
|
|
||||||
name='email'
|
|
||||||
type='email'
|
|
||||||
action={
|
|
||||||
<Button onClick={sendVerificationCode} disabled={loading}>
|
|
||||||
Get verification code
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Form.Input
|
|
||||||
fluid
|
|
||||||
icon='lock'
|
|
||||||
iconPosition='left'
|
|
||||||
placeholder='Enter Verification Code'
|
|
||||||
onChange={handleChange}
|
|
||||||
name='verification_code'
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
{turnstileEnabled ? (
|
|
||||||
<Turnstile
|
|
||||||
sitekey={turnstileSiteKey}
|
|
||||||
onVerify={(token) => {
|
|
||||||
setTurnstileToken(token);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
<Form.Input
|
||||||
<></>
|
fluid
|
||||||
)}
|
icon='lock'
|
||||||
<Button
|
iconPosition='left'
|
||||||
color='green'
|
placeholder='Enter password, minimum 8 characters, maximum 20 characters'
|
||||||
fluid
|
onChange={handleChange}
|
||||||
size='large'
|
name='password'
|
||||||
onClick={handleSubmit}
|
type='password'
|
||||||
loading={loading}
|
style={{ marginBottom: '1em' }}
|
||||||
>
|
/>
|
||||||
Sign up
|
<Form.Input
|
||||||
</Button>
|
fluid
|
||||||
</Segment>
|
icon='lock'
|
||||||
</Form>
|
iconPosition='left'
|
||||||
<Message>
|
placeholder='Re-enter password'
|
||||||
Already have an account?
|
onChange={handleChange}
|
||||||
<Link to='/login' className='btn btn-link'>
|
name='password2'
|
||||||
Click to log in
|
type='password'
|
||||||
</Link>
|
style={{ marginBottom: '1em' }}
|
||||||
</Message>
|
/>
|
||||||
|
|
||||||
|
{showEmailVerification && (
|
||||||
|
<>
|
||||||
|
<Form.Input
|
||||||
|
fluid
|
||||||
|
icon='mail'
|
||||||
|
iconPosition='left'
|
||||||
|
placeholder='Enter email address'
|
||||||
|
onChange={handleChange}
|
||||||
|
name='email'
|
||||||
|
type='email'
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
onClick={sendVerificationCode}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Get Verification Code
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
style={{ marginBottom: '1em' }}
|
||||||
|
/>
|
||||||
|
<Form.Input
|
||||||
|
fluid
|
||||||
|
icon='lock'
|
||||||
|
iconPosition='left'
|
||||||
|
placeholder='Enter verification code'
|
||||||
|
onChange={handleChange}
|
||||||
|
name='verification_code'
|
||||||
|
style={{ marginBottom: '1em' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{turnstileEnabled && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '1em',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Turnstile
|
||||||
|
sitekey={turnstileSiteKey}
|
||||||
|
onVerify={(token) => {
|
||||||
|
setTurnstileToken(token);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fluid
|
||||||
|
size='large'
|
||||||
|
onClick={handleSubmit}
|
||||||
|
style={{
|
||||||
|
background: '#2F73FF', // Use a more modern blue
|
||||||
|
color: 'white',
|
||||||
|
marginBottom: '1.5em',
|
||||||
|
}}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Message style={{ background: 'transparent', boxShadow: 'none' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '0.9em',
|
||||||
|
color: '#666',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Already have an account?
|
||||||
|
<Link to='/login' style={{ color: '#2185d0' }}>
|
||||||
|
Click to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Message>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
|
@ -1,7 +1,22 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Dropdown, Form, Label, Pagination, Popup, Table } from 'semantic-ui-react';
|
import {
|
||||||
|
Button,
|
||||||
|
Dropdown,
|
||||||
|
Form,
|
||||||
|
Label,
|
||||||
|
Pagination,
|
||||||
|
Popup,
|
||||||
|
Table,
|
||||||
|
} from 'semantic-ui-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { API, copy, showError, showSuccess, showWarning, timestamp2string } from '../helpers';
|
import {
|
||||||
|
API,
|
||||||
|
copy,
|
||||||
|
showError,
|
||||||
|
showSuccess,
|
||||||
|
showWarning,
|
||||||
|
timestamp2string,
|
||||||
|
} from '../helpers';
|
||||||
|
|
||||||
import { ITEMS_PER_PAGE } from '../constants';
|
import { ITEMS_PER_PAGE } from '../constants';
|
||||||
import { renderQuota } from '../helpers/render';
|
import { renderQuota } from '../helpers/render';
|
||||||
@ -20,25 +35,41 @@ const OPEN_LINK_OPTIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function renderTimestamp(timestamp) {
|
function renderTimestamp(timestamp) {
|
||||||
return (
|
return <>{timestamp2string(timestamp)}</>;
|
||||||
<>
|
|
||||||
{timestamp2string(timestamp)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStatus(status) {
|
function renderStatus(status) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 1:
|
case 1:
|
||||||
return <Label basic color='green'>Enabled</Label>;
|
return (
|
||||||
|
<Label basic color='green'>
|
||||||
|
Enabled
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
case 2:
|
case 2:
|
||||||
return <Label basic color='red'> Disabled </Label>;
|
return (
|
||||||
|
<Label basic color='red'>
|
||||||
|
Disabled
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
case 3:
|
case 3:
|
||||||
return <Label basic color='yellow'> Expired </Label>;
|
return (
|
||||||
|
<Label basic color='yellow'>
|
||||||
|
Expired
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
case 4:
|
case 4:
|
||||||
return <Label basic color='grey'> Exhausted </Label>;
|
return (
|
||||||
|
<Label basic color='grey'>
|
||||||
|
Exhausted
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
default:
|
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;
|
serverAddress = window.location.origin;
|
||||||
}
|
}
|
||||||
let encodedServerAddress = encodeURIComponent(serverAddress);
|
let encodedServerAddress = encodeURIComponent(serverAddress);
|
||||||
// const nextLink = localStorage.getItem('chat_link');
|
const nextLink = localStorage.getItem('chat_link');
|
||||||
// let nextUrl;
|
let nextUrl;
|
||||||
|
|
||||||
// if (nextLink) {
|
if (nextLink) {
|
||||||
// nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
|
nextUrl =
|
||||||
// } else {
|
nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
|
||||||
// nextUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
|
} else {
|
||||||
// }
|
nextUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
|
||||||
|
}
|
||||||
|
|
||||||
let url;
|
let url;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@ -116,7 +148,9 @@ const TokensTable = () => {
|
|||||||
url = `https://chat.laisky.com?apikey=sk-${key}`;
|
url = `https://chat.laisky.com?apikey=sk-${key}`;
|
||||||
break;
|
break;
|
||||||
case 'lobechat':
|
case 'lobechat':
|
||||||
url = nextLink + `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`;
|
url =
|
||||||
|
nextLink +
|
||||||
|
`/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
url = `sk-${key}`;
|
url = `sk-${key}`;
|
||||||
@ -144,7 +178,8 @@ const TokensTable = () => {
|
|||||||
let defaultUrl;
|
let defaultUrl;
|
||||||
|
|
||||||
if (chatLink) {
|
if (chatLink) {
|
||||||
defaultUrl = chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
|
defaultUrl =
|
||||||
|
chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
|
||||||
} else {
|
} else {
|
||||||
defaultUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
|
defaultUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
|
||||||
}
|
}
|
||||||
@ -159,7 +194,9 @@ const TokensTable = () => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'lobechat':
|
case 'lobechat':
|
||||||
url = chatLink + `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`;
|
url =
|
||||||
|
chatLink +
|
||||||
|
`/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@ -167,7 +204,7 @@ const TokensTable = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTokens(0, orderBy)
|
loadTokens(0, orderBy)
|
||||||
@ -273,7 +310,7 @@ const TokensTable = () => {
|
|||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table basic compact size='small'>
|
<Table basic={'very'} compact size='small'>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.HeaderCell
|
<Table.HeaderCell
|
||||||
@ -336,81 +373,110 @@ const TokensTable = () => {
|
|||||||
)
|
)
|
||||||
.map((token, idx) => {
|
.map((token, idx) => {
|
||||||
if (token.deleted) return <></>;
|
if (token.deleted) return <></>;
|
||||||
return (
|
return (
|
||||||
<Table.Row key={token.id}>
|
<Table.Row key={token.id}>
|
||||||
<Table.Cell>{token.name ? token.name : 'None'}</Table.Cell>
|
<Table.Cell>{token.name ? token.name : 'None'}</Table.Cell>
|
||||||
<Table.Cell>{renderStatus(token.status)}</Table.Cell>
|
<Table.Cell>{renderStatus(token.status)}</Table.Cell>
|
||||||
<Table.Cell>{renderQuota(token.used_quota)}</Table.Cell>
|
<Table.Cell>{renderQuota(token.used_quota)}</Table.Cell>
|
||||||
<Table.Cell>{token.unlimited_quota ? '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>
|
<Table.Cell>
|
||||||
<div>
|
{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>
|
||||||
|
<div>
|
||||||
<Button.Group color='green' size={'small'}>
|
<Button.Group color='green' size={'small'}>
|
||||||
<Button
|
<Button
|
||||||
size={'small'}
|
size={'small'}
|
||||||
positive
|
positive
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await onCopy('', token.key);
|
await onCopy('', token.key);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Copy
|
Copy
|
||||||
</Button>
|
</Button>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
className='button icon'
|
className='button icon'
|
||||||
floating
|
floating
|
||||||
options={COPY_OPTIONS.map(option => ({
|
options={COPY_OPTIONS.map((option) => ({
|
||||||
...option,
|
...option,
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
await onCopy(option.value, token.key);
|
await onCopy(option.value, token.key);
|
||||||
}
|
},
|
||||||
}))}
|
}))}
|
||||||
trigger={<></>}
|
trigger={<></>}
|
||||||
/>
|
/>
|
||||||
</Button.Group>
|
</Button.Group>{' '}
|
||||||
{' '}
|
<Button.Group color='blue' size={'small'}>
|
||||||
<Popup
|
<Button
|
||||||
trigger={
|
size={'small'}
|
||||||
<Button size='small' negative>
|
positive
|
||||||
Delete
|
onClick={() => {
|
||||||
</Button>
|
onOpenLink('', token.key);
|
||||||
}
|
}}
|
||||||
on='click'
|
>
|
||||||
flowing
|
Chat
|
||||||
hoverable
|
</Button>
|
||||||
>
|
<Dropdown
|
||||||
<Button
|
className='button icon'
|
||||||
negative
|
floating
|
||||||
onClick={() => {
|
options={OPEN_LINK_OPTIONS.map((option) => ({
|
||||||
manageToken(token.id, 'delete', idx);
|
...option,
|
||||||
}}
|
onClick: async () => {
|
||||||
>
|
await onOpenLink(option.value, token.key);
|
||||||
Delete Token {token.name}
|
},
|
||||||
</Button>
|
}))}
|
||||||
</Popup>
|
trigger={<></>}
|
||||||
<Button
|
/>
|
||||||
size={'small'}
|
</Button.Group>{' '}
|
||||||
onClick={() => {
|
<Popup
|
||||||
manageToken(
|
trigger={
|
||||||
token.id,
|
<Button size='small' negative>
|
||||||
token.status === 1 ? 'disable' : 'enable',
|
Delete
|
||||||
idx
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{token.status === 1 ? 'Disable' : 'Enable'}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
}
|
||||||
size={'small'}
|
on='click'
|
||||||
as={Link}
|
flowing
|
||||||
to={'/token/edit/' + token.id}
|
hoverable
|
||||||
>
|
>
|
||||||
Edit
|
<Button
|
||||||
</Button>
|
negative
|
||||||
</div>
|
onClick={() => {
|
||||||
|
manageToken(token.id, 'delete', idx);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete Token {token.name}
|
||||||
|
</Button>
|
||||||
|
</Popup>
|
||||||
|
<Button
|
||||||
|
size={'small'}
|
||||||
|
onClick={() => {
|
||||||
|
manageToken(
|
||||||
|
token.id,
|
||||||
|
token.status === 1 ? 'disable' : 'enable',
|
||||||
|
idx
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{token.status === 1 ? 'Disable' : 'Enable'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size={'small'}
|
||||||
|
as={Link}
|
||||||
|
to={'/token/edit/' + token.id}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
|
|
||||||
@ -420,14 +486,24 @@ const TokensTable = () => {
|
|||||||
<Button size='small' as={Link} to='/token/add' loading={loading}>
|
<Button size='small' as={Link} to='/token/add' loading={loading}>
|
||||||
Add New Token
|
Add New Token
|
||||||
</Button>
|
</Button>
|
||||||
<Button size='small' onClick={refresh} loading={loading}>Refresh</Button>
|
<Button size='small' onClick={refresh} loading={loading}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
placeholder='Sort By'
|
placeholder='Sort By'
|
||||||
selection
|
selection
|
||||||
options={[
|
options={[
|
||||||
{ key: '', text: 'Default Order', value: '' },
|
{ 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}
|
value={orderBy}
|
||||||
onChange={handleOrderByChange}
|
onChange={handleOrderByChange}
|
||||||
|
@ -1,10 +1,23 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Form, Label, Pagination, Popup, Table, Dropdown } from 'semantic-ui-react';
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Label,
|
||||||
|
Pagination,
|
||||||
|
Popup,
|
||||||
|
Table,
|
||||||
|
Dropdown,
|
||||||
|
} from 'semantic-ui-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { API, showError, showSuccess } from '../helpers';
|
import { API, showError, showSuccess } from '../helpers';
|
||||||
|
|
||||||
import { ITEMS_PER_PAGE } from '../constants';
|
import { ITEMS_PER_PAGE } from '../constants';
|
||||||
import { renderGroup, renderNumber, renderQuota, renderText } from '../helpers/render';
|
import {
|
||||||
|
renderGroup,
|
||||||
|
renderNumber,
|
||||||
|
renderQuota,
|
||||||
|
renderText,
|
||||||
|
} from '../helpers/render';
|
||||||
|
|
||||||
function renderRole(role) {
|
function renderRole(role) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
@ -66,7 +79,7 @@ const UsersTable = () => {
|
|||||||
(async () => {
|
(async () => {
|
||||||
const res = await API.post('/api/user/manage', {
|
const res = await API.post('/api/user/manage', {
|
||||||
username,
|
username,
|
||||||
action
|
action,
|
||||||
});
|
});
|
||||||
const { success, message } = res.data;
|
const { success, message } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
@ -169,7 +182,7 @@ const UsersTable = () => {
|
|||||||
/>
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table basic compact size='small'>
|
<Table basic={'very'} compact size='small'>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.HeaderCell
|
<Table.HeaderCell
|
||||||
@ -239,7 +252,9 @@ const UsersTable = () => {
|
|||||||
<Popup
|
<Popup
|
||||||
content={user.email ? user.email : 'Email not bound'}
|
content={user.email ? user.email : 'Email not bound'}
|
||||||
key={user.username}
|
key={user.username}
|
||||||
header={user.display_name ? user.display_name : user.username}
|
header={
|
||||||
|
user.display_name ? user.display_name : user.username
|
||||||
|
}
|
||||||
trigger={<span>{renderText(user.username, 15)}</span>}
|
trigger={<span>{renderText(user.username, 15)}</span>}
|
||||||
hoverable
|
hoverable
|
||||||
/>
|
/>
|
||||||
@ -249,9 +264,22 @@ const UsersTable = () => {
|
|||||||
{/* {user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : 'None'}*/}
|
{/* {user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : 'None'}*/}
|
||||||
{/*</Table.Cell>*/}
|
{/*</Table.Cell>*/}
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Popup content='Remaining quota' trigger={<Label basic>{renderQuota(user.quota)}</Label>} />
|
<Popup
|
||||||
<Popup content='Used quota' trigger={<Label basic>{renderQuota(user.used_quota)}</Label>} />
|
content='Remaining Quota'
|
||||||
<Popup content='Number of Requests' trigger={<Label basic>{renderNumber(user.request_count)}</Label>} />
|
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>
|
||||||
<Table.Cell>{renderRole(user.role)}</Table.Cell>
|
<Table.Cell>{renderRole(user.role)}</Table.Cell>
|
||||||
<Table.Cell>{renderStatus(user.status)}</Table.Cell>
|
<Table.Cell>{renderStatus(user.status)}</Table.Cell>
|
||||||
@ -279,7 +307,11 @@ const UsersTable = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Popup
|
<Popup
|
||||||
trigger={
|
trigger={
|
||||||
<Button size='small' negative disabled={user.role === 100}>
|
<Button
|
||||||
|
size='small'
|
||||||
|
negative
|
||||||
|
disabled={user.role === 100}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@ -335,8 +367,16 @@ const UsersTable = () => {
|
|||||||
options={[
|
options={[
|
||||||
{ key: '', text: 'Default Order', value: '' },
|
{ key: '', text: 'Default Order', value: '' },
|
||||||
{ key: 'quota', text: 'Sort by Remaining Quota', value: 'quota' },
|
{ 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}
|
value={orderBy}
|
||||||
onChange={handleOrderByChange}
|
onChange={handleOrderByChange}
|
||||||
|
@ -13,16 +13,18 @@ export function renderGroup(group) {
|
|||||||
}
|
}
|
||||||
let groups = group.split(',');
|
let groups = group.split(',');
|
||||||
groups.sort();
|
groups.sort();
|
||||||
return <>
|
return (
|
||||||
{groups.map((group) => {
|
<>
|
||||||
if (group === 'vip' || group === 'pro') {
|
{groups.map((group) => {
|
||||||
return <Label color='yellow'>{group}</Label>;
|
if (group === 'vip' || group === 'pro') {
|
||||||
} else if (group === 'svip' || group === 'premium') {
|
return <Label color='yellow'>{group}</Label>;
|
||||||
return <Label color='red'>{group}</Label>;
|
} else if (group === 'svip' || group === 'premium') {
|
||||||
}
|
return <Label color='red'>{group}</Label>;
|
||||||
return <Label>{group}</Label>;
|
}
|
||||||
})}
|
return <Label>{group}</Label>;
|
||||||
</>;
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderNumber(num) {
|
export function renderNumber(num) {
|
||||||
@ -56,3 +58,32 @@ export function renderQuotaWithPrompt(quota, digits) {
|
|||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'red',
|
||||||
|
'orange',
|
||||||
|
'yellow',
|
||||||
|
'olive',
|
||||||
|
'green',
|
||||||
|
'teal',
|
||||||
|
'blue',
|
||||||
|
'violet',
|
||||||
|
'purple',
|
||||||
|
'pink',
|
||||||
|
'brown',
|
||||||
|
'grey',
|
||||||
|
'black',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function renderColorLabel(text) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
hash = text.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
let index = Math.abs(hash % colors.length);
|
||||||
|
return (
|
||||||
|
<Label basic color={colors[index]}>
|
||||||
|
{text}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
23
web/default/src/i18n.js
Normal file
23
web/default/src/i18n.js
Normal 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;
|
@ -11,6 +11,7 @@ import { UserProvider } from './context/User';
|
|||||||
import { ToastContainer } from 'react-toastify';
|
import { ToastContainer } from 'react-toastify';
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
import { StatusProvider } from './context/Status';
|
import { StatusProvider } from './context/Status';
|
||||||
|
import './i18n';
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
root.render(
|
root.render(
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Header, Segment } from 'semantic-ui-react';
|
import { Card, Header, Segment } from 'semantic-ui-react';
|
||||||
import { API, showError } from '../../helpers';
|
import { API, showError } from '../../helpers';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
|
||||||
@ -28,31 +28,38 @@ const About = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
displayAbout().then();
|
displayAbout().then();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{
|
{aboutLoaded && about === '' ? (
|
||||||
aboutLoaded && about === '' ? <>
|
<div className='dashboard-container'>
|
||||||
<Segment>
|
<Card fluid className='chart-card'>
|
||||||
<Header as='h3'>About</Header>
|
<Card.Content>
|
||||||
<p>You can set the content about in the settings page, support HTML & Markdown</p>
|
<Card.Header className='header'>About the System</Card.Header>
|
||||||
Project Repository Address:
|
<p>You can set the about content on the settings page, supporting HTML & Markdown</p>
|
||||||
<a href='https://github.com/Laisky/one-api'>
|
Project repository address:
|
||||||
https://github.com/Laisky/one-api
|
<a href='https://github.com/Laisky/one-api'>
|
||||||
</a>
|
https://github.com/Laisky/one-api
|
||||||
</Segment>
|
</a>
|
||||||
</> : <>
|
</Card.Content>
|
||||||
{
|
</Card>
|
||||||
about.startsWith('https://') ? <iframe
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{about.startsWith('https://') ? (
|
||||||
|
<iframe
|
||||||
src={about}
|
src={about}
|
||||||
style={{ width: '100%', height: '100vh', border: 'none' }}
|
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;
|
export default About;
|
||||||
|
@ -1,32 +1,49 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
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 { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { API, copy, getChannelModels, showError, showInfo, showSuccess, verifyJSON } from '../../helpers';
|
import {
|
||||||
|
API,
|
||||||
|
copy,
|
||||||
|
getChannelModels,
|
||||||
|
showError,
|
||||||
|
showInfo,
|
||||||
|
showSuccess,
|
||||||
|
verifyJSON,
|
||||||
|
} from '../../helpers';
|
||||||
import { CHANNEL_OPTIONS } from '../../constants';
|
import { CHANNEL_OPTIONS } from '../../constants';
|
||||||
|
|
||||||
const MODEL_MAPPING_EXAMPLE = {
|
const MODEL_MAPPING_EXAMPLE = {
|
||||||
'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',
|
'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',
|
||||||
'gpt-4-0314': 'gpt-4',
|
'gpt-4-0314': 'gpt-4',
|
||||||
'gpt-4-32k-0314': 'gpt-4-32k'
|
'gpt-4-32k-0314': 'gpt-4-32k',
|
||||||
};
|
};
|
||||||
|
|
||||||
function type2secretPrompt(type) {
|
function type2secretPrompt(type, t) {
|
||||||
// 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')
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 15:
|
case 15:
|
||||||
return 'Enter in the following format:APIKey|SecretKey';
|
return t('channel.edit.key_prompts.zhipu');
|
||||||
case 18:
|
case 18:
|
||||||
return 'Enter in the following format:APPID|APISecret|APIKey';
|
return t('channel.edit.key_prompts.spark');
|
||||||
case 22:
|
case 22:
|
||||||
return 'Enter in the following format:APIKey-AppId,For example:fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041';
|
return t('channel.edit.key_prompts.fastgpt');
|
||||||
case 23:
|
case 23:
|
||||||
return 'Enter in the following format:AppId|SecretId|SecretKey';
|
return t('channel.edit.key_prompts.tencent');
|
||||||
default:
|
default:
|
||||||
return 'Please enter the authentication key corresponding to the channel';
|
return t('channel.edit.key_prompts.default');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditChannel = () => {
|
const EditChannel = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const channelId = params.id;
|
const channelId = params.id;
|
||||||
@ -45,7 +62,7 @@ const EditChannel = () => {
|
|||||||
model_mapping: '',
|
model_mapping: '',
|
||||||
system_prompt: '',
|
system_prompt: '',
|
||||||
models: [],
|
models: [],
|
||||||
groups: ['default']
|
groups: ['default'],
|
||||||
};
|
};
|
||||||
const [batch, setBatch] = useState(false);
|
const [batch, setBatch] = useState(false);
|
||||||
const [inputs, setInputs] = useState(originInputs);
|
const [inputs, setInputs] = useState(originInputs);
|
||||||
@ -61,7 +78,7 @@ const EditChannel = () => {
|
|||||||
ak: '',
|
ak: '',
|
||||||
user_id: '',
|
user_id: '',
|
||||||
vertex_ai_project_id: '',
|
vertex_ai_project_id: '',
|
||||||
vertex_ai_adc: ''
|
vertex_ai_adc: '',
|
||||||
});
|
});
|
||||||
const handleInputChange = (e, { name, value }) => {
|
const handleInputChange = (e, { name, value }) => {
|
||||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||||
@ -93,7 +110,11 @@ const EditChannel = () => {
|
|||||||
data.groups = data.group.split(',');
|
data.groups = data.group.split(',');
|
||||||
}
|
}
|
||||||
if (data.model_mapping !== '') {
|
if (data.model_mapping !== '') {
|
||||||
data.model_mapping = JSON.stringify(JSON.parse(data.model_mapping), null, 2);
|
data.model_mapping = JSON.stringify(
|
||||||
|
JSON.parse(data.model_mapping),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setInputs(data);
|
setInputs(data);
|
||||||
if (data.config !== '') {
|
if (data.config !== '') {
|
||||||
@ -112,7 +133,7 @@ const EditChannel = () => {
|
|||||||
let localModelOptions = res.data.data.map((model) => ({
|
let localModelOptions = res.data.data.map((model) => ({
|
||||||
key: model.id,
|
key: model.id,
|
||||||
text: model.id,
|
text: model.id,
|
||||||
value: model.id
|
value: model.id,
|
||||||
}));
|
}));
|
||||||
setOriginModelOptions(localModelOptions);
|
setOriginModelOptions(localModelOptions);
|
||||||
setFullModels(res.data.data.map((model) => model.id));
|
setFullModels(res.data.data.map((model) => model.id));
|
||||||
@ -124,11 +145,13 @@ const EditChannel = () => {
|
|||||||
const fetchGroups = async () => {
|
const fetchGroups = async () => {
|
||||||
try {
|
try {
|
||||||
let res = await API.get(`/api/group/`);
|
let res = await API.get(`/api/group/`);
|
||||||
setGroupOptions(res.data.data.map((group) => ({
|
setGroupOptions(
|
||||||
key: group,
|
res.data.data.map((group) => ({
|
||||||
text: group,
|
key: group,
|
||||||
value: group
|
text: group,
|
||||||
})));
|
value: group,
|
||||||
|
}))
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error.message);
|
showError(error.message);
|
||||||
}
|
}
|
||||||
@ -141,7 +164,7 @@ const EditChannel = () => {
|
|||||||
localModelOptions.push({
|
localModelOptions.push({
|
||||||
key: model,
|
key: model,
|
||||||
text: model,
|
text: model,
|
||||||
value: model
|
value: model,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -163,25 +186,32 @@ const EditChannel = () => {
|
|||||||
if (inputs.key === '') {
|
if (inputs.key === '') {
|
||||||
if (config.ak !== '' && config.sk !== '' && config.region !== '') {
|
if (config.ak !== '' && config.sk !== '' && config.region !== '') {
|
||||||
inputs.key = `${config.ak}|${config.sk}|${config.region}`;
|
inputs.key = `${config.ak}|${config.sk}|${config.region}`;
|
||||||
} else if (config.region !== '' && config.vertex_ai_project_id !== '' && config.vertex_ai_adc !== '') {
|
} else if (
|
||||||
|
config.region !== '' &&
|
||||||
|
config.vertex_ai_project_id !== '' &&
|
||||||
|
config.vertex_ai_adc !== ''
|
||||||
|
) {
|
||||||
inputs.key = `${config.region}|${config.vertex_ai_project_id}|${config.vertex_ai_adc}`;
|
inputs.key = `${config.region}|${config.vertex_ai_project_id}|${config.vertex_ai_adc}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isEdit && (inputs.name === '' || inputs.key === '')) {
|
if (!isEdit && (inputs.name === '' || inputs.key === '')) {
|
||||||
showInfo('Please fill in the ChannelName and ChannelKey!');
|
showInfo(t('channel.edit.messages.name_required'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (inputs.type !== 43 && inputs.models.length === 0) {
|
if (inputs.type !== 43 && inputs.models.length === 0) {
|
||||||
showInfo('Please select at least one Model!');
|
showInfo(t('channel.edit.messages.models_required'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let localInputs = {...inputs};
|
let localInputs = { ...inputs };
|
||||||
if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
|
if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
|
||||||
localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1);
|
localInputs.base_url = localInputs.base_url.slice(
|
||||||
|
0,
|
||||||
|
localInputs.base_url.length - 1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (localInputs.type === 3 && localInputs.other === '') {
|
if (localInputs.type === 3 && localInputs.other === '') {
|
||||||
localInputs.other = '2024-03-01-preview';
|
localInputs.other = '2024-03-01-preview';
|
||||||
@ -191,16 +221,19 @@ const EditChannel = () => {
|
|||||||
localInputs.group = localInputs.groups.join(',');
|
localInputs.group = localInputs.groups.join(',');
|
||||||
localInputs.config = JSON.stringify(config);
|
localInputs.config = JSON.stringify(config);
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
res = await API.put(`/api/channel/`, { ...localInputs, id: parseInt(channelId) });
|
res = await API.put(`/api/channel/`, {
|
||||||
|
...localInputs,
|
||||||
|
id: parseInt(channelId),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
res = await API.post(`/api/channel/`, localInputs);
|
res = await API.post(`/api/channel/`, localInputs);
|
||||||
}
|
}
|
||||||
const { success, message } = res.data;
|
const { success, message } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
showSuccess('Channel updated successfully!');
|
showSuccess(t('channel.edit.messages.update_success'));
|
||||||
} else {
|
} else {
|
||||||
showSuccess('Channel created successfully!');
|
showSuccess(t('channel.edit.messages.create_success'));
|
||||||
setInputs(originInputs);
|
setInputs(originInputs);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -217,9 +250,9 @@ const EditChannel = () => {
|
|||||||
localModelOptions.push({
|
localModelOptions.push({
|
||||||
key: customModel,
|
key: customModel,
|
||||||
text: customModel,
|
text: customModel,
|
||||||
value: customModel
|
value: customModel,
|
||||||
});
|
});
|
||||||
setModelOptions(modelOptions => {
|
setModelOptions((modelOptions) => {
|
||||||
return [...modelOptions, ...localModelOptions];
|
return [...modelOptions, ...localModelOptions];
|
||||||
});
|
});
|
||||||
setCustomModel('');
|
setCustomModel('');
|
||||||
@ -227,156 +260,161 @@ const EditChannel = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className='dashboard-container'>
|
||||||
<Segment loading={loading}>
|
<Card fluid className='chart-card'>
|
||||||
<Header as='h3'>{isEdit ? 'Update Channel Information' : 'Create New Channel'}</Header>
|
<Card.Content>
|
||||||
<Form autoComplete='new-password'>
|
<Card.Header className='header'>
|
||||||
<Form.Field>
|
{isEdit
|
||||||
<Form.Select
|
? t('channel.edit.title_edit')
|
||||||
label='Type'
|
: t('channel.edit.title_create')}
|
||||||
name='type'
|
</Card.Header>
|
||||||
required
|
<Form loading={loading} autoComplete='new-password'>
|
||||||
search
|
<Form.Field>
|
||||||
options={CHANNEL_OPTIONS}
|
<Form.Select
|
||||||
value={inputs.type}
|
label={t('channel.edit.type')}
|
||||||
onChange={handleInputChange}
|
name='type'
|
||||||
/>
|
required
|
||||||
</Form.Field>
|
search
|
||||||
{
|
options={CHANNEL_OPTIONS}
|
||||||
inputs.type === 3 && (
|
value={inputs.type}
|
||||||
<>
|
onChange={handleInputChange}
|
||||||
<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
|
</Form.Field>
|
||||||
Replace the parameter with your deployment name (dots in the model name will be removed),<a target='_blank'
|
<Form.Field>
|
||||||
href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'>Image demo</a>。
|
<Form.Input
|
||||||
</Message>
|
label={t('channel.edit.name')}
|
||||||
<Form.Field>
|
name='name'
|
||||||
<Form.Input
|
placeholder={t('channel.edit.name_placeholder')}
|
||||||
label='AZURE_OPENAI_ENDPOINT'
|
onChange={handleInputChange}
|
||||||
name='base_url'
|
value={inputs.name}
|
||||||
placeholder={'Please enter AZURE_OPENAI_ENDPOINT,For example:https://docs-test-001.openai.azure.com'}
|
required
|
||||||
onChange={handleInputChange}
|
/>
|
||||||
value={inputs.base_url}
|
</Form.Field>
|
||||||
autoComplete='new-password'
|
<Form.Field>
|
||||||
/>
|
<Form.Dropdown
|
||||||
</Form.Field>
|
label={t('channel.edit.group')}
|
||||||
<Form.Field>
|
placeholder={t('channel.edit.group_placeholder')}
|
||||||
<Form.Input
|
name='groups'
|
||||||
label='Default API Version'
|
required
|
||||||
name='other'
|
fluid
|
||||||
placeholder={'Please enter default API version, for example: 2024-03-01-preview. This configuration can be overridden by actual request query parameters'}
|
multiple
|
||||||
onChange={handleInputChange}
|
selection
|
||||||
value={inputs.other}
|
allowAdditions
|
||||||
autoComplete='new-password'
|
additionLabel={t('channel.edit.group_addition')}
|
||||||
/>
|
onChange={handleInputChange}
|
||||||
</Form.Field>
|
value={inputs.groups}
|
||||||
</>
|
autoComplete='new-password'
|
||||||
)
|
options={groupOptions}
|
||||||
}
|
/>
|
||||||
{
|
</Form.Field>
|
||||||
inputs.type === 8 && (
|
|
||||||
|
{/* Azure OpenAI specific fields */}
|
||||||
|
{inputs.type === 3 && (
|
||||||
|
<>
|
||||||
|
<Message>
|
||||||
|
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.Field>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
label='Base URL'
|
label='AZURE_OPENAI_ENDPOINT'
|
||||||
name='base_url'
|
name='base_url'
|
||||||
placeholder={'Please enter the Base URL of the custom channel,For example:https://openai.justsong.cn'}
|
placeholder='Please enter AZURE_OPENAI_ENDPOINT, for example: https://docs-test-001.openai.azure.com'
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
value={inputs.base_url}
|
value={inputs.base_url}
|
||||||
autoComplete='new-password'
|
autoComplete='new-password'
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
)
|
|
||||||
}
|
|
||||||
<Form.Field>
|
|
||||||
<Form.Input
|
|
||||||
label='Name'
|
|
||||||
required
|
|
||||||
name='name'
|
|
||||||
placeholder={'Please name the channel'}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
value={inputs.name}
|
|
||||||
autoComplete='new-password'
|
|
||||||
/>
|
|
||||||
</Form.Field>
|
|
||||||
<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.Field>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
label='Model version'
|
label='Default API Version'
|
||||||
name='other'
|
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'}
|
placeholder='Please enter default API version, for example: 2024-03-01-preview. This configuration can be overridden by actual request query parameters'
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
value={inputs.other}
|
value={inputs.other}
|
||||||
autoComplete='new-password'
|
autoComplete='new-password'
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
)
|
</>
|
||||||
}
|
)}
|
||||||
{
|
|
||||||
inputs.type === 21 && (
|
{/* Custom base URL field */}
|
||||||
|
{inputs.type === 8 && (
|
||||||
<Form.Field>
|
<Form.Field>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
label='Knowledge Base ID'
|
label={t('channel.edit.base_url')}
|
||||||
|
name='base_url'
|
||||||
|
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={t('channel.edit.spark_version')}
|
||||||
name='other'
|
name='other'
|
||||||
placeholder={'Please enter Knowledge Base ID, for example: 123456'}
|
placeholder={t('channel.edit.spark_version_placeholder')}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
value={inputs.other}
|
value={inputs.other}
|
||||||
autoComplete='new-password'
|
autoComplete='new-password'
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
)
|
)}
|
||||||
}
|
{inputs.type === 21 && (
|
||||||
{
|
|
||||||
inputs.type === 17 && (
|
|
||||||
<Form.Field>
|
<Form.Field>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
label='Plugin Parameters'
|
label={t('channel.edit.knowledge_id')}
|
||||||
name='other'
|
name='other'
|
||||||
placeholder={'Please enter plugin parameters, i.e., the value of the X-DashScope-Plugin request header'}
|
placeholder={t('channel.edit.knowledge_id_placeholder')}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
value={inputs.other}
|
value={inputs.other}
|
||||||
autoComplete='new-password'
|
autoComplete='new-password'
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
)
|
)}
|
||||||
}
|
{inputs.type === 17 && (
|
||||||
{
|
<Form.Field>
|
||||||
inputs.type === 34 && (
|
<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>
|
<Message>
|
||||||
For Coze, the Model name is the Bot ID. You can add a prefix `bot-`, for example: `bot-123456`.
|
{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>
|
</Message>
|
||||||
)
|
)}
|
||||||
}
|
{inputs.type !== 43 && (
|
||||||
{
|
|
||||||
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.Field>
|
||||||
<Form.Dropdown
|
<Form.Dropdown
|
||||||
label='Model'
|
label={t('channel.edit.models')}
|
||||||
placeholder={'Please select the model supported by the channel'}
|
placeholder={t('channel.edit.models_placeholder')}
|
||||||
name='models'
|
name='models'
|
||||||
required
|
required
|
||||||
fluid
|
fluid
|
||||||
@ -392,25 +430,46 @@ const EditChannel = () => {
|
|||||||
options={modelOptions}
|
options={modelOptions}
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
)
|
)}
|
||||||
}
|
{inputs.type !== 43 && (
|
||||||
{
|
|
||||||
inputs.type !== 43 && (
|
|
||||||
<div style={{ lineHeight: '40px', marginBottom: '12px' }}>
|
<div style={{ lineHeight: '40px', marginBottom: '12px' }}>
|
||||||
<Button type={'button'} onClick={() => {
|
<Button
|
||||||
handleInputChange(null, { name: 'models', value: basicModels });
|
type={'button'}
|
||||||
}}>Fill in Related Models</Button>
|
onClick={() => {
|
||||||
<Button type={'button'} onClick={() => {
|
handleInputChange(null, {
|
||||||
handleInputChange(null, { name: 'models', value: fullModels });
|
name: 'models',
|
||||||
}}>Fill in all models</Button>
|
value: basicModels,
|
||||||
<Button type={'button'} onClick={() => {
|
});
|
||||||
handleInputChange(null, { name: 'models', value: [] });
|
}}
|
||||||
}}>Clear all models</Button>
|
>
|
||||||
|
{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: [] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('channel.edit.buttons.clear')}
|
||||||
|
</Button>
|
||||||
<Input
|
<Input
|
||||||
action={
|
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}
|
value={customModel}
|
||||||
onChange={(e, { value }) => {
|
onChange={(e, { value }) => {
|
||||||
setCustomModel(value);
|
setCustomModel(value);
|
||||||
@ -423,43 +482,48 @@ const EditChannel = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
}
|
{inputs.type !== 43 && (
|
||||||
{
|
<>
|
||||||
inputs.type !== 43 && (<>
|
<Form.Field>
|
||||||
<Form.Field>
|
<Form.TextArea
|
||||||
<Form.TextArea
|
label={t('channel.edit.model_mapping')}
|
||||||
label='Model redirection'
|
placeholder={`${t(
|
||||||
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)}`}
|
'channel.edit.model_mapping_placeholder'
|
||||||
name='model_mapping'
|
)}\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
|
||||||
onChange={handleInputChange}
|
name='model_mapping'
|
||||||
value={inputs.model_mapping}
|
onChange={handleInputChange}
|
||||||
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
|
value={inputs.model_mapping}
|
||||||
autoComplete='new-password'
|
style={{
|
||||||
/>
|
minHeight: 150,
|
||||||
</Form.Field>
|
fontFamily: 'JetBrains Mono, Consolas',
|
||||||
<Form.Field>
|
}}
|
||||||
<Form.TextArea
|
autoComplete='new-password'
|
||||||
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`}
|
</Form.Field>
|
||||||
name='system_prompt'
|
<Form.Field>
|
||||||
onChange={handleInputChange}
|
<Form.TextArea
|
||||||
value={inputs.system_prompt}
|
label={t('channel.edit.system_prompt')}
|
||||||
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
|
placeholder={t('channel.edit.system_prompt_placeholder')}
|
||||||
autoComplete='new-password'
|
name='system_prompt'
|
||||||
/>
|
onChange={handleInputChange}
|
||||||
</Form.Field>
|
value={inputs.system_prompt}
|
||||||
|
style={{
|
||||||
|
minHeight: 150,
|
||||||
|
fontFamily: 'JetBrains Mono, Consolas',
|
||||||
|
}}
|
||||||
|
autoComplete='new-password'
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
</>
|
</>
|
||||||
)
|
)}
|
||||||
}
|
{inputs.type === 33 && (
|
||||||
{
|
|
||||||
inputs.type === 33 && (
|
|
||||||
<Form.Field>
|
<Form.Field>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
label='Region'
|
label='Region'
|
||||||
name='region'
|
name='region'
|
||||||
required
|
required
|
||||||
placeholder={'region,e.g. us-west-2'}
|
placeholder={t('channel.edit.aws_region_placeholder')}
|
||||||
onChange={handleConfigChange}
|
onChange={handleConfigChange}
|
||||||
value={config.region}
|
value={config.region}
|
||||||
autoComplete=''
|
autoComplete=''
|
||||||
@ -468,7 +532,7 @@ const EditChannel = () => {
|
|||||||
label='AK'
|
label='AK'
|
||||||
name='ak'
|
name='ak'
|
||||||
required
|
required
|
||||||
placeholder={'AWS IAM Access Key'}
|
placeholder={t('channel.edit.aws_ak_placeholder')}
|
||||||
onChange={handleConfigChange}
|
onChange={handleConfigChange}
|
||||||
value={config.ak}
|
value={config.ak}
|
||||||
autoComplete=''
|
autoComplete=''
|
||||||
@ -477,141 +541,137 @@ const EditChannel = () => {
|
|||||||
label='SK'
|
label='SK'
|
||||||
name='sk'
|
name='sk'
|
||||||
required
|
required
|
||||||
placeholder={'AWS IAM Secret Key'}
|
placeholder={t('channel.edit.aws_sk_placeholder')}
|
||||||
onChange={handleConfigChange}
|
onChange={handleConfigChange}
|
||||||
value={config.sk}
|
value={config.sk}
|
||||||
autoComplete=''
|
autoComplete=''
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
)
|
)}
|
||||||
}
|
{inputs.type === 42 && (
|
||||||
{
|
|
||||||
inputs.type === 42 && (
|
|
||||||
<Form.Field>
|
<Form.Field>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
label='Region'
|
label='Region'
|
||||||
name='region'
|
name='region'
|
||||||
required
|
required
|
||||||
placeholder={'Vertex AI Region.g. us-east5'}
|
placeholder={t('channel.edit.vertex_region_placeholder')}
|
||||||
onChange={handleConfigChange}
|
onChange={handleConfigChange}
|
||||||
value={config.region}
|
value={config.region}
|
||||||
autoComplete=''
|
autoComplete=''
|
||||||
/>
|
/>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
label='Vertex AI Project ID'
|
label={t('channel.edit.vertex_project_id')}
|
||||||
name='vertex_ai_project_id'
|
name='vertex_ai_project_id'
|
||||||
required
|
required
|
||||||
placeholder={'Vertex AI Project ID'}
|
placeholder={t('channel.edit.vertex_project_id_placeholder')}
|
||||||
onChange={handleConfigChange}
|
onChange={handleConfigChange}
|
||||||
value={config.vertex_ai_project_id}
|
value={config.vertex_ai_project_id}
|
||||||
autoComplete=''
|
autoComplete=''
|
||||||
/>
|
/>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
label='Google Cloud Application Default Credentials JSON'
|
label={t('channel.edit.vertex_credentials')}
|
||||||
name='vertex_ai_adc'
|
name='vertex_ai_adc'
|
||||||
required
|
required
|
||||||
placeholder={'Google Cloud Application Default Credentials JSON'}
|
placeholder={t('channel.edit.vertex_credentials_placeholder')}
|
||||||
onChange={handleConfigChange}
|
onChange={handleConfigChange}
|
||||||
value={config.vertex_ai_adc}
|
value={config.vertex_ai_adc}
|
||||||
autoComplete=''
|
autoComplete=''
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
)
|
)}
|
||||||
}
|
{inputs.type === 34 && (
|
||||||
{
|
|
||||||
inputs.type === 34 && (
|
|
||||||
<Form.Input
|
<Form.Input
|
||||||
label='User ID'
|
label={t('channel.edit.user_id')}
|
||||||
name='user_id'
|
name='user_id'
|
||||||
required
|
required
|
||||||
placeholder={'User ID that generated this Key'}
|
placeholder={t('channel.edit.user_id_placeholder')}
|
||||||
onChange={handleConfigChange}
|
onChange={handleConfigChange}
|
||||||
value={config.user_id}
|
value={config.user_id}
|
||||||
autoComplete=''
|
autoComplete=''
|
||||||
/>)
|
|
||||||
}
|
|
||||||
{
|
|
||||||
inputs.type !== 33 && inputs.type !== 42 && (batch ? <Form.Field>
|
|
||||||
<Form.TextArea
|
|
||||||
label='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
|
{inputs.type !== 33 &&
|
||||||
label='Key'
|
inputs.type !== 42 &&
|
||||||
name='key'
|
(batch ? (
|
||||||
required
|
<Form.Field>
|
||||||
placeholder={type2secretPrompt(inputs.type)}
|
<Form.TextArea
|
||||||
onChange={handleInputChange}
|
label={t('channel.edit.key')}
|
||||||
value={inputs.key}
|
name='key'
|
||||||
autoComplete='new-password'
|
required
|
||||||
/>
|
placeholder={t('channel.edit.batch_placeholder')}
|
||||||
</Form.Field>)
|
onChange={handleInputChange}
|
||||||
}
|
value={inputs.key}
|
||||||
{
|
style={{
|
||||||
inputs.type === 37 && (
|
minHeight: 150,
|
||||||
<Form.Field>
|
fontFamily: 'JetBrains Mono, Consolas',
|
||||||
<Form.Input
|
}}
|
||||||
label='Account ID'
|
autoComplete='new-password'
|
||||||
name='user_id'
|
/>
|
||||||
required
|
</Form.Field>
|
||||||
placeholder={'Enter Account ID,For example:d8d7c61dbc334c32d3ced580e4bf42b4'}
|
) : (
|
||||||
onChange={handleConfigChange}
|
<Form.Field>
|
||||||
value={config.user_id}
|
<Form.Input
|
||||||
autoComplete=''
|
label={t('channel.edit.key')}
|
||||||
/>
|
name='key'
|
||||||
</Form.Field>
|
required
|
||||||
)
|
placeholder={type2secretPrompt(inputs.type, t)}
|
||||||
}
|
onChange={handleInputChange}
|
||||||
{
|
value={inputs.key}
|
||||||
inputs.type !== 33 && !isEdit && (
|
autoComplete='new-password'
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
))}
|
||||||
|
{inputs.type !== 33 && !isEdit && (
|
||||||
<Form.Checkbox
|
<Form.Checkbox
|
||||||
checked={batch}
|
checked={batch}
|
||||||
label='Batch Create'
|
label={t('channel.edit.batch')}
|
||||||
name='batch'
|
name='batch'
|
||||||
onChange={() => setBatch(!batch)}
|
onChange={() => setBatch(!batch)}
|
||||||
/>
|
/>
|
||||||
)
|
)}
|
||||||
}
|
{inputs.type !== 3 &&
|
||||||
{
|
inputs.type !== 33 &&
|
||||||
inputs.type !== 3 && inputs.type !== 33 && inputs.type !== 8 && inputs.type !== 22 && (
|
inputs.type !== 8 &&
|
||||||
<Form.Field>
|
inputs.type !== 22 && (
|
||||||
<Form.Input
|
<Form.Field>
|
||||||
label='Proxy'
|
<Form.Input
|
||||||
name='base_url'
|
label={t('channel.edit.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'}
|
name='base_url'
|
||||||
onChange={handleInputChange}
|
placeholder={t('channel.edit.base_url_placeholder')}
|
||||||
value={inputs.base_url}
|
onChange={handleInputChange}
|
||||||
autoComplete='new-password'
|
value={inputs.base_url}
|
||||||
/>
|
autoComplete='new-password'
|
||||||
</Form.Field>
|
/>
|
||||||
)
|
</Form.Field>
|
||||||
}
|
)}
|
||||||
{
|
{inputs.type === 22 && (
|
||||||
inputs.type === 22 && (
|
|
||||||
<Form.Field>
|
<Form.Field>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
label='Private Deployment URL'
|
label='Private Deployment URL'
|
||||||
name='base_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}
|
onChange={handleInputChange}
|
||||||
value={inputs.base_url}
|
value={inputs.base_url}
|
||||||
autoComplete='new-password'
|
autoComplete='new-password'
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
)
|
)}
|
||||||
}
|
<Button onClick={handleCancel}>
|
||||||
<Button onClick={handleCancel}>Cancel</Button>
|
{t('channel.edit.buttons.cancel')}
|
||||||
<Button type={isEdit ? 'button' : 'submit'} positive onClick={submit}>Submit</Button>
|
</Button>
|
||||||
</Form>
|
<Button
|
||||||
</Segment>
|
type={isEdit ? 'button' : 'submit'}
|
||||||
</>
|
positive
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
{t('channel.edit.buttons.submit')}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,14 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Header, Segment } from 'semantic-ui-react';
|
import { Card } from 'semantic-ui-react';
|
||||||
import ChannelsTable from '../../components/ChannelsTable';
|
import ChannelsTable from '../../components/ChannelsTable';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const Channel = () => (
|
const Channel = () => {
|
||||||
<>
|
const { t } = useTranslation();
|
||||||
<Segment>
|
|
||||||
<Header as='h3'>Manage Channels</Header>
|
return (
|
||||||
<ChannelsTable />
|
<div className='dashboard-container'>
|
||||||
</Segment>
|
<Card fluid className='chart-card'>
|
||||||
</>
|
<Card.Content>
|
||||||
);
|
<Card.Header className='header'>{t('channel.title')}</Card.Header>
|
||||||
|
<ChannelsTable />
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Channel;
|
export default Channel;
|
||||||
|
109
web/default/src/pages/Dashboard/Dashboard.css
Normal file
109
web/default/src/pages/Dashboard/Dashboard.css
Normal 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;
|
||||||
|
}
|
389
web/default/src/pages/Dashboard/index.js
Normal file
389
web/default/src/pages/Dashboard/index.js
Normal 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;
|
@ -3,22 +3,25 @@ import { Card, Grid, Header, Segment } from 'semantic-ui-react';
|
|||||||
import { API, showError, showNotice, timestamp2string } from '../../helpers';
|
import { API, showError, showNotice, timestamp2string } from '../../helpers';
|
||||||
import { StatusContext } from '../../context/Status';
|
import { StatusContext } from '../../context/Status';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
import { UserContext } from '../../context/User';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||||
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
|
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
|
||||||
const [homePageContent, setHomePageContent] = useState('');
|
const [homePageContent, setHomePageContent] = useState('');
|
||||||
|
const [userState] = useContext(UserContext);
|
||||||
|
|
||||||
const displayNotice = async () => {
|
const displayNotice = async () => {
|
||||||
const res = await API.get('/api/notice');
|
const res = await API.get('/api/notice');
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
let oldNotice = localStorage.getItem('notice');
|
let oldNotice = localStorage.getItem('notice');
|
||||||
if (data !== oldNotice && data !== '') {
|
if (data !== oldNotice && data !== '') {
|
||||||
const htmlNotice = marked(data);
|
const htmlNotice = marked(data);
|
||||||
showNotice(htmlNotice, true);
|
showNotice(htmlNotice, true);
|
||||||
localStorage.setItem('notice', data);
|
localStorage.setItem('notice', data);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
@ -51,81 +54,236 @@ const Home = () => {
|
|||||||
displayNotice().then();
|
displayNotice().then();
|
||||||
displayHomePageContent().then();
|
displayHomePageContent().then();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{
|
{homePageContentLoaded && homePageContent === '' ? (
|
||||||
homePageContentLoaded && homePageContent === '' ? <>
|
<div className='dashboard-container'>
|
||||||
<Segment>
|
<Card fluid className='chart-card'>
|
||||||
<Header as='h3'>System status</Header>
|
<Card.Content>
|
||||||
<Grid columns={2} stackable>
|
<Card.Header className='header'>Welcome to One API</Card.Header>
|
||||||
<Grid.Column>
|
<Card.Description style={{ lineHeight: '1.6' }}>
|
||||||
<Card fluid>
|
<p>
|
||||||
<Card.Content>
|
One API is an LLM API interface management and distribution system that helps you better manage and use LLM APIs from various vendors.
|
||||||
<Card.Header>System information</Card.Header>
|
</p>
|
||||||
<Card.Meta>System information overview</Card.Meta>
|
{!userState.user && (
|
||||||
<Card.Description>
|
<p>
|
||||||
<p>Name:{statusState?.status?.system_name}</p>
|
To use, please <Link to='/login'>log in</Link> or <Link to='/register'>register</Link>.
|
||||||
<p>Version:{statusState?.status?.version ? statusState?.status?.version : "unknown"}</p>
|
</p>
|
||||||
<p>
|
)}
|
||||||
Source code:
|
</Card.Description>
|
||||||
<a
|
</Card.Content>
|
||||||
href='https://github.com/Laisky/one-api'
|
</Card>
|
||||||
target='_blank'
|
<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
|
||||||
|
className='chart-card'
|
||||||
|
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
|
||||||
|
>
|
||||||
|
<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',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
https://github.com/Laisky/one-api
|
<i className='info circle icon'></i>
|
||||||
</a>
|
<span style={{ fontWeight: 'bold' }}>Name:</span>
|
||||||
</p>
|
<span>{statusState?.status?.system_name}</span>
|
||||||
<p>Startup time:{getStartTimeString()}</p>
|
</p>
|
||||||
</Card.Description>
|
<p
|
||||||
</Card.Content>
|
style={{
|
||||||
</Card>
|
display: 'flex',
|
||||||
</Grid.Column>
|
alignItems: 'center',
|
||||||
<Grid.Column>
|
gap: '0.5em',
|
||||||
<Card fluid>
|
}}
|
||||||
<Card.Content>
|
>
|
||||||
<Card.Header>System configuration</Card.Header>
|
<i className='code branch icon'></i>
|
||||||
<Card.Meta>System configuration overview</Card.Meta>
|
<span style={{ fontWeight: 'bold' }}>Version:</span>
|
||||||
<Card.Description>
|
<span>
|
||||||
<p>
|
{statusState?.status?.version || 'unknown'}
|
||||||
Email verification:
|
</span>
|
||||||
{statusState?.status?.email_verification === true
|
</p>
|
||||||
? 'Enabled'
|
<p
|
||||||
: 'Not enabled'}
|
style={{
|
||||||
</p>
|
display: 'flex',
|
||||||
<p>
|
alignItems: 'center',
|
||||||
GitHub Authentication:
|
gap: '0.5em',
|
||||||
{statusState?.status?.github_oauth === true
|
}}
|
||||||
? 'Enabled'
|
>
|
||||||
: 'Not enabled'}
|
<i className='github icon'></i>
|
||||||
</p>
|
<span style={{ fontWeight: 'bold' }}>Source Code:</span>
|
||||||
<p>
|
<a
|
||||||
WeChat Authentication:
|
href='https://github.com/songquanpeng/one-api'
|
||||||
{statusState?.status?.wechat_login === true
|
target='_blank'
|
||||||
? 'Enabled'
|
style={{ color: '#2185d0' }}
|
||||||
: 'Not enabled'}
|
>
|
||||||
</p>
|
GitHub Repository
|
||||||
<p>
|
</a>
|
||||||
Turnstile user verification:
|
</p>
|
||||||
{statusState?.status?.turnstile_check === true
|
<p
|
||||||
? 'Enabled'
|
style={{
|
||||||
: 'Not enabled'}
|
display: 'flex',
|
||||||
</p>
|
alignItems: 'center',
|
||||||
</Card.Description>
|
gap: '0.5em',
|
||||||
</Card.Content>
|
}}
|
||||||
</Card>
|
>
|
||||||
</Grid.Column>
|
<i className='clock outline icon'></i>
|
||||||
</Grid>
|
<span style={{ fontWeight: 'bold' }}>Start Time:</span>
|
||||||
</Segment>
|
<span>{getStartTimeString()}</span>
|
||||||
</> : <>
|
</p>
|
||||||
{
|
</Card.Description>
|
||||||
homePageContent.startsWith('https://') ? <iframe
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</Grid.Column>
|
||||||
|
|
||||||
|
<Grid.Column>
|
||||||
|
<Card
|
||||||
|
fluid
|
||||||
|
className='chart-card'
|
||||||
|
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
|
||||||
|
>
|
||||||
|
<Card.Content>
|
||||||
|
<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'
|
||||||
|
: 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<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'
|
||||||
|
: 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<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'
|
||||||
|
: 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<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'
|
||||||
|
: 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</Grid.Column>
|
||||||
|
</Grid>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>{' '}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{homePageContent.startsWith('https://') ? (
|
||||||
|
<iframe
|
||||||
src={homePageContent}
|
src={homePageContent}
|
||||||
style={{ width: '100%', height: '100vh', border: 'none' }}
|
style={{ width: '100%', height: '100vh', border: 'none' }}
|
||||||
/> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: homePageContent }}></div>
|
/>
|
||||||
}
|
) : (
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 'larger' }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: homePageContent }}
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
)}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Header, Segment } from 'semantic-ui-react';
|
import { Card } from 'semantic-ui-react';
|
||||||
import LogsTable from '../../components/LogsTable';
|
import LogsTable from '../../components/LogsTable';
|
||||||
|
|
||||||
const Token = () => (
|
const Log = () => (
|
||||||
<>
|
<div className='dashboard-container'>
|
||||||
<LogsTable />
|
<Card fluid className='chart-card'>
|
||||||
</>
|
<Card.Content>
|
||||||
|
{/*<Card.Header className='header'>操作日志</Card.Header>*/}
|
||||||
|
<LogsTable />
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default Token;
|
export default Log;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Form, Header, Segment } from 'semantic-ui-react';
|
import { Button, Form, Card } from 'semantic-ui-react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { API, downloadTextAsFile, showError, showSuccess } from '../../helpers';
|
import { API, downloadTextAsFile, showError, showSuccess } from '../../helpers';
|
||||||
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
|
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
|
||||||
@ -13,7 +13,7 @@ const EditRedemption = () => {
|
|||||||
const originInputs = {
|
const originInputs = {
|
||||||
name: '',
|
name: '',
|
||||||
quota: 100000,
|
quota: 100000,
|
||||||
count: 1
|
count: 1,
|
||||||
};
|
};
|
||||||
const [inputs, setInputs] = useState(originInputs);
|
const [inputs, setInputs] = useState(originInputs);
|
||||||
const { name, quota, count } = inputs;
|
const { name, quota, count } = inputs;
|
||||||
@ -21,7 +21,7 @@ const EditRedemption = () => {
|
|||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
navigate('/redemption');
|
navigate('/redemption');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (e, { name, value }) => {
|
const handleInputChange = (e, { name, value }) => {
|
||||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||||
};
|
};
|
||||||
@ -49,10 +49,13 @@ const EditRedemption = () => {
|
|||||||
localInputs.quota = parseInt(localInputs.quota);
|
localInputs.quota = parseInt(localInputs.quota);
|
||||||
let res;
|
let res;
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
res = await API.put(`/api/redemption/`, { ...localInputs, id: parseInt(redemptionId) });
|
res = await API.put(`/api/redemption/`, {
|
||||||
|
...localInputs,
|
||||||
|
id: parseInt(redemptionId),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
res = await API.post(`/api/redemption/`, {
|
res = await API.post(`/api/redemption/`, {
|
||||||
...localInputs
|
...localInputs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
@ -67,61 +70,67 @@ const EditRedemption = () => {
|
|||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
if (!isEdit && data) {
|
if (!isEdit && data) {
|
||||||
let text = "";
|
let text = '';
|
||||||
for (let i = 0; i < data.length; i++) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
text += data[i] + "\n";
|
text += data[i] + '\n';
|
||||||
}
|
}
|
||||||
downloadTextAsFile(text, `${inputs.name}.txt`);
|
downloadTextAsFile(text, `${inputs.name}.txt`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className='dashboard-container'>
|
||||||
<Segment loading={loading}>
|
<Card fluid className='chart-card'>
|
||||||
<Header as='h3'>{isEdit ? 'Update redemption code information' : 'Create a new redemption code'}</Header>
|
<Card.Content>
|
||||||
<Form autoComplete='new-password'>
|
<Card.Header className='header'>
|
||||||
<Form.Field>
|
{isEdit ? 'Update Redemption Code Information' : 'Create New Redemption Code'}
|
||||||
<Form.Input
|
</Card.Header>
|
||||||
label='Name'
|
<Form loading={loading} autoComplete='new-password'>
|
||||||
name='name'
|
<Form.Field>
|
||||||
placeholder={'Please enter a name'}
|
<Form.Input
|
||||||
onChange={handleInputChange}
|
label='Name'
|
||||||
value={name}
|
name='name'
|
||||||
autoComplete='new-password'
|
placeholder={'Please enter name'}
|
||||||
required={!isEdit}
|
onChange={handleInputChange}
|
||||||
/>
|
value={name}
|
||||||
</Form.Field>
|
autoComplete='new-password'
|
||||||
<Form.Field>
|
required={!isEdit}
|
||||||
<Form.Input
|
/>
|
||||||
label={`Quota${renderQuotaWithPrompt(quota)}`}
|
</Form.Field>
|
||||||
name='quota'
|
<Form.Field>
|
||||||
placeholder={'Please enter the quota included in a single redemption code'}
|
<Form.Input
|
||||||
onChange={handleInputChange}
|
label={`Quota ${renderQuotaWithPrompt(quota)}`}
|
||||||
value={quota}
|
name='quota'
|
||||||
autoComplete='new-password'
|
placeholder={'Please enter the quota included in each redemption code'}
|
||||||
type='number'
|
onChange={handleInputChange}
|
||||||
/>
|
value={quota}
|
||||||
</Form.Field>
|
autoComplete='new-password'
|
||||||
{
|
type='number'
|
||||||
!isEdit && <>
|
/>
|
||||||
<Form.Field>
|
</Form.Field>
|
||||||
<Form.Input
|
{!isEdit && (
|
||||||
label='Generate quantity'
|
<>
|
||||||
name='count'
|
<Form.Field>
|
||||||
placeholder={'Please enter the quantity to generate'}
|
<Form.Input
|
||||||
onChange={handleInputChange}
|
label='Quantity'
|
||||||
value={count}
|
name='count'
|
||||||
autoComplete='new-password'
|
placeholder={'Please enter the quantity to generate'}
|
||||||
type='number'
|
onChange={handleInputChange}
|
||||||
/>
|
value={count}
|
||||||
</Form.Field>
|
autoComplete='new-password'
|
||||||
</>
|
type='number'
|
||||||
}
|
/>
|
||||||
<Button positive onClick={submit}>Submit</Button>
|
</Form.Field>
|
||||||
<Button onClick={handleCancel}>Cancel</Button>
|
</>
|
||||||
</Form>
|
)}
|
||||||
</Segment>
|
<Button positive onClick={submit}>
|
||||||
</>
|
Submit
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCancel}>Cancel</Button>
|
||||||
|
</Form>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,14 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Segment, Header } from 'semantic-ui-react';
|
import { Card } from 'semantic-ui-react';
|
||||||
import RedemptionsTable from '../../components/RedemptionsTable';
|
import RedemptionsTable from '../../components/RedemptionsTable';
|
||||||
|
|
||||||
const Redemption = () => (
|
const Redemption = () => (
|
||||||
<>
|
<div className='dashboard-container'>
|
||||||
<Segment>
|
<Card fluid className='chart-card'>
|
||||||
<Header as='h3'>Manage Redeem Codes</Header>
|
<Card.Content>
|
||||||
<RedemptionsTable/>
|
<Card.Header className='header'>Manage Redeem Code</Card.Header>
|
||||||
</Segment>
|
<RedemptionsTable />
|
||||||
</>
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default Redemption;
|
export default Redemption;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Segment, Tab } from 'semantic-ui-react';
|
import { Card, Tab } from 'semantic-ui-react';
|
||||||
import SystemSetting from '../../components/SystemSetting';
|
import SystemSetting from '../../components/SystemSetting';
|
||||||
import { isRoot } from '../../helpers';
|
import { isRoot } from '../../helpers';
|
||||||
import OtherSetting from '../../components/OtherSetting';
|
import OtherSetting from '../../components/OtherSetting';
|
||||||
@ -14,8 +14,8 @@ const Setting = () => {
|
|||||||
<Tab.Pane attached={false}>
|
<Tab.Pane attached={false}>
|
||||||
<PersonalSetting />
|
<PersonalSetting />
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
)
|
),
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isRoot()) {
|
if (isRoot()) {
|
||||||
@ -25,7 +25,7 @@ const Setting = () => {
|
|||||||
<Tab.Pane attached={false}>
|
<Tab.Pane attached={false}>
|
||||||
<OperationSetting />
|
<OperationSetting />
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
)
|
),
|
||||||
});
|
});
|
||||||
panes.push({
|
panes.push({
|
||||||
menuItem: 'System settings',
|
menuItem: 'System settings',
|
||||||
@ -33,7 +33,7 @@ const Setting = () => {
|
|||||||
<Tab.Pane attached={false}>
|
<Tab.Pane attached={false}>
|
||||||
<SystemSetting />
|
<SystemSetting />
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
)
|
),
|
||||||
});
|
});
|
||||||
panes.push({
|
panes.push({
|
||||||
menuItem: 'Other settings',
|
menuItem: 'Other settings',
|
||||||
@ -41,14 +41,26 @@ const Setting = () => {
|
|||||||
<Tab.Pane attached={false}>
|
<Tab.Pane attached={false}>
|
||||||
<OtherSetting />
|
<OtherSetting />
|
||||||
</Tab.Pane>
|
</Tab.Pane>
|
||||||
)
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Segment>
|
<div className='dashboard-container'>
|
||||||
<Tab menu={{ secondary: true, pointing: true }} panes={panes} />
|
<Card fluid className='chart-card'>
|
||||||
</Segment>
|
<Card.Content>
|
||||||
|
<Card.Header className='header'>系统设置</Card.Header>
|
||||||
|
<Tab
|
||||||
|
menu={{
|
||||||
|
secondary: true,
|
||||||
|
pointing: true,
|
||||||
|
className: 'settings-tab', // 添加自定义类名以便样式化
|
||||||
|
}}
|
||||||
|
panes={panes}
|
||||||
|
/>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,7 +1,20 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Form, Header, Message, Segment } from 'semantic-ui-react';
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Header,
|
||||||
|
Message,
|
||||||
|
Segment,
|
||||||
|
Card,
|
||||||
|
} from 'semantic-ui-react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { API, copy, showError, showSuccess, timestamp2string } from '../../helpers';
|
import {
|
||||||
|
API,
|
||||||
|
copy,
|
||||||
|
showError,
|
||||||
|
showSuccess,
|
||||||
|
timestamp2string,
|
||||||
|
} from '../../helpers';
|
||||||
import { renderQuotaWithPrompt } from '../../helpers/render';
|
import { renderQuotaWithPrompt } from '../../helpers/render';
|
||||||
|
|
||||||
const EditToken = () => {
|
const EditToken = () => {
|
||||||
@ -16,7 +29,7 @@ const EditToken = () => {
|
|||||||
expired_time: -1,
|
expired_time: -1,
|
||||||
unlimited_quota: false,
|
unlimited_quota: false,
|
||||||
models: [],
|
models: [],
|
||||||
subnet: "",
|
subnet: '',
|
||||||
};
|
};
|
||||||
const [inputs, setInputs] = useState(originInputs);
|
const [inputs, setInputs] = useState(originInputs);
|
||||||
const { name, remain_quota, expired_time, unlimited_quota } = inputs;
|
const { name, remain_quota, expired_time, unlimited_quota } = inputs;
|
||||||
@ -79,7 +92,7 @@ const EditToken = () => {
|
|||||||
return {
|
return {
|
||||||
key: model,
|
key: model,
|
||||||
text: model,
|
text: model,
|
||||||
value: model
|
value: model,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
setModelOptions(options);
|
setModelOptions(options);
|
||||||
@ -103,7 +116,10 @@ const EditToken = () => {
|
|||||||
localInputs.models = localInputs.models.join(',');
|
localInputs.models = localInputs.models.join(',');
|
||||||
let res;
|
let res;
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
res = await API.put(`/api/token/`, { ...localInputs, id: parseInt(tokenId) });
|
res = await API.put(`/api/token/`, {
|
||||||
|
...localInputs,
|
||||||
|
id: parseInt(tokenId),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
res = await API.post(`/api/token/`, localInputs);
|
res = await API.post(`/api/token/`, localInputs);
|
||||||
}
|
}
|
||||||
@ -121,99 +137,142 @@ const EditToken = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className='dashboard-container'>
|
||||||
<Segment loading={loading}>
|
<Card fluid className='chart-card'>
|
||||||
<Header as='h3'>{isEdit ? 'Update key information' : 'Create a new key'}</Header>
|
<Card.Content>
|
||||||
<Form autoComplete='new-password'>
|
<Card.Header className='header'>
|
||||||
<Form.Field>
|
{isEdit ? 'Update Token Information' : 'Create New Token'}
|
||||||
<Form.Input
|
</Card.Header>
|
||||||
label='Name'
|
<Form loading={loading} autoComplete='new-password'>
|
||||||
name='name'
|
<Form.Field>
|
||||||
placeholder={'Please enter a name'}
|
<Form.Input
|
||||||
onChange={handleInputChange}
|
label='Name'
|
||||||
value={name}
|
name='name'
|
||||||
autoComplete='new-password'
|
placeholder={'Please enter name'}
|
||||||
required={!isEdit}
|
onChange={handleInputChange}
|
||||||
/>
|
value={name}
|
||||||
</Form.Field>
|
autoComplete='new-password'
|
||||||
<Form.Field>
|
required={!isEdit}
|
||||||
<Form.Dropdown
|
/>
|
||||||
label='Model Range'
|
</Form.Field>
|
||||||
placeholder={'Please select the allowed models, leave blank for no restriction'}
|
<Form.Field>
|
||||||
name='models'
|
<Form.Dropdown
|
||||||
fluid
|
label='Model Scope'
|
||||||
multiple
|
placeholder={'Please select allowed models, leave blank for no restriction'}
|
||||||
search
|
name='models'
|
||||||
onLabelClick={(e, { value }) => {
|
fluid
|
||||||
copy(value).then();
|
multiple
|
||||||
|
search
|
||||||
|
onLabelClick={(e, { value }) => {
|
||||||
|
copy(value).then();
|
||||||
|
}}
|
||||||
|
selection
|
||||||
|
onChange={handleInputChange}
|
||||||
|
value={inputs.models}
|
||||||
|
autoComplete='new-password'
|
||||||
|
options={modelOptions}
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field>
|
||||||
|
<Form.Input
|
||||||
|
label='IP Restriction'
|
||||||
|
name='subnet'
|
||||||
|
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'
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field>
|
||||||
|
<Form.Input
|
||||||
|
label='Expiration Time'
|
||||||
|
name='expired_time'
|
||||||
|
placeholder={
|
||||||
|
'Please enter expiration time, format: yyyy-MM-dd HH:mm:ss, -1 means no restriction'
|
||||||
|
}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
value={expired_time}
|
||||||
|
autoComplete='new-password'
|
||||||
|
type='datetime-local'
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
<div style={{ lineHeight: '40px' }}>
|
||||||
|
<Button
|
||||||
|
type={'button'}
|
||||||
|
onClick={() => {
|
||||||
|
setExpiredTime(0, 0, 0, 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Never Expires
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type={'button'}
|
||||||
|
onClick={() => {
|
||||||
|
setExpiredTime(1, 0, 0, 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Expires in One Month
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type={'button'}
|
||||||
|
onClick={() => {
|
||||||
|
setExpiredTime(0, 1, 0, 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Expires in One Day
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type={'button'}
|
||||||
|
onClick={() => {
|
||||||
|
setExpiredTime(0, 0, 1, 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Expires in One Hour
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type={'button'}
|
||||||
|
onClick={() => {
|
||||||
|
setExpiredTime(0, 0, 0, 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Expires in One Minute
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<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 quota'}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
value={remain_quota}
|
||||||
|
autoComplete='new-password'
|
||||||
|
type='number'
|
||||||
|
disabled={unlimited_quota}
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
<Button
|
||||||
|
type={'button'}
|
||||||
|
onClick={() => {
|
||||||
|
setUnlimitedQuota();
|
||||||
}}
|
}}
|
||||||
selection
|
>
|
||||||
onChange={handleInputChange}
|
{unlimited_quota ? 'Cancel Unlimited Quota' : 'Set as Unlimited Quota'}
|
||||||
value={inputs.models}
|
</Button>
|
||||||
autoComplete='new-password'
|
<Button floated='right' positive onClick={submit}>
|
||||||
options={modelOptions}
|
Submit
|
||||||
/>
|
</Button>
|
||||||
</Form.Field>
|
<Button floated='right' onClick={handleCancel}>
|
||||||
<Form.Field>
|
Cancel
|
||||||
<Form.Input
|
</Button>
|
||||||
label='IP Restriction'
|
</Form>
|
||||||
name='subnet'
|
</Card.Content>
|
||||||
placeholder={'Please enter the allowed subnet, e.g., 192.168.0.0/24, use commas to separate multiple subnets'}
|
</Card>
|
||||||
onChange={handleInputChange}
|
</div>
|
||||||
value={inputs.subnet}
|
|
||||||
autoComplete='new-password'
|
|
||||||
/>
|
|
||||||
</Form.Field>
|
|
||||||
<Form.Field>
|
|
||||||
<Form.Input
|
|
||||||
label='Expiration Time'
|
|
||||||
name='expired_time'
|
|
||||||
placeholder={'Please enter the expiration time, format: yyyy-MM-dd HH:mm:ss, -1 means unlimited'}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
value={expired_time}
|
|
||||||
autoComplete='new-password'
|
|
||||||
type='datetime-local'
|
|
||||||
/>
|
|
||||||
</Form.Field>
|
|
||||||
<div style={{ lineHeight: '40px' }}>
|
|
||||||
<Button type={'button'} onClick={() => {
|
|
||||||
setExpiredTime(0, 0, 0, 0);
|
|
||||||
}}>Never expires</Button>
|
|
||||||
<Button type={'button'} onClick={() => {
|
|
||||||
setExpiredTime(1, 0, 0, 0);
|
|
||||||
}}>Expires after one month</Button>
|
|
||||||
<Button type={'button'} onClick={() => {
|
|
||||||
setExpiredTime(0, 1, 0, 0);
|
|
||||||
}}>Expires after one day</Button>
|
|
||||||
<Button type={'button'} onClick={() => {
|
|
||||||
setExpiredTime(0, 0, 1, 0);
|
|
||||||
}}>Expires after one hour</Button>
|
|
||||||
<Button type={'button'} onClick={() => {
|
|
||||||
setExpiredTime(0, 0, 0, 1);
|
|
||||||
}}>Expires after 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>
|
|
||||||
<Form.Field>
|
|
||||||
<Form.Input
|
|
||||||
label={`Quota${renderQuotaWithPrompt(remain_quota)}`}
|
|
||||||
name='remain_quota'
|
|
||||||
placeholder={'Please enter the quota'}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
value={remain_quota}
|
|
||||||
autoComplete='new-password'
|
|
||||||
type='number'
|
|
||||||
disabled={unlimited_quota}
|
|
||||||
/>
|
|
||||||
</Form.Field>
|
|
||||||
<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>
|
|
||||||
</Form>
|
|
||||||
</Segment>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EditToken;
|
export default EditToken;
|
||||||
|
@ -1,14 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Segment, Header } from 'semantic-ui-react';
|
import { Card } from 'semantic-ui-react';
|
||||||
import TokensTable from '../../components/TokensTable';
|
import TokensTable from '../../components/TokensTable';
|
||||||
|
|
||||||
const Token = () => (
|
const Token = () => (
|
||||||
<>
|
<div className='dashboard-container'>
|
||||||
<Segment>
|
<Card fluid className='chart-card'>
|
||||||
<Header as='h3'>My keys</Header>
|
<Card.Content>
|
||||||
<TokensTable/>
|
<Card.Header className='header'>Apikeys</Card.Header>
|
||||||
</Segment>
|
<TokensTable />
|
||||||
</>
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default Token;
|
export default Token;
|
||||||
|
@ -1,9 +1,19 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Form, Grid, Header, Segment, Statistic } from 'semantic-ui-react';
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Grid,
|
||||||
|
Header,
|
||||||
|
Card,
|
||||||
|
Statistic,
|
||||||
|
Divider,
|
||||||
|
} from 'semantic-ui-react';
|
||||||
import { API, showError, showInfo, showSuccess } from '../../helpers';
|
import { API, showError, showInfo, showSuccess } from '../../helpers';
|
||||||
import { renderQuota } from '../../helpers/render';
|
import { renderQuota } from '../../helpers/render';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const TopUp = () => {
|
const TopUp = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [redemptionCode, setRedemptionCode] = useState('');
|
const [redemptionCode, setRedemptionCode] = useState('');
|
||||||
const [topUpLink, setTopUpLink] = useState('');
|
const [topUpLink, setTopUpLink] = useState('');
|
||||||
const [userQuota, setUserQuota] = useState(0);
|
const [userQuota, setUserQuota] = useState(0);
|
||||||
@ -12,17 +22,17 @@ const TopUp = () => {
|
|||||||
|
|
||||||
const topUp = async () => {
|
const topUp = async () => {
|
||||||
if (redemptionCode === '') {
|
if (redemptionCode === '') {
|
||||||
showInfo('Please enter the recharge code!')
|
showInfo(t('topup.redeem_code.empty_code'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const res = await API.post('/api/user/topup', {
|
const res = await API.post('/api/user/topup', {
|
||||||
key: redemptionCode
|
key: redemptionCode,
|
||||||
});
|
});
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
showSuccess('Recharge successful!');
|
showSuccess(t('topup.redeem_code.success'));
|
||||||
setUserQuota((quota) => {
|
setUserQuota((quota) => {
|
||||||
return quota + data;
|
return quota + data;
|
||||||
});
|
});
|
||||||
@ -31,37 +41,36 @@ const TopUp = () => {
|
|||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError('Request failed');
|
showError(t('topup.redeem_code.request_failed'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openTopUpLink = () => {
|
const openTopUpLink = () => {
|
||||||
if (!topUpLink) {
|
if (!topUpLink) {
|
||||||
showError('The super administrator did not set a recharge link!');
|
showError(t('topup.redeem_code.no_link'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let url = new URL(topUpLink);
|
let url = new URL(topUpLink);
|
||||||
let username = user.username;
|
let username = user.username;
|
||||||
let user_id = user.id;
|
let user_id = user.id;
|
||||||
// add username and user_id to the topup link
|
|
||||||
url.searchParams.append('username', username);
|
url.searchParams.append('username', username);
|
||||||
url.searchParams.append('user_id', user_id);
|
url.searchParams.append('user_id', user_id);
|
||||||
url.searchParams.append('transaction_id', crypto.randomUUID());
|
url.searchParams.append('transaction_id', crypto.randomUUID());
|
||||||
window.open(url.toString(), '_blank');
|
window.open(url.toString(), '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUserQuota = async ()=>{
|
const getUserQuota = async () => {
|
||||||
let res = await API.get(`/api/user/self`);
|
let res = await API.get(`/api/user/self`);
|
||||||
const {success, message, data} = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
setUserQuota(data.quota);
|
setUserQuota(data.quota);
|
||||||
setUser(data);
|
setUser(data);
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let status = localStorage.getItem('status');
|
let status = localStorage.getItem('status');
|
||||||
@ -75,38 +84,170 @@ const TopUp = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Segment>
|
<div className='dashboard-container'>
|
||||||
<Header as='h3'>Recharge quota</Header>
|
<Card fluid className='chart-card'>
|
||||||
<Grid columns={2} stackable>
|
<Card.Content>
|
||||||
<Grid.Column>
|
<Card.Header>
|
||||||
<Form>
|
<Header as='h2'>{t('topup.title')}</Header>
|
||||||
<Form.Input
|
</Card.Header>
|
||||||
placeholder='Redeem Code'
|
|
||||||
name='redemptionCode'
|
<Grid columns={2} stackable>
|
||||||
value={redemptionCode}
|
<Grid.Column>
|
||||||
onChange={(e) => {
|
<Card
|
||||||
setRedemptionCode(e.target.value);
|
fluid
|
||||||
}}
|
style={{
|
||||||
/>
|
height: '100%',
|
||||||
<Button color='green' onClick={openTopUpLink}>
|
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||||
Recharge
|
}}
|
||||||
</Button>
|
>
|
||||||
<Button color='yellow' onClick={topUp} disabled={isSubmitting}>
|
<Card.Content
|
||||||
{isSubmitting ? 'Redeeming...' : 'Redeem'}
|
style={{
|
||||||
</Button>
|
height: '100%',
|
||||||
</Form>
|
display: 'flex',
|
||||||
</Grid.Column>
|
flexDirection: 'column',
|
||||||
<Grid.Column>
|
}}
|
||||||
<Statistic.Group widths='one'>
|
>
|
||||||
<Statistic>
|
<Card.Header>
|
||||||
<Statistic.Value>{renderQuota(userQuota)}</Statistic.Value>
|
<Header as='h3' style={{ color: '#2185d0', margin: '1em' }}>
|
||||||
<Statistic.Label>Remaining quota</Statistic.Label>
|
<i className='credit card icon'></i>
|
||||||
</Statistic>
|
{t('topup.get_code.title')}
|
||||||
</Statistic.Group>
|
</Header>
|
||||||
</Grid.Column>
|
</Card.Header>
|
||||||
</Grid>
|
<Card.Description
|
||||||
</Segment>
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', paddingTop: '1em' }}>
|
||||||
|
<Statistic>
|
||||||
|
<Statistic.Value style={{ color: '#2185d0' }}>
|
||||||
|
{renderQuota(userQuota)}
|
||||||
|
</Statistic.Value>
|
||||||
|
<Statistic.Label>
|
||||||
|
{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
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</Grid.Column>
|
||||||
|
</Grid>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TopUp;
|
export default TopUp;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Form, Header, Segment } from 'semantic-ui-react';
|
import { Button, Form, Card } from 'semantic-ui-react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { API, showError, showSuccess } from '../../helpers';
|
import { API, showError, showSuccess } from '../../helpers';
|
||||||
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
|
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
|
||||||
@ -16,30 +16,40 @@ const EditUser = () => {
|
|||||||
wechat_id: '',
|
wechat_id: '',
|
||||||
email: '',
|
email: '',
|
||||||
quota: 0,
|
quota: 0,
|
||||||
group: 'default'
|
group: 'default',
|
||||||
});
|
});
|
||||||
const [groupOptions, setGroupOptions] = useState([]);
|
const [groupOptions, setGroupOptions] = useState([]);
|
||||||
const { username, display_name, password, github_id, wechat_id, email, quota, group } =
|
const {
|
||||||
inputs;
|
username,
|
||||||
|
display_name,
|
||||||
|
password,
|
||||||
|
github_id,
|
||||||
|
wechat_id,
|
||||||
|
email,
|
||||||
|
quota,
|
||||||
|
group,
|
||||||
|
} = inputs;
|
||||||
const handleInputChange = (e, { name, value }) => {
|
const handleInputChange = (e, { name, value }) => {
|
||||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||||
};
|
};
|
||||||
const fetchGroups = async () => {
|
const fetchGroups = async () => {
|
||||||
try {
|
try {
|
||||||
let res = await API.get(`/api/group/`);
|
let res = await API.get(`/api/group/`);
|
||||||
setGroupOptions(res.data.data.map((group) => ({
|
setGroupOptions(
|
||||||
key: group,
|
res.data.data.map((group) => ({
|
||||||
text: group,
|
key: group,
|
||||||
value: group,
|
text: group,
|
||||||
})));
|
value: group,
|
||||||
|
}))
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error.message);
|
showError(error.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
navigate("/setting");
|
navigate('/setting');
|
||||||
}
|
};
|
||||||
const loadUser = async () => {
|
const loadUser = async () => {
|
||||||
let res = undefined;
|
let res = undefined;
|
||||||
if (userId) {
|
if (userId) {
|
||||||
@ -83,107 +93,113 @@ const EditUser = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className='dashboard-container'>
|
||||||
<Segment loading={loading}>
|
<Card fluid className='chart-card'>
|
||||||
<Header as='h3'>Update user information</Header>
|
<Card.Content>
|
||||||
<Form autoComplete='new-password'>
|
<Card.Header className='header'>Update User Information</Card.Header>
|
||||||
<Form.Field>
|
<Form loading={loading} autoComplete='new-password'>
|
||||||
<Form.Input
|
<Form.Field>
|
||||||
label='Username'
|
<Form.Input
|
||||||
name='username'
|
label='Username'
|
||||||
placeholder={'Please enter a new username'}
|
name='username'
|
||||||
onChange={handleInputChange}
|
placeholder={'Please enter new username'}
|
||||||
value={username}
|
onChange={handleInputChange}
|
||||||
autoComplete='new-password'
|
value={username}
|
||||||
/>
|
autoComplete='new-password'
|
||||||
</Form.Field>
|
/>
|
||||||
<Form.Field>
|
</Form.Field>
|
||||||
<Form.Input
|
<Form.Field>
|
||||||
label='Password'
|
<Form.Input
|
||||||
name='password'
|
label='Password'
|
||||||
type={'password'}
|
name='password'
|
||||||
placeholder={'Please enter a new password, at least 8 characters'}
|
type={'password'}
|
||||||
onChange={handleInputChange}
|
placeholder={'Please enter new password, minimum 8 characters'}
|
||||||
value={password}
|
onChange={handleInputChange}
|
||||||
autoComplete='new-password'
|
value={password}
|
||||||
/>
|
autoComplete='new-password'
|
||||||
</Form.Field>
|
/>
|
||||||
<Form.Field>
|
</Form.Field>
|
||||||
<Form.Input
|
<Form.Field>
|
||||||
label='Display name'
|
<Form.Input
|
||||||
name='display_name'
|
label='Display Name'
|
||||||
placeholder={'Please enter a new display name'}
|
name='display_name'
|
||||||
onChange={handleInputChange}
|
placeholder={'Please enter new display name'}
|
||||||
value={display_name}
|
onChange={handleInputChange}
|
||||||
autoComplete='new-password'
|
value={display_name}
|
||||||
/>
|
autoComplete='new-password'
|
||||||
</Form.Field>
|
/>
|
||||||
{
|
</Form.Field>
|
||||||
userId && <>
|
{userId && (
|
||||||
<Form.Field>
|
<>
|
||||||
<Form.Dropdown
|
<Form.Field>
|
||||||
label='Group'
|
<Form.Dropdown
|
||||||
placeholder={'Please select a group'}
|
label='Group'
|
||||||
name='group'
|
placeholder={'Please select group'}
|
||||||
fluid
|
name='group'
|
||||||
search
|
fluid
|
||||||
selection
|
search
|
||||||
allowAdditions
|
selection
|
||||||
additionLabel={'Please edit the group rate on the system settings page to add a new group:'}
|
allowAdditions
|
||||||
onChange={handleInputChange}
|
additionLabel={
|
||||||
value={inputs.group}
|
'Please edit group ratios in system settings to add new groups:'
|
||||||
autoComplete='new-password'
|
}
|
||||||
options={groupOptions}
|
onChange={handleInputChange}
|
||||||
/>
|
value={inputs.group}
|
||||||
</Form.Field>
|
autoComplete='new-password'
|
||||||
<Form.Field>
|
options={groupOptions}
|
||||||
<Form.Input
|
/>
|
||||||
label={`Remaining quota${renderQuotaWithPrompt(quota)}`}
|
</Form.Field>
|
||||||
name='quota'
|
<Form.Field>
|
||||||
placeholder={'Please enter a new remaining quota'}
|
<Form.Input
|
||||||
onChange={handleInputChange}
|
label={`Remaining Quota ${renderQuotaWithPrompt(quota)}`}
|
||||||
value={quota}
|
name='quota'
|
||||||
type={'number'}
|
placeholder={'Please enter new remaining quota'}
|
||||||
autoComplete='new-password'
|
onChange={handleInputChange}
|
||||||
/>
|
value={quota}
|
||||||
</Form.Field>
|
type={'number'}
|
||||||
</>
|
autoComplete='new-password'
|
||||||
}
|
/>
|
||||||
<Form.Field>
|
</Form.Field>
|
||||||
<Form.Input
|
</>
|
||||||
label='Bound GitHub account'
|
)}
|
||||||
name='github_id'
|
<Form.Field>
|
||||||
value={github_id}
|
<Form.Input
|
||||||
autoComplete='new-password'
|
label='Bound GitHub Account'
|
||||||
placeholder='This item is read-only, users need to bind through the relevant binding button on the personal settings page, cannot be directly modified'
|
name='github_id'
|
||||||
readOnly
|
value={github_id}
|
||||||
/>
|
autoComplete='new-password'
|
||||||
</Form.Field>
|
placeholder='This field is read-only. Users need to bind through the relevant button on the personal settings page, cannot be modified directly'
|
||||||
<Form.Field>
|
readOnly
|
||||||
<Form.Input
|
/>
|
||||||
label='Bound WeChat account'
|
</Form.Field>
|
||||||
name='wechat_id'
|
<Form.Field>
|
||||||
value={wechat_id}
|
<Form.Input
|
||||||
autoComplete='new-password'
|
label='Bound WeChat Account'
|
||||||
placeholder='This item is read-only, users need to bind through the relevant binding button on the personal settings page, cannot be directly modified'
|
name='wechat_id'
|
||||||
readOnly
|
value={wechat_id}
|
||||||
/>
|
autoComplete='new-password'
|
||||||
</Form.Field>
|
placeholder='This field is read-only. Users need to bind through the relevant button on the personal settings page, cannot be modified directly'
|
||||||
<Form.Field>
|
readOnly
|
||||||
<Form.Input
|
/>
|
||||||
label='Bound email account'
|
</Form.Field>
|
||||||
name='email'
|
<Form.Field>
|
||||||
value={email}
|
<Form.Input
|
||||||
autoComplete='new-password'
|
label='Bound Email Account'
|
||||||
placeholder='This item is read-only, users need to bind through the relevant binding button on the personal settings page, cannot be directly modified'
|
name='email'
|
||||||
readOnly
|
value={email}
|
||||||
/>
|
autoComplete='new-password'
|
||||||
</Form.Field>
|
placeholder='This field is read-only. Users need to bind through the relevant button on the personal settings page, cannot be modified directly'
|
||||||
<Button onClick={handleCancel}>Cancel</Button>
|
readOnly
|
||||||
<Button positive onClick={submit}>Submit</Button>
|
/>
|
||||||
</Form>
|
</Form.Field>
|
||||||
</Segment>
|
<Button onClick={handleCancel}>Cancel</Button>
|
||||||
</>
|
<Button positive onClick={submit}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,14 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Segment, Header } from 'semantic-ui-react';
|
import { Card } from 'semantic-ui-react';
|
||||||
import UsersTable from '../../components/UsersTable';
|
import UsersTable from '../../components/UsersTable';
|
||||||
|
|
||||||
const User = () => (
|
const User = () => (
|
||||||
<>
|
<div className='dashboard-container'>
|
||||||
<Segment>
|
<Card fluid className='chart-card'>
|
||||||
<Header as='h3'>Manage Users</Header>
|
<Card.Content>
|
||||||
<UsersTable/>
|
<Card.Header className='header'>Manage Users</Card.Header>
|
||||||
</Segment>
|
<UsersTable />
|
||||||
</>
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default User;
|
export default User;
|
||||||
|
Loading…
Reference in New Issue
Block a user