Compare commits

...

74 Commits

Author SHA1 Message Date
JustSong
562964238c chore: refactor docker image workflow to use format function for repository URLs 2025-02-02 14:21:20 +08:00
JustSong
46b1d35d83 chore: update docker image workflow for alpha releases 2025-02-02 14:17:48 +08:00
JustSong
6a197ceb69 chore: update default theme style 2025-02-02 14:10:38 +08:00
JustSong
bd7d0d1e96 chore: update default theme style 2025-02-02 14:00:40 +08:00
JustSong
f325f58edf chore: update default theme style 2025-02-02 13:51:19 +08:00
JustSong
aef2100be1 chore: update dashboard 2025-02-02 13:40:09 +08:00
JustSong
4685c52a5d chore: update dashboard 2025-02-02 13:38:48 +08:00
JustSong
174dea7763 feat: i18n support 2025-02-02 13:32:52 +08:00
JustSong
9b81c88250 feat: i18n support 2025-02-02 13:21:14 +08:00
JustSong
72d911986c feat: i18n support 2025-02-02 13:16:56 +08:00
JustSong
a01d769a83 chore: make buttons tiny 2025-02-02 13:14:52 +08:00
JustSong
d0965050a9 feat: i18n support 2025-02-02 00:12:22 +08:00
JustSong
e7ea7c866f feat: i18n support 2025-02-02 00:08:02 +08:00
JustSong
b1fe81a84f feat: i18n support 2025-02-02 00:05:40 +08:00
JustSong
e183e3b9b0 feat: i18n support 2025-02-01 23:58:55 +08:00
JustSong
b7f008cd72 feat: i18n support 2025-02-01 23:52:42 +08:00
JustSong
33102c4586 feat: i18n support 2025-02-01 23:50:32 +08:00
JustSong
ee3ed65356 feat: i18n support 2025-02-01 23:48:05 +08:00
JustSong
958f2f4ea8 feat: i18n support 2025-02-01 23:42:00 +08:00
JustSong
4a5f872dce feat: i18n support 2025-02-01 23:32:36 +08:00
JustSong
6ca6a3ea74 feat: i18n support 2025-02-01 17:05:46 +08:00
JustSong
2c8c29bfc7 feat: i18n support 2025-02-01 17:04:31 +08:00
JustSong
ae20aea555 feat: i18n support 2025-02-01 17:00:24 +08:00
JustSong
60f2776795 feat: i18n for token related pages 2025-02-01 15:11:07 +08:00
JustSong
93ce6c4cd7 chore: disable arm64 build for now
Some checks are pending
CI / Unit tests (push) Waiting to run
CI / commit_lint (push) Waiting to run
2025-02-01 14:06:00 +08:00
JustSong
4fe5ab8d09 fix: fix syntax error 2025-02-01 13:38:57 +08:00
JustSong
afbbfbbf83 chore: do not static build anymore 2025-02-01 13:32:50 +08:00
JustSong
75d9d9d560 fix: fix Dockerfile 2025-02-01 13:30:58 +08:00
JustSong
d1af30ee5a chore: use ubuntu to replace alpine 2025-02-01 13:27:21 +08:00
JustSong
a3924a2353 chore: update go-sqlite version 2025-02-01 13:19:55 +08:00
JustSong
9af5a1d11d fix: try to fix docker build problem 2025-02-01 13:13:43 +08:00
JustSong
57f9f7dfbb chore: drop docker-image-en.yml 2025-02-01 12:27:09 +08:00
JustSong
d9f2df2baf feat: initial i18n support 2025-02-01 12:25:58 +08:00
JustSong
bdf312e5dc feat: initial i18n support 2025-02-01 12:15:38 +08:00
JustSong
1521df6551 fix: fix unable to login via wechat 2025-02-01 12:04:28 +08:00
JustSong
c67b167f4f fix: try to fix docker build error 2025-02-01 11:56:23 +08:00
JustSong
c351e196e6 chore: add build-base to Dockerfile for enhanced build capabilities
Some checks are pending
CI / Unit tests (push) Waiting to run
CI / commit_lint (push) Waiting to run
2025-02-01 02:22:38 +08:00
JustSong
a316ed7abc fix: handle empty dashboard data and improve summary calculation 2025-02-01 01:54:00 +08:00
JustSong
0895d8660e fix: fix about page 2025-02-01 01:42:38 +08:00
JustSong
be1ed114f4 chore: add gcc and sqlite-dev dependencies to Dockerfile 2025-02-01 01:37:21 +08:00
JustSong
eb6da573a3 chore: optimize Dockerfile for multi-directory npm installation and build 2025-02-01 01:12:26 +08:00
JustSong
0a6273fc08 chore: bug fix for home page 2025-02-01 01:08:28 +08:00
JustSong
5997fce454 chore: update button style 2025-02-01 00:36:33 +08:00
JustSong
0df6d7a131 chore: fix prompt 2025-02-01 00:27:05 +08:00
JustSong
93fdb60de5 feat: update log table style 2025-02-01 00:21:04 +08:00
JustSong
4db834da95 chore: update default theme style 2025-02-01 00:13:09 +08:00
JustSong
6818ed5ca8 chore: update default theme style 2025-02-01 00:07:41 +08:00
JustSong
7be3b5547d chore: update default theme style 2025-02-01 00:06:19 +08:00
JustSong
2d7ea61d67 chore: update default theme style 2025-02-01 00:02:30 +08:00
JustSong
83b34be067 chore: update default theme style 2025-02-01 00:01:06 +08:00
JustSong
d5d879afdc chore: update default theme style 2025-01-31 23:54:45 +08:00
JustSong
0f205a3aa3 chore: update default theme style 2025-01-31 23:53:00 +08:00
JustSong
76c3f87351 chore: update default theme style 2025-01-31 23:46:05 +08:00
JustSong
6d9a92f8f7 chore: update default theme style 2025-01-31 23:44:39 +08:00
JustSong
835f0e0d67 chore: update default theme style 2025-01-31 23:38:40 +08:00
JustSong
a6981f0d51 chore: update default theme style 2025-01-31 23:33:14 +08:00
JustSong
678d613179 chore: update default theme style 2025-01-31 23:31:41 +08:00
JustSong
be089a072b chore: update default theme style 2025-01-31 23:25:32 +08:00
JustSong
45d10aa3df chore: update default theme style 2025-01-31 23:24:11 +08:00
JustSong
9cdd48ac22 feat: update log table style
Some checks are pending
CI / Unit tests (push) Waiting to run
CI / commit_lint (push) Waiting to run
2025-01-31 23:21:42 +08:00
JustSong
310e7120e5 chore: update default theme style 2025-01-31 23:20:57 +08:00
JustSong
3d29713268 chore: update default theme style 2025-01-31 23:10:02 +08:00
JustSong
f2c7c424e9 chore: update default theme style 2025-01-31 23:08:07 +08:00
JustSong
38a42bb265 chore: update default theme style 2025-01-31 23:01:03 +08:00
JustSong
fa2e8f44b1 chore: update default theme style 2025-01-31 22:55:45 +08:00
JustSong
9f74101543 chore: update default theme style 2025-01-31 22:53:40 +08:00
JustSong
28a271a896 chore: update default theme style 2025-01-31 22:50:48 +08:00
JustSong
e8ea87fff3 chore: update home page style 2025-01-31 22:45:57 +08:00
JustSong
abe2d2dba8 chore: update style 2025-01-31 22:38:39 +08:00
JustSong
4bcaa064d6 chore: update style 2025-01-31 22:27:26 +08:00
JustSong
52d81e0e24 feat: remove first section for overview 2025-01-31 22:24:13 +08:00
JustSong
dc8c3bc69e feat: basic overview is done 2025-01-31 22:18:02 +08:00
JustSong
b4e69df802 fix: do not send access_token 2025-01-31 21:53:56 +08:00
JustSong
d9f74bdff3 feat: support new log type 2025-01-31 21:49:34 +08:00
48 changed files with 5649 additions and 2313 deletions

View File

@@ -1,64 +0,0 @@
name: Publish Docker image (English)
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: Translate
run: |
python ./i18n/translate.py --repository_path . --json_file_path ./i18n/en.json
- 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: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: |
justsong/one-api-en
- name: Build and push Docker images
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -55,14 +55,15 @@ jobs:
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: | images: |
justsong/one-api ${{ contains(github.ref, 'alpha') && 'justsong/one-api-alpha' || 'justsong/one-api' }}
ghcr.io/${{ github.repository }} ${{ contains(github.ref, 'alpha') && format('ghcr.io/{0}-alpha', github.repository) || format('ghcr.io/{0}', github.repository) }}
- name: Build and push Docker images - name: Build and push Docker images
uses: docker/build-push-action@v3 uses: docker/build-push-action@v3
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 # platforms: linux/amd64,linux/arm64
platforms: linux/amd64 # TODO disable arm64 for now, because it cause error
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}

View File

@@ -4,41 +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 FROM golang AS builder2
RUN npm install
RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat VERSION) npm run build
FROM golang:alpine AS builder2 RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
RUN apk add --no-cache g++ sqlite3 libsqlite3-dev \
&& 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
FROM alpine RUN go build -trimpath -ldflags "-s -w -X 'github.com/songquanpeng/one-api/common.Version=$(cat VERSION)'" -o one-api
RUN apk update \ # Final runtime image
&& apk upgrade \ FROM ubuntu:22.04
&& apk add --no-cache ca-certificates tzdata \
&& update-ca-certificates 2>/dev/null || true RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata bash \
&& 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"]

7
go.mod
View File

@@ -1,6 +1,5 @@
module github.com/songquanpeng/one-api module github.com/songquanpeng/one-api
// +heroku goVersion go1.18
go 1.20 go 1.20
require ( require (
@@ -27,10 +26,11 @@ require (
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.31.0
golang.org/x/image v0.18.0 golang.org/x/image v0.18.0
golang.org/x/sync v0.10.0
google.golang.org/api v0.187.0 google.golang.org/api v0.187.0
gorm.io/driver/mysql v1.5.6 gorm.io/driver/mysql v1.5.6
gorm.io/driver/postgres v1.5.7 gorm.io/driver/postgres v1.5.7
gorm.io/driver/sqlite v1.5.5 gorm.io/driver/sqlite v1.5.1
gorm.io/gorm v1.25.10 gorm.io/gorm v1.25.10
) )
@@ -82,7 +82,7 @@ require (
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/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.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
@@ -99,7 +99,6 @@ require (
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.26.0 // indirect golang.org/x/net v0.26.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.5.0 // indirect golang.org/x/time v0.5.0 // indirect

8
go.sum
View File

@@ -163,8 +163,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=
@@ -306,8 +306,8 @@ gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= gorm.io/driver/sqlite v1.5.1 h1:hYyrLkAWE71bcarJDPdZNTLWtr8XrSjOWyjUYI6xdL4=
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= gorm.io/driver/sqlite v1.5.1/go.mod h1:7MZZ2Z8bqyfSQA1gYEV6MagQWj3cpUkJj9Z+d1HEMEQ=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=

View File

@@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/songquanpeng/one-api/common" "github.com/songquanpeng/one-api/common"
"github.com/songquanpeng/one-api/common/config" "github.com/songquanpeng/one-api/common/config"
) )
@@ -71,7 +72,7 @@ func memoryRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark s
} }
func rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gin.Context) { func rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gin.Context) {
if maxRequestNum == 0 { if maxRequestNum == 0 || config.DebugEnabled {
return func(c *gin.Context) { return func(c *gin.Context) {
c.Next() c.Next()
} }

View File

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

View File

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

View File

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

View File

@@ -5,14 +5,20 @@
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",
"history": "^5.3.0", "history": "^5.3.0",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.2",
"marked": "^4.1.1", "marked": "^4.1.1",
"moment": "^2.30.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": "^15.4.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"
}, },

View File

@@ -0,0 +1,758 @@
{
"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"
}
}
},
"token": {
"title": "Token Management",
"search": "Search tokens by name ...",
"table": {
"name": "Name",
"status": "Status",
"used_quota": "Used Quota",
"remain_quota": "Remaining Quota",
"created_time": "Created Time",
"expired_time": "Expiry Time",
"actions": "Actions",
"no_name": "None",
"never_expire": "Never Expires",
"unlimited": "Unlimited",
"status_enabled": "Enabled",
"status_disabled": "Disabled",
"status_expired": "Expired",
"status_depleted": "Depleted",
"status_unknown": "Unknown Status"
},
"buttons": {
"copy": "Copy",
"chat": "Chat",
"delete": "Delete",
"confirm_delete": "Delete Token",
"enable": "Enable",
"disable": "Disable",
"edit": "Edit",
"add": "Add New Token",
"refresh": "Refresh"
},
"edit": {
"title_edit": "Update Token Information",
"title_create": "Create New Token",
"name": "Name",
"name_placeholder": "Please enter name",
"models": "Model Scope",
"models_placeholder": "Please select allowed models, leave empty for no restrictions",
"ip_limit": "IP Restriction",
"ip_limit_placeholder": "Please enter allowed subnets, e.g.: 192.168.0.0/24, use commas to separate multiple subnets",
"expire_time": "Expiry Time",
"expire_time_placeholder": "Please enter expiry time in yyyy-MM-dd HH:mm:ss format, -1 for no limit",
"quota_notice": "Note: Token quota only limits the maximum usage of the token itself, actual usage is subject to account remaining quota.",
"quota": "Quota",
"quota_placeholder": "Please enter quota",
"buttons": {
"never_expire": "Never Expire",
"expire_1_month": "Expire in 1 Month",
"expire_1_day": "Expire in 1 Day",
"expire_1_hour": "Expire in 1 Hour",
"expire_1_minute": "Expire in 1 Minute",
"unlimited_quota": "Set Unlimited Quota",
"cancel_unlimited": "Cancel Unlimited Quota",
"submit": "Submit",
"cancel": "Cancel"
},
"messages": {
"update_success": "Token updated successfully!",
"create_success": "Token created successfully, please copy it from the list page!",
"expire_time_invalid": "Invalid expiry time format!"
}
},
"copy_options": {
"raw": "Copy Raw Token",
"ama": "Copy AMA Link",
"opencat": "Copy OpenCat Link",
"next": "Copy NextChat Link",
"lobe": "Copy LobeChat Link"
},
"messages": {
"copy_success": "Copied to clipboard!",
"copy_failed": "Unable to copy to clipboard, please copy manually. Token has been filled in the search box.",
"operation_success": "Operation completed successfully!"
},
"sort": {
"placeholder": "Sort By",
"default": "Default Order",
"by_remain": "Sort by Remaining Quota",
"by_used": "Sort by Used Quota"
}
},
"common": {
"quota": {
"display": "Equivalent: ${{amount}}",
"display_short": "${{amount}}",
"unit": "$"
}
},
"redemption": {
"title": "Redemption Management",
"search": "Search redemption codes by ID and name ...",
"table": {
"id": "ID",
"name": "Name",
"status": "Status",
"quota": "Quota",
"created_time": "Created Time",
"redeemed_time": "Redeemed Time",
"actions": "Actions",
"no_name": "None",
"not_redeemed": "Not Redeemed"
},
"buttons": {
"copy": "Copy",
"delete": "Delete",
"confirm_delete": "Confirm Delete",
"enable": "Enable",
"disable": "Disable",
"edit": "Edit",
"add": "Add New Code",
"refresh": "Refresh"
},
"status": {
"unused": "Unused",
"disabled": "Disabled",
"used": "Used",
"unknown": "Unknown"
},
"edit": {
"title_edit": "Update Redemption Code",
"title_create": "Create New Redemption Code",
"name": "Name",
"name_placeholder": "Please enter name",
"quota": "Quota",
"quota_placeholder": "Please enter quota per redemption code",
"count": "Generate Count",
"count_placeholder": "Please enter number of codes to generate",
"buttons": {
"submit": "Submit",
"cancel": "Cancel"
}
},
"messages": {
"update_success": "Redemption code updated successfully!",
"create_success": "Redemption code created successfully!"
}
},
"log": {
"title": "Operation Log",
"search": "Search logs...",
"usage_details": "Usage Details",
"total_quota": "Total Quota Used",
"click_to_view": "Click to View",
"type": {
"select": "Select Log Type",
"all": "All",
"topup": "Top Up",
"usage": "Usage",
"admin": "Admin",
"system": "System",
"test": "Test"
},
"table": {
"time": "Time",
"channel": "Channel",
"type": "Type",
"model": "Model",
"username": "Username",
"token_name": "Token Name",
"token_name_placeholder": "Optional",
"model_name": "Model Name",
"model_name_placeholder": "Optional",
"start_time": "Start Time",
"end_time": "End Time",
"channel_id": "Channel ID",
"channel_id_placeholder": "Optional",
"username_placeholder": "Optional",
"prompt_tokens": "Prompt Tokens",
"completion_tokens": "Completion Tokens",
"quota": "Quota",
"detail": "Detail"
},
"buttons": {
"query": "Action",
"submit": "Query",
"refresh": "Refresh"
}
},
"user": {
"title": "User Management",
"edit": {
"title": "Update User Information",
"username": "Username",
"username_placeholder": "Please enter new username",
"password": "Password",
"password_placeholder": "Please enter new password, minimum 8 characters",
"display_name": "Display Name",
"display_name_placeholder": "Please enter new display name",
"group": "Group",
"group_placeholder": "Please select group",
"group_addition": "Please edit group multipliers in system settings to add new group:",
"quota": "Remaining Quota",
"quota_placeholder": "Please enter new remaining quota",
"github_id": "Linked GitHub Account",
"github_id_placeholder": "Read-only, user must link through personal settings page, cannot be modified directly",
"wechat_id": "Linked WeChat Account",
"wechat_id_placeholder": "Read-only, user must link through personal settings page, cannot be modified directly",
"email": "Linked Email Account",
"email_placeholder": "Read-only, user must link through personal settings page, cannot be modified directly",
"buttons": {
"submit": "Submit",
"cancel": "Cancel"
}
},
"add": {
"title": "Create New User Account"
},
"messages": {
"update_success": "User information updated successfully!",
"create_success": "User account created successfully!",
"operation_success": "Operation completed successfully!"
},
"search": "Search users...",
"table": {
"id": "ID",
"username": "Username",
"group": "Group",
"quota": "Quota",
"role_text": "Role",
"status_text": "Status",
"actions": "Actions",
"remaining_quota": "Remaining Quota",
"used_quota": "Used Quota",
"request_count": "Request Count",
"role_types": {
"normal": "Normal User",
"admin": "Admin",
"super_admin": "Super Admin",
"unknown": "Unknown Role"
},
"status_types": {
"activated": "Activated",
"banned": "Banned",
"unknown": "Unknown Status"
},
"sort": {
"default": "Default Order",
"by_quota": "Sort by Remaining Quota",
"by_used_quota": "Sort by Used Quota",
"by_request_count": "Sort by Request Count"
},
"sort_by": "Sort By"
},
"buttons": {
"add": "Add New User",
"delete": "Delete",
"delete_user": "Delete User",
"enable": "Enable",
"disable": "Disable",
"edit": "Edit",
"promote": "Promote",
"demote": "Demote"
}
},
"dashboard": {
"charts": {
"requests": {
"title": "Model Request Trend",
"tooltip": "Request Count"
},
"quota": {
"title": "Quota Usage Trend",
"tooltip": "Quota Used"
},
"tokens": {
"title": "Token Usage Trend",
"tooltip": "Token Count"
}
},
"statistics": {
"title": "Statistics",
"tooltip": {
"date": "Date",
"value": "Value"
}
}
},
"setting": {
"title": "System Settings",
"tabs": {
"personal": "Personal Settings",
"operation": "Operation Settings",
"system": "System Settings",
"other": "Other Settings"
},
"personal": {
"general": {
"title": "General Settings",
"system_token_notice": "Note: The token generated here is for system management, not for requesting OpenAI related services.",
"buttons": {
"update_profile": "Update Profile",
"generate_token": "Generate System Token",
"copy_invite": "Copy Invite Link",
"delete_account": "Delete Account"
}
},
"binding": {
"title": "Account Binding",
"buttons": {
"bind_wechat": "Bind WeChat Account",
"bind_github": "Bind GitHub Account",
"bind_email": "Bind Email Address",
"bind_lark": "Bind Lark Account"
},
"wechat": {
"title": "WeChat Binding",
"description": "Scan QR code to follow the official account, enter 'verification code' to get the code (valid for 3 minutes)",
"verification_code": "Verification Code",
"bind": "Bind"
},
"email": {
"title": "Bind Email Address",
"email_placeholder": "Enter email address",
"code_placeholder": "Verification code",
"get_code": "Get Code",
"get_code_retry": "Resend({{countdown}})",
"bind": "Confirm Binding",
"cancel": "Cancel"
}
},
"delete_account": {
"title": "Dangerous Operation",
"warning": "You are deleting your account. All data will be cleared and cannot be recovered",
"confirm_placeholder": "Enter your username {{username}} to confirm deletion",
"buttons": {
"confirm": "Confirm Delete",
"cancel": "Cancel"
}
}
},
"system": {
"general": {
"title": "General Settings",
"server_address": "Server Address",
"server_address_placeholder": "e.g.: https://yourdomain.com",
"buttons": {
"update": "Update Server Address"
}
},
"login": {
"title": "Login & Registration Settings",
"password_login": "Allow Password Login",
"password_register": "Allow Password Registration",
"email_verification": "Require Email Verification for Password Registration",
"github_oauth": "Allow GitHub OAuth Login & Registration",
"wechat_login": "Allow WeChat Login & Registration",
"registration": "Allow New User Registration (When disabled, new users cannot register by any means)",
"turnstile": "Enable Turnstile User Verification"
},
"email_restriction": {
"title": "Email Domain Whitelist",
"subtitle": "Used to prevent malicious users from batch registering using temporary emails",
"enable": "Enable Email Domain Whitelist",
"allowed_domains": "Allowed Email Domains",
"add_domain": "Add New Allowed Email Domain",
"add_domain_placeholder": "Enter new allowed email domain",
"buttons": {
"fill": "Fill",
"save": "Save Email Domain Whitelist Settings"
}
},
"smtp": {
"title": "SMTP Configuration",
"subtitle": "Used to support system email sending",
"server": "SMTP Server Address",
"server_placeholder": "e.g.: smtp.gmail.com",
"port": "SMTP Port",
"port_placeholder": "Default: 587",
"account": "SMTP Account",
"account_placeholder": "Usually your email address",
"from": "SMTP Sender Email",
"from_placeholder": "Usually same as email address",
"token": "SMTP Access Token",
"token_placeholder": "Sensitive information will not be sent to frontend",
"buttons": {
"save": "Save SMTP Settings"
}
},
"github": {
"title": "GitHub OAuth App Configuration",
"subtitle": "Used to support GitHub login and registration",
"manage_link": "Click here",
"manage_text": "to manage your GitHub OAuth Apps",
"url_notice": "Set Homepage URL to {{server_url}}, and Authorization callback URL to {{callback_url}}",
"client_id": "GitHub Client ID",
"client_id_placeholder": "Enter your registered GitHub OAuth APP ID",
"client_secret": "GitHub Client Secret",
"client_secret_placeholder": "Sensitive information will not be sent to frontend",
"buttons": {
"save": "Save GitHub OAuth Settings"
}
},
"lark": {
"title": "Lark OAuth Configuration",
"subtitle": "Used to support Lark login and registration",
"manage_link": "Click here",
"manage_text": "to manage your Lark applications",
"url_notice": "Set Homepage URL to {{server_url}}, and Redirect URL to {{callback_url}}",
"client_id": "App ID",
"client_id_placeholder": "Enter App ID",
"client_secret": "App Secret",
"client_secret_placeholder": "Sensitive information will not be sent to frontend",
"buttons": {
"save": "Save Lark OAuth Settings"
}
},
"wechat": {
"title": "WeChat Server Configuration",
"subtitle": "Used to support WeChat login and registration",
"learn_more": "Learn about WeChat Server",
"server_address": "WeChat Server Address",
"server_address_placeholder": "e.g.: https://yourdomain.com",
"token": "WeChat Server Access Token",
"token_placeholder": "Sensitive information will not be sent to frontend",
"qrcode": "WeChat Official Account QR Code Image URL",
"qrcode_placeholder": "Enter an image URL",
"buttons": {
"save": "Save WeChat Server Settings"
}
},
"turnstile": {
"title": "Turnstile Configuration",
"subtitle": "Used to support user verification",
"manage_link": "Click here",
"manage_text": "to manage your Turnstile Sites, Invisible Widget Type recommended",
"site_key": "Turnstile Site Key",
"site_key_placeholder": "Enter your registered Turnstile Site Key",
"secret_key": "Turnstile Secret Key",
"secret_key_placeholder": "Sensitive information will not be sent to frontend",
"buttons": {
"save": "Save Turnstile Settings"
}
},
"password_login": {
"warning": {
"title": "Warning",
"content": "Disabling password login will prevent all users (including administrators) who haven't bound other login methods from logging in via password. Confirm disable?",
"buttons": {
"confirm": "Confirm",
"cancel": "Cancel"
}
}
}
},
"operation": {
"quota": {
"title": "Quota Settings",
"new_user": "Initial Quota for New Users",
"new_user_placeholder": "e.g.: 100",
"pre_consume": "Pre-consumed Quota per Request",
"pre_consume_placeholder": "Refund or charge difference after request",
"inviter_reward": "Reward Quota for Inviter",
"inviter_reward_placeholder": "e.g.: 2000",
"invitee_reward": "Reward Quota for Using Invite Code",
"invitee_reward_placeholder": "e.g.: 1000",
"buttons": {
"save": "Save Quota Settings"
}
},
"ratio": {
"title": "Ratio Settings",
"model": {
"title": "Model Ratio",
"placeholder": "A JSON text where keys are model names and values are ratios"
},
"completion": {
"title": "Completion Ratio",
"placeholder": "A JSON text where keys are model names and values are ratios. These ratios are the proportion of completion to prompt ratio, which can override One API's internal ratios"
},
"group": {
"title": "Group Ratio",
"placeholder": "A JSON text where keys are group names and values are ratios"
},
"buttons": {
"save": "Save Ratio Settings"
}
},
"log": {
"title": "Log Settings",
"enable_consume": "Enable Quota Consumption Logging",
"target_time": "Target Time",
"buttons": {
"clean": "Clean Historical Logs"
}
},
"monitor": {
"title": "Monitor Settings",
"max_response_time": "Maximum Response Time",
"max_response_time_placeholder": "In seconds, channels exceeding this time during testing will be automatically disabled",
"quota_reminder": "Quota Reminder Threshold",
"quota_reminder_placeholder": "Users will receive email reminders when quota falls below this value",
"auto_disable": "Automatically Disable Channel on Failure",
"auto_enable": "Automatically Enable Channel on Success",
"buttons": {
"save": "Save Monitor Settings"
}
},
"general": {
"title": "General Settings",
"topup_link": "Top-up Link",
"topup_link_placeholder": "e.g.: Card selling website purchase link",
"chat_link": "Chat Page Link",
"chat_link_placeholder": "e.g.: ChatGPT Next Web deployment address",
"quota_per_unit": "Quota per Dollar",
"quota_per_unit_placeholder": "Quota exchangeable per unit of currency",
"retry_times": "Retry Times on Failure",
"retry_times_placeholder": "Number of retry attempts on failure",
"display_in_currency": "Display Quota in Currency Format",
"display_token_stat": "Show Token Quota Instead of User Quota in Billing APIs",
"approximate_token": "Use Approximate Method to Estimate Token Count",
"buttons": {
"save": "Save General Settings"
}
}
},
"other": {
"notice": {
"title": "Notice Settings",
"content": "Notice Content",
"content_placeholder": "Enter new notice content here, supports Markdown & HTML code",
"buttons": {
"save": "Save Notice"
}
},
"system": {
"title": "System Settings",
"name": "System Name",
"name_placeholder": "Please enter system name",
"logo": "Logo Image URL",
"logo_placeholder": "Enter Logo image URL here",
"theme": {
"title": "Theme Name",
"link": "Available Themes",
"placeholder": "Please enter theme name"
},
"buttons": {
"save_name": "Set System Name",
"save_logo": "Set Logo",
"save_theme": "Set Theme (Restart Required)"
}
},
"content": {
"title": "Content Settings",
"homepage": {
"title": "Homepage Content",
"placeholder": "Enter homepage content here, supports Markdown & HTML code. Status information will not be shown after setting. If a link is entered, it will be used as the src attribute of an iframe, allowing you to set any webpage as homepage."
},
"about": {
"title": "About System",
"description": "You can set about content in settings page, supports HTML & Markdown",
"repository": "Project Repository:",
"loading_failed": "Failed to load about content..."
},
"footer": {
"title": "Footer",
"placeholder": "Enter new footer here, leave empty to use default footer, supports HTML code"
},
"buttons": {
"save_homepage": "Save Homepage Content",
"save_about": "Save About",
"save_footer": "Set Footer"
}
},
"copyright": {
"notice": "Removing One API's copyright notice requires authorization. Project maintenance requires significant effort, if this project is meaningful to you, please actively support it."
}
}
},
"footer": {
"built_by": "built by",
"built_by_name": "JustSong",
"license": ", source code is licensed under the",
"mit": "MIT License"
},
"home": {
"welcome": {
"title": "Welcome to One API",
"description": "One API is a LLM API management and distribution system that helps you better manage and use LLM APIs from various providers.",
"login_notice": "To use the service, please login or register first."
},
"system_status": {
"title": "System Status",
"info": {
"title": "System Information",
"name": "Name: ",
"version": "Version: ",
"source": "Source: ",
"source_link": "GitHub Repository",
"start_time": "Start Time: "
},
"config": {
"title": "System Configuration",
"email_verify": "Email Verification: ",
"github_oauth": "GitHub OAuth: ",
"wechat_login": "WeChat Login: ",
"turnstile": "Turnstile Check: ",
"enabled": "Enabled",
"disabled": "Disabled"
}
},
"loading_failed": "Failed to load homepage content..."
}
}

View File

@@ -0,0 +1,762 @@
{
"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"
}
}
},
"token": {
"title": "令牌管理",
"search": "搜索令牌的名称 ...",
"table": {
"name": "名称",
"status": "状态",
"used_quota": "已用额度",
"remain_quota": "剩余额度",
"created_time": "创建时间",
"expired_time": "过期时间",
"actions": "操作",
"no_name": "无",
"never_expire": "永不过期",
"unlimited": "无限制",
"status_enabled": "已启用",
"status_disabled": "已禁用",
"status_expired": "已过期",
"status_depleted": "已耗尽",
"status_unknown": "未知状态"
},
"buttons": {
"copy": "复制",
"chat": "聊天",
"delete": "删除",
"confirm_delete": "删除令牌",
"enable": "启用",
"disable": "禁用",
"edit": "编辑",
"add": "添加新的令牌",
"refresh": "刷新"
},
"edit": {
"title_edit": "更新令牌信息",
"title_create": "创建新的令牌",
"name": "名称",
"name_placeholder": "请输入名称",
"models": "模型范围",
"models_placeholder": "请选择允许使用的模型,留空则不进行限制",
"ip_limit": "IP 限制",
"ip_limit_placeholder": "请输入允许访问的网段例如192.168.0.0/24请使用英文逗号分隔多个网段",
"expire_time": "过期时间",
"expire_time_placeholder": "请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss-1 表示无限制",
"quota_notice": "注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。",
"quota": "额度",
"quota_placeholder": "请输入额度",
"buttons": {
"never_expire": "永不过期",
"expire_1_month": "一个月后过期",
"expire_1_day": "一天后过期",
"expire_1_hour": "一小时后过期",
"expire_1_minute": "一分钟后过期",
"unlimited_quota": "设为无限额度",
"cancel_unlimited": "取消无限额度",
"submit": "提交",
"cancel": "取消"
},
"messages": {
"update_success": "令牌更新成功!",
"create_success": "令牌创建成功,请在列表页面点击复制获取令牌!",
"expire_time_invalid": "过期时间格式错误!"
}
},
"copy_options": {
"raw": "复制原始令牌",
"ama": "复制 AMA 链接",
"opencat": "复制 OpenCat 链接",
"next": "复制 NextChat 链接",
"lobe": "复制 LobeChat 链接"
},
"messages": {
"copy_success": "已复制到剪贴板!",
"copy_failed": "无法复制到剪贴板,请手动复制,已将令牌填入搜索框。",
"operation_success": "操作成功完成!"
},
"sort": {
"placeholder": "排序方式",
"default": "默认排序",
"by_remain": "按剩余额度排序",
"by_used": "按已用额度排序"
}
},
"common": {
"quota": {
"display": "等价金额:${{amount}}",
"display_short": "${{amount}}",
"unit": "$"
}
},
"redemption": {
"title": "兑换管理",
"search": "搜索兑换码的 ID 和名称 ...",
"table": {
"id": "ID",
"name": "名称",
"status": "状态",
"quota": "额度",
"created_time": "创建时间",
"redeemed_time": "兑换时间",
"actions": "操作",
"no_name": "无",
"not_redeemed": "尚未兑换"
},
"buttons": {
"copy": "复制",
"delete": "删除",
"confirm_delete": "确认删除",
"enable": "启用",
"disable": "禁用",
"edit": "编辑",
"add": "添加新的兑换码",
"refresh": "刷新"
},
"status": {
"unused": "未使用",
"disabled": "已禁用",
"used": "已使用",
"unknown": "未知状态"
},
"edit": {
"title_edit": "更新兑换码信息",
"title_create": "创建新的兑换码",
"name": "名称",
"name_placeholder": "请输入名称",
"quota": "额度",
"quota_placeholder": "请输入单个兑换码中包含的额度",
"count": "生成数量",
"count_placeholder": "请输入生成数量",
"buttons": {
"submit": "提交",
"cancel": "取消"
}
},
"messages": {
"update_success": "兑换码更新成功!",
"create_success": "兑换码创建成功!"
}
},
"log": {
"title": "操作日志",
"search": "搜索日志...",
"usage_details": "使用明细",
"total_quota": "总消耗额度",
"click_to_view": "点击查看",
"type": {
"select": "选择明细分类",
"all": "全部",
"topup": "充值",
"usage": "消费",
"admin": "管理",
"system": "系统",
"test": "测试"
},
"table": {
"time": "时间",
"channel": "渠道",
"type": "类型",
"model": "模型",
"username": "用户名",
"token_name": "令牌名称",
"token_name_placeholder": "可选值",
"model_name": "模型名称",
"model_name_placeholder": "可选值",
"start_time": "起始时间",
"end_time": "结束时间",
"channel_id": "渠道 ID",
"channel_id_placeholder": "可选值",
"username_placeholder": "可选值",
"prompt_tokens": "提示词消耗",
"completion_tokens": "补全消耗",
"quota": "额度",
"detail": "详情"
},
"buttons": {
"query": "操作",
"submit": "查询",
"refresh": "刷新"
}
},
"user": {
"title": "用户管理",
"edit": {
"title": "更新用户信息",
"username": "用户名",
"username_placeholder": "请输入新的用户名",
"password": "密码",
"password_placeholder": "请输入新的密码,最短 8 位",
"display_name": "显示名称",
"display_name_placeholder": "请输入新的显示名称",
"group": "分组",
"group_placeholder": "请选择分组",
"group_addition": "请在系统设置页面编辑分组倍率以添加新的分组:",
"quota": "剩余额度",
"quota_placeholder": "请输入新的剩余额度",
"github_id": "已绑定的 GitHub 账户",
"github_id_placeholder": "此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改",
"wechat_id": "已绑定的微信账户",
"wechat_id_placeholder": "此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改",
"email": "已绑定的邮箱账户",
"email_placeholder": "此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改",
"buttons": {
"submit": "提交",
"cancel": "取消"
}
},
"add": {
"title": "创建新用户账户"
},
"messages": {
"update_success": "用户信息更新成功!",
"create_success": "用户账户创建成功!",
"operation_success": "操作成功完成!"
},
"search": "搜索用户...",
"table": {
"id": "ID",
"username": "用户名",
"group": "分组",
"quota": "额度",
"role_text": "角色",
"status_text": "状态",
"actions": "操作",
"remaining_quota": "剩余额度",
"used_quota": "已用额度",
"request_count": "请求次数",
"role_types": {
"normal": "普通用户",
"admin": "管理员",
"super_admin": "超级管理员",
"unknown": "未知身份"
},
"status_types": {
"activated": "已激活",
"banned": "已封禁",
"unknown": "未知状态"
},
"sort": {
"default": "默认排序",
"by_quota": "按剩余额度排序",
"by_used_quota": "按已用额度排序",
"by_request_count": "按请求次数排序"
},
"sort_by": "排序方式"
},
"buttons": {
"add": "添加新的用户",
"delete": "删除",
"delete_user": "删除用户",
"enable": "启用",
"disable": "禁用",
"edit": "编辑",
"promote": "提升",
"demote": "降级"
}
},
"dashboard": {
"charts": {
"requests": {
"title": "模型请求趋势",
"tooltip": "请求次数"
},
"quota": {
"title": "额度消费趋势",
"tooltip": "消费额度"
},
"tokens": {
"title": "Token 消费趋势",
"tooltip": "Token 数量"
}
},
"statistics": {
"title": "统计",
"tooltip": {
"date": "日期",
"value": "数值"
}
}
},
"setting": {
"title": "系统设置",
"tabs": {
"personal": "个人设置",
"operation": "运营设置",
"system": "系统设置",
"other": "其他设置"
},
"personal": {
"general": {
"title": "通用设置",
"system_token_notice": "注意,此处生成的令牌用于系统管理,而非用于请求 OpenAI 相关的服务,请知悉。",
"buttons": {
"update_profile": "更新个人信息",
"generate_token": "生成系统访问令牌",
"copy_invite": "复制邀请链接",
"delete_account": "删除个人账户"
}
},
"binding": {
"title": "账号绑定",
"buttons": {
"bind_wechat": "绑定微信账号",
"bind_github": "绑定 GitHub 账号",
"bind_email": "绑定邮箱地址",
"bind_lark": "绑定飞书账号"
},
"wechat": {
"title": "微信绑定",
"description": "微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)",
"verification_code": "验证码",
"bind": "绑定"
},
"email": {
"title": "绑定邮箱地址",
"email_placeholder": "输入邮箱地址",
"code_placeholder": "验证码",
"get_code": "获取验证码",
"get_code_retry": "重新发送({{countdown}})",
"bind": "确认绑定",
"cancel": "取消"
}
},
"delete_account": {
"title": "危险操作",
"warning": "您正在删除自己的帐户,将清空所有数据且不可恢复",
"confirm_placeholder": "输入你的账户名 {{username}} 以确认删除",
"buttons": {
"confirm": "确认删除",
"cancel": "取消"
}
}
},
"system": {
"general": {
"title": "通用设置",
"server_address": "服务器地址",
"server_address_placeholder": "例如https://yourdomain.com",
"buttons": {
"update": "更新服务器地址"
}
},
"login": {
"title": "配置登录注册",
"password_login": "允许通过密码进行登录",
"password_register": "允许通过密码进行注册",
"email_verification": "通过密码注册时需要进行邮箱验证",
"github_oauth": "允许通过 GitHub 账户登录 & 注册",
"wechat_login": "允许通过微信登录 & 注册",
"registration": "允许新用户注册(此项为否时,新用户将无法以任何方式进行注册)",
"turnstile": "启用 Turnstile 用户校验"
},
"email_restriction": {
"title": "配置邮箱域名白名单",
"subtitle": "用以防止恶意用户利用临时邮箱批量注册",
"enable": "启用邮箱域名白名单",
"allowed_domains": "允许的邮箱域名",
"add_domain": "添加新的允许的邮箱域名",
"add_domain_placeholder": "输入新的允许的邮箱域名",
"buttons": {
"fill": "填入",
"save": "保存邮箱域名白名单设置"
}
},
"smtp": {
"title": "配置 SMTP",
"subtitle": "用以支持系统的邮件发送",
"server": "SMTP 服务器地址",
"server_placeholder": "例如smtp.qq.com",
"port": "SMTP 端口",
"port_placeholder": "默认: 587",
"account": "SMTP 账户",
"account_placeholder": "通常是邮箱地址",
"from": "SMTP 发送者邮箱",
"from_placeholder": "通常和邮箱地址保持一致",
"token": "SMTP 访问凭证",
"token_placeholder": "敏感信息不会发送到前端显示",
"buttons": {
"save": "保存 SMTP 设置"
}
},
"github": {
"title": "配置 GitHub OAuth App",
"subtitle": "用以支持通过 GitHub 进行登录注册",
"manage_link": "点击此处",
"manage_text": "管理你的 GitHub OAuth App",
"url_notice": "Homepage URL 填 {{server_url}}Authorization callback URL 填 {{callback_url}}",
"client_id": "GitHub Client ID",
"client_id_placeholder": "输入你注册的 GitHub OAuth APP 的 ID",
"client_secret": "GitHub Client Secret",
"client_secret_placeholder": "敏感信息不会发送到前端显示",
"buttons": {
"save": "保存 GitHub OAuth 设置"
}
},
"lark": {
"title": "配置飞书授权登录",
"subtitle": "用以支持通过飞书进行登录注册",
"manage_link": "点击此处",
"manage_text": "管理你的飞书应用",
"url_notice": "主页链接填 {{server_url}},重定向 URL 填 {{callback_url}}",
"client_id": "App ID",
"client_id_placeholder": "输入 App ID",
"client_secret": "App Secret",
"client_secret_placeholder": "敏感信息不会发送到前端显示",
"buttons": {
"save": "保存飞书 OAuth 设置"
}
},
"wechat": {
"title": "配置 WeChat Server",
"subtitle": "用以支持通过微信进行登录注册",
"learn_more": "了解 WeChat Server",
"server_address": "WeChat Server 服务器地址",
"server_address_placeholder": "例如https://yourdomain.com",
"token": "WeChat Server 访问凭证",
"token_placeholder": "敏感信息不会发送到前端显示",
"qrcode": "微信公众号二维码图片链接",
"qrcode_placeholder": "输入一个图片链接",
"buttons": {
"save": "保存 WeChat Server 设置"
}
},
"turnstile": {
"title": "配置 Turnstile",
"subtitle": "用以支持用户校验",
"manage_link": "点击此处",
"manage_text": "管理你的 Turnstile Sites推荐选择 Invisible Widget Type",
"site_key": "Turnstile Site Key",
"site_key_placeholder": "输入你注册的 Turnstile Site Key",
"secret_key": "Turnstile Secret Key",
"secret_key_placeholder": "敏感信息不会发送到前端显示",
"buttons": {
"save": "保存 Turnstile 设置"
}
},
"password_login": {
"warning": {
"title": "警告",
"content": "取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?",
"buttons": {
"confirm": "确定",
"cancel": "取消"
}
}
}
},
"operation": {
"quota": {
"title": "额度设置",
"new_user": "新用户初始额度",
"new_user_placeholder": "例如100",
"pre_consume": "请求预扣费额度",
"pre_consume_placeholder": "请求结束后多退少补",
"inviter_reward": "邀请新用户奖励额度",
"inviter_reward_placeholder": "例如2000",
"invitee_reward": "新用户使用邀请码奖励额度",
"invitee_reward_placeholder": "例如1000",
"buttons": {
"save": "保存额度设置"
}
},
"ratio": {
"title": "倍率设置",
"model": {
"title": "模型倍率",
"placeholder": "为一个 JSON 文本,键为模型名称,值为倍率"
},
"completion": {
"title": "补全倍率",
"placeholder": "为一个 JSON 文本,键为模型名称,值为倍率,此处的倍率设置是模型补全倍率相较于提示倍率的比例,使用该设置可强制覆盖 One API 的内部比例"
},
"group": {
"title": "分组倍率",
"placeholder": "为一个 JSON 文本,键为分组名称,值为倍率"
},
"buttons": {
"save": "保存倍率设置"
}
},
"log": {
"title": "日志设置",
"enable_consume": "启用额度消费日志记录",
"target_time": "目标时间",
"buttons": {
"clean": "清理历史日志"
}
},
"monitor": {
"title": "监控设置",
"max_response_time": "最长响应时间",
"max_response_time_placeholder": "单位秒,当运行渠道全部测试时,超过此时间将自动禁用渠道",
"quota_reminder": "额度提醒阈值",
"quota_reminder_placeholder": "低于此额度时将发送邮件提醒用户",
"auto_disable": "失败时自动禁用渠道",
"auto_enable": "成功时自动启用渠道",
"buttons": {
"save": "保存监控设置"
}
},
"general": {
"title": "通用设置",
"topup_link": "充值链接",
"topup_link_placeholder": "例如发卡网站的购买链接",
"chat_link": "聊天页面链接",
"chat_link_placeholder": "例如 ChatGPT Next Web 的部署地址",
"quota_per_unit": "单位美元额度",
"quota_per_unit_placeholder": "一单位货币能兑换的额度",
"retry_times": "失败重试次数",
"retry_times_placeholder": "失败重试次数",
"display_in_currency": "以货币形式显示额度",
"display_token_stat": "Billing 相关 API 显示令牌额度而非用户额度",
"approximate_token": "使用近似的方式估算 token 数以减少计算量",
"buttons": {
"save": "保存通用设置"
}
}
},
"other": {
"notice": {
"title": "公告设置",
"content": "公告内容",
"content_placeholder": "在此输入新的公告内容,支持 Markdown & HTML 代码",
"buttons": {
"save": "保存公告"
}
},
"system": {
"title": "系统设置",
"name": "系统名称",
"name_placeholder": "请输入系统名称",
"logo": "Logo 图片地址",
"logo_placeholder": "在此输入 Logo 图片地址",
"theme": {
"title": "主题名称",
"link": "当前可用主题",
"placeholder": "请输入主题名称"
},
"buttons": {
"save_name": "设置系统名称",
"save_logo": "设置 Logo",
"save_theme": "设置主题(重启生效)"
}
},
"content": {
"title": "内容设置",
"homepage": {
"title": "首页内容",
"placeholder": "在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。"
},
"about": {
"title": "关于",
"placeholder": "在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面。"
},
"footer": {
"title": "页脚",
"placeholder": "在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码"
},
"buttons": {
"save_homepage": "保存首页内容",
"save_about": "保存关于",
"save_footer": "设置页脚"
}
},
"copyright": {
"notice": "移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目。"
}
}
},
"about": {
"title": "关于系统",
"description": "可在设置页面设置关于内容,支持 HTML & Markdown",
"repository": "项目仓库地址:",
"loading_failed": "加载关于内容失败..."
},
"footer": {
"built_by": "由",
"built_by_name": "JustSong",
"license": "构建,源代码遵循",
"mit": "MIT 协议"
},
"home": {
"welcome": {
"title": "欢迎使用 One API",
"description": "One API 是一个 LLM API 接口管理和分发系统,可以帮助您更好地管理和使用各大厂商的 LLM API。",
"login_notice": "如需使用,请先登录或注册。"
},
"system_status": {
"title": "系统状况",
"info": {
"title": "系统信息",
"name": "名称:",
"version": "版本:",
"source": "源码:",
"source_link": "GitHub 仓库",
"start_time": "启动时间:"
},
"config": {
"title": "系统配置",
"email_verify": "邮箱验证:",
"github_oauth": "GitHub 身份验证:",
"wechat_login": "微信身份验证:",
"turnstile": "Turnstile 校验:",
"enabled": "已启用",
"disabled": "未启用"
}
},
"loading_failed": "加载首页内容失败..."
}
}

View File

@@ -25,6 +25,7 @@ import TopUp from './pages/TopUp';
import Log from './pages/Log'; import Log from './pages/Log';
import Chat from './pages/Chat'; import Chat from './pages/Chat';
import LarkOAuth from './components/LarkOAuth'; import LarkOAuth from './components/LarkOAuth';
import Dashboard from './pages/Dashboard';
const Home = lazy(() => import('./pages/Home')); const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About')); const About = lazy(() => import('./pages/About'));
@@ -41,32 +42,37 @@ function App() {
} }
}; };
const loadStatus = async () => { const loadStatus = async () => {
const res = await API.get('/api/status'); try {
const { success, data } = res.data; const res = await API.get('/api/status');
if (success) { const { success, message, data } = res.data || {}; // Add default empty object
localStorage.setItem('status', JSON.stringify(data)); if (success && data) {
statusDispatch({ type: 'set', payload: data }); // Check data exists
localStorage.setItem('system_name', data.system_name); localStorage.setItem('status', JSON.stringify(data));
localStorage.setItem('logo', data.logo); statusDispatch({ type: 'set', payload: data });
localStorage.setItem('footer_html', data.footer_html); localStorage.setItem('system_name', data.system_name);
localStorage.setItem('quota_per_unit', data.quota_per_unit); localStorage.setItem('logo', data.logo);
localStorage.setItem('display_in_currency', data.display_in_currency); localStorage.setItem('footer_html', data.footer_html);
if (data.chat_link) { localStorage.setItem('quota_per_unit', data.quota_per_unit);
localStorage.setItem('chat_link', data.chat_link); localStorage.setItem('display_in_currency', data.display_in_currency);
if (data.chat_link) {
localStorage.setItem('chat_link', data.chat_link);
} else {
localStorage.removeItem('chat_link');
}
if (
data.version !== process.env.REACT_APP_VERSION &&
data.version !== 'v0.0.0' &&
process.env.REACT_APP_VERSION !== ''
) {
showNotice(
`新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`
);
}
} else { } else {
localStorage.removeItem('chat_link'); showError(message || '无法正常连接至服务器!');
} }
if ( } catch (error) {
data.version !== process.env.REACT_APP_VERSION && showError(error.message || '无法正常连接至服务器!');
data.version !== 'v0.0.0' &&
process.env.REACT_APP_VERSION !== ''
) {
showNotice(
`新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`
);
}
} else {
showError('无法正常连接至服务器!');
} }
}; };
@@ -261,11 +267,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 +298,15 @@ function App() {
</Suspense> </Suspense>
} }
/> />
<Route path='*' element={ <Route
<NotFound /> path='/dashboard'
} /> element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
<Route path='*' element={<NotFound />} />
</Routes> </Routes>
); );
} }

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
Button, Button,
Dropdown, Dropdown,
@@ -31,13 +32,17 @@ function renderTimestamp(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: '未知类型', color: 'grey' }; type2label[0] = {
value: 0,
text: t('channel.table.status_unknown'),
color: 'grey',
};
} }
return ( return (
<Label basic color={type2label[type]?.color}> <Label basic color={type2label[type]?.color}>
@@ -46,7 +51,7 @@ function renderType(type) {
); );
} }
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>;
@@ -67,7 +72,7 @@ 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>不支持</span>; return <span>{t('channel.table.balance_not_supported')}</span>;
} }
} }
@@ -78,6 +83,7 @@ function isShowDetail() {
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);
@@ -192,7 +198,7 @@ const ChannelsTable = () => {
} }
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
showSuccess('操作成功完成!'); showSuccess(t('channel.messages.operation_success'));
let channel = res.data.data; let channel = res.data.data;
let newChannels = [...channels]; let newChannels = [...channels];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
@@ -207,12 +213,12 @@ const ChannelsTable = () => {
} }
}; };
const renderStatus = (status) => { const renderStatus = (status, t) => {
switch (status) { switch (status) {
case 1: case 1:
return ( return (
<Label basic color='green'> <Label basic color='green'>
已启用 {t('channel.table.status_enabled')}
</Label> </Label>
); );
case 2: case 2:
@@ -220,10 +226,10 @@ const ChannelsTable = () => {
<Popup <Popup
trigger={ trigger={
<Label basic color='red'> <Label basic color='red'>
已禁用 {t('channel.table.status_disabled')}
</Label> </Label>
} }
content='本渠道被手动禁用' content={t('channel.table.status_disabled_tip')}
basic basic
/> />
); );
@@ -232,29 +238,29 @@ const ChannelsTable = () => {
<Popup <Popup
trigger={ trigger={
<Label basic color='yellow'> <Label basic color='yellow'>
已禁用 {t('channel.table.status_auto_disabled')}
</Label> </Label>
} }
content='本渠道被程序自动禁用' content={t('channel.table.status_auto_disabled_tip')}
basic basic
/> />
); );
default: default:
return ( return (
<Label basic color='grey'> <Label basic color='grey'>
未知状态 {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) + ''; time = time.toFixed(2) + 's';
if (responseTime === 0) { if (responseTime === 0) {
return ( return (
<Label basic color='grey'> <Label basic color='grey'>
未测试 {t('channel.table.not_tested')}
</Label> </Label>
); );
} else if (responseTime <= 1000) { } else if (responseTime <= 1000) {
@@ -319,10 +325,8 @@ 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( showSuccess(
`渠道 ${name} 测试成功,模型 ${model},耗时 ${time.toFixed( t('channel.messages.test_success', { name, model, time, message })
2
)} 秒,模型输出:${message}`
); );
} else { } else {
showError(message); showError(message);
@@ -338,7 +342,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('已成功开始测试渠道,请刷新页面查看结果。'); showInfo(t('channel.messages.test_all_started'));
} else { } else {
showError(message); showError(message);
} }
@@ -348,7 +352,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(`已删除所有禁用渠道,共计 ${data}`); showSuccess(
t('channel.messages.delete_disabled_success', { count: data })
);
await refresh(); await refresh();
} else { } else {
showError(message); showError(message);
@@ -364,7 +370,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(`渠道 ${name} 余额更新成功!`); showSuccess(t('channel.messages.balance_update_success', { name }));
} else { } else {
showError(message); showError(message);
} }
@@ -375,7 +381,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('已更新完毕所有已启用渠道余额!'); showInfo(t('channel.messages.all_balance_updated'));
} else { } else {
showError(message); showError(message);
} }
@@ -413,7 +419,7 @@ const ChannelsTable = () => {
icon='search' icon='search'
fluid fluid
iconPosition='left' iconPosition='left'
placeholder='搜索渠道的 ID名称和密钥 ...' placeholder={t('channel.search')}
value={searchKeyword} value={searchKeyword}
loading={searching} loading={searching}
onChange={handleKeywordChange} onChange={handleKeywordChange}
@@ -426,16 +432,14 @@ const ChannelsTable = () => {
setPromptShown(promptID); setPromptShown(promptID);
}} }}
> >
OpenAI 渠道已经不再支持通过 key 获取余额因此余额显示为 {t('channel.balance_notice')}
0对于支持的渠道类型请点击余额进行刷新
<br /> <br />
渠道测试仅支持 chat 模型优先使用 {t('channel.test_notice')}
gpt-3.5-turbo如果该模型不可用则使用你所配置的模型列表中的第一个模型
<br /> <br />
点击下方详情按钮可以显示余额以及设置额外的测试模型 {t('channel.detail_notice')}
</Message> </Message>
)} )}
<Table basic compact size='small'> <Table basic={'very'} compact size='small'>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.HeaderCell <Table.HeaderCell
@@ -444,7 +448,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' }}
@@ -452,7 +456,7 @@ const ChannelsTable = () => {
sortChannel('name'); sortChannel('name');
}} }}
> >
名称 {t('channel.table.name')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -460,7 +464,7 @@ const ChannelsTable = () => {
sortChannel('group'); sortChannel('group');
}} }}
> >
分组 {t('channel.table.group')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -468,7 +472,7 @@ const ChannelsTable = () => {
sortChannel('type'); sortChannel('type');
}} }}
> >
类型 {t('channel.table.type')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -476,7 +480,7 @@ const ChannelsTable = () => {
sortChannel('status'); sortChannel('status');
}} }}
> >
状态 {t('channel.table.status')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -484,7 +488,7 @@ const ChannelsTable = () => {
sortChannel('response_time'); sortChannel('response_time');
}} }}
> >
响应时间 {t('channel.table.response_time')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -493,7 +497,7 @@ const ChannelsTable = () => {
}} }}
hidden={!showDetail} hidden={!showDetail}
> >
余额 {t('channel.table.balance')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -501,10 +505,12 @@ const ChannelsTable = () => {
sortChannel('priority'); sortChannel('priority');
}} }}
> >
优先级 {t('channel.table.priority')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell hidden={!showDetail}>测试模型</Table.HeaderCell> <Table.HeaderCell hidden={!showDetail}>
<Table.HeaderCell>操作</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>
@@ -519,19 +525,21 @@ 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 : '无'}</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={ content={
channel.test_time channel.test_time
? renderTimestamp(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>
@@ -544,10 +552,10 @@ const ChannelsTable = () => {
}} }}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
{renderBalance(channel.type, channel.balance)} {renderBalance(channel.type, channel.balance, t)}
</span> </span>
} }
content='点击更新' content={t('channel.table.click_to_update')}
basic basic
/> />
</Table.Cell> </Table.Cell>
@@ -569,13 +577,13 @@ const ChannelsTable = () => {
<input style={{ maxWidth: '60px' }} /> <input style={{ maxWidth: '60px' }} />
</Input> </Input>
} }
content='渠道选择优先级,越高越优先' content={t('channel.table.priority_tip')}
basic basic
/> />
</Table.Cell> </Table.Cell>
<Table.Cell hidden={!showDetail}> <Table.Cell hidden={!showDetail}>
<Dropdown <Dropdown
placeholder='请选择测试模型' placeholder={t('channel.table.select_test_model')}
selection selection
options={channel.model_options} options={channel.model_options}
defaultValue={channel.test_model} defaultValue={channel.test_model}
@@ -587,7 +595,7 @@ const ChannelsTable = () => {
<Table.Cell> <Table.Cell>
<div> <div>
<Button <Button
size={'small'} size={'tiny'}
positive positive
onClick={() => { onClick={() => {
testChannel( testChannel(
@@ -598,22 +606,12 @@ const ChannelsTable = () => {
); );
}} }}
> >
测试 {t('channel.buttons.test')}
</Button> </Button>
{/*<Button*/}
{/* size={'small'}*/}
{/* positive*/}
{/* loading={updatingBalance}*/}
{/* onClick={() => {*/}
{/* updateChannelBalance(channel.id, channel.name, idx);*/}
{/* }}*/}
{/*>*/}
{/* 更新余额*/}
{/*</Button>*/}
<Popup <Popup
trigger={ trigger={
<Button size='small' negative> <Button size='tiny' negative>
删除 {t('channel.buttons.delete')}
</Button> </Button>
} }
on='click' on='click'
@@ -621,16 +619,17 @@ const ChannelsTable = () => {
hoverable hoverable
> >
<Button <Button
size={'tiny'}
negative negative
onClick={() => { onClick={() => {
manageChannel(channel.id, 'delete', idx); manageChannel(channel.id, 'delete', idx);
}} }}
> >
删除渠道 {channel.name} {t('channel.buttons.confirm_delete')} {channel.name}
</Button> </Button>
</Popup> </Popup>
<Button <Button
size={'small'} size={'tiny'}
onClick={() => { onClick={() => {
manageChannel( manageChannel(
channel.id, channel.id,
@@ -639,14 +638,16 @@ const ChannelsTable = () => {
); );
}} }}
> >
{channel.status === 1 ? '禁用' : '启用'} {channel.status === 1
? t('channel.buttons.disable')
: t('channel.buttons.enable')}
</Button> </Button>
<Button <Button
size={'small'} size={'tiny'}
as={Link} as={Link}
to={'/channel/edit/' + channel.id} to={'/channel/edit/' + channel.id}
> >
编辑 {t('channel.buttons.edit')}
</Button> </Button>
</div> </div>
</Table.Cell> </Table.Cell>
@@ -658,38 +659,31 @@ const ChannelsTable = () => {
<Table.Footer> <Table.Footer>
<Table.Row> <Table.Row>
<Table.HeaderCell colSpan={showDetail ? '10' : '8'}> <Table.HeaderCell colSpan={showDetail ? '10' : '8'}>
<Button <Button size='tiny' as={Link} to='/channel/add' loading={loading}>
size='small' {t('channel.buttons.add')}
as={Link}
to='/channel/add'
loading={loading}
>
添加新的渠道
</Button> </Button>
<Button <Button
size='small' size='tiny'
loading={loading} loading={loading}
onClick={() => { onClick={() => {
testChannels('all'); testChannels('all');
}} }}
> >
测试所有渠道 {t('channel.buttons.test_all')}
</Button> </Button>
<Button <Button
size='small' size='tiny'
loading={loading} loading={loading}
onClick={() => { onClick={() => {
testChannels('disabled'); testChannels('disabled');
}} }}
> >
测试禁用渠道 {t('channel.buttons.test_disabled')}
</Button> </Button>
{/*<Button size='small' onClick={updateAllChannelsBalance}*/}
{/* loading={loading || updatingBalance}>更新已启用渠道余额</Button>*/}
<Popup <Popup
trigger={ trigger={
<Button size='small' loading={loading}> <Button size='tiny' loading={loading}>
删除禁用渠道 {t('channel.buttons.delete_disabled')}
</Button> </Button>
} }
on='click' on='click'
@@ -697,30 +691,32 @@ const ChannelsTable = () => {
hoverable hoverable
> >
<Button <Button
size='small' size='tiny'
loading={loading} loading={loading}
negative negative
onClick={deleteAllDisabledChannels} onClick={deleteAllDisabledChannels}
> >
确认删除 {t('channel.buttons.confirm_delete_disabled')}
</Button> </Button>
</Popup> </Popup>
<Pagination <Pagination
floated='right' floated='right'
activePage={activePage} activePage={activePage}
onPageChange={onPaginationChange} onPageChange={onPaginationChange}
size='small' size='tiny'
siblingRange={1} siblingRange={1}
totalPages={ totalPages={
Math.ceil(channels.length / ITEMS_PER_PAGE) + Math.ceil(channels.length / ITEMS_PER_PAGE) +
(channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0) (channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
} }
/> />
<Button size='small' onClick={refresh} loading={loading}> <Button size='tiny' onClick={refresh} loading={loading}>
刷新 {t('channel.buttons.refresh')}
</Button> </Button>
<Button size='small' onClick={toggleShowDetail}> <Button size='tiny' onClick={toggleShowDetail}>
{showDetail ? '隐藏详情' : '详情'} {showDetail
? t('channel.buttons.hide_detail')
: t('channel.buttons.show_detail')}
</Button> </Button>
</Table.HeaderCell> </Table.HeaderCell>
</Table.Row> </Table.Row>

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Container, Segment } from 'semantic-ui-react'; import { Container, Segment } from 'semantic-ui-react';
import { getFooterHTML, getSystemName } from '../helpers'; import { getFooterHTML, getSystemName } from '../helpers';
const Footer = () => { const Footer = () => {
const { t } = useTranslation();
const systemName = getSystemName(); const systemName = getSystemName();
const [footer, setFooter] = useState(getFooterHTML()); const [footer, setFooter] = useState(getFooterHTML());
let remainCheckTimes = 5; let remainCheckTimes = 5;
@@ -29,7 +30,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,19 +38,16 @@ const Footer = () => {
></div> ></div>
) : ( ) : (
<div className='custom-footer'> <div className='custom-footer'>
<a <a href='https://github.com/songquanpeng/one-api' target='_blank'>
href='https://github.com/songquanpeng/one-api'
target='_blank'
>
{systemName} {process.env.REACT_APP_VERSION}{' '} {systemName} {process.env.REACT_APP_VERSION}{' '}
</a> </a>
{' '} {t('footer.built_by')}{' '}
<a href='https://github.com/songquanpeng' target='_blank'> <a href='https://github.com/songquanpeng' target='_blank'>
JustSong {t('footer.built_by_name')}
</a>{' '} </a>{' '}
构建源代码遵循{' '} {t('footer.license')}{' '}
<a href='https://opensource.org/licenses/mit-license.php'> <a href='https://opensource.org/licenses/mit-license.php'>
MIT 协议 {t('footer.mit')}
</a> </a>
</div> </div>
)} )}

View File

@@ -1,72 +1,88 @@
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: '首页', name: 'header.channel',
to: '/',
icon: 'home'
},
{
name: '渠道',
to: '/channel', to: '/channel',
icon: 'sitemap', icon: 'sitemap',
admin: true admin: true,
}, },
{ {
name: '令牌', name: 'header.token',
to: '/token', to: '/token',
icon: 'key' icon: 'key',
}, },
{ {
name: '兑换', name: 'header.redemption',
to: '/redemption', to: '/redemption',
icon: 'dollar sign', icon: 'dollar sign',
admin: true admin: true,
}, },
{ {
name: '充值', name: 'header.topup',
to: '/topup', to: '/topup',
icon: 'cart' icon: 'cart',
}, },
{ {
name: '用户', name: 'header.user',
to: '/user', to: '/user',
icon: 'user', icon: 'user',
admin: true admin: true,
}, },
{ {
name: '日志', name: 'header.dashboard',
to: '/dashboard',
icon: 'chart bar',
},
{
name: 'header.log',
to: '/log', to: '/log',
icon: 'book' icon: 'book',
}, },
{ {
name: '设置', name: 'header.setting',
to: '/setting', to: '/setting',
icon: 'setting' icon: 'setting',
}, },
{ {
name: '关于', 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: '聊天', 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 +109,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 +157,23 @@ 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
style={{
width: '100%',
maxWidth: isMobile() ? '100%' : '1200px',
padding: isMobile() ? '0 10px' : '0 20px',
}}
>
<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 +189,25 @@ 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
trigger={
<Icon
name='language'
style={{ margin: 0, fontSize: '18px' }}
/>
}
options={languageOptions}
value={i18n.language}
onChange={(_, { value }) => changeLanguage(value)}
/>
</Menu.Item>
<Menu.Item> <Menu.Item>
{userState.user ? ( {userState.user ? (
<Button onClick={logout}>注销</Button> <Button onClick={logout} style={{ color: '#666666' }}>
{t('header.logout')}
</Button>
) : ( ) : (
<> <>
<Button <Button
@@ -161,7 +216,7 @@ const Header = () => {
navigate('/login'); navigate('/login');
}} }}
> >
登录 {t('header.login')}
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
@@ -169,7 +224,7 @@ const Header = () => {
navigate('/register'); navigate('/register');
}} }}
> >
注册 {t('header.register')}
</Button> </Button>
</> </>
)} )}
@@ -185,32 +240,85 @@ const Header = () => {
return ( return (
<> <>
<Menu borderless style={{ borderTop: 'none' }}> <Menu
<Container> borderless
style={{
borderTop: 'none',
boxShadow: 'rgba(0, 0, 0, 0.04) 0px 2px 12px 0px',
border: 'none',
}}
>
<Container
style={{
width: '100%',
maxWidth: isMobile() ? '100%' : '1200px',
padding: isMobile() ? '0 10px' : '0 20px',
}}
>
<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
trigger={
<Icon name='language' style={{ margin: 0, fontSize: '18px' }} />
}
options={languageOptions}
value={i18n.language}
onChange={(_, { value }) => changeLanguage(value)}
style={{
fontSize: '16px',
fontWeight: '400',
color: '#666',
padding: '0 10px',
}}
/>
{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}>注销</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='登录' 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>

View File

@@ -1,5 +1,16 @@
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { Button, Divider, Form, Grid, Header, Image, Message, Modal, Segment } from 'semantic-ui-react'; import {
Button,
Divider,
Form,
Grid,
Header,
Image,
Message,
Modal,
Segment,
Card,
} from 'semantic-ui-react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { UserContext } from '../context/User'; import { UserContext } from '../context/User';
import { API, getLogo, showError, showSuccess, showWarning } from '../helpers'; import { API, getLogo, showError, showSuccess, showWarning } from '../helpers';
@@ -10,7 +21,7 @@ const LoginForm = () => {
const [inputs, setInputs] = useState({ const [inputs, setInputs] = useState({
username: '', username: '',
password: '', password: '',
wechat_verification_code: '' wechat_verification_code: '',
}); });
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
@@ -63,7 +74,7 @@ const LoginForm = () => {
if (username && password) { if (username && password) {
const res = await API.post(`/api/user/login`, { const res = await API.post(`/api/user/login`, {
username, username,
password password,
}); });
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
@@ -86,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} /> 用户登录 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='用户名 / 邮箱地址' textAlign='center'
name='username' style={{ marginBottom: '1.5em' }}
value={username} >
onChange={handleChange} <Image src={logo} style={{ marginBottom: '10px' }} />
/> <Header.Content>用户登录</Header.Content>
<Form.Input </Header>
fluid </Card.Header>
icon='lock' <Form size='large'>
iconPosition='left' <Form.Input
placeholder='密码' fluid
name='password' icon='user'
type='password' iconPosition='left'
value={password} placeholder='用户名 / 邮箱地址'
onChange={handleChange} name='username'
/> value={username}
<Button color='green' fluid size='large' onClick={handleSubmit}> onChange={handleChange}
登录 style={{ marginBottom: '1em' }}
</Button> />
</Segment> <Form.Input
</Form> fluid
<Message> icon='lock'
忘记密码 iconPosition='left'
<Link to='/reset' className='btn btn-link'> placeholder='密码'
点击重置 name='password'
</Link> type='password'
没有账户 value={password}
<Link to='/register' className='btn btn-link'> onChange={handleChange}
点击注册 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', // 使用更现代的蓝色
{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 登录
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>
忘记密码
<Link to='/reset' style={{ color: '#2185d0' }}>
点击重置
</Link>
</div> </div>
) : ( <div>
<></> 没有账户
)} <Link to='/register' style={{ color: '#2185d0' }}>
</div> 点击注册
</> </Link>
) : ( </div>
<></> </div>
)} </Message>
{(status.github_oauth ||
status.wechat_login ||
status.lark_client_id) && (
<>
<Divider
horizontal
style={{ color: '#666', fontSize: '0.9em' }}
>
使用其他方式登录
</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', // 使用更现代的蓝色
color: 'white',
marginBottom: '1.5em',
}}
onClick={onSubmitWeChatVerificationCode} onClick={onSubmitWeChatVerificationCode}
> >
登录 登录

View File

@@ -8,6 +8,7 @@ import {
Segment, Segment,
Select, Select,
Table, Table,
Popup,
} from 'semantic-ui-react'; } from 'semantic-ui-react';
import { import {
API, API,
@@ -18,6 +19,7 @@ import {
showWarning, showWarning,
timestamp2string, timestamp2string,
} from '../helpers'; } from '../helpers';
import { useTranslation } from 'react-i18next';
import { ITEMS_PER_PAGE } from '../constants'; import { ITEMS_PER_PAGE } from '../constants';
import { renderColorLabel, renderQuota } from '../helpers/render'; import { renderColorLabel, renderQuota } from '../helpers/render';
@@ -45,15 +47,6 @@ const MODE_OPTIONS = [
{ key: 'self', text: '当前用户', value: 'self' }, { key: 'self', text: '当前用户', value: 'self' },
]; ];
const LOG_OPTIONS = [
{ key: '0', text: '全部', value: 0 },
{ key: '1', text: '充值', value: 1 },
{ key: '2', text: '消费', value: 2 },
{ key: '3', text: '管理', value: 3 },
{ key: '4', text: '系统', value: 4 },
{ key: '5', text: '测试', value: 5 },
];
function renderType(type) { function renderType(type) {
switch (type) { switch (type) {
case 1: case 1:
@@ -137,6 +130,7 @@ function renderDetail(log) {
} }
const LogsTable = () => { const LogsTable = () => {
const { t } = useTranslation();
const [logs, setLogs] = useState([]); const [logs, setLogs] = useState([]);
const [showStat, setShowStat] = useState(false); const [showStat, setShowStat] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -168,6 +162,15 @@ const LogsTable = () => {
token: 0, token: 0,
}); });
const LOG_OPTIONS = [
{ key: '0', text: t('log.type.all'), value: 0 },
{ key: '1', text: t('log.type.topup'), value: 1 },
{ key: '2', text: t('log.type.usage'), value: 2 },
{ key: '3', text: t('log.type.admin'), value: 3 },
{ key: '4', text: t('log.type.system'), value: 4 },
{ key: '5', text: t('log.type.test'), value: 5 },
];
const handleInputChange = (e, { name, value }) => { const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value })); setInputs((inputs) => ({ ...inputs, [name]: value }));
}; };
@@ -307,296 +310,295 @@ const LogsTable = () => {
return ( return (
<> <>
<Segment> <Header as='h3'>
<Header as='h3'> {t('log.usage_details')}{t('log.total_quota')}
使用明细总消耗额度 {showStat && renderQuota(stat.quota, t)}
{showStat && renderQuota(stat.quota)} {!showStat && (
{!showStat && ( <span
<span onClick={handleEyeClick}
onClick={handleEyeClick} style={{ cursor: 'pointer', color: 'gray' }}
style={{ cursor: 'pointer', color: 'gray' }} >
> {t('log.click_to_view')}
点击查看 </span>
</span> )}
)}
</Header>
</Header> <Form>
<Form> <Form.Group>
<Form.Group> <Form.Input
<Form.Input fluid
fluid label={t('log.table.token_name')}
label={'令牌名称'} width={3}
width={3} value={token_name}
value={token_name} placeholder={t('log.table.token_name_placeholder')}
placeholder={'可选值'} name='token_name'
name='token_name' onChange={handleInputChange}
onChange={handleInputChange} />
/> <Form.Input
<Form.Input fluid
fluid label={t('log.table.model_name')}
label='模型名称' width={3}
width={3} value={model_name}
value={model_name} placeholder={t('log.table.model_name_placeholder')}
placeholder='可选值' name='model_name'
name='model_name' onChange={handleInputChange}
onChange={handleInputChange} />
/> <Form.Input
<Form.Input fluid
fluid label={t('log.table.start_time')}
label='起始时间' width={4}
width={4} value={start_timestamp}
value={start_timestamp} type='datetime-local'
type='datetime-local' name='start_timestamp'
name='start_timestamp' onChange={handleInputChange}
onChange={handleInputChange} />
/> <Form.Input
<Form.Input fluid
fluid label={t('log.table.end_time')}
label='结束时间' width={4}
width={4} value={end_timestamp}
value={end_timestamp} type='datetime-local'
type='datetime-local' name='end_timestamp'
name='end_timestamp' onChange={handleInputChange}
onChange={handleInputChange} />
/> <Form.Button
<Form.Button fluid label='操作' width={2} onClick={refresh}> fluid
查询 label={t('log.buttons.query')}
</Form.Button> width={2}
</Form.Group> onClick={refresh}
{isAdminUser && ( >
<> {t('log.buttons.submit')}
<Form.Group> </Form.Button>
<Form.Input </Form.Group>
fluid {isAdminUser && (
label={'渠道 ID'} <>
width={3} <Form.Group>
value={channel} <Form.Input
placeholder='可选值' fluid
name='channel' label={t('log.table.channel_id')}
onChange={handleInputChange}
/>
<Form.Input
fluid
label={'用户名称'}
width={3}
value={username}
placeholder={'可选值'}
name='username'
onChange={handleInputChange}
/>
</Form.Group>
</>
)}
</Form>
<Table basic compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('created_time');
}}
width={3} width={3}
> value={channel}
时间 placeholder={t('log.table.channel_id_placeholder')}
</Table.HeaderCell> name='channel'
{isAdminUser && ( onChange={handleInputChange}
<Table.HeaderCell />
style={{ cursor: 'pointer' }} <Form.Input
onClick={() => { fluid
sortLog('channel'); label={t('log.table.username')}
}} width={3}
width={1} value={username}
> placeholder={t('log.table.username_placeholder')}
渠道 name='username'
</Table.HeaderCell> onChange={handleInputChange}
)} />
</Form.Group>
</>
)}
<Form.Input
icon='search'
placeholder={t('log.search')}
value={searchKeyword}
onChange={(e, { value }) => setSearchKeyword(value)}
/>
</Form>
<Table basic={'very'} compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('created_time');
}}
width={3}
>
{t('log.table.time')}
</Table.HeaderCell>
{isAdminUser && (
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => { onClick={() => {
sortLog('type'); sortLog('channel');
}} }}
width={1} width={1}
> >
类型 {t('log.table.channel')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell )}
style={{ cursor: 'pointer' }} <Table.HeaderCell
onClick={() => { style={{ cursor: 'pointer' }}
sortLog('model_name'); onClick={() => {
}} sortLog('type');
width={2} }}
> width={1}
模型 >
</Table.HeaderCell> {t('log.table.type')}
{showUserTokenQuota() && ( </Table.HeaderCell>
<> <Table.HeaderCell
{isAdminUser && ( style={{ cursor: 'pointer' }}
<Table.HeaderCell onClick={() => {
style={{ cursor: 'pointer' }} sortLog('model_name');
onClick={() => { }}
sortLog('username'); width={2}
}} >
width={1} {t('log.table.model')}
> </Table.HeaderCell>
用户 {showUserTokenQuota() && (
</Table.HeaderCell> <>
)} {isAdminUser && (
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => { onClick={() => {
sortLog('token_name'); sortLog('username');
}} }}
width={1} width={2}
> >
令牌 {t('log.table.username')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell )}
style={{ cursor: 'pointer' }} <Table.HeaderCell
onClick={() => { style={{ cursor: 'pointer' }}
sortLog('prompt_tokens'); onClick={() => {
}} sortLog('token_name');
width={1}
>
提示
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('completion_tokens');
}}
width={1}
>
补全
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('quota');
}}
width={1}
>
额度
</Table.HeaderCell>
</>
)}
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('content');
}}
width={isAdminUser ? 4 : 6}
>
详情
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{logs
.slice(
(activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE
)
.map((log, idx) => {
if (log.deleted) return <></>;
return (
<Table.Row key={log.id}>
<Table.Cell>
{renderTimestamp(log.created_at, log.request_id)}
</Table.Cell>
{isAdminUser && (
<Table.Cell>
{log.channel ? (
<Label
basic
as={Link}
to={`/channel/edit/${log.channel}`}
>
{log.channel}
</Label>
) : (
''
)}
</Table.Cell>
)}
<Table.Cell>{renderType(log.type)}</Table.Cell>
<Table.Cell>
{log.model_name ? renderColorLabel(log.model_name) : ''}
</Table.Cell>
{showUserTokenQuota() && (
<>
{isAdminUser && (
<Table.Cell>
{log.username ? (
<Label
basic
as={Link}
to={`/user/edit/${log.user_id}`}
>
{log.username}
</Label>
) : (
''
)}
</Table.Cell>
)}
<Table.Cell>
{log.token_name
? renderColorLabel(log.token_name)
: ''}
</Table.Cell>
<Table.Cell>
{log.prompt_tokens ? log.prompt_tokens : ''}
</Table.Cell>
<Table.Cell>
{log.completion_tokens ? log.completion_tokens : ''}
</Table.Cell>
<Table.Cell>
{log.quota ? renderQuota(log.quota, 6) : ''}
</Table.Cell>
</>
)}
<Table.Cell>{renderDetail(log)}</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan={'10'}>
<Select
placeholder='选择明细分类'
options={LOG_OPTIONS}
style={{ marginRight: '8px' }}
name='logType'
value={logType}
onChange={(e, { name, value }) => {
setLogType(value);
}} }}
/> width={2}
<Button size='small' onClick={refresh} loading={loading}> >
刷新 {t('log.table.token_name')}
</Button> </Table.HeaderCell>
<Pagination <Table.HeaderCell
floated='right' style={{ cursor: 'pointer' }}
activePage={activePage} onClick={() => {
onPageChange={onPaginationChange} sortLog('prompt_tokens');
size='small' }}
siblingRange={1} width={1}
totalPages={ >
Math.ceil(logs.length / ITEMS_PER_PAGE) + {t('log.table.prompt_tokens')}
(logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0) </Table.HeaderCell>
} <Table.HeaderCell
/> style={{ cursor: 'pointer' }}
</Table.HeaderCell> onClick={() => {
</Table.Row> sortLog('completion_tokens');
</Table.Footer> }}
</Table> width={1}
</Segment> >
{t('log.table.completion_tokens')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortLog('quota');
}}
width={1}
>
{t('log.table.quota')}
</Table.HeaderCell>
</>
)}
<Table.HeaderCell>{t('log.table.detail')}</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{logs
.slice(
(activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE
)
.map((log, idx) => {
if (log.deleted) return <></>;
return (
<Table.Row key={log.id}>
<Table.Cell>
{renderTimestamp(log.created_at, log.request_id)}
</Table.Cell>
{isAdminUser && (
<Table.Cell>
{log.channel ? (
<Label
basic
as={Link}
to={`/channel/edit/${log.channel}`}
>
{log.channel}
</Label>
) : (
''
)}
</Table.Cell>
)}
<Table.Cell>{renderType(log.type)}</Table.Cell>
<Table.Cell>
{log.model_name ? renderColorLabel(log.model_name) : ''}
</Table.Cell>
{showUserTokenQuota() && (
<>
{isAdminUser && (
<Table.Cell>
{log.username ? (
<Label
basic
as={Link}
to={`/user/edit/${log.user_id}`}
>
{log.username}
</Label>
) : (
''
)}
</Table.Cell>
)}
<Table.Cell>
{log.token_name ? renderColorLabel(log.token_name) : ''}
</Table.Cell>
<Table.Cell>
{log.prompt_tokens ? log.prompt_tokens : ''}
</Table.Cell>
<Table.Cell>
{log.completion_tokens ? log.completion_tokens : ''}
</Table.Cell>
<Table.Cell>
{log.quota ? renderQuota(log.quota, t, 6) : ''}
</Table.Cell>
</>
)}
<Table.Cell>{renderDetail(log)}</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan={'10'}>
<Select
placeholder={t('log.type.select')}
options={LOG_OPTIONS}
style={{ marginRight: '8px' }}
name='logType'
value={logType}
onChange={(e, { name, value }) => {
setLogType(value);
}}
/>
<Button size='small' onClick={refresh} loading={loading}>
{t('log.buttons.refresh')}
</Button>
<Pagination
floated='right'
activePage={activePage}
onPageChange={onPaginationChange}
size='small'
siblingRange={1}
totalPages={
Math.ceil(logs.length / ITEMS_PER_PAGE) +
(logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
}
/>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
</> </>
); );
}; };

View File

@@ -1,8 +1,16 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Divider, Form, Grid, Header } from 'semantic-ui-react'; import { Divider, Form, Grid, Header } from 'semantic-ui-react';
import { API, showError, showSuccess, timestamp2string, verifyJSON } from '../helpers'; import {
API,
showError,
showSuccess,
timestamp2string,
verifyJSON,
} from '../helpers';
const OperationSetting = () => { const OperationSetting = () => {
const { t } = useTranslation();
let now = new Date(); let now = new Date();
let [inputs, setInputs] = useState({ let [inputs, setInputs] = useState({
QuotaForNewUser: 0, QuotaForNewUser: 0,
@@ -23,11 +31,13 @@ const OperationSetting = () => {
DisplayInCurrencyEnabled: '', DisplayInCurrencyEnabled: '',
DisplayTokenStatEnabled: '', DisplayTokenStatEnabled: '',
ApproximateTokenEnabled: '', ApproximateTokenEnabled: '',
RetryTimes: 0 RetryTimes: 0,
}); });
const [originInputs, setOriginInputs] = useState({}); const [originInputs, setOriginInputs] = useState({});
let [loading, setLoading] = useState(false); let [loading, setLoading] = useState(false);
let [historyTimestamp, setHistoryTimestamp] = useState(timestamp2string(now.getTime() / 1000 - 30 * 24 * 3600)); // a month ago let [historyTimestamp, setHistoryTimestamp] = useState(
timestamp2string(now.getTime() / 1000 - 30 * 24 * 3600)
); // a month ago
const getOptions = async () => { const getOptions = async () => {
const res = await API.get('/api/option/'); const res = await API.get('/api/option/');
@@ -35,7 +45,11 @@ const OperationSetting = () => {
if (success) { if (success) {
let newInputs = {}; let newInputs = {};
data.forEach((item) => { data.forEach((item) => {
if (item.key === 'ModelRatio' || item.key === 'GroupRatio' || item.key === 'CompletionRatio') { if (
item.key === 'ModelRatio' ||
item.key === 'GroupRatio' ||
item.key === 'CompletionRatio'
) {
item.value = JSON.stringify(JSON.parse(item.value), null, 2); item.value = JSON.stringify(JSON.parse(item.value), null, 2);
} }
if (item.value === '{}') { if (item.value === '{}') {
@@ -61,7 +75,7 @@ const OperationSetting = () => {
} }
const res = await API.put('/api/option/', { const res = await API.put('/api/option/', {
key, key,
value value,
}); });
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
@@ -83,11 +97,22 @@ const OperationSetting = () => {
const submitConfig = async (group) => { const submitConfig = async (group) => {
switch (group) { switch (group) {
case 'monitor': case 'monitor':
if (originInputs['ChannelDisableThreshold'] !== inputs.ChannelDisableThreshold) { if (
await updateOption('ChannelDisableThreshold', inputs.ChannelDisableThreshold); originInputs['ChannelDisableThreshold'] !==
inputs.ChannelDisableThreshold
) {
await updateOption(
'ChannelDisableThreshold',
inputs.ChannelDisableThreshold
);
} }
if (originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold) { if (
await updateOption('QuotaRemindThreshold', inputs.QuotaRemindThreshold); originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold
) {
await updateOption(
'QuotaRemindThreshold',
inputs.QuotaRemindThreshold
);
} }
break; break;
case 'ratio': case 'ratio':
@@ -146,7 +171,9 @@ const OperationSetting = () => {
const deleteHistoryLogs = async () => { const deleteHistoryLogs = async () => {
console.log(inputs); console.log(inputs);
const res = await API.delete(`/api/log/?target_timestamp=${Date.parse(historyTimestamp) / 1000}`); const res = await API.delete(
`/api/log/?target_timestamp=${Date.parse(historyTimestamp) / 1000}`
);
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
showSuccess(`${data} 条日志已清理!`); showSuccess(`${data} 条日志已清理!`);
@@ -159,40 +186,218 @@ const OperationSetting = () => {
<Grid columns={1}> <Grid columns={1}>
<Grid.Column> <Grid.Column>
<Form loading={loading}> <Form loading={loading}>
<Header as='h3'> <Header as='h3'>{t('setting.operation.quota.title')}</Header>
通用设置 <Form.Group widths='equal'>
</Header> <Form.Input
label={t('setting.operation.quota.new_user')}
name='QuotaForNewUser'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForNewUser}
type='number'
min='0'
placeholder={t('setting.operation.quota.new_user_placeholder')}
/>
<Form.Input
label={t('setting.operation.quota.pre_consume')}
name='PreConsumedQuota'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.PreConsumedQuota}
type='number'
min='0'
placeholder={t('setting.operation.quota.pre_consume_placeholder')}
/>
<Form.Input
label={t('setting.operation.quota.inviter_reward')}
name='QuotaForInviter'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForInviter}
type='number'
min='0'
placeholder={t(
'setting.operation.quota.inviter_reward_placeholder'
)}
/>
<Form.Input
label={t('setting.operation.quota.invitee_reward')}
name='QuotaForInvitee'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForInvitee}
type='number'
min='0'
placeholder={t(
'setting.operation.quota.invitee_reward_placeholder'
)}
/>
</Form.Group>
<Form.Button
onClick={() => {
submitConfig('quota').then();
}}
>
{t('setting.operation.quota.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>{t('setting.operation.ratio.title')}</Header>
<Form.Group widths='equal'>
<Form.TextArea
label={t('setting.operation.ratio.model.title')}
name='ModelRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.ModelRatio}
placeholder={t('setting.operation.ratio.model.placeholder')}
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.TextArea
label={t('setting.operation.ratio.completion.title')}
name='CompletionRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.CompletionRatio}
placeholder={t('setting.operation.ratio.completion.placeholder')}
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.TextArea
label={t('setting.operation.ratio.group.title')}
name='GroupRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.GroupRatio}
placeholder={t('setting.operation.ratio.group.placeholder')}
/>
</Form.Group>
<Form.Button
onClick={() => {
submitConfig('ratio').then();
}}
>
{t('setting.operation.ratio.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>{t('setting.operation.log.title')}</Header>
<Form.Group inline>
<Form.Checkbox
checked={inputs.LogConsumeEnabled === 'true'}
label={t('setting.operation.log.enable_consume')}
name='LogConsumeEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group widths={4}> <Form.Group widths={4}>
<Form.Input <Form.Input
label='充值链接' label={t('setting.operation.log.target_time')}
value={historyTimestamp}
type='datetime-local'
name='history_timestamp'
onChange={(e, { name, value }) => {
setHistoryTimestamp(value);
}}
/>
</Form.Group>
<Form.Button
onClick={() => {
deleteHistoryLogs().then();
}}
>
{t('setting.operation.log.buttons.clean')}
</Form.Button>
<Divider />
<Header as='h3'>{t('setting.operation.monitor.title')}</Header>
<Form.Group widths={3}>
<Form.Input
label={t('setting.operation.monitor.max_response_time')}
name='ChannelDisableThreshold'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.ChannelDisableThreshold}
type='number'
min='0'
placeholder={t(
'setting.operation.monitor.max_response_time_placeholder'
)}
/>
<Form.Input
label={t('setting.operation.monitor.quota_reminder')}
name='QuotaRemindThreshold'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaRemindThreshold}
type='number'
min='0'
placeholder={t(
'setting.operation.monitor.quota_reminder_placeholder'
)}
/>
</Form.Group>
<Form.Group inline>
<Form.Checkbox
checked={inputs.AutomaticDisableChannelEnabled === 'true'}
label={t('setting.operation.monitor.auto_disable')}
name='AutomaticDisableChannelEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.AutomaticEnableChannelEnabled === 'true'}
label={t('setting.operation.monitor.auto_enable')}
name='AutomaticEnableChannelEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button
onClick={() => {
submitConfig('monitor').then();
}}
>
{t('setting.operation.monitor.buttons.save')}
</Form.Button>
<Divider />
<Header as='h3'>{t('setting.operation.general.title')}</Header>
<Form.Group widths={4}>
<Form.Input
label={t('setting.operation.general.topup_link')}
name='TopUpLink' name='TopUpLink'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.TopUpLink} value={inputs.TopUpLink}
type='link' type='link'
placeholder='例如发卡网站的购买链接' placeholder={t(
'setting.operation.general.topup_link_placeholder'
)}
/> />
<Form.Input <Form.Input
label='聊天页面链接' label={t('setting.operation.general.chat_link')}
name='ChatLink' name='ChatLink'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.ChatLink} value={inputs.ChatLink}
type='link' type='link'
placeholder='例如 ChatGPT Next Web 的部署地址' placeholder={t('setting.operation.general.chat_link_placeholder')}
/> />
<Form.Input <Form.Input
label='单位美元额度' label={t('setting.operation.general.quota_per_unit')}
name='QuotaPerUnit' name='QuotaPerUnit'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.QuotaPerUnit} value={inputs.QuotaPerUnit}
type='number' type='number'
step='0.01' step='0.01'
placeholder='一单位货币能兑换的额度' placeholder={t(
'setting.operation.general.quota_per_unit_placeholder'
)}
/> />
<Form.Input <Form.Input
label='失败重试次数' label={t('setting.operation.general.retry_times')}
name='RetryTimes' name='RetryTimes'
type={'number'} type={'number'}
step='1' step='1'
@@ -200,186 +405,38 @@ const OperationSetting = () => {
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.RetryTimes} value={inputs.RetryTimes}
placeholder='失败重试次数' placeholder={t(
'setting.operation.general.retry_times_placeholder'
)}
/> />
</Form.Group> </Form.Group>
<Form.Group inline> <Form.Group inline>
<Form.Checkbox <Form.Checkbox
checked={inputs.DisplayInCurrencyEnabled === 'true'} checked={inputs.DisplayInCurrencyEnabled === 'true'}
label='以货币形式显示额度' label={t('setting.operation.general.display_in_currency')}
name='DisplayInCurrencyEnabled' name='DisplayInCurrencyEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
<Form.Checkbox <Form.Checkbox
checked={inputs.DisplayTokenStatEnabled === 'true'} checked={inputs.DisplayTokenStatEnabled === 'true'}
label='Billing 相关 API 显示令牌额度而非用户额度' label={t('setting.operation.general.display_token_stat')}
name='DisplayTokenStatEnabled' name='DisplayTokenStatEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
<Form.Checkbox <Form.Checkbox
checked={inputs.ApproximateTokenEnabled === 'true'} checked={inputs.ApproximateTokenEnabled === 'true'}
label='使用近似的方式估算 token 数以减少计算量' label={t('setting.operation.general.approximate_token')}
name='ApproximateTokenEnabled' name='ApproximateTokenEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={() => { <Form.Button
submitConfig('general').then(); onClick={() => {
}}>保存通用设置</Form.Button> submitConfig('general').then();
<Divider /> }}
<Header as='h3'> >
日志设置 {t('setting.operation.general.buttons.save')}
</Header> </Form.Button>
<Form.Group inline>
<Form.Checkbox
checked={inputs.LogConsumeEnabled === 'true'}
label='启用额度消费日志记录'
name='LogConsumeEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group widths={4}>
<Form.Input label='目标时间' value={historyTimestamp} type='datetime-local'
name='history_timestamp'
onChange={(e, { name, value }) => {
setHistoryTimestamp(value);
}} />
</Form.Group>
<Form.Button onClick={() => {
deleteHistoryLogs().then();
}}>清理历史日志</Form.Button>
<Divider />
<Header as='h3'>
监控设置
</Header>
<Form.Group widths={3}>
<Form.Input
label='最长响应时间'
name='ChannelDisableThreshold'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.ChannelDisableThreshold}
type='number'
min='0'
placeholder='单位秒,当运行渠道全部测试时,超过此时间将自动禁用渠道'
/>
<Form.Input
label='额度提醒阈值'
name='QuotaRemindThreshold'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaRemindThreshold}
type='number'
min='0'
placeholder='低于此额度时将发送邮件提醒用户'
/>
</Form.Group>
<Form.Group inline>
<Form.Checkbox
checked={inputs.AutomaticDisableChannelEnabled === 'true'}
label='失败时自动禁用渠道'
name='AutomaticDisableChannelEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.AutomaticEnableChannelEnabled === 'true'}
label='成功时自动启用渠道'
name='AutomaticEnableChannelEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={() => {
submitConfig('monitor').then();
}}>保存监控设置</Form.Button>
<Divider />
<Header as='h3'>
额度设置
</Header>
<Form.Group widths={4}>
<Form.Input
label='新用户初始额度'
name='QuotaForNewUser'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForNewUser}
type='number'
min='0'
placeholder='例如100'
/>
<Form.Input
label='请求预扣费额度'
name='PreConsumedQuota'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.PreConsumedQuota}
type='number'
min='0'
placeholder='请求结束后多退少补'
/>
<Form.Input
label='邀请新用户奖励额度'
name='QuotaForInviter'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForInviter}
type='number'
min='0'
placeholder='例如2000'
/>
<Form.Input
label='新用户使用邀请码奖励额度'
name='QuotaForInvitee'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.QuotaForInvitee}
type='number'
min='0'
placeholder='例如1000'
/>
</Form.Group>
<Form.Button onClick={() => {
submitConfig('quota').then();
}}>保存额度设置</Form.Button>
<Divider />
<Header as='h3'>
倍率设置
</Header>
<Form.Group widths='equal'>
<Form.TextArea
label='模型倍率'
name='ModelRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.ModelRatio}
placeholder='为一个 JSON 文本,键为模型名称,值为倍率'
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.TextArea
label='补全倍率'
name='CompletionRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.CompletionRatio}
placeholder='为一个 JSON 文本,键为模型名称,值为倍率,此处的倍率设置是模型补全倍率相较于提示倍率的比例,使用该设置可强制覆盖 One API 的内部比例'
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.TextArea
label='分组倍率'
name='GroupRatio'
onChange={handleInputChange}
style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
value={inputs.GroupRatio}
placeholder='为一个 JSON 文本,键为分组名称,值为倍率'
/>
</Form.Group>
<Form.Button onClick={() => {
submitConfig('ratio').then();
}}>保存倍率设置</Form.Button>
</Form> </Form>
</Grid.Column> </Grid.Column>
</Grid> </Grid>

View File

@@ -1,10 +1,20 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Divider, Form, Grid, Header, Message, Modal } from 'semantic-ui-react'; import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../helpers'; import {
import { marked } from 'marked'; Button,
Divider,
Form,
Grid,
Header,
Message,
Modal,
} from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { API, showError, showSuccess, verifyJSON } from '../helpers';
import { marked } from 'marked';
const OtherSetting = () => { const OtherSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({ let [inputs, setInputs] = useState({
Footer: '', Footer: '',
Notice: '', Notice: '',
@@ -12,13 +22,13 @@ const OtherSetting = () => {
SystemName: '', SystemName: '',
Logo: '', Logo: '',
HomePageContent: '', HomePageContent: '',
Theme: '' Theme: '',
}); });
let [loading, setLoading] = useState(false); let [loading, setLoading] = useState(false);
const [showUpdateModal, setShowUpdateModal] = useState(false); const [showUpdateModal, setShowUpdateModal] = useState(false);
const [updateData, setUpdateData] = useState({ const [updateData, setUpdateData] = useState({
tag_name: '', tag_name: '',
content: '' content: '',
}); });
const getOptions = async () => { const getOptions = async () => {
@@ -45,7 +55,7 @@ const OtherSetting = () => {
setLoading(true); setLoading(true);
const res = await API.put('/api/option/', { const res = await API.put('/api/option/', {
key, key,
value value,
}); });
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
@@ -64,10 +74,6 @@ const OtherSetting = () => {
await updateOption('Notice', inputs.Notice); await updateOption('Notice', inputs.Notice);
}; };
const submitFooter = async () => {
await updateOption('Footer', inputs.Footer);
};
const submitSystemName = async () => { const submitSystemName = async () => {
await updateOption('SystemName', inputs.SystemName); await updateOption('SystemName', inputs.SystemName);
}; };
@@ -89,8 +95,7 @@ const OtherSetting = () => {
}; };
const openGitHubRelease = () => { const openGitHubRelease = () => {
window.location = window.location = 'https://github.com/songquanpeng/one-api/releases/latest';
'https://github.com/songquanpeng/one-api/releases/latest';
}; };
const checkUpdate = async () => { const checkUpdate = async () => {
@@ -103,7 +108,7 @@ const OtherSetting = () => {
} else { } else {
setUpdateData({ setUpdateData({
tag_name: tag_name, tag_name: tag_name,
content: marked.parse(body) content: marked.parse(body),
}); });
setShowUpdateModal(true); setShowUpdateModal(true);
} }
@@ -113,87 +118,110 @@ const OtherSetting = () => {
<Grid columns={1}> <Grid columns={1}>
<Grid.Column> <Grid.Column>
<Form loading={loading}> <Form loading={loading}>
<Header as='h3'>通用设置</Header> <Header as='h3'>{t('setting.other.notice.title')}</Header>
<Form.Button onClick={checkUpdate}>检查更新</Form.Button>
<Form.Group widths='equal'> <Form.Group widths='equal'>
<Form.TextArea <Form.TextArea
label='公告' label={t('setting.other.notice.content')}
placeholder='在此输入新的公告内容,支持 Markdown & HTML 代码' placeholder={t('setting.other.notice.content_placeholder')}
value={inputs.Notice} value={inputs.Notice}
name='Notice' name='Notice'
onChange={handleInputChange} onChange={handleInputChange}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} style={{ minHeight: 100, fontFamily: 'JetBrains Mono, Consolas' }}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitNotice}>保存公告</Form.Button> <Form.Button onClick={submitNotice}>
{t('setting.other.notice.buttons.save')}
</Form.Button>
<Divider /> <Divider />
<Header as='h3'>个性化设置</Header> <Header as='h3'>{t('setting.other.system.title')}</Header>
<Form.Group widths='equal'> <Form.Group widths='equal'>
<Form.Input <Form.Input
label='系统名称' label={t('setting.other.system.name')}
placeholder='在此输入系统名称' placeholder={t('setting.other.system.name_placeholder')}
value={inputs.SystemName} value={inputs.SystemName}
name='SystemName' name='SystemName'
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitSystemName}>设置系统名称</Form.Button> <Form.Button onClick={submitSystemName}>
{t('setting.other.system.buttons.save_name')}
</Form.Button>
<Form.Group widths='equal'> <Form.Group widths='equal'>
<Form.Input <Form.Input
label={<label>主题名称<Link label={
to='https://github.com/songquanpeng/one-api/blob/main/web/README.md'>当前可用主题</Link></label>} <label>
placeholder='请输入主题名称' {t('setting.other.system.theme.title')}
<Link to='https://github.com/songquanpeng/one-api/blob/main/web/README.md'>
{t('setting.other.system.theme.link')}
</Link>
</label>
}
placeholder={t('setting.other.system.theme.placeholder')}
value={inputs.Theme} value={inputs.Theme}
name='Theme' name='Theme'
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitTheme}>设置主题重启生效</Form.Button> <Form.Button onClick={submitTheme}>
{t('setting.other.system.buttons.save_theme')}
</Form.Button>
<Form.Group widths='equal'> <Form.Group widths='equal'>
<Form.Input <Form.Input
label='Logo 图片地址' label={t('setting.other.system.logo')}
placeholder='在此输入 Logo 图片地址' placeholder={t('setting.other.system.logo_placeholder')}
value={inputs.Logo} value={inputs.Logo}
name='Logo' name='Logo'
type='url' type='url'
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitLogo}>设置 Logo</Form.Button> <Form.Button onClick={submitLogo}>
{t('setting.other.system.buttons.save_logo')}
</Form.Button>
<Divider />
<Header as='h3'>{t('setting.other.content.title')}</Header>
<Form.Group widths='equal'> <Form.Group widths='equal'>
<Form.TextArea <Form.TextArea
label='首页内容' label={t('setting.other.content.homepage.title')}
placeholder='在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。' placeholder={t('setting.other.content.homepage.placeholder')}
value={inputs.HomePageContent} value={inputs.HomePageContent}
name='HomePageContent' name='HomePageContent'
onChange={handleInputChange} onChange={handleInputChange}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={() => submitOption('HomePageContent')}>保存首页内容</Form.Button> <Form.Button onClick={() => submitOption('HomePageContent')}>
{t('setting.other.content.buttons.save_homepage')}
</Form.Button>
<Form.Group widths='equal'> <Form.Group widths='equal'>
<Form.TextArea <Form.TextArea
label='关于' label={t('setting.other.content.about.title')}
placeholder='在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面。' placeholder={t('setting.other.content.about.placeholder')}
value={inputs.About} value={inputs.About}
name='About' name='About'
onChange={handleInputChange} onChange={handleInputChange}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitAbout}>保存关于</Form.Button> <Form.Button onClick={submitAbout}>
<Message>移除 One API {t('setting.other.content.buttons.save_about')}
的版权标识必须首先获得授权项目维护需要花费大量精力如果本项目对你有意义请主动支持本项目</Message> </Form.Button>
<Message>{t('setting.other.copyright.notice')}</Message>
<Form.Group widths='equal'> <Form.Group widths='equal'>
<Form.Input <Form.Input
label='页脚' label={t('setting.other.content.footer.title')}
placeholder='在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码' placeholder={t('setting.other.content.footer.placeholder')}
value={inputs.Footer} value={inputs.Footer}
name='Footer' name='Footer'
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitFooter}>设置页脚</Form.Button> <Form.Button onClick={() => submitOption('Footer')}>
{t('setting.other.content.buttons.save_footer')}
</Form.Button>
</Form> </Form>
</Grid.Column> </Grid.Column>
<Modal <Modal

View File

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

View File

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

View File

@@ -1,12 +1,29 @@
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { Button, Divider, Form, Header, Image, Message, Modal } from 'semantic-ui-react'; import { useTranslation } from 'react-i18next';
import {
Button,
Divider,
Form,
Header,
Image,
Message,
Modal,
} from 'semantic-ui-react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers'; import {
API,
copy,
showError,
showInfo,
showNotice,
showSuccess,
} from '../helpers';
import Turnstile from 'react-turnstile'; import Turnstile from 'react-turnstile';
import { UserContext } from '../context/User'; import { UserContext } from '../context/User';
import { onGitHubOAuthClicked, onLarkOAuthClicked } from './utils'; import { onGitHubOAuthClicked, onLarkOAuthClicked } from './utils';
const PersonalSetting = () => { const PersonalSetting = () => {
const { t } = useTranslation();
const [userState, userDispatch] = useContext(UserContext); const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate(); let navigate = useNavigate();
@@ -14,7 +31,7 @@ const PersonalSetting = () => {
wechat_verification_code: '', wechat_verification_code: '',
email_verification_code: '', email_verification_code: '',
email: '', email: '',
self_account_deletion_confirmation: '' self_account_deletion_confirmation: '',
}); });
const [status, setStatus] = useState({}); const [status, setStatus] = useState({});
const [showWeChatBindModal, setShowWeChatBindModal] = useState(false); const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
@@ -26,8 +43,8 @@ const PersonalSetting = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false); const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30); const [countdown, setCountdown] = useState(30);
const [affLink, setAffLink] = useState(""); const [affLink, setAffLink] = useState('');
const [systemToken, setSystemToken] = useState(""); const [systemToken, setSystemToken] = useState('');
useEffect(() => { useEffect(() => {
let status = localStorage.getItem('status'); let status = localStorage.getItem('status');
@@ -63,7 +80,7 @@ const PersonalSetting = () => {
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success) { if (success) {
setSystemToken(data); setSystemToken(data);
setAffLink(""); setAffLink('');
await copy(data); await copy(data);
showSuccess(`令牌已重置并已复制到剪贴板`); showSuccess(`令牌已重置并已复制到剪贴板`);
} else { } else {
@@ -77,7 +94,7 @@ const PersonalSetting = () => {
if (success) { if (success) {
let link = `${window.location.origin}/register?aff=${data}`; let link = `${window.location.origin}/register?aff=${data}`;
setAffLink(link); setAffLink(link);
setSystemToken(""); setSystemToken('');
await copy(link); await copy(link);
showSuccess(`邀请链接已复制到剪切板`); showSuccess(`邀请链接已复制到剪切板`);
} else { } else {
@@ -169,50 +186,50 @@ const PersonalSetting = () => {
return ( return (
<div style={{ lineHeight: '40px' }}> <div style={{ lineHeight: '40px' }}>
<Header as='h3'>通用设置</Header> <Header as='h3'>{t('setting.personal.general.title')}</Header>
<Message> <Message>{t('setting.personal.general.system_token_notice')}</Message>
注意此处生成的令牌用于系统管理而非用于请求 OpenAI 相关的服务请知悉
</Message>
<Button as={Link} to={`/user/edit/`}> <Button as={Link} to={`/user/edit/`}>
更新个人信息 {t('setting.personal.general.buttons.update_profile')}
</Button> </Button>
<Button onClick={generateAccessToken}>生成系统访问令牌</Button> <Button onClick={generateAccessToken}>
<Button onClick={getAffLink}>复制邀请链接</Button> {t('setting.personal.general.buttons.generate_token')}
<Button onClick={() => { </Button>
setShowAccountDeleteModal(true); <Button onClick={getAffLink}>
}}>删除个人账户</Button> {t('setting.personal.general.buttons.copy_invite')}
</Button>
<Button
onClick={() => {
setShowAccountDeleteModal(true);
}}
>
{t('setting.personal.general.buttons.delete_account')}
</Button>
{systemToken && ( {systemToken && (
<Form.Input <Form.Input
fluid fluid
readOnly readOnly
value={systemToken} value={systemToken}
onClick={handleSystemTokenClick} onClick={handleSystemTokenClick}
style={{ marginTop: '10px' }} style={{ marginTop: '10px' }}
/> />
)} )}
{affLink && ( {affLink && (
<Form.Input <Form.Input
fluid fluid
readOnly readOnly
value={affLink} value={affLink}
onClick={handleAffLinkClick} onClick={handleAffLinkClick}
style={{ marginTop: '10px' }} style={{ marginTop: '10px' }}
/> />
)} )}
<Divider /> <Divider />
<Header as='h3'>账号绑定</Header> <Header as='h3'>{t('setting.personal.binding.title')}</Header>
{ {status.wechat_login && (
status.wechat_login && ( <Button onClick={() => setShowWeChatBindModal(true)}>
<Button {t('setting.personal.binding.buttons.bind_wechat')}
onClick={() => { </Button>
setShowWeChatBindModal(true); )}
}}
>
绑定微信账号
</Button>
)
}
<Modal <Modal
onClose={() => setShowWeChatBindModal(false)} onClose={() => setShowWeChatBindModal(false)}
onOpen={() => setShowWeChatBindModal(true)} onOpen={() => setShowWeChatBindModal(true)}
@@ -223,41 +240,37 @@ const PersonalSetting = () => {
<Modal.Description> <Modal.Description>
<Image src={status.wechat_qrcode} fluid /> <Image src={status.wechat_qrcode} fluid />
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<p> <p>{t('setting.personal.binding.wechat.description')}</p>
微信扫码关注公众号输入验证码获取验证码三分钟内有效
</p>
</div> </div>
<Form size='large'> <Form size='large'>
<Form.Input <Form.Input
fluid fluid
placeholder='验证码' placeholder={t(
'setting.personal.binding.wechat.verification_code'
)}
name='wechat_verification_code' name='wechat_verification_code'
value={inputs.wechat_verification_code} value={inputs.wechat_verification_code}
onChange={handleInputChange} onChange={handleInputChange}
/> />
<Button color='' fluid size='large' onClick={bindWeChat}> <Button color='' fluid size='large' onClick={bindWeChat}>
绑定 {t('setting.personal.binding.wechat.bind')}
</Button> </Button>
</Form> </Form>
</Modal.Description> </Modal.Description>
</Modal.Content> </Modal.Content>
</Modal> </Modal>
{ {status.github_oauth && (
status.github_oauth && ( <Button onClick={() => onGitHubOAuthClicked(status.github_client_id)}>
<Button onClick={()=>{onGitHubOAuthClicked(status.github_client_id)}}>绑定 GitHub 账号</Button> {t('setting.personal.binding.buttons.bind_github')}
) </Button>
} )}
{ {status.lark_client_id && (
status.lark_client_id && ( <Button onClick={() => onLarkOAuthClicked(status.lark_client_id)}>
<Button onClick={()=>{onLarkOAuthClicked(status.lark_client_id)}}>绑定飞书账号</Button> {t('setting.personal.binding.buttons.bind_lark')}
) </Button>
} )}
<Button <Button onClick={() => setShowEmailBindModal(true)}>
onClick={() => { {t('setting.personal.binding.buttons.bind_email')}
setShowEmailBindModal(true);
}}
>
绑定邮箱地址
</Button> </Button>
<Modal <Modal
onClose={() => setShowEmailBindModal(false)} onClose={() => setShowEmailBindModal(false)}
@@ -266,57 +279,72 @@ const PersonalSetting = () => {
size={'tiny'} size={'tiny'}
style={{ maxWidth: '450px' }} style={{ maxWidth: '450px' }}
> >
<Modal.Header>绑定邮箱地址</Modal.Header> <Modal.Header>{t('setting.personal.binding.email.title')}</Modal.Header>
<Modal.Content> <Modal.Content>
<Modal.Description> <Modal.Description>
<Form size='large'> <Form size='large'>
<Form.Input <Form.Input
fluid fluid
placeholder='输入邮箱地址' placeholder={t(
'setting.personal.binding.email.email_placeholder'
)}
onChange={handleInputChange} onChange={handleInputChange}
name='email' name='email'
type='email' type='email'
action={ action={
<Button onClick={sendVerificationCode} disabled={disableButton || loading}> <Button
{disableButton ? `重新发送(${countdown})` : '获取验证码'} onClick={sendVerificationCode}
disabled={disableButton || loading}
>
{disableButton
? t('setting.personal.binding.email.get_code_retry', {
countdown,
})
: t('setting.personal.binding.email.get_code')}
</Button> </Button>
} }
/> />
<Form.Input <Form.Input
fluid fluid
placeholder='验证码' placeholder={t(
'setting.personal.binding.email.code_placeholder'
)}
name='email_verification_code' name='email_verification_code'
value={inputs.email_verification_code} value={inputs.email_verification_code}
onChange={handleInputChange} onChange={handleInputChange}
/> />
{turnstileEnabled ? ( {turnstileEnabled && (
<Turnstile <Turnstile
sitekey={turnstileSiteKey} sitekey={turnstileSiteKey}
onVerify={(token) => { onVerify={(token) => {
setTurnstileToken(token); setTurnstileToken(token);
}} }}
/> />
) : (
<></>
)} )}
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}> <div
<Button style={{
color='' display: 'flex',
fluid justifyContent: 'space-between',
size='large' marginTop: '1rem',
onClick={bindEmail} }}
loading={loading}
> >
确认绑定 <Button
</Button> color=''
<div style={{ width: '1rem' }}></div> fluid
<Button size='large'
fluid onClick={bindEmail}
size='large' loading={loading}
onClick={() => setShowEmailBindModal(false)} >
> {t('setting.personal.binding.email.bind')}
取消 </Button>
</Button> <div style={{ width: '1rem' }}></div>
<Button
fluid
size='large'
onClick={() => setShowEmailBindModal(false)}
>
{t('setting.personal.binding.email.cancel')}
</Button>
</div> </div>
</Form> </Form>
</Modal.Description> </Modal.Description>
@@ -329,29 +357,40 @@ const PersonalSetting = () => {
size={'tiny'} size={'tiny'}
style={{ maxWidth: '450px' }} style={{ maxWidth: '450px' }}
> >
<Modal.Header>危险操作</Modal.Header> <Modal.Header>
{t('setting.personal.delete_account.title')}
</Modal.Header>
<Modal.Content> <Modal.Content>
<Message>您正在删除自己的帐户将清空所有数据且不可恢复</Message> <Message>{t('setting.personal.delete_account.warning')}</Message>
<Modal.Description> <Modal.Description>
<Form size='large'> <Form size='large'>
<Form.Input <Form.Input
fluid fluid
placeholder={`输入你的账户名 ${userState?.user?.username} 以确认删除`} placeholder={t(
'setting.personal.delete_account.confirm_placeholder',
{
username: userState?.user?.username,
}
)}
name='self_account_deletion_confirmation' name='self_account_deletion_confirmation'
value={inputs.self_account_deletion_confirmation} value={inputs.self_account_deletion_confirmation}
onChange={handleInputChange} onChange={handleInputChange}
/> />
{turnstileEnabled ? ( {turnstileEnabled && (
<Turnstile <Turnstile
sitekey={turnstileSiteKey} sitekey={turnstileSiteKey}
onVerify={(token) => { onVerify={(token) => {
setTurnstileToken(token); setTurnstileToken(token);
}} }}
/> />
) : (
<></>
)} )}
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}> <div
style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: '1rem',
}}
>
<Button <Button
color='red' color='red'
fluid fluid
@@ -359,7 +398,7 @@ const PersonalSetting = () => {
onClick={deleteAccount} onClick={deleteAccount}
loading={loading} loading={loading}
> >
确认删除 {t('setting.personal.delete_account.buttons.confirm')}
</Button> </Button>
<div style={{ width: '1rem' }}></div> <div style={{ width: '1rem' }}></div>
<Button <Button
@@ -367,7 +406,7 @@ const PersonalSetting = () => {
size='large' size='large'
onClick={() => setShowAccountDeleteModal(false)} onClick={() => setShowAccountDeleteModal(false)}
> >
取消 {t('setting.personal.delete_account.buttons.cancel')}
</Button> </Button>
</div> </div>
</Form> </Form>

View File

@@ -1,33 +1,62 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Label, Popup, Pagination, Table } from 'semantic-ui-react'; import { useTranslation } from 'react-i18next';
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, t) {
switch (status) { switch (status) {
case 1: case 1:
return <Label basic color='green'>未使用</Label>; return (
<Label basic color='green'>
{t('redemption.status.unused')}
</Label>
);
case 2: case 2:
return <Label basic color='red'> 已禁用 </Label>; return (
<Label basic color='red'>
{t('redemption.status.disabled')}
</Label>
);
case 3: case 3:
return <Label basic color='grey'> 已使用 </Label>; return (
<Label basic color='grey'>
{t('redemption.status.used')}
</Label>
);
default: default:
return <Label basic color='black'> 未知状态 </Label>; return (
<Label basic color='black'>
{t('redemption.status.unknown')}
</Label>
);
} }
} }
const RedemptionsTable = () => { const RedemptionsTable = () => {
const { t } = useTranslation();
const [redemptions, setRedemptions] = useState([]); const [redemptions, setRedemptions] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1); const [activePage, setActivePage] = useState(1);
@@ -87,7 +116,7 @@ const RedemptionsTable = () => {
} }
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
showSuccess('操作成功完成!'); showSuccess(t('token.messages.operation_success'));
let redemption = res.data.data; let redemption = res.data.data;
let newRedemptions = [...redemptions]; let newRedemptions = [...redemptions];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
@@ -110,7 +139,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);
@@ -145,6 +176,12 @@ const RedemptionsTable = () => {
setLoading(false); setLoading(false);
}; };
const refresh = async () => {
setLoading(true);
await loadRedemptions(0);
setActivePage(1);
};
return ( return (
<> <>
<Form onSubmit={searchRedemptions}> <Form onSubmit={searchRedemptions}>
@@ -152,14 +189,14 @@ const RedemptionsTable = () => {
icon='search' icon='search'
fluid fluid
iconPosition='left' iconPosition='left'
placeholder='搜索兑换码的 ID 和名称 ...' placeholder={t('redemption.search')}
value={searchKeyword} value={searchKeyword}
loading={searching} loading={searching}
onChange={handleKeywordChange} onChange={handleKeywordChange}
/> />
</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
@@ -168,7 +205,7 @@ const RedemptionsTable = () => {
sortRedemption('id'); sortRedemption('id');
}} }}
> >
ID {t('redemption.table.id')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -176,7 +213,7 @@ const RedemptionsTable = () => {
sortRedemption('name'); sortRedemption('name');
}} }}
> >
名称 {t('redemption.table.name')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -184,7 +221,7 @@ const RedemptionsTable = () => {
sortRedemption('status'); sortRedemption('status');
}} }}
> >
状态 {t('redemption.table.status')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -192,7 +229,7 @@ const RedemptionsTable = () => {
sortRedemption('quota'); sortRedemption('quota');
}} }}
> >
额度 {t('redemption.table.quota')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -200,7 +237,7 @@ const RedemptionsTable = () => {
sortRedemption('created_time'); sortRedemption('created_time');
}} }}
> >
创建时间 {t('redemption.table.created_time')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -208,9 +245,9 @@ const RedemptionsTable = () => {
sortRedemption('redeemed_time'); sortRedemption('redeemed_time');
}} }}
> >
兑换时间 {t('redemption.table.redeemed_time')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell> <Table.HeaderCell>{t('redemption.table.actions')}</Table.HeaderCell>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
@@ -225,31 +262,39 @@ const RedemptionsTable = () => {
return ( return (
<Table.Row key={redemption.id}> <Table.Row key={redemption.id}>
<Table.Cell>{redemption.id}</Table.Cell> <Table.Cell>{redemption.id}</Table.Cell>
<Table.Cell>{redemption.name ? redemption.name : '无'}</Table.Cell> <Table.Cell>
<Table.Cell>{renderStatus(redemption.status)}</Table.Cell> {redemption.name ? redemption.name : t('redemption.table.no_name')}
<Table.Cell>{renderQuota(redemption.quota)}</Table.Cell> </Table.Cell>
<Table.Cell>{renderTimestamp(redemption.created_time)}</Table.Cell> <Table.Cell>{renderStatus(redemption.status, t)}</Table.Cell>
<Table.Cell>{redemption.redeemed_time ? renderTimestamp(redemption.redeemed_time) : "尚未兑换"} </Table.Cell> <Table.Cell>{renderQuota(redemption.quota, t)}</Table.Cell>
<Table.Cell>
{renderTimestamp(redemption.created_time)}
</Table.Cell>
<Table.Cell>
{redemption.redeemed_time
? renderTimestamp(redemption.redeemed_time)
: t('redemption.table.not_redeemed')}{' '}
</Table.Cell>
<Table.Cell> <Table.Cell>
<div> <div>
<Button <Button
size={'small'} size={'tiny'}
positive positive
onClick={async () => { onClick={async () => {
if (await copy(redemption.key)) { if (await copy(redemption.key)) {
showSuccess('已复制到剪贴板!'); showSuccess(t('token.messages.copy_success'));
} else { } else {
showWarning('无法复制到剪贴板,请手动复制,已将兑换码填入搜索框。') showWarning(t('token.messages.copy_failed'));
setSearchKeyword(redemption.key); setSearchKeyword(redemption.key);
} }
}} }}
> >
复制 {t('redemption.buttons.copy')}
</Button> </Button>
<Popup <Popup
trigger={ trigger={
<Button size='small' negative> <Button size='tiny' negative>
删除 {t('redemption.buttons.delete')}
</Button> </Button>
} }
on='click' on='click'
@@ -262,12 +307,12 @@ const RedemptionsTable = () => {
manageRedemption(redemption.id, 'delete', idx); manageRedemption(redemption.id, 'delete', idx);
}} }}
> >
确认删除 {t('redemption.buttons.confirm_delete')}
</Button> </Button>
</Popup> </Popup>
<Button <Button
size={'small'} size={'tiny'}
disabled={redemption.status === 3} // used disabled={redemption.status === 3} // used
onClick={() => { onClick={() => {
manageRedemption( manageRedemption(
redemption.id, redemption.id,
@@ -276,14 +321,16 @@ const RedemptionsTable = () => {
); );
}} }}
> >
{redemption.status === 1 ? '禁用' : '启用'} {redemption.status === 1
? t('redemption.buttons.disable')
: t('redemption.buttons.enable')}
</Button> </Button>
<Button <Button
size={'small'} size={'tiny'}
as={Link} as={Link}
to={'/redemption/edit/' + redemption.id} to={'/redemption/edit/' + redemption.id}
> >
编辑 {t('redemption.buttons.edit')}
</Button> </Button>
</div> </div>
</Table.Cell> </Table.Cell>
@@ -294,9 +341,17 @@ const RedemptionsTable = () => {
<Table.Footer> <Table.Footer>
<Table.Row> <Table.Row>
<Table.HeaderCell colSpan='8'> <Table.HeaderCell colSpan='7'>
<Button size='small' as={Link} to='/redemption/add' loading={loading}> <Button
添加新的兑换码 size='small'
as={Link}
to='/redemption/add'
loading={loading}
>
{t('redemption.buttons.add')}
</Button>
<Button size='small' onClick={refresh} loading={loading}>
{t('redemption.buttons.refresh')}
</Button> </Button>
<Pagination <Pagination
floated='right' floated='right'

View File

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

View File

@@ -1,8 +1,18 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Divider, Form, Grid, Header, Modal, Message } from 'semantic-ui-react'; import { useTranslation } from 'react-i18next';
import {
Button,
Divider,
Form,
Grid,
Header,
Modal,
Message,
} from 'semantic-ui-react';
import { API, removeTrailingSlash, showError } from '../helpers'; import { API, removeTrailingSlash, showError } from '../helpers';
const SystemSetting = () => { const SystemSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({ let [inputs, setInputs] = useState({
PasswordLoginEnabled: '', PasswordLoginEnabled: '',
PasswordRegisterEnabled: '', PasswordRegisterEnabled: '',
@@ -31,13 +41,14 @@ const SystemSetting = () => {
TurnstileSecretKey: '', TurnstileSecretKey: '',
RegisterEnabled: '', RegisterEnabled: '',
EmailDomainRestrictionEnabled: '', EmailDomainRestrictionEnabled: '',
EmailDomainWhitelist: '' EmailDomainWhitelist: '',
}); });
const [originInputs, setOriginInputs] = useState({}); const [originInputs, setOriginInputs] = useState({});
let [loading, setLoading] = useState(false); let [loading, setLoading] = useState(false);
const [EmailDomainWhitelist, setEmailDomainWhitelist] = useState([]); const [EmailDomainWhitelist, setEmailDomainWhitelist] = useState([]);
const [restrictedDomainInput, setRestrictedDomainInput] = useState(''); const [restrictedDomainInput, setRestrictedDomainInput] = useState('');
const [showPasswordWarningModal, setShowPasswordWarningModal] = useState(false); const [showPasswordWarningModal, setShowPasswordWarningModal] =
useState(false);
const getOptions = async () => { const getOptions = async () => {
const res = await API.get('/api/option/'); const res = await API.get('/api/option/');
@@ -49,13 +60,15 @@ const SystemSetting = () => {
}); });
setInputs({ setInputs({
...newInputs, ...newInputs,
EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(',') EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(','),
}); });
setOriginInputs(newInputs); setOriginInputs(newInputs);
setEmailDomainWhitelist(newInputs.EmailDomainWhitelist.split(',').map((item) => { setEmailDomainWhitelist(
return { key: item, text: item, value: item }; newInputs.EmailDomainWhitelist.split(',').map((item) => {
})); return { key: item, text: item, value: item };
})
);
} else { } else {
showError(message); showError(message);
} }
@@ -83,7 +96,7 @@ const SystemSetting = () => {
} }
const res = await API.put('/api/option/', { const res = await API.put('/api/option/', {
key, key,
value value,
}); });
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
@@ -91,7 +104,8 @@ const SystemSetting = () => {
value = value.split(','); value = value.split(',');
} }
setInputs((inputs) => ({ setInputs((inputs) => ({
...inputs, [key]: value ...inputs,
[key]: value,
})); }));
} else { } else {
showError(message); showError(message);
@@ -155,13 +169,16 @@ const SystemSetting = () => {
} }
}; };
const submitEmailDomainWhitelist = async () => { const submitEmailDomainWhitelist = async () => {
if ( if (
originInputs['EmailDomainWhitelist'] !== inputs.EmailDomainWhitelist.join(',') && originInputs['EmailDomainWhitelist'] !==
inputs.EmailDomainWhitelist.join(',') &&
inputs.SMTPToken !== '' inputs.SMTPToken !== ''
) { ) {
await updateOption('EmailDomainWhitelist', inputs.EmailDomainWhitelist.join(',')); await updateOption(
'EmailDomainWhitelist',
inputs.EmailDomainWhitelist.join(',')
);
} }
}; };
@@ -216,7 +233,7 @@ const SystemSetting = () => {
} }
}; };
const submitLarkOAuth = async () => { const submitLarkOAuth = async () => {
if (originInputs['LarkClientId'] !== inputs.LarkClientId) { if (originInputs['LarkClientId'] !== inputs.LarkClientId) {
await updateOption('LarkClientId', inputs.LarkClientId); await updateOption('LarkClientId', inputs.LarkClientId);
} }
@@ -242,60 +259,71 @@ const SystemSetting = () => {
const submitNewRestrictedDomain = () => { const submitNewRestrictedDomain = () => {
const localDomainList = inputs.EmailDomainWhitelist; const localDomainList = inputs.EmailDomainWhitelist;
if (restrictedDomainInput !== '' && !localDomainList.includes(restrictedDomainInput)) { if (
restrictedDomainInput !== '' &&
!localDomainList.includes(restrictedDomainInput)
) {
setRestrictedDomainInput(''); setRestrictedDomainInput('');
setInputs({ setInputs({
...inputs, ...inputs,
EmailDomainWhitelist: [...localDomainList, restrictedDomainInput], EmailDomainWhitelist: [...localDomainList, restrictedDomainInput],
}); });
setEmailDomainWhitelist([...EmailDomainWhitelist, { setEmailDomainWhitelist([
key: restrictedDomainInput, ...EmailDomainWhitelist,
text: restrictedDomainInput, {
value: restrictedDomainInput, key: restrictedDomainInput,
}]); text: restrictedDomainInput,
value: restrictedDomainInput,
},
]);
} }
} };
return ( return (
<Grid columns={1}> <Grid columns={1}>
<Grid.Column> <Grid.Column>
<Form loading={loading}> <Form loading={loading}>
<Header as='h3'>通用设置</Header> <Header as='h3'>{t('setting.system.general.title')}</Header>
<Form.Group widths='equal'> <Form.Group widths='equal'>
<Form.Input <Form.Input
label='服务器地址' label={t('setting.system.general.server_address')}
placeholder='例如https://yourdomain.com' placeholder={t(
'setting.system.general.server_address_placeholder'
)}
value={inputs.ServerAddress} value={inputs.ServerAddress}
name='ServerAddress' name='ServerAddress'
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitServerAddress}> <Form.Button onClick={submitServerAddress}>
更新服务器地址 {t('setting.system.general.buttons.update')}
</Form.Button> </Form.Button>
<Divider /> <Divider />
<Header as='h3'>配置登录注册</Header> <Header as='h3'>{t('setting.system.login.title')}</Header>
<Form.Group inline> <Form.Group inline>
<Form.Checkbox <Form.Checkbox
checked={inputs.PasswordLoginEnabled === 'true'} checked={inputs.PasswordLoginEnabled === 'true'}
label='允许通过密码进行登录' label={t('setting.system.login.password_login')}
name='PasswordLoginEnabled' name='PasswordLoginEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
{ {showPasswordWarningModal && (
showPasswordWarningModal &&
<Modal <Modal
open={showPasswordWarningModal} open={showPasswordWarningModal}
onClose={() => setShowPasswordWarningModal(false)} onClose={() => setShowPasswordWarningModal(false)}
size={'tiny'} size={'tiny'}
style={{ maxWidth: '450px' }} style={{ maxWidth: '450px' }}
> >
<Modal.Header>警告</Modal.Header> <Modal.Header>
{t('setting.system.password_login.warning.title')}
</Modal.Header>
<Modal.Content> <Modal.Content>
<p>取消密码登录将导致所有未绑定其他登录方式的用户包括管理员无法通过密码登录确认取消</p> <p>{t('setting.system.password_login.warning.content')}</p>
</Modal.Content> </Modal.Content>
<Modal.Actions> <Modal.Actions>
<Button onClick={() => setShowPasswordWarningModal(false)}>取消</Button> <Button onClick={() => setShowPasswordWarningModal(false)}>
{t('setting.system.password_login.warning.buttons.cancel')}
</Button>
<Button <Button
color='yellow' color='yellow'
onClick={async () => { onClick={async () => {
@@ -303,32 +331,32 @@ const SystemSetting = () => {
await updateOption('PasswordLoginEnabled', 'false'); await updateOption('PasswordLoginEnabled', 'false');
}} }}
> >
确定 {t('setting.system.password_login.warning.buttons.confirm')}
</Button> </Button>
</Modal.Actions> </Modal.Actions>
</Modal> </Modal>
} )}
<Form.Checkbox <Form.Checkbox
checked={inputs.PasswordRegisterEnabled === 'true'} checked={inputs.PasswordRegisterEnabled === 'true'}
label='允许通过密码进行注册' label={t('setting.system.login.password_register')}
name='PasswordRegisterEnabled' name='PasswordRegisterEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
<Form.Checkbox <Form.Checkbox
checked={inputs.EmailVerificationEnabled === 'true'} checked={inputs.EmailVerificationEnabled === 'true'}
label='通过密码注册时需要进行邮箱验证' label={t('setting.system.login.email_verification')}
name='EmailVerificationEnabled' name='EmailVerificationEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
<Form.Checkbox <Form.Checkbox
checked={inputs.GitHubOAuthEnabled === 'true'} checked={inputs.GitHubOAuthEnabled === 'true'}
label='允许通过 GitHub 账户登录 & 注册' label={t('setting.system.login.github_oauth')}
name='GitHubOAuthEnabled' name='GitHubOAuthEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
<Form.Checkbox <Form.Checkbox
checked={inputs.WeChatAuthEnabled === 'true'} checked={inputs.WeChatAuthEnabled === 'true'}
label='允许通过微信登录 & 注册' label={t('setting.system.login.wechat_login')}
name='WeChatAuthEnabled' name='WeChatAuthEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
@@ -336,304 +364,295 @@ const SystemSetting = () => {
<Form.Group inline> <Form.Group inline>
<Form.Checkbox <Form.Checkbox
checked={inputs.RegisterEnabled === 'true'} checked={inputs.RegisterEnabled === 'true'}
label='允许新用户注册(此项为否时,新用户将无法以任何方式进行注册)' label={t('setting.system.login.registration')}
name='RegisterEnabled' name='RegisterEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
<Form.Checkbox <Form.Checkbox
checked={inputs.TurnstileCheckEnabled === 'true'} checked={inputs.TurnstileCheckEnabled === 'true'}
label='启用 Turnstile 用户校验' label={t('setting.system.login.turnstile')}
name='TurnstileCheckEnabled' name='TurnstileCheckEnabled'
onChange={handleInputChange} onChange={handleInputChange}
/> />
</Form.Group> </Form.Group>
<Divider /> <Divider />
<Header as='h3'> <Header as='h3'>{t('setting.system.email_restriction.title')}</Header>
配置邮箱域名白名单 <Message>{t('setting.system.email_restriction.subtitle')}</Message>
<Header.Subheader>用以防止恶意用户利用临时邮箱批量注册</Header.Subheader> <Form.Group inline>
</Header>
<Form.Group widths={3}>
<Form.Checkbox <Form.Checkbox
label='启用邮箱域名白名单' checked={inputs.EmailDomainRestrictionEnabled === 'true'}
label={t('setting.system.email_restriction.enable')}
name='EmailDomainRestrictionEnabled' name='EmailDomainRestrictionEnabled'
onChange={handleInputChange} onChange={handleInputChange}
checked={inputs.EmailDomainRestrictionEnabled === 'true'}
/> />
</Form.Group> </Form.Group>
<Form.Group widths={2}> <Form.Group widths={3}>
<Form.Dropdown
label='允许的邮箱域名'
placeholder='允许的邮箱域名'
name='EmailDomainWhitelist'
required
fluid
multiple
selection
onChange={handleInputChange}
value={inputs.EmailDomainWhitelist}
autoComplete='new-password'
options={EmailDomainWhitelist}
/>
<Form.Input <Form.Input
label='添加新的允许的邮箱域名' label={t('setting.system.email_restriction.add_domain')}
action={ placeholder={t(
<Button type='button' onClick={() => { 'setting.system.email_restriction.add_domain_placeholder'
submitNewRestrictedDomain(); )}
}}>填入</Button>
}
onKeyDown={(e) => {
if (e.key === 'Enter') {
submitNewRestrictedDomain();
}
}}
autoComplete='new-password'
placeholder='输入新的允许的邮箱域名'
value={restrictedDomainInput} value={restrictedDomainInput}
onChange={(e, { value }) => { onChange={(e, { value }) => {
setRestrictedDomainInput(value); setRestrictedDomainInput(value);
}} }}
action={
<Button
onClick={() => {
if (restrictedDomainInput === '') return;
setEmailDomainWhitelist([
...EmailDomainWhitelist,
{
key: restrictedDomainInput,
text: restrictedDomainInput,
value: restrictedDomainInput,
},
]);
setRestrictedDomainInput('');
}}
>
{t('setting.system.email_restriction.buttons.fill')}
</Button>
}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitEmailDomainWhitelist}>保存邮箱域名白名单设置</Form.Button> <Form.Dropdown
label={t('setting.system.email_restriction.allowed_domains')}
placeholder={t('setting.system.email_restriction.allowed_domains')}
fluid
multiple
search
selection
allowAdditions
value={EmailDomainWhitelist.map((item) => item.value)}
options={EmailDomainWhitelist}
onAddItem={(e, { value }) => {
setEmailDomainWhitelist([
...EmailDomainWhitelist,
{
key: value,
text: value,
value: value,
},
]);
}}
onChange={(e, { value }) => {
let newEmailDomainWhitelist = [];
value.forEach((item) => {
newEmailDomainWhitelist.push({
key: item,
text: item,
value: item,
});
});
setEmailDomainWhitelist(newEmailDomainWhitelist);
}}
/>
<Form.Button onClick={submitEmailDomainWhitelist}>
{t('setting.system.email_restriction.buttons.save')}
</Form.Button>
<Divider /> <Divider />
<Header as='h3'> <Header as='h3'>{t('setting.system.smtp.title')}</Header>
配置 SMTP <Message>{t('setting.system.smtp.subtitle')}</Message>
<Header.Subheader>用以支持系统的邮件发送</Header.Subheader>
</Header>
<Form.Group widths={3}> <Form.Group widths={3}>
<Form.Input <Form.Input
label='SMTP 服务器地址' label={t('setting.system.smtp.server')}
placeholder={t('setting.system.smtp.server_placeholder')}
name='SMTPServer' name='SMTPServer'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password'
value={inputs.SMTPServer} value={inputs.SMTPServer}
placeholder='例如smtp.qq.com'
/> />
<Form.Input <Form.Input
label='SMTP 端口' label={t('setting.system.smtp.port')}
placeholder={t('setting.system.smtp.port_placeholder')}
name='SMTPPort' name='SMTPPort'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password'
value={inputs.SMTPPort} value={inputs.SMTPPort}
placeholder='默认: 587'
/> />
<Form.Input <Form.Input
label='SMTP 账户' label={t('setting.system.smtp.account')}
placeholder={t('setting.system.smtp.account_placeholder')}
name='SMTPAccount' name='SMTPAccount'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password'
value={inputs.SMTPAccount} value={inputs.SMTPAccount}
placeholder='通常是邮箱地址'
/> />
</Form.Group> </Form.Group>
<Form.Group widths={3}> <Form.Group widths={3}>
<Form.Input <Form.Input
label='SMTP 发送者邮箱' label={t('setting.system.smtp.from')}
placeholder={t('setting.system.smtp.from_placeholder')}
name='SMTPFrom' name='SMTPFrom'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password'
value={inputs.SMTPFrom} value={inputs.SMTPFrom}
placeholder='通常和邮箱地址保持一致'
/> />
<Form.Input <Form.Input
label='SMTP 访问凭证' label={t('setting.system.smtp.token')}
placeholder={t('setting.system.smtp.token_placeholder')}
name='SMTPToken' name='SMTPToken'
onChange={handleInputChange} onChange={handleInputChange}
type='password' type='password'
autoComplete='new-password' value={inputs.SMTPToken}
checked={inputs.RegisterEnabled === 'true'}
placeholder='敏感信息不会发送到前端显示'
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitSMTP}>保存 SMTP 设置</Form.Button> <Form.Button onClick={submitSMTP}>
{t('setting.system.smtp.buttons.save')}
</Form.Button>
<Divider /> <Divider />
<Header as='h3'> <Header as='h3'>{t('setting.system.github.title')}</Header>
配置 GitHub OAuth App
<Header.Subheader>
用以支持通过 GitHub 进行登录注册
<a href='https://github.com/settings/developers' target='_blank'>
点击此处
</a>
管理你的 GitHub OAuth App
</Header.Subheader>
</Header>
<Message> <Message>
Homepage URL <code>{inputs.ServerAddress}</code> {t('setting.system.github.subtitle')}
Authorization callback URL {' '} <a href='https://github.com/settings/developers' target='_blank'>
<code>{`${inputs.ServerAddress}/oauth/github`}</code> {t('setting.system.github.manage_link')}
</a>
{t('setting.system.github.manage_text')}
</Message>
<Message>
{t('setting.system.github.url_notice', {
server_url: originInputs.ServerAddress,
callback_url: `${originInputs.ServerAddress}/oauth/github`,
})}
</Message> </Message>
<Form.Group widths={3}> <Form.Group widths={3}>
<Form.Input <Form.Input
label='GitHub Client ID' label={t('setting.system.github.client_id')}
placeholder={t('setting.system.github.client_id_placeholder')}
name='GitHubClientId' name='GitHubClientId'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password'
value={inputs.GitHubClientId} value={inputs.GitHubClientId}
placeholder='输入你注册的 GitHub OAuth APP 的 ID'
/> />
<Form.Input <Form.Input
label='GitHub Client Secret' label={t('setting.system.github.client_secret')}
placeholder={t('setting.system.github.client_secret_placeholder')}
name='GitHubClientSecret' name='GitHubClientSecret'
onChange={handleInputChange} onChange={handleInputChange}
type='password' type='password'
autoComplete='new-password'
value={inputs.GitHubClientSecret} value={inputs.GitHubClientSecret}
placeholder='敏感信息不会发送到前端显示'
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitGitHubOAuth}> <Form.Button onClick={submitGitHubOAuth}>
保存 GitHub OAuth 设置 {t('setting.system.github.buttons.save')}
</Form.Button> </Form.Button>
<Divider /> <Divider />
<Header as='h3'> <Header as='h3'>
配置飞书授权登录 {t('setting.system.lark.title')}
<Header.Subheader> <Header.Subheader>
用以支持通过飞书进行登录注册 {t('setting.system.lark.subtitle')}
<a href='https://open.feishu.cn/app' target='_blank'> <a href='https://open.feishu.cn/app' target='_blank'>
点击此处 {t('setting.system.lark.manage_link')}
</a> </a>
管理你的飞书应用 {t('setting.system.lark.manage_text')}
</Header.Subheader> </Header.Subheader>
</Header> </Header>
<Message> <Message>
主页链接填 <code>{inputs.ServerAddress}</code> {t('setting.system.lark.url_notice', {
重定向 URL {' '} server_url: inputs.ServerAddress,
<code>{`${inputs.ServerAddress}/oauth/lark`}</code> callback_url: `${inputs.ServerAddress}/oauth/lark`,
})}
</Message> </Message>
<Form.Group widths={3}> <Form.Group widths={3}>
<Form.Input <Form.Input
label='App ID' label={t('setting.system.lark.client_id')}
name='LarkClientId' name='LarkClientId'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.LarkClientId} value={inputs.LarkClientId}
placeholder='输入 App ID' placeholder={t('setting.system.lark.client_id_placeholder')}
/> />
<Form.Input <Form.Input
label='App Secret' label={t('setting.system.lark.client_secret')}
name='LarkClientSecret' name='LarkClientSecret'
onChange={handleInputChange} onChange={handleInputChange}
type='password' type='password'
autoComplete='new-password' autoComplete='new-password'
value={inputs.LarkClientSecret} value={inputs.LarkClientSecret}
placeholder='敏感信息不会发送到前端显示' placeholder={t('setting.system.lark.client_secret_placeholder')}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitLarkOAuth}> <Form.Button onClick={submitLarkOAuth}>
保存飞书 OAuth 设置 {t('setting.system.lark.buttons.save')}
</Form.Button> </Form.Button>
<Divider /> <Divider />
<Header as='h3'> <Header as='h3'>
配置 WeChat Server {t('setting.system.wechat.title')}
<Header.Subheader> <Header.Subheader>
用以支持通过微信进行登录注册 {t('setting.system.wechat.subtitle')}
<a <a
href='https://github.com/songquanpeng/wechat-server' href='https://github.com/songquanpeng/wechat-server'
target='_blank' target='_blank'
> >
点击此处 {t('setting.system.wechat.learn_more')}
</a> </a>
了解 WeChat Server
</Header.Subheader> </Header.Subheader>
</Header> </Header>
<Form.Group widths={3}> <Form.Group widths={3}>
<Form.Input <Form.Input
label='WeChat Server 服务器地址' label={t('setting.system.wechat.server_address')}
name='WeChatServerAddress' name='WeChatServerAddress'
placeholder='例如https://yourdomain.com'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.WeChatServerAddress} value={inputs.WeChatServerAddress}
placeholder={t(
'setting.system.wechat.server_address_placeholder'
)}
/> />
<Form.Input <Form.Input
label='WeChat Server 访问凭证' label={t('setting.system.wechat.token')}
name='WeChatServerToken' name='WeChatServerToken'
type='password'
onChange={handleInputChange} onChange={handleInputChange}
type='password'
autoComplete='new-password' autoComplete='new-password'
value={inputs.WeChatServerToken} value={inputs.WeChatServerToken}
placeholder='敏感信息不会发送到前端显示' placeholder={t('setting.system.wechat.token_placeholder')}
/> />
<Form.Input <Form.Input
label='微信公众号二维码图片链接' label={t('setting.system.wechat.qrcode')}
name='WeChatAccountQRCodeImageURL' name='WeChatAccountQRCodeImageURL'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.WeChatAccountQRCodeImageURL} value={inputs.WeChatAccountQRCodeImageURL}
placeholder='输入一个图片链接' placeholder={t('setting.system.wechat.qrcode_placeholder')}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitWeChat}> <Form.Button onClick={submitWeChat}>
保存 WeChat Server 设置 {t('setting.system.wechat.buttons.save')}
</Form.Button> </Form.Button>
<Divider /> <Divider />
<Header as='h3'> <Header as='h3'>
配置 Message Pusher {t('setting.system.turnstile.title')}
<Header.Subheader> <Header.Subheader>
用以推送报警信息 {t('setting.system.turnstile.subtitle')}
<a
href='https://github.com/songquanpeng/message-pusher'
target='_blank'
>
点击此处
</a>
了解 Message Pusher
</Header.Subheader>
</Header>
<Form.Group widths={3}>
<Form.Input
label='Message Pusher 推送地址'
name='MessagePusherAddress'
placeholder='例如https://msgpusher.com/push/your_username'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.MessagePusherAddress}
/>
<Form.Input
label='Message Pusher 访问凭证'
name='MessagePusherToken'
type='password'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.MessagePusherToken}
placeholder='敏感信息不会发送到前端显示'
/>
</Form.Group>
<Form.Button onClick={submitMessagePusher}>
保存 Message Pusher 设置
</Form.Button>
<Divider />
<Header as='h3'>
配置 Turnstile
<Header.Subheader>
用以支持用户校验
<a href='https://dash.cloudflare.com/' target='_blank'> <a href='https://dash.cloudflare.com/' target='_blank'>
点击此处 {t('setting.system.turnstile.manage_link')}
</a> </a>
管理你的 Turnstile Sites推荐选择 Invisible Widget Type {t('setting.system.turnstile.manage_text')}
</Header.Subheader> </Header.Subheader>
</Header> </Header>
<Form.Group widths={3}> <Form.Group widths={3}>
<Form.Input <Form.Input
label='Turnstile Site Key' label={t('setting.system.turnstile.site_key')}
name='TurnstileSiteKey' name='TurnstileSiteKey'
onChange={handleInputChange} onChange={handleInputChange}
autoComplete='new-password' autoComplete='new-password'
value={inputs.TurnstileSiteKey} value={inputs.TurnstileSiteKey}
placeholder='输入你注册的 Turnstile Site Key' placeholder={t('setting.system.turnstile.site_key_placeholder')}
/> />
<Form.Input <Form.Input
label='Turnstile Secret Key' label={t('setting.system.turnstile.secret_key')}
name='TurnstileSecretKey' name='TurnstileSecretKey'
onChange={handleInputChange} onChange={handleInputChange}
type='password' type='password'
autoComplete='new-password' autoComplete='new-password'
value={inputs.TurnstileSecretKey} value={inputs.TurnstileSecretKey}
placeholder='敏感信息不会发送到前端显示' placeholder={t('setting.system.turnstile.secret_key_placeholder')}
/> />
</Form.Group> </Form.Group>
<Form.Button onClick={submitTurnstile}> <Form.Button onClick={submitTurnstile}>
保存 Turnstile 设置 {t('setting.system.turnstile.buttons.save')}
</Form.Button> </Form.Button>
</Form> </Form>
</Grid.Column> </Grid.Column>

View File

@@ -1,49 +1,84 @@
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 { useTranslation } from 'react-i18next';
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';
const COPY_OPTIONS = [
{ key: 'next', text: 'ChatGPT Next Web', value: 'next' },
{ key: 'ama', text: 'BotGem', value: 'ama' },
{ key: 'opencat', text: 'OpenCat', value: 'opencat' },
{ key: 'lobechat', text: 'LobeChat', value: 'lobechat' },
];
const OPEN_LINK_OPTIONS = [
{ key: 'next', text: 'ChatGPT Next Web', value: 'next' },
{ key: 'ama', text: 'BotGem', value: 'ama' },
{ key: 'opencat', text: 'OpenCat', value: 'opencat' },
{ key: 'lobechat', text: 'LobeChat', value: 'lobechat' },
];
function renderTimestamp(timestamp) { function renderTimestamp(timestamp) {
return ( return <>{timestamp2string(timestamp)}</>;
<>
{timestamp2string(timestamp)}
</>
);
} }
function renderStatus(status) { function renderStatus(status, t) {
switch (status) { switch (status) {
case 1: case 1:
return <Label basic color='green'>已启用</Label>; return (
<Label basic color='green'>
{t('token.table.status_enabled')}
</Label>
);
case 2: case 2:
return <Label basic color='red'> 已禁用 </Label>; return (
<Label basic color='red'>
{t('token.table.status_disabled')}
</Label>
);
case 3: case 3:
return <Label basic color='yellow'> 已过期 </Label>; return (
<Label basic color='yellow'>
{t('token.table.status_expired')}
</Label>
);
case 4: case 4:
return <Label basic color='grey'> 已耗尽 </Label>; return (
<Label basic color='grey'>
{t('token.table.status_depleted')}
</Label>
);
default: default:
return <Label basic color='black'> 未知状态 </Label>; return (
<Label basic color='black'>
{t('token.table.status_unknown')}
</Label>
);
} }
} }
const TokensTable = () => { const TokensTable = () => {
const { t } = useTranslation();
const COPY_OPTIONS = [
{ key: 'raw', text: t('token.copy_options.raw'), value: '' },
{ key: 'next', text: t('token.copy_options.next'), value: 'next' },
{ key: 'ama', text: t('token.copy_options.ama'), value: 'ama' },
{ key: 'opencat', text: t('token.copy_options.opencat'), value: 'opencat' },
{ key: 'lobe', text: t('token.copy_options.lobe'), value: 'lobechat' },
];
const OPEN_LINK_OPTIONS = [
{ key: 'next', text: t('token.copy_options.next'), value: 'next' },
{ key: 'ama', text: t('token.copy_options.ama'), value: 'ama' },
{ key: 'opencat', text: t('token.copy_options.opencat'), value: 'opencat' },
{ key: 'lobe', text: t('token.copy_options.lobe'), value: 'lobechat' },
];
const [tokens, setTokens] = useState([]); const [tokens, setTokens] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1); const [activePage, setActivePage] = useState(1);
@@ -98,9 +133,10 @@ const TokensTable = () => {
let encodedServerAddress = encodeURIComponent(serverAddress); let encodedServerAddress = encodeURIComponent(serverAddress);
const nextLink = localStorage.getItem('chat_link'); const nextLink = localStorage.getItem('chat_link');
let nextUrl; let nextUrl;
if (nextLink) { if (nextLink) {
nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; nextUrl =
nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
} else { } else {
nextUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; nextUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
} }
@@ -117,15 +153,17 @@ const TokensTable = () => {
url = nextUrl; url = nextUrl;
break; break;
case 'lobechat': case 'lobechat':
url = nextLink + `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`; url =
nextLink +
`/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`;
break; break;
default: default:
url = `sk-${key}`; url = `sk-${key}`;
} }
if (await copy(url)) { if (await copy(url)) {
showSuccess('已复制到剪贴板!'); showSuccess(t('token.messages.copy_success'));
} else { } else {
showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。'); showWarning(t('token.messages.copy_failed'));
setSearchKeyword(url); setSearchKeyword(url);
} }
}; };
@@ -135,7 +173,7 @@ const TokensTable = () => {
let serverAddress = ''; let serverAddress = '';
if (status) { if (status) {
status = JSON.parse(status); status = JSON.parse(status);
serverAddress = status.server_address; serverAddress = status.server_address;
} }
if (serverAddress === '') { if (serverAddress === '') {
serverAddress = window.location.origin; serverAddress = window.location.origin;
@@ -143,9 +181,10 @@ const TokensTable = () => {
let encodedServerAddress = encodeURIComponent(serverAddress); let encodedServerAddress = encodeURIComponent(serverAddress);
const chatLink = localStorage.getItem('chat_link'); const chatLink = localStorage.getItem('chat_link');
let defaultUrl; let defaultUrl;
if (chatLink) { if (chatLink) {
defaultUrl = chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; defaultUrl =
chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
} else { } else {
defaultUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`; defaultUrl = `https://app.nextchat.dev/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
} }
@@ -154,21 +193,23 @@ const TokensTable = () => {
case 'ama': case 'ama':
url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`; url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`;
break; break;
case 'opencat': case 'opencat':
url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`; url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
break; break;
case 'lobechat': case 'lobechat':
url = chatLink + `/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`; url =
chatLink +
`/?settings={"keyVaults":{"openai":{"apiKey":"sk-${key}","baseURL":"${serverAddress}/v1"}}}`;
break; break;
default: default:
url = defaultUrl; url = defaultUrl;
} }
window.open(url, '_blank'); window.open(url, '_blank');
} };
useEffect(() => { useEffect(() => {
loadTokens(0, orderBy) loadTokens(0, orderBy)
@@ -196,7 +237,7 @@ const TokensTable = () => {
} }
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
showSuccess('操作成功完成!'); showSuccess(t('token.messages.operation_success'));
let token = res.data.data; let token = res.data.data;
let newTokens = [...tokens]; let newTokens = [...tokens];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
@@ -267,14 +308,14 @@ const TokensTable = () => {
icon='search' icon='search'
fluid fluid
iconPosition='left' iconPosition='left'
placeholder='搜索令牌的名称 ...' placeholder={t('token.search')}
value={searchKeyword} value={searchKeyword}
loading={searching} loading={searching}
onChange={handleKeywordChange} onChange={handleKeywordChange}
/> />
</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
@@ -283,7 +324,7 @@ const TokensTable = () => {
sortToken('name'); sortToken('name');
}} }}
> >
名称 {t('token.table.name')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -291,7 +332,7 @@ const TokensTable = () => {
sortToken('status'); sortToken('status');
}} }}
> >
状态 {t('token.table.status')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -299,7 +340,7 @@ const TokensTable = () => {
sortToken('used_quota'); sortToken('used_quota');
}} }}
> >
已用额度 {t('token.table.used_quota')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -307,7 +348,7 @@ const TokensTable = () => {
sortToken('remain_quota'); sortToken('remain_quota');
}} }}
> >
剩余额度 {t('token.table.remain_quota')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -315,7 +356,7 @@ const TokensTable = () => {
sortToken('created_time'); sortToken('created_time');
}} }}
> >
创建时间 {t('token.table.created_time')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -323,9 +364,9 @@ const TokensTable = () => {
sortToken('expired_time'); sortToken('expired_time');
}} }}
> >
过期时间 {t('token.table.expired_time')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell> <Table.HeaderCell>{t('token.table.actions')}</Table.HeaderCell>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
@@ -337,65 +378,77 @@ const TokensTable = () => {
) )
.map((token, idx) => { .map((token, idx) => {
if (token.deleted) return <></>; if (token.deleted) return <></>;
const copyOptionsWithHandlers = COPY_OPTIONS.map((option) => ({
...option,
onClick: async () => {
await onCopy(option.value, token.key);
},
}));
const openLinkOptionsWithHandlers = OPEN_LINK_OPTIONS.map(
(option) => ({
...option,
onClick: async () => {
await onOpenLink(option.value, token.key);
},
})
);
return ( return (
<Table.Row key={token.id}> <Table.Row key={token.id}>
<Table.Cell>{token.name ? token.name : '无'}</Table.Cell> <Table.Cell>
<Table.Cell>{renderStatus(token.status)}</Table.Cell> {token.name ? token.name : t('token.table.no_name')}
<Table.Cell>{renderQuota(token.used_quota)}</Table.Cell> </Table.Cell>
<Table.Cell>{token.unlimited_quota ? '无限制' : renderQuota(token.remain_quota, 2)}</Table.Cell> <Table.Cell>{renderStatus(token.status, t)}</Table.Cell>
<Table.Cell>{renderQuota(token.used_quota, t)}</Table.Cell>
<Table.Cell>
{token.unlimited_quota
? t('token.table.unlimited')
: renderQuota(token.remain_quota, t, 2)}
</Table.Cell>
<Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell> <Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell>
<Table.Cell>{token.expired_time === -1 ? '永不过期' : renderTimestamp(token.expired_time)}</Table.Cell> <Table.Cell>
{token.expired_time === -1
? t('token.table.never_expire')
: renderTimestamp(token.expired_time)}
</Table.Cell>
<Table.Cell> <Table.Cell>
<div> <div>
<Button.Group color='green' size={'small'}> <Button.Group color='green' size={'mini'}>
<Button <Button
size={'small'} size={'mini'}
positive positive
onClick={async () => { onClick={async () => await onCopy('', token.key)}
await onCopy('', token.key);
}}
> >
复制 {t('token.buttons.copy')}
</Button> </Button>
<Dropdown <Dropdown
className='button icon' className='button icon'
floating floating
options={COPY_OPTIONS.map(option => ({ options={copyOptionsWithHandlers}
...option,
onClick: async () => {
await onCopy(option.value, token.key);
}
}))}
trigger={<></>} trigger={<></>}
/> />
</Button.Group> </Button.Group>{' '}
{' '} <Button.Group color='blue' size={'mini'}>
<Button.Group color='blue' size={'small'}>
<Button <Button
size={'small'} size={'mini'}
positive positive
onClick={() => { onClick={() => onOpenLink('', token.key)}
onOpenLink('', token.key); >
}}> {t('token.buttons.chat')}
聊天 </Button>
</Button> <Dropdown
<Dropdown className='button icon'
className="button icon" floating
floating options={openLinkOptionsWithHandlers}
options={OPEN_LINK_OPTIONS.map(option => ({ trigger={<></>}
...option, />
onClick: async () => { </Button.Group>{' '}
await onOpenLink(option.value, token.key);
}
}))}
trigger={<></>}
/>
</Button.Group>
{' '}
<Popup <Popup
trigger={ trigger={
<Button size='small' negative> <Button size='mini' negative>
删除 {t('token.buttons.delete')}
</Button> </Button>
} }
on='click' on='click'
@@ -403,16 +456,17 @@ const TokensTable = () => {
hoverable hoverable
> >
<Button <Button
size={'mini'}
negative negative
onClick={() => { onClick={() => {
manageToken(token.id, 'delete', idx); manageToken(token.id, 'delete', idx);
}} }}
> >
删除令牌 {token.name} {t('token.buttons.confirm_delete')} {token.name}
</Button> </Button>
</Popup> </Popup>
<Button <Button
size={'small'} size={'mini'}
onClick={() => { onClick={() => {
manageToken( manageToken(
token.id, token.id,
@@ -421,14 +475,16 @@ const TokensTable = () => {
); );
}} }}
> >
{token.status === 1 ? '禁用' : '启用'} {token.status === 1
? t('token.buttons.disable')
: t('token.buttons.enable')}
</Button> </Button>
<Button <Button
size={'small'} size={'mini'}
as={Link} as={Link}
to={'/token/edit/' + token.id} to={'/token/edit/' + token.id}
> >
编辑 {t('token.buttons.edit')}
</Button> </Button>
</div> </div>
</Table.Cell> </Table.Cell>
@@ -441,16 +497,26 @@ const TokensTable = () => {
<Table.Row> <Table.Row>
<Table.HeaderCell colSpan='7'> <Table.HeaderCell colSpan='7'>
<Button size='small' as={Link} to='/token/add' loading={loading}> <Button size='small' as={Link} to='/token/add' loading={loading}>
添加新的令牌 {t('token.buttons.add')}
</Button>
<Button size='small' onClick={refresh} loading={loading}>
{t('token.buttons.refresh')}
</Button> </Button>
<Button size='small' onClick={refresh} loading={loading}>刷新</Button>
<Dropdown <Dropdown
placeholder='排序方式' placeholder={t('token.sort.placeholder')}
selection selection
options={[ options={[
{ key: '', text: '默认排序', value: '' }, { key: '', text: t('token.sort.default'), value: '' },
{ key: 'remain_quota', text: '按剩余额度排序', value: 'remain_quota' }, {
{ key: 'used_quota', text: '按已用额度排序', value: 'used_quota' }, key: 'remain_quota',
text: t('token.sort.by_remain'),
value: 'remain_quota',
},
{
key: 'used_quota',
text: t('token.sort.by_used'),
value: 'used_quota',
},
]} ]}
value={orderBy} value={orderBy}
onChange={handleOrderByChange} onChange={handleOrderByChange}

View File

@@ -1,25 +1,42 @@
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 { useTranslation } from 'react-i18next';
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, t) {
switch (role) { switch (role) {
case 1: case 1:
return <Label>普通用户</Label>; return <Label>{t('user.table.role_types.normal')}</Label>;
case 10: case 10:
return <Label color='yellow'>管理员</Label>; return <Label color='yellow'>{t('user.table.role_types.admin')}</Label>;
case 100: case 100:
return <Label color='orange'>超级管理员</Label>; return (
<Label color='orange'>{t('user.table.role_types.super_admin')}</Label>
);
default: default:
return <Label color='red'>未知身份</Label>; return <Label color='red'>{t('user.table.role_types.unknown')}</Label>;
} }
} }
const UsersTable = () => { const UsersTable = () => {
const { t } = useTranslation();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1); const [activePage, setActivePage] = useState(1);
@@ -66,11 +83,11 @@ 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) {
showSuccess('操作成功完成!'); showSuccess(t('user.messages.operation_success'));
let user = res.data.data; let user = res.data.data;
let newUsers = [...users]; let newUsers = [...users];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
@@ -90,17 +107,17 @@ const UsersTable = () => {
const renderStatus = (status) => { const renderStatus = (status) => {
switch (status) { switch (status) {
case 1: case 1:
return <Label basic>已激活</Label>; return <Label basic>{t('user.table.status_types.activated')}</Label>;
case 2: case 2:
return ( return (
<Label basic color='red'> <Label basic color='red'>
已封禁 {t('user.table.status_types.banned')}
</Label> </Label>
); );
default: default:
return ( return (
<Label basic color='grey'> <Label basic color='grey'>
未知状态 {t('user.table.status_types.unknown')}
</Label> </Label>
); );
} }
@@ -162,14 +179,14 @@ const UsersTable = () => {
icon='search' icon='search'
fluid fluid
iconPosition='left' iconPosition='left'
placeholder='搜索用户的 ID用户名显示名称以及邮箱地址 ...' placeholder={t('user.search')}
value={searchKeyword} value={searchKeyword}
loading={searching} loading={searching}
onChange={handleKeywordChange} onChange={handleKeywordChange}
/> />
</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
@@ -178,7 +195,7 @@ const UsersTable = () => {
sortUser('id'); sortUser('id');
}} }}
> >
ID {t('user.table.id')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -186,7 +203,7 @@ const UsersTable = () => {
sortUser('username'); sortUser('username');
}} }}
> >
用户名 {t('user.table.username')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -194,7 +211,7 @@ const UsersTable = () => {
sortUser('group'); sortUser('group');
}} }}
> >
分组 {t('user.table.group')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -202,7 +219,7 @@ const UsersTable = () => {
sortUser('quota'); sortUser('quota');
}} }}
> >
统计信息 {t('user.table.quota')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -210,7 +227,7 @@ const UsersTable = () => {
sortUser('role'); sortUser('role');
}} }}
> >
用户角色 {t('user.table.role_text')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell <Table.HeaderCell
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -218,9 +235,9 @@ const UsersTable = () => {
sortUser('status'); sortUser('status');
}} }}
> >
状态 {t('user.table.status_text')}
</Table.HeaderCell> </Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell> <Table.HeaderCell>{t('user.table.actions')}</Table.HeaderCell>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
@@ -239,7 +256,9 @@ const UsersTable = () => {
<Popup <Popup
content={user.email ? user.email : '未绑定邮箱地址'} content={user.email ? user.email : '未绑定邮箱地址'}
key={user.username} key={user.username}
header={user.display_name ? user.display_name : user.username} header={
user.display_name ? user.display_name : user.username
}
trigger={<span>{renderText(user.username, 15)}</span>} trigger={<span>{renderText(user.username, 15)}</span>}
hoverable hoverable
/> />
@@ -249,38 +268,57 @@ const UsersTable = () => {
{/* {user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : '无'}*/} {/* {user.email ? <Popup hoverable content={user.email} trigger={<span>{renderText(user.email, 24)}</span>} /> : '无'}*/}
{/*</Table.Cell>*/} {/*</Table.Cell>*/}
<Table.Cell> <Table.Cell>
<Popup content='剩余额度' trigger={<Label basic>{renderQuota(user.quota)}</Label>} /> <Popup
<Popup content='已用额度' trigger={<Label basic>{renderQuota(user.used_quota)}</Label>} /> content={t('user.table.remaining_quota')}
<Popup content='请求次数' trigger={<Label basic>{renderNumber(user.request_count)}</Label>} /> trigger={
<Label basic>{renderQuota(user.quota, t)}</Label>
}
/>
<Popup
content={t('user.table.used_quota')}
trigger={
<Label basic>{renderQuota(user.used_quota, t)}</Label>
}
/>
<Popup
content={t('user.table.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, t)}</Table.Cell>
<Table.Cell>{renderStatus(user.status)}</Table.Cell> <Table.Cell>{renderStatus(user.status)}</Table.Cell>
<Table.Cell> <Table.Cell>
<div> <div>
<Button <Button
size={'small'} size={'tiny'}
positive positive
onClick={() => { onClick={() => {
manageUser(user.username, 'promote', idx); manageUser(user.username, 'promote', idx);
}} }}
disabled={user.role === 100} disabled={user.role === 100}
> >
提升 {t('user.buttons.promote')}
</Button> </Button>
<Button <Button
size={'small'} size={'tiny'}
color={'yellow'} color={'yellow'}
onClick={() => { onClick={() => {
manageUser(user.username, 'demote', idx); manageUser(user.username, 'demote', idx);
}} }}
disabled={user.role === 100} disabled={user.role === 100}
> >
降级 {t('user.buttons.demote')}
</Button> </Button>
<Popup <Popup
trigger={ trigger={
<Button size='small' negative disabled={user.role === 100}> <Button
删除 size='tiny'
negative
disabled={user.role === 100}
>
{t('user.buttons.delete')}
</Button> </Button>
} }
on='click' on='click'
@@ -289,15 +327,16 @@ const UsersTable = () => {
> >
<Button <Button
negative negative
size={'tiny'}
onClick={() => { onClick={() => {
manageUser(user.username, 'delete', idx); manageUser(user.username, 'delete', idx);
}} }}
> >
删除用户 {user.username} {t('user.buttons.delete_user')} {user.username}
</Button> </Button>
</Popup> </Popup>
<Button <Button
size={'small'} size={'tiny'}
onClick={() => { onClick={() => {
manageUser( manageUser(
user.username, user.username,
@@ -307,14 +346,16 @@ const UsersTable = () => {
}} }}
disabled={user.role === 100} disabled={user.role === 100}
> >
{user.status === 1 ? '禁用' : '启用'} {user.status === 1
? t('user.buttons.disable')
: t('user.buttons.enable')}
</Button> </Button>
<Button <Button
size={'small'} size={'tiny'}
as={Link} as={Link}
to={'/user/edit/' + user.id} to={'/user/edit/' + user.id}
> >
编辑 {t('user.buttons.edit')}
</Button> </Button>
</div> </div>
</Table.Cell> </Table.Cell>
@@ -327,16 +368,28 @@ const UsersTable = () => {
<Table.Row> <Table.Row>
<Table.HeaderCell colSpan='7'> <Table.HeaderCell colSpan='7'>
<Button size='small' as={Link} to='/user/add' loading={loading}> <Button size='small' as={Link} to='/user/add' loading={loading}>
添加新的用户 {t('user.buttons.add')}
</Button> </Button>
<Dropdown <Dropdown
placeholder='排序方式' placeholder={t('user.table.sort_by')}
selection selection
options={[ options={[
{ key: '', text: '默认排序', value: '' }, { key: '', text: t('user.table.sort.default'), value: '' },
{ key: 'quota', text: '按剩余额度排序', value: 'quota' }, {
{ key: 'used_quota', text: '按已用额度排序', value: 'used_quota' }, key: 'quota',
{ key: 'request_count', text: '按请求次数排序', value: 'request_count' }, text: t('user.table.sort.by_quota'),
value: 'quota',
},
{
key: 'used_quota',
text: t('user.table.sort.by_used_quota'),
value: 'used_quota',
},
{
key: 'request_count',
text: t('user.table.sort.by_request_count'),
value: 'request_count',
},
]} ]}
value={orderBy} value={orderBy}
onChange={handleOrderByChange} onChange={handleOrderByChange}

View File

@@ -1,4 +1,5 @@
import { Label } from 'semantic-ui-react'; import { Label } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
export function renderText(text, limit) { export function renderText(text, limit) {
if (text.length > limit) { if (text.length > limit) {
@@ -39,23 +40,33 @@ export function renderNumber(num) {
} }
} }
export function renderQuota(quota, digits = 2) { export function renderQuota(quota, t, precision = 2) {
let quotaPerUnit = localStorage.getItem('quota_per_unit'); const displayInCurrency =
let displayInCurrency = localStorage.getItem('display_in_currency'); localStorage.getItem('display_in_currency') === 'true';
quotaPerUnit = parseFloat(quotaPerUnit); const quotaPerUnit = parseFloat(
displayInCurrency = displayInCurrency === 'true'; localStorage.getItem('quota_per_unit') || '1'
);
if (displayInCurrency) { if (displayInCurrency) {
return '$' + (quota / quotaPerUnit).toFixed(digits); const amount = (quota / quotaPerUnit).toFixed(precision);
return t('common.quota.display_short', { amount });
} }
return renderNumber(quota); return renderNumber(quota);
} }
export function renderQuotaWithPrompt(quota, digits) { export function renderQuotaWithPrompt(quota, t) {
let displayInCurrency = localStorage.getItem('display_in_currency'); const displayInCurrency =
displayInCurrency = displayInCurrency === 'true'; localStorage.getItem('display_in_currency') === 'true';
const quotaPerUnit = parseFloat(
localStorage.getItem('quota_per_unit') || '1'
);
if (displayInCurrency) { if (displayInCurrency) {
return `(等价金额:${renderQuota(quota, digits)}`; const amount = (quota / quotaPerUnit).toFixed(2);
return ` (${t('common.quota.display', { amount })})`;
} }
return ''; return '';
} }

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

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

View File

@@ -33,3 +33,85 @@ code {
display: none !important; display: none !important;
} }
} }
@media screen and (max-width: 768px) {
.ui.container {
width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
padding: 0 10px !important;
}
.ui.card,
.ui.cards,
.ui.segment {
margin-left: 0 !important;
margin-right: 0 !important;
}
.ui.table {
padding-left: 0 !important;
padding-right: 0 !important;
}
}
/* 小屏笔记本 (13-14寸) */
@media screen and (min-width: 769px) and (max-width: 1366px) {
.ui.container {
width: auto !important;
max-width: 100% !important;
margin-left: auto !important;
margin-right: auto !important;
padding: 0 24px !important;
}
/* 调整表格显示 */
.ui.table {
font-size: 0.9em;
}
/* 调整卡片布局 */
.ui.cards {
margin-left: -0.5em !important;
margin-right: -0.5em !important;
}
.ui.cards > .card {
margin: 0.5em !important;
width: calc(50% - 1em) !important;
}
}
/* 大屏幕 */
@media screen and (min-width: 1367px) {
.ui.container {
width: 1200px !important;
margin-left: auto !important;
margin-right: auto !important;
padding: 0 !important;
}
}
/* 优化 Dashboard 网格布局 */
@media screen and (max-width: 1366px) {
.charts-grid {
margin: 0 -0.5em !important;
}
.charts-grid .column {
padding: 0.5em !important;
}
.chart-card {
margin: 0 !important;
}
/* 调整字体大小 */
.ui.header {
font-size: 1.1em !important;
}
.stat-value {
font-size: 0.9em !important;
}
}

View File

@@ -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(

View File

@@ -1,9 +1,11 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Header, Segment } from 'semantic-ui-react'; import { useTranslation } from 'react-i18next';
import { Card } from 'semantic-ui-react';
import { API, showError } from '../../helpers'; import { API, showError } from '../../helpers';
import { marked } from 'marked'; import { marked } from 'marked';
const About = () => { const About = () => {
const { t } = useTranslation();
const [about, setAbout] = useState(''); const [about, setAbout] = useState('');
const [aboutLoaded, setAboutLoaded] = useState(false); const [aboutLoaded, setAboutLoaded] = useState(false);
@@ -20,7 +22,7 @@ const About = () => {
localStorage.setItem('about', aboutContent); localStorage.setItem('about', aboutContent);
} else { } else {
showError(message); showError(message);
setAbout('加载关于内容失败...'); setAbout(t('about.loading_failed'));
} }
setAboutLoaded(true); setAboutLoaded(true);
}; };
@@ -31,28 +33,36 @@ const About = () => {
return ( return (
<> <>
{ {aboutLoaded && about === '' ? (
aboutLoaded && about === '' ? <> <div className='dashboard-container'>
<Segment> <Card fluid className='chart-card'>
<Header as='h3'>关于</Header> <Card.Content>
<p>可在设置页面设置关于内容支持 HTML & Markdown</p> <Card.Header className='header'>{t('about.title')}</Card.Header>
项目仓库地址 <p>{t('about.description')}</p>
<a href='https://github.com/songquanpeng/one-api'> {t('about.repository')}
https://github.com/songquanpeng/one-api <a href='https://github.com/songquanpeng/one-api'>
</a> https://github.com/songquanpeng/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;

View File

@@ -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 ? '按照如下格式输入APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')
switch (type) { switch (type) {
case 15: case 15:
return '按照如下格式输入APIKey|SecretKey'; return t('channel.edit.key_prompts.zhipu');
case 18: case 18:
return '按照如下格式输入APPID|APISecret|APIKey'; return t('channel.edit.key_prompts.spark');
case 22: case 22:
return '按照如下格式输入APIKey-AppId例如fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041'; return t('channel.edit.key_prompts.fastgpt');
case 23: case 23:
return '按照如下格式输入AppId|SecretId|SecretKey'; return t('channel.edit.key_prompts.tencent');
default: default:
return '请输入渠道对应的鉴权密钥'; 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('请填写渠道名称和渠道密钥!'); 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('请至少选择一个模型!'); 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('模型映射必须是合法的 JSON 格式!'); 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('渠道更新成功!'); showSuccess(t('channel.edit.messages.update_success'));
} else { } else {
showSuccess('渠道创建成功!'); 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,34 +260,74 @@ const EditChannel = () => {
}; };
return ( return (
<> <div className='dashboard-container'>
<Segment loading={loading}> <Card fluid className='chart-card'>
<Header as='h3'>{isEdit ? '更新渠道信息' : '创建新的渠道'}</Header> <Card.Content>
<Form autoComplete='new-password'> <Card.Header className='header'>
<Form.Field> {isEdit
<Form.Select ? t('channel.edit.title_edit')
label='类型' : 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}
/>
</Form.Field>
<Form.Field>
<Form.Input
label={t('channel.edit.name')}
name='name'
placeholder={t('channel.edit.name_placeholder')}
onChange={handleInputChange}
value={inputs.name}
required
/>
</Form.Field>
<Form.Field>
<Form.Dropdown
label={t('channel.edit.group')}
placeholder={t('channel.edit.group_placeholder')}
name='groups'
required
fluid
multiple
selection
allowAdditions
additionLabel={t('channel.edit.group_addition')}
onChange={handleInputChange}
value={inputs.groups}
autoComplete='new-password'
options={groupOptions}
/>
</Form.Field>
{/* Azure OpenAI specific fields */}
{inputs.type === 3 && (
<> <>
<Message> <Message>
注意<strong>模型部署名称必须和模型名称保持一致</strong> One API model 注意<strong>模型部署名称必须和模型名称保持一致</strong>
参数替换为你的部署名称模型名称中的点会被剔除<a target='_blank' 因为 One API 会把请求体中的 model
href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'>图片演示</a> 参数替换为你的部署名称模型名称中的点会被剔除
<a
target='_blank'
href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'
>
图片演示
</a>
</Message> </Message>
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='AZURE_OPENAI_ENDPOINT' label='AZURE_OPENAI_ENDPOINT'
name='base_url' name='base_url'
placeholder={'请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com'} placeholder='请输入 AZURE_OPENAI_ENDPOINT例如https://docs-test-001.openai.azure.com'
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.base_url} value={inputs.base_url}
autoComplete='new-password' autoComplete='new-password'
@@ -264,119 +337,85 @@ const EditChannel = () => {
<Form.Input <Form.Input
label='默认 API 版本' label='默认 API 版本'
name='other' name='other'
placeholder={'请输入默认 API 版本例如2024-03-01-preview该配置可以被实际的请求查询参数所覆盖'} placeholder='请输入默认 API 版本例如2024-03-01-preview该配置可以被实际的请求查询参数所覆盖'
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.other} value={inputs.other}
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
</> </>
) )}
}
{ {/* Custom base URL field */}
inputs.type === 8 && ( {inputs.type === 8 && (
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='Base URL' label={t('channel.edit.base_url')}
name='base_url' name='base_url'
placeholder={'请输入自定义渠道的 Base URL例如https://openai.justsong.cn'} placeholder={t('channel.edit.base_url_placeholder')}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.base_url} value={inputs.base_url}
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
) )}
}
<Form.Field> {inputs.type === 18 && (
<Form.Input
label='名称'
required
name='name'
placeholder={'请为渠道命名'}
onChange={handleInputChange}
value={inputs.name}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.Dropdown
label='分组'
placeholder={'请选择可以使用该渠道的分组'}
name='groups'
required
fluid
multiple
selection
allowAdditions
additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
onChange={handleInputChange}
value={inputs.groups}
autoComplete='new-password'
options={groupOptions}
/>
</Form.Field>
{
inputs.type === 18 && (
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='模型版本' label={t('channel.edit.spark_version')}
name='other' name='other'
placeholder={'请输入星火大模型版本注意是接口地址中的版本号例如v2.1'} 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 === 21 && (
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='知识库 ID' label={t('channel.edit.knowledge_id')}
name='other' name='other'
placeholder={'请输入知识库 ID例如123456'} 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 && (
{
inputs.type === 17 && (
<Form.Field> <Form.Field>
<Form.Input <Form.Input
label='插件参数' label={t('channel.edit.plugin_param')}
name='other' name='other'
placeholder={'请输入插件参数,即 X-DashScope-Plugin 请求头的取值'} placeholder={t('channel.edit.plugin_param_placeholder')}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.other} value={inputs.other}
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
) )}
} {inputs.type === 34 && (
{ <Message>{t('channel.edit.coze_notice')}</Message>
inputs.type === 34 && ( )}
{inputs.type === 40 && (
<Message> <Message>
对于 Coze 而言模型名称即 Bot ID你可以添加一个前缀 `bot-`例如`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>
对于豆包而言需要手动去 <a target="_blank" href="https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint">模型推理页面</a> `ep-20240608051426-tkxvl`
</Message>
)
}
{
inputs.type !== 43 && (
<Form.Field> <Form.Field>
<Form.Dropdown <Form.Dropdown
label='模型' label={t('channel.edit.models')}
placeholder={'请选择该渠道所支持的模型'} placeholder={t('channel.edit.models_placeholder')}
name='models' name='models'
required required
fluid fluid
@@ -392,25 +431,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'}
}}>填入相关模型</Button> onClick={() => {
<Button type={'button'} onClick={() => { handleInputChange(null, {
handleInputChange(null, { name: 'models', value: fullModels }); name: 'models',
}}>填入所有模型</Button> value: basicModels,
<Button type={'button'} onClick={() => { });
handleInputChange(null, { name: 'models', value: [] }); }}
}}>清除所有模型</Button> >
{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}>填入</Button> <Button type={'button'} onClick={addCustomModel}>
{t('channel.edit.buttons.add_custom')}
</Button>
} }
placeholder='输入自定义模型名称' placeholder={t('channel.edit.buttons.custom_placeholder')}
value={customModel} value={customModel}
onChange={(e, { value }) => { onChange={(e, { value }) => {
setCustomModel(value); setCustomModel(value);
@@ -423,43 +483,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='模型重定向' placeholder={`${t(
placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:\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='系统提示词' />
placeholder={`此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型`} </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={'regione.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 +533,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 +542,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={'生成该密钥的用户 ID'} 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='密钥'
name='key'
required
placeholder={'请输入密钥,一行一个'}
onChange={handleInputChange}
value={inputs.key}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
/> />
</Form.Field> : <Form.Field> )}
<Form.Input {inputs.type !== 33 &&
label='密钥' inputs.type !== 42 &&
name='key' (batch ? (
required <Form.Field>
placeholder={type2secretPrompt(inputs.type)} <Form.TextArea
onChange={handleInputChange} label={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={'请输入 Account ID例如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='批量创建' 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='代理' <Form.Input
name='base_url' label={t('channel.edit.base_url')}
placeholder={'此项可选,用于通过代理站来进行 API 调用请输入代理站地址格式为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='私有部署地址' label='私有部署地址'
name='base_url' name='base_url'
placeholder={'请输入私有部署地址格式为https://fastgpt.run/api/openapi'} placeholder={
'请输入私有部署地址格式为https://fastgpt.run/api/openapi'
}
onChange={handleInputChange} onChange={handleInputChange}
value={inputs.base_url} value={inputs.base_url}
autoComplete='new-password' autoComplete='new-password'
/> />
</Form.Field> </Form.Field>
) )}
} <Button onClick={handleCancel}>
<Button onClick={handleCancel}>取消</Button> {t('channel.edit.buttons.cancel')}
<Button type={isEdit ? 'button' : 'submit'} positive onClick={submit}>提交</Button> </Button>
</Form> <Button
</Segment> type={isEdit ? 'button' : 'submit'}
</> positive
onClick={submit}
>
{t('channel.edit.buttons.submit')}
</Button>
</Form>
</Card.Content>
</Card>
</div>
); );
}; };

View File

@@ -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'>管理渠道</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;

View File

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

View File

@@ -0,0 +1,454 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, Grid, Header, Segment, Statistic } from 'semantic-ui-react';
import { API, showError } from '../../helpers';
import moment from 'moment';
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 { t } = useTranslation();
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 maxDate = new Date(); // 总是使用今天作为最后一天
let minDate =
dates.length > 0
? new Date(Math.min(...dates.map((d) => new Date(d))))
: new Date();
// 确保至少显示5天的数据
const fiveDaysAgo = new Date();
fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 4); // -4是因为包含今天
if (minDate > fiveDaysAgo) {
minDate = fiveDaysAgo;
}
// 生成所有日期
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 maxDate = new Date(); // 总是使用今天作为最后一天
let minDate =
dates.length > 0
? new Date(Math.min(...dates.map((d) => new Date(d))))
: new Date();
// 确保至少显示5天的数据
const fiveDaysAgo = new Date();
fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 4); // -4是因为包含今天
if (minDate > fiveDaysAgo) {
minDate = fiveDaysAgo;
}
// 生成所有日期
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];
};
// 添加一个日期格式化函数
const formatDate = (dateStr) => {
const date = new Date(dateStr);
return date.toLocaleDateString('zh-CN', {
month: 'numeric',
day: 'numeric',
});
};
// 修改所有 XAxis 配置
const xAxisConfig = {
dataKey: 'date',
axisLine: false,
tickLine: false,
tick: {
fontSize: 12,
fill: '#A3AED0',
textAnchor: 'middle', // 文本居中对齐
},
tickFormatter: formatDate,
interval: 0,
minTickGap: 5,
padding: { left: 30, right: 30 }, // 增加两侧的内边距,确保首尾标签完整显示
};
return (
<div className='dashboard-container'>
{/* 三个并排的折线图 */}
<Grid columns={3} stackable className='charts-grid'>
<Grid.Column>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
{t('dashboard.charts.requests.title')}
<span className='stat-value'>{summaryData.todayRequests}</span>
</Card.Header>
<div className='chart-container'>
<ResponsiveContainer
width='100%'
height={120}
margin={{ left: 10, right: 10 }} // 调整容器边距
>
<LineChart data={timeSeriesData}>
<CartesianGrid
strokeDasharray='3 3'
vertical={chartConfig.lineChart.grid.vertical}
horizontal={chartConfig.lineChart.grid.horizontal}
opacity={chartConfig.lineChart.grid.opacity}
/>
<XAxis {...xAxisConfig} />
<YAxis hide={true} />
<Tooltip
contentStyle={{
background: '#fff',
border: 'none',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
formatter={(value) => [
value,
t('dashboard.charts.requests.tooltip'),
]}
labelFormatter={(label) =>
`${t('dashboard.tooltip.date')}: ${formatDate(label)}`
}
/>
<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>
{t('dashboard.charts.quota.title')}
<span className='stat-value'>
${summaryData.todayQuota.toFixed(3)}
</span>
</Card.Header>
<div className='chart-container'>
<ResponsiveContainer
width='100%'
height={120}
margin={{ left: 10, right: 10 }} // 调整容器边距
>
<LineChart data={timeSeriesData}>
<CartesianGrid
strokeDasharray='3 3'
vertical={chartConfig.lineChart.grid.vertical}
horizontal={chartConfig.lineChart.grid.horizontal}
opacity={chartConfig.lineChart.grid.opacity}
/>
<XAxis {...xAxisConfig} />
<YAxis hide={true} />
<Tooltip
contentStyle={{
background: '#fff',
border: 'none',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
formatter={(value) => [
value,
t('dashboard.charts.quota.tooltip'),
]}
labelFormatter={(label) =>
`${t('dashboard.tooltip.date')}: ${formatDate(label)}`
}
/>
<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>
{t('dashboard.charts.tokens.title')}
<span className='stat-value'>{summaryData.todayTokens}</span>
</Card.Header>
<div className='chart-container'>
<ResponsiveContainer
width='100%'
height={120}
margin={{ left: 10, right: 10 }} // 调整容器边距
>
<LineChart data={timeSeriesData}>
<CartesianGrid
strokeDasharray='3 3'
vertical={chartConfig.lineChart.grid.vertical}
horizontal={chartConfig.lineChart.grid.horizontal}
opacity={chartConfig.lineChart.grid.opacity}
/>
<XAxis {...xAxisConfig} />
<YAxis hide={true} />
<Tooltip
contentStyle={{
background: '#fff',
border: 'none',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
formatter={(value) => [
value,
t('dashboard.charts.tokens.tooltip'),
]}
labelFormatter={(label) =>
`${t('dashboard.tooltip.date')}: ${formatDate(label)}`
}
/>
<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>{t('dashboard.statistics.title')}</Card.Header>
<div className='chart-container'>
<ResponsiveContainer width='100%' height={300}>
<BarChart data={modelData}>
<CartesianGrid
strokeDasharray='3 3'
vertical={false}
opacity={0.1}
/>
<XAxis {...xAxisConfig} />
<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)',
}}
labelFormatter={(label) =>
`${t('dashboard.tooltip.date')}: ${formatDate(label)}`
}
/>
<Legend
wrapperStyle={{
paddingTop: '20px',
}}
/>
{models.map((model, index) => (
<Bar
key={model}
dataKey={model}
stackId='a'
fill={getRandomColor(index)}
name={model}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</Card.Content>
</Card>
</div>
);
};
export default Dashboard;

View File

@@ -1,24 +1,29 @@
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { Card, Grid, Header, Segment } from 'semantic-ui-react'; import { useTranslation } from 'react-i18next';
import { Card, Grid, Header } 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 { t } = useTranslation();
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);
} }
@@ -37,7 +42,7 @@ const Home = () => {
localStorage.setItem('home_page_content', content); localStorage.setItem('home_page_content', content);
} else { } else {
showError(message); showError(message);
setHomePageContent('加载首页内容失败...'); setHomePageContent(t('home.loading_failed'));
} }
setHomePageContentLoaded(true); setHomePageContentLoaded(true);
}; };
@@ -51,81 +56,242 @@ 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'>系统状况</Header> <Card.Content>
<Grid columns={2} stackable> <Card.Header className='header'>
<Grid.Column> {t('home.welcome.title')}
<Card fluid> </Card.Header>
<Card.Content> <Card.Description style={{ lineHeight: '1.6' }}>
<Card.Header>系统信息</Card.Header> <p>{t('home.welcome.description')}</p>
<Card.Meta>系统信息总览</Card.Meta> {!userState.user && <p>{t('home.welcome.login_notice')}</p>}
<Card.Description> </Card.Description>
<p>名称{statusState?.status?.system_name}</p> </Card.Content>
<p>版本{statusState?.status?.version ? statusState?.status?.version : "unknown"}</p> </Card>
<p> <Card fluid className='chart-card'>
源码 <Card.Content>
<a <Card.Header>
href='https://github.com/songquanpeng/one-api' <Header as='h3'>{t('home.system_status.title')}</Header>
target='_blank' </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' }}>
{t('home.system_status.info.title')}
</Header>
</Card.Header>
<Card.Description
style={{ lineHeight: '2', marginTop: '1em' }}
>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
> >
https://github.com/songquanpeng/one-api <i className='info circle icon'></i>
</a> <span style={{ fontWeight: 'bold' }}>
</p> {t('home.system_status.info.name')}
<p>启动时间{getStartTimeString()}</p> </span>
</Card.Description> <span>{statusState?.status?.system_name}</span>
</Card.Content> </p>
</Card> <p
</Grid.Column> style={{
<Grid.Column> display: 'flex',
<Card fluid> alignItems: 'center',
<Card.Content> gap: '0.5em',
<Card.Header>系统配置</Card.Header> }}
<Card.Meta>系统配置总览</Card.Meta> >
<Card.Description> <i className='code branch icon'></i>
<p> <span style={{ fontWeight: 'bold' }}>
邮箱验证 {t('home.system_status.info.version')}
{statusState?.status?.email_verification === true </span>
? '已启用' <span>
: '未启用'} {statusState?.status?.version || 'unknown'}
</p> </span>
<p> </p>
GitHub 身份验证 <p
{statusState?.status?.github_oauth === true style={{
? '已启用' display: 'flex',
: '未启用'} alignItems: 'center',
</p> gap: '0.5em',
<p> }}
微信身份验证 >
{statusState?.status?.wechat_login === true <i className='github icon'></i>
? '已启用' <span style={{ fontWeight: 'bold' }}>
: '未启用'} {t('home.system_status.info.source')}
</p> </span>
<p> <a
Turnstile 用户校验 href='https://github.com/songquanpeng/one-api'
{statusState?.status?.turnstile_check === true target='_blank'
? '已启用' style={{ color: '#2185d0' }}
: '未启用'} >
</p> {t('home.system_status.info.source_link')}
</Card.Description> </a>
</Card.Content> </p>
</Card> <p
</Grid.Column> style={{
</Grid> display: 'flex',
</Segment> alignItems: 'center',
</> : <> gap: '0.5em',
{ }}
homePageContent.startsWith('https://') ? <iframe >
<i className='clock outline icon'></i>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.info.start_time')}
</span>
<span>{getStartTimeString()}</span>
</p>
</Card.Description>
</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' }}>
{t('home.system_status.config.title')}
</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' }}>
{t('home.system_status.config.email_verify')}
</span>
<span
style={{
color: statusState?.status?.email_verification
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.email_verification
? t('home.system_status.config.enabled')
: t('home.system_status.config.disabled')}
</span>
</p>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='github icon'></i>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.config.github_oauth')}
</span>
<span
style={{
color: statusState?.status?.github_oauth
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.github_oauth
? t('home.system_status.config.enabled')
: t('home.system_status.config.disabled')}
</span>
</p>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='wechat icon'></i>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.config.wechat_login')}
</span>
<span
style={{
color: statusState?.status?.wechat_login
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.wechat_login
? t('home.system_status.config.enabled')
: t('home.system_status.config.disabled')}
</span>
</p>
<p
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5em',
}}
>
<i className='shield alternate icon'></i>
<span style={{ fontWeight: 'bold' }}>
{t('home.system_status.config.turnstile')}
</span>
<span
style={{
color: statusState?.status?.turnstile_check
? '#21ba45'
: '#db2828',
fontWeight: '500',
}}
>
{statusState?.status?.turnstile_check
? t('home.system_status.config.enabled')
: t('home.system_status.config.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>
)}
</> </>
} )}
</> </>
); );
}; };

View File

@@ -1,11 +1,21 @@
import React from 'react'; import React from 'react';
import { Header, Segment } from 'semantic-ui-react'; import { Card } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import LogsTable from '../../components/LogsTable'; import LogsTable from '../../components/LogsTable';
const Token = () => ( const Log = () => {
<> const { t } = useTranslation();
<LogsTable />
</> return (
); <div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>{t('log.title')}</Card.Header>
<LogsTable />
</Card.Content>
</Card>
</div>
);
};
export default Token; export default Log;

View File

@@ -1,10 +1,12 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Segment } from 'semantic-ui-react'; import { useTranslation } from 'react-i18next';
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';
const EditRedemption = () => { const EditRedemption = () => {
const { t } = useTranslation();
const params = useParams(); const params = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const redemptionId = params.id; const redemptionId = params.id;
@@ -13,7 +15,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 +23,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,79 +51,90 @@ 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;
if (success) { if (success) {
if (isEdit) { if (isEdit) {
showSuccess('兑换码更新成功!'); showSuccess(t('redemption.messages.update_success'));
} else { } else {
showSuccess('兑换码创建成功!'); showSuccess(t('redemption.messages.create_success'));
setInputs(originInputs); setInputs(originInputs);
} }
} else { } else {
showError(message); showError(message);
} }
if (!isEdit && data) { if (!isEdit && data) {
let text = ""; let text = '';
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
text += data[i] + "\n"; text += data[i] + '\n';
} }
downloadTextAsFile(text, `${inputs.name}.txt`); downloadTextAsFile(text, `${inputs.name}.txt`);
} }
}; };
return ( return (
<> <div className='dashboard-container'>
<Segment loading={loading}> <Card fluid className='chart-card'>
<Header as='h3'>{isEdit ? '更新兑换码信息' : '创建新的兑换码'}</Header> <Card.Content>
<Form autoComplete='new-password'> <Card.Header className='header'>
<Form.Field> {isEdit ? t('redemption.edit.title_edit') : t('redemption.edit.title_create')}
<Form.Input </Card.Header>
label='名称' <Form loading={loading} autoComplete='new-password'>
name='name' <Form.Field>
placeholder={'请输入名称'} <Form.Input
onChange={handleInputChange} label={t('redemption.edit.name')}
value={name} name='name'
autoComplete='new-password' placeholder={t('redemption.edit.name_placeholder')}
required={!isEdit} onChange={handleInputChange}
/> value={name}
</Form.Field> autoComplete='new-password'
<Form.Field> required={!isEdit}
<Form.Input />
label={`额度${renderQuotaWithPrompt(quota)}`} </Form.Field>
name='quota' <Form.Field>
placeholder={'请输入单个兑换码中包含的额度'} <Form.Input
onChange={handleInputChange} label={`${t('redemption.edit.quota')}${renderQuotaWithPrompt(quota, t)}`}
value={quota} name='quota'
autoComplete='new-password' placeholder={t('redemption.edit.quota_placeholder')}
type='number' onChange={handleInputChange}
/> value={quota}
</Form.Field> autoComplete='new-password'
{ type='number'
!isEdit && <> />
<Form.Field> </Form.Field>
<Form.Input {!isEdit && (
label='生成数量' <>
name='count' <Form.Field>
placeholder={'请输入生成数量'} <Form.Input
onChange={handleInputChange} label={t('redemption.edit.count')}
value={count} name='count'
autoComplete='new-password' placeholder={t('redemption.edit.count_placeholder')}
type='number' onChange={handleInputChange}
/> value={count}
</Form.Field> autoComplete='new-password'
</> type='number'
} />
<Button positive onClick={submit}>提交</Button> </Form.Field>
<Button onClick={handleCancel}>取消</Button> </>
</Form> )}
</Segment> <Button positive onClick={submit}>
</> {t('redemption.edit.buttons.submit')}
</Button>
<Button onClick={handleCancel}>
{t('redemption.edit.buttons.cancel')}
</Button>
</Form>
</Card.Content>
</Card>
</div>
); );
}; };

View File

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

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { Segment, Tab } from 'semantic-ui-react'; import { useTranslation } from 'react-i18next';
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';
@@ -7,48 +8,62 @@ import PersonalSetting from '../../components/PersonalSetting';
import OperationSetting from '../../components/OperationSetting'; import OperationSetting from '../../components/OperationSetting';
const Setting = () => { const Setting = () => {
const { t } = useTranslation();
let panes = [ let panes = [
{ {
menuItem: '个人设置', menuItem: t('setting.tabs.personal'),
render: () => ( render: () => (
<Tab.Pane attached={false}> <Tab.Pane attached={false}>
<PersonalSetting /> <PersonalSetting />
</Tab.Pane> </Tab.Pane>
) ),
} },
]; ];
if (isRoot()) { if (isRoot()) {
panes.push({ panes.push({
menuItem: '运营设置', menuItem: t('setting.tabs.operation'),
render: () => ( render: () => (
<Tab.Pane attached={false}> <Tab.Pane attached={false}>
<OperationSetting /> <OperationSetting />
</Tab.Pane> </Tab.Pane>
) ),
}); });
panes.push({ panes.push({
menuItem: '系统设置', menuItem: t('setting.tabs.system'),
render: () => ( render: () => (
<Tab.Pane attached={false}> <Tab.Pane attached={false}>
<SystemSetting /> <SystemSetting />
</Tab.Pane> </Tab.Pane>
) ),
}); });
panes.push({ panes.push({
menuItem: '其他设置', menuItem: t('setting.tabs.other'),
render: () => ( render: () => (
<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'>{t('setting.title')}</Card.Header>
<Tab
menu={{
secondary: true,
pointing: true,
className: 'settings-tab',
}}
panes={panes}
/>
</Card.Content>
</Card>
</div>
); );
}; };

View File

@@ -1,10 +1,25 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Message, Segment } from 'semantic-ui-react'; import { useTranslation } from 'react-i18next';
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 = () => {
const { t } = useTranslation();
const params = useParams(); const params = useParams();
const tokenId = params.id; const tokenId = params.id;
const isEdit = tokenId !== undefined; const isEdit = tokenId !== undefined;
@@ -16,7 +31,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;
@@ -47,47 +62,61 @@ const EditToken = () => {
}; };
const loadToken = async () => { const loadToken = async () => {
let res = await API.get(`/api/token/${tokenId}`); try {
const { success, message, data } = res.data; let res = await API.get(`/api/token/${tokenId}`);
if (success) { const { success, message, data } = res.data || {};
if (data.expired_time !== -1) { if (success && data) {
data.expired_time = timestamp2string(data.expired_time); if (data.expired_time !== -1) {
} data.expired_time = timestamp2string(data.expired_time);
if (data.models === '') { }
data.models = []; if (data.models === '') {
data.models = [];
} else {
data.models = data.models.split(',');
}
setInputs(data);
} else { } else {
data.models = data.models.split(','); showError(message || 'Failed to load token');
} }
setInputs(data); } catch (error) {
} else { showError(error.message || 'Network error');
showError(message);
} }
setLoading(false); setLoading(false);
}; };
useEffect(() => {
if (isEdit) {
loadToken().then();
}
loadAvailableModels().then();
}, []);
const loadAvailableModels = async () => { const loadAvailableModels = async () => {
let res = await API.get(`/api/user/available_models`); try {
const { success, message, data } = res.data; let res = await API.get(`/api/user/available_models`);
if (success) { const { success, message, data } = res.data || {};
let options = data.map((model) => { if (success && data) {
return { let options = data.map((model) => {
key: model, return {
text: model, key: model,
value: model text: model,
}; value: model,
}); };
setModelOptions(options); });
} else { setModelOptions(options);
showError(message); } else {
showError(message || 'Failed to load models');
}
} catch (error) {
showError(error.message || 'Network error');
} }
}; };
useEffect(() => {
if (isEdit) {
loadToken().catch((error) => {
showError(error.message || 'Failed to load token');
setLoading(false);
});
}
loadAvailableModels().catch((error) => {
showError(error.message || 'Failed to load models');
});
}, []);
const submit = async () => { const submit = async () => {
if (!isEdit && inputs.name === '') return; if (!isEdit && inputs.name === '') return;
let localInputs = inputs; let localInputs = inputs;
@@ -95,7 +124,7 @@ const EditToken = () => {
if (localInputs.expired_time !== -1) { if (localInputs.expired_time !== -1) {
let time = Date.parse(localInputs.expired_time); let time = Date.parse(localInputs.expired_time);
if (isNaN(time)) { if (isNaN(time)) {
showError('过期时间格式错误!'); showError(t('token.edit.messages.expire_time_invalid'));
return; return;
} }
localInputs.expired_time = Math.ceil(time / 1000); localInputs.expired_time = Math.ceil(time / 1000);
@@ -103,16 +132,19 @@ 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);
} }
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
if (isEdit) { if (isEdit) {
showSuccess('令牌更新成功!'); showSuccess(t('token.edit.messages.update_success'));
} else { } else {
showSuccess('令牌创建成功,请在列表页面点击复制获取令牌!'); showSuccess(t('token.edit.messages.create_success'));
setInputs(originInputs); setInputs(originInputs);
} }
} else { } else {
@@ -121,98 +153,141 @@ const EditToken = () => {
}; };
return ( return (
<> <div className='dashboard-container'>
<Segment loading={loading}> <Card fluid className='chart-card'>
<Header as='h3'>{isEdit ? '更新令牌信息' : '创建新的令牌'}</Header> <Card.Content>
<Form autoComplete='new-password'> <Card.Header className='header'>
<Form.Field> {isEdit ? t('token.edit.title_edit') : t('token.edit.title_create')}
<Form.Input </Card.Header>
label='名称' <Form loading={loading} autoComplete='new-password'>
name='name' <Form.Field>
placeholder={'请输入名称'} <Form.Input
onChange={handleInputChange} label={t('token.edit.name')}
value={name} name='name'
autoComplete='new-password' placeholder={t('token.edit.name_placeholder')}
required={!isEdit} onChange={handleInputChange}
/> value={name}
</Form.Field> autoComplete='new-password'
<Form.Field> required={!isEdit}
<Form.Dropdown />
label='模型范围' </Form.Field>
placeholder={'请选择允许使用的模型,留空则不进行限制'} <Form.Field>
name='models' <Form.Dropdown
fluid label={t('token.edit.models')}
multiple placeholder={t('token.edit.models_placeholder')}
search name='models'
onLabelClick={(e, { value }) => { fluid
copy(value).then(); multiple
search
onLabelClick={(e, { value }) => {
copy(value).then();
}}
selection
onChange={handleInputChange}
value={inputs.models}
autoComplete='new-password'
options={modelOptions}
/>
</Form.Field>
<Form.Field>
<Form.Input
label={t('token.edit.ip_limit')}
name='subnet'
placeholder={t('token.edit.ip_limit_placeholder')}
onChange={handleInputChange}
value={inputs.subnet}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.Input
label={t('token.edit.expire_time')}
name='expired_time'
placeholder={t('token.edit.expire_time_placeholder')}
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);
}}
>
{t('token.edit.buttons.never_expire')}
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(1, 0, 0, 0);
}}
>
{t('token.edit.buttons.expire_1_month')}
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 1, 0, 0);
}}
>
{t('token.edit.buttons.expire_1_day')}
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 0, 1, 0);
}}
>
{t('token.edit.buttons.expire_1_hour')}
</Button>
<Button
type={'button'}
onClick={() => {
setExpiredTime(0, 0, 0, 1);
}}
>
{t('token.edit.buttons.expire_1_minute')}
</Button>
</div>
<Message>{t('token.edit.quota_notice')}</Message>
<Form.Field>
<Form.Input
label={`${t('token.edit.quota')}${renderQuotaWithPrompt(
remain_quota,
t
)}`}
name='remain_quota'
placeholder={t('token.edit.quota_placeholder')}
onChange={handleInputChange}
value={remain_quota}
autoComplete='new-password'
type='number'
disabled={unlimited_quota}
/>
</Form.Field>
<Button
type={'button'}
onClick={() => {
setUnlimitedQuota();
}} }}
selection >
onChange={handleInputChange} {unlimited_quota
value={inputs.models} ? t('token.edit.buttons.cancel_unlimited')
autoComplete='new-password' : t('token.edit.buttons.unlimited_quota')}
options={modelOptions} </Button>
/> <Button floated='right' positive onClick={submit}>
</Form.Field> {t('token.edit.buttons.submit')}
<Form.Field> </Button>
<Form.Input <Button floated='right' onClick={handleCancel}>
label='IP 限制' {t('token.edit.buttons.cancel')}
name='subnet' </Button>
placeholder={'请输入允许访问的网段例如192.168.0.0/24请使用英文逗号分隔多个网段'} </Form>
onChange={handleInputChange} </Card.Content>
value={inputs.subnet} </Card>
autoComplete='new-password' </div>
/>
</Form.Field>
<Form.Field>
<Form.Input
label='过期时间'
name='expired_time'
placeholder={'请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss-1 表示无限制'}
onChange={handleInputChange}
value={expired_time}
autoComplete='new-password'
type='datetime-local'
/>
</Form.Field>
<div style={{ lineHeight: '40px' }}>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 0, 0, 0);
}}>永不过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(1, 0, 0, 0);
}}>一个月后过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 1, 0, 0);
}}>一天后过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 0, 1, 0);
}}>一小时后过期</Button>
<Button type={'button'} onClick={() => {
setExpiredTime(0, 0, 0, 1);
}}>一分钟后过期</Button>
</div>
<Message>注意令牌的额度仅用于限制令牌本身的最大额度使用量实际的使用受到账户的剩余额度限制</Message>
<Form.Field>
<Form.Input
label={`额度${renderQuotaWithPrompt(remain_quota)}`}
name='remain_quota'
placeholder={'请输入额度'}
onChange={handleInputChange}
value={remain_quota}
autoComplete='new-password'
type='number'
disabled={unlimited_quota}
/>
</Form.Field>
<Button type={'button'} onClick={() => {
setUnlimitedQuota();
}}>{unlimited_quota ? '取消无限额度' : '设为无限额度'}</Button>
<Button floated='right' positive onClick={submit}>提交</Button>
<Button floated='right' onClick={handleCancel}>取消</Button>
</Form>
</Segment>
</>
); );
}; };

View File

@@ -1,14 +1,21 @@
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';
import { useTranslation } from 'react-i18next';
const Token = () => ( const Token = () => {
<> const { t } = useTranslation();
<Segment>
<Header as='h3'>我的令牌</Header> return (
<TokensTable/> <div className='dashboard-container'>
</Segment> <Card fluid className='chart-card'>
</> <Card.Content>
); <Card.Header className='header'>{t('token.title')}</Card.Header>
<TokensTable />
</Card.Content>
</Card>
</div>
);
};
export default Token; export default Token;

View File

@@ -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('请输入充值码!') 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('充值成功!'); 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('请求失败'); showError(t('topup.redeem_code.request_failed'));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
const openTopUpLink = () => { const openTopUpLink = () => {
if (!topUpLink) { if (!topUpLink) {
showError('超级管理员未设置充值链接!'); 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'>充值额度</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='兑换码'
name='redemptionCode' <Grid columns={2} stackable>
value={redemptionCode} <Grid.Column>
onChange={(e) => { <Card
setRedemptionCode(e.target.value); fluid
}} style={{
/> height: '100%',
<Button color='green' onClick={openTopUpLink}> boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
充值 }}
</Button> >
<Button color='yellow' onClick={topUp} disabled={isSubmitting}> <Card.Content
{isSubmitting ? '兑换中...' : '兑换'} style={{
</Button> height: '100%',
</Form> display: 'flex',
</Grid.Column> flexDirection: 'column',
<Grid.Column> }}
<Statistic.Group widths='one'> >
<Statistic> <Card.Header>
<Statistic.Value>{renderQuota(userQuota)}</Statistic.Value> <Header as='h3' style={{ color: '#2185d0', margin: '1em' }}>
<Statistic.Label>剩余额度</Statistic.Label> <i className='credit card icon'></i>
</Statistic> {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, t)}
</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;

View File

@@ -1,8 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Button, Form, Header, Segment } from 'semantic-ui-react'; import { useTranslation } from 'react-i18next';
import { Button, Form, Card } from 'semantic-ui-react';
import { API, showError, showSuccess } from '../../helpers'; import { API, showError, showSuccess } from '../../helpers';
const AddUser = () => { const AddUser = () => {
const { t } = useTranslation();
const originInputs = { const originInputs = {
username: '', username: '',
display_name: '', display_name: '',
@@ -20,7 +22,7 @@ const AddUser = () => {
const res = await API.post(`/api/user/`, inputs); const res = await API.post(`/api/user/`, inputs);
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
showSuccess('用户账户创建成功!'); showSuccess(t('user.messages.create_success'));
setInputs(originInputs); setInputs(originInputs);
} else { } else {
showError(message); showError(message);
@@ -28,49 +30,51 @@ const AddUser = () => {
}; };
return ( return (
<> <div className='dashboard-container'>
<Segment> <Card fluid className='chart-card'>
<Header as="h3">创建新用户账户</Header> <Card.Content>
<Form autoComplete="off"> <Card.Header className='header'>{t('user.add.title')}</Card.Header>
<Form.Field> <Form autoComplete='off'>
<Form.Input <Form.Field>
label="用户名" <Form.Input
name="username" label={t('user.edit.username')}
placeholder={'请输入用户名'} name='username'
onChange={handleInputChange} placeholder={t('user.edit.username_placeholder')}
value={username} onChange={handleInputChange}
autoComplete="off" value={username}
required autoComplete='off'
/> required
</Form.Field> />
<Form.Field> </Form.Field>
<Form.Input <Form.Field>
label="显示名称" <Form.Input
name="display_name" label={t('user.edit.display_name')}
placeholder={'请输入显示名称'} name='display_name'
onChange={handleInputChange} placeholder={t('user.edit.display_name_placeholder')}
value={display_name} onChange={handleInputChange}
autoComplete="off" value={display_name}
/> autoComplete='off'
</Form.Field> />
<Form.Field> </Form.Field>
<Form.Input <Form.Field>
label="密码" <Form.Input
name="password" label={t('user.edit.password')}
type={'password'} name='password'
placeholder={'请输入密码'} type='password'
onChange={handleInputChange} placeholder={t('user.edit.password_placeholder')}
value={password} onChange={handleInputChange}
autoComplete="off" value={password}
required autoComplete='off'
/> required
</Form.Field> />
<Button positive type={'submit'} onClick={submit}> </Form.Field>
提交 <Button positive type='submit' onClick={submit}>
</Button> {t('user.edit.buttons.submit')}
</Form> </Button>
</Segment> </Form>
</> </Card.Content>
</Card>
</div>
); );
}; };

View File

@@ -1,10 +1,12 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Segment } from 'semantic-ui-react'; import { useTranslation } from 'react-i18next';
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';
const EditUser = () => { const EditUser = () => {
const { t } = useTranslation();
const params = useParams(); const params = useParams();
const userId = params.id; const userId = params.id;
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -16,30 +18,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) {
@@ -76,114 +88,123 @@ const EditUser = () => {
} }
const { success, message } = res.data; const { success, message } = res.data;
if (success) { if (success) {
showSuccess('用户信息更新成功!'); showSuccess(t('user.messages.update_success'));
} else { } else {
showError(message); showError(message);
} }
}; };
return ( return (
<> <div className='dashboard-container'>
<Segment loading={loading}> <Card fluid className='chart-card'>
<Header as='h3'>更新用户信息</Header> <Card.Content>
<Form autoComplete='new-password'> <Card.Header className='header'>{t('user.edit.title')}</Card.Header>
<Form.Field> <Form loading={loading} autoComplete='new-password'>
<Form.Input <Form.Field>
label='用户名' <Form.Input
name='username' label={t('user.edit.username')}
placeholder={'请输入新的用户名'} name='username'
onChange={handleInputChange} placeholder={t('user.edit.username_placeholder')}
value={username} onChange={handleInputChange}
autoComplete='new-password' value={username}
/> autoComplete='new-password'
</Form.Field> />
<Form.Field> </Form.Field>
<Form.Input <Form.Field>
label='密码' <Form.Input
name='password' label={t('user.edit.password')}
type={'password'} name='password'
placeholder={'请输入新的密码,最短 8 位'} type={'password'}
onChange={handleInputChange} placeholder={t('user.edit.password_placeholder')}
value={password} onChange={handleInputChange}
autoComplete='new-password' value={password}
/> autoComplete='new-password'
</Form.Field> />
<Form.Field> </Form.Field>
<Form.Input <Form.Field>
label='显示名称' <Form.Input
name='display_name' label={t('user.edit.display_name')}
placeholder={'请输入新的显示名称'} name='display_name'
onChange={handleInputChange} placeholder={t('user.edit.display_name_placeholder')}
value={display_name} onChange={handleInputChange}
autoComplete='new-password' value={display_name}
/> autoComplete='new-password'
</Form.Field> />
{ </Form.Field>
userId && <> {userId && (
<Form.Field> <>
<Form.Dropdown <Form.Field>
label='分组' <Form.Dropdown
placeholder={'请选择分组'} label={t('user.edit.group')}
name='group' placeholder={t('user.edit.group_placeholder')}
fluid name='group'
search fluid
selection search
allowAdditions selection
additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'} allowAdditions
onChange={handleInputChange} additionLabel={t('user.edit.group_addition')}
value={inputs.group} onChange={handleInputChange}
autoComplete='new-password' value={inputs.group}
options={groupOptions} autoComplete='new-password'
/> options={groupOptions}
</Form.Field> />
<Form.Field> </Form.Field>
<Form.Input <Form.Field>
label={`剩余额度${renderQuotaWithPrompt(quota)}`} <Form.Input
name='quota' label={`${t('user.edit.quota')}${renderQuotaWithPrompt(
placeholder={'请输入新的剩余额度'} quota,
onChange={handleInputChange} t
value={quota} )}`}
type={'number'} name='quota'
autoComplete='new-password' placeholder={t('user.edit.quota_placeholder')}
/> onChange={handleInputChange}
</Form.Field> value={quota}
</> type={'number'}
} autoComplete='new-password'
<Form.Field> />
<Form.Input </Form.Field>
label='已绑定的 GitHub 账户' </>
name='github_id' )}
value={github_id} <Form.Field>
autoComplete='new-password' <Form.Input
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' label={t('user.edit.github_id')}
readOnly name='github_id'
/> value={github_id}
</Form.Field> autoComplete='new-password'
<Form.Field> placeholder={t('user.edit.github_id_placeholder')}
<Form.Input readOnly
label='已绑定的微信账户' />
name='wechat_id' </Form.Field>
value={wechat_id} <Form.Field>
autoComplete='new-password' <Form.Input
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' label={t('user.edit.wechat_id')}
readOnly name='wechat_id'
/> value={wechat_id}
</Form.Field> autoComplete='new-password'
<Form.Field> placeholder={t('user.edit.wechat_id_placeholder')}
<Form.Input readOnly
label='已绑定的邮箱账户' />
name='email' </Form.Field>
value={email} <Form.Field>
autoComplete='new-password' <Form.Input
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' label={t('user.edit.email')}
readOnly name='email'
/> value={email}
</Form.Field> autoComplete='new-password'
<Button onClick={handleCancel}>取消</Button> placeholder={t('user.edit.email_placeholder')}
<Button positive onClick={submit}>提交</Button> readOnly
</Form> />
</Segment> </Form.Field>
</> <Button onClick={handleCancel}>
{t('user.edit.buttons.cancel')}
</Button>
<Button positive onClick={submit}>
{t('user.edit.buttons.submit')}
</Button>
</Form>
</Card.Content>
</Card>
</div>
); );
}; };

View File

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