Compare commits

...

50 Commits

Author SHA1 Message Date
JustSong
93ce6c4cd7 chore: disable arm64 build for now 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 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 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
40 changed files with 2989 additions and 1268 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

@@ -62,7 +62,8 @@ jobs:
uses: docker/build-push-action@v3
with:
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
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

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

7
go.mod
View File

@@ -1,6 +1,5 @@
module github.com/songquanpeng/one-api
// +heroku goVersion go1.18
go 1.20
require (
@@ -27,10 +26,11 @@ require (
github.com/stretchr/testify v1.9.0
golang.org/x/crypto v0.31.0
golang.org/x/image v0.18.0
golang.org/x/sync v0.10.0
google.golang.org/api v0.187.0
gorm.io/driver/mysql v1.5.6
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
)
@@ -82,7 +82,7 @@ require (
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
@@ -99,7 +99,6 @@ require (
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.26.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/text v0.21.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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -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/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
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.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
gorm.io/driver/sqlite v1.5.1 h1:hYyrLkAWE71bcarJDPdZNTLWtr8XrSjOWyjUYI6xdL4=
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.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Dropdown,
@@ -31,13 +32,17 @@ function renderTimestamp(timestamp) {
let type2label = undefined;
function renderType(type) {
function renderType(type, t) {
if (!type2label) {
type2label = new Map();
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
}
type2label[0] = { value: 0, text: '未知类型', color: 'grey' };
type2label[0] = {
value: 0,
text: t('channel.table.status_unknown'),
color: 'grey',
};
}
return (
<Label basic color={type2label[type]?.color}>
@@ -46,7 +51,7 @@ function renderType(type) {
);
}
function renderBalance(type, balance) {
function renderBalance(type, balance, t) {
switch (type) {
case 1: // OpenAI
return <span>${balance.toFixed(2)}</span>;
@@ -67,7 +72,7 @@ function renderBalance(type, balance) {
case 44: // SiliconFlow
return <span>¥{balance.toFixed(2)}</span>;
default:
return <span>不支持</span>;
return <span>{t('channel.table.balance_not_supported')}</span>;
}
}
@@ -78,6 +83,7 @@ function isShowDetail() {
const promptID = 'detail';
const ChannelsTable = () => {
const { t } = useTranslation();
const [channels, setChannels] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
@@ -207,12 +213,12 @@ const ChannelsTable = () => {
}
};
const renderStatus = (status) => {
const renderStatus = (status, t) => {
switch (status) {
case 1:
return (
<Label basic color='green'>
已启用
{t('channel.table.status_enabled')}
</Label>
);
case 2:
@@ -220,10 +226,10 @@ const ChannelsTable = () => {
<Popup
trigger={
<Label basic color='red'>
已禁用
{t('channel.table.status_disabled')}
</Label>
}
content='本渠道被手动禁用'
content={t('channel.table.status_disabled_tip')}
basic
/>
);
@@ -232,29 +238,29 @@ const ChannelsTable = () => {
<Popup
trigger={
<Label basic color='yellow'>
已禁用
{t('channel.table.status_auto_disabled')}
</Label>
}
content='本渠道被程序自动禁用'
content={t('channel.table.status_auto_disabled_tip')}
basic
/>
);
default:
return (
<Label basic color='grey'>
未知状态
{t('channel.table.status_unknown')}
</Label>
);
}
};
const renderResponseTime = (responseTime) => {
const renderResponseTime = (responseTime, t) => {
let time = responseTime / 1000;
time = time.toFixed(2) + '';
time = time.toFixed(2) + 's';
if (responseTime === 0) {
return (
<Label basic color='grey'>
未测试
{t('channel.table.not_tested')}
</Label>
);
} else if (responseTime <= 1000) {
@@ -320,9 +326,12 @@ const ChannelsTable = () => {
newChannels[realIdx].test_time = Date.now() / 1000;
setChannels(newChannels);
showInfo(
`渠道 ${name} 测试成功,模型 ${model},耗时 ${time.toFixed(
2
)} 秒,模型输出:${message}`
t('channel.messages.test_success', {
name: name,
model: model,
time: time.toFixed(2),
message: message,
})
);
} else {
showError(message);
@@ -338,7 +347,7 @@ const ChannelsTable = () => {
const res = await API.get(`/api/channel/test?scope=${scope}`);
const { success, message } = res.data;
if (success) {
showInfo('已成功开始测试渠道,请刷新页面查看结果。');
showInfo(t('channel.messages.test_all_started'));
} else {
showError(message);
}
@@ -348,7 +357,9 @@ const ChannelsTable = () => {
const res = await API.delete(`/api/channel/disabled`);
const { success, message, data } = res.data;
if (success) {
showSuccess(`已删除所有禁用渠道,共计 ${data}`);
showSuccess(
t('channel.messages.delete_disabled_success', { count: data })
);
await refresh();
} else {
showError(message);
@@ -364,7 +375,7 @@ const ChannelsTable = () => {
newChannels[realIdx].balance = balance;
newChannels[realIdx].balance_updated_time = Date.now() / 1000;
setChannels(newChannels);
showInfo(`渠道 ${name} 余额更新成功!`);
showInfo(t('channel.messages.balance_update_success', { name: name }));
} else {
showError(message);
}
@@ -375,7 +386,7 @@ const ChannelsTable = () => {
const res = await API.get(`/api/channel/update_balance`);
const { success, message } = res.data;
if (success) {
showInfo('已更新完毕所有已启用渠道余额!');
showInfo(t('channel.messages.all_balance_updated'));
} else {
showError(message);
}
@@ -413,7 +424,7 @@ const ChannelsTable = () => {
icon='search'
fluid
iconPosition='left'
placeholder='搜索渠道的 ID名称和密钥 ...'
placeholder={t('channel.search')}
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
@@ -426,16 +437,14 @@ const ChannelsTable = () => {
setPromptShown(promptID);
}}
>
OpenAI 渠道已经不再支持通过 key 获取余额因此余额显示为
0对于支持的渠道类型请点击余额进行刷新
{t('channel.balance_notice')}
<br />
渠道测试仅支持 chat 模型优先使用
gpt-3.5-turbo如果该模型不可用则使用你所配置的模型列表中的第一个模型
{t('channel.test_notice')}
<br />
点击下方详情按钮可以显示余额以及设置额外的测试模型
{t('channel.detail_notice')}
</Message>
)}
<Table basic compact size='small'>
<Table basic={'very'} compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
@@ -444,7 +453,7 @@ const ChannelsTable = () => {
sortChannel('id');
}}
>
ID
{t('channel.table.id')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -452,7 +461,7 @@ const ChannelsTable = () => {
sortChannel('name');
}}
>
名称
{t('channel.table.name')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -460,7 +469,7 @@ const ChannelsTable = () => {
sortChannel('group');
}}
>
分组
{t('channel.table.group')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -468,7 +477,7 @@ const ChannelsTable = () => {
sortChannel('type');
}}
>
类型
{t('channel.table.type')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -476,7 +485,7 @@ const ChannelsTable = () => {
sortChannel('status');
}}
>
状态
{t('channel.table.status')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -484,7 +493,7 @@ const ChannelsTable = () => {
sortChannel('response_time');
}}
>
响应时间
{t('channel.table.response_time')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -493,7 +502,7 @@ const ChannelsTable = () => {
}}
hidden={!showDetail}
>
余额
{t('channel.table.balance')}
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
@@ -501,10 +510,12 @@ const ChannelsTable = () => {
sortChannel('priority');
}}
>
优先级
{t('channel.table.priority')}
</Table.HeaderCell>
<Table.HeaderCell hidden={!showDetail}>测试模型</Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell>
<Table.HeaderCell hidden={!showDetail}>
{t('channel.table.test_model')}
</Table.HeaderCell>
<Table.HeaderCell>{t('channel.table.actions')}</Table.HeaderCell>
</Table.Row>
</Table.Header>
@@ -519,19 +530,21 @@ const ChannelsTable = () => {
return (
<Table.Row key={channel.id}>
<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>{renderType(channel.type)}</Table.Cell>
<Table.Cell>{renderStatus(channel.status)}</Table.Cell>
<Table.Cell>{renderType(channel.type, t)}</Table.Cell>
<Table.Cell>{renderStatus(channel.status, t)}</Table.Cell>
<Table.Cell>
<Popup
content={
channel.test_time
? renderTimestamp(channel.test_time)
: '未测试'
: t('channel.table.not_tested')
}
key={channel.id}
trigger={renderResponseTime(channel.response_time)}
trigger={renderResponseTime(channel.response_time, t)}
basic
/>
</Table.Cell>
@@ -544,10 +557,10 @@ const ChannelsTable = () => {
}}
style={{ cursor: 'pointer' }}
>
{renderBalance(channel.type, channel.balance)}
{renderBalance(channel.type, channel.balance, t)}
</span>
}
content='点击更新'
content={t('channel.table.click_to_update')}
basic
/>
</Table.Cell>
@@ -569,13 +582,13 @@ const ChannelsTable = () => {
<input style={{ maxWidth: '60px' }} />
</Input>
}
content='渠道选择优先级,越高越优先'
content={t('channel.table.priority_tip')}
basic
/>
</Table.Cell>
<Table.Cell hidden={!showDetail}>
<Dropdown
placeholder='请选择测试模型'
placeholder={t('channel.table.select_test_model')}
selection
options={channel.model_options}
defaultValue={channel.test_model}
@@ -598,22 +611,12 @@ const ChannelsTable = () => {
);
}}
>
测试
{t('channel.buttons.test')}
</Button>
{/*<Button*/}
{/* size={'small'}*/}
{/* positive*/}
{/* loading={updatingBalance}*/}
{/* onClick={() => {*/}
{/* updateChannelBalance(channel.id, channel.name, idx);*/}
{/* }}*/}
{/*>*/}
{/* 更新余额*/}
{/*</Button>*/}
<Popup
trigger={
<Button size='small' negative>
删除
{t('channel.buttons.delete')}
</Button>
}
on='click'
@@ -626,7 +629,7 @@ const ChannelsTable = () => {
manageChannel(channel.id, 'delete', idx);
}}
>
删除渠道 {channel.name}
{t('channel.buttons.confirm_delete')} {channel.name}
</Button>
</Popup>
<Button
@@ -639,14 +642,16 @@ const ChannelsTable = () => {
);
}}
>
{channel.status === 1 ? '禁用' : '启用'}
{channel.status === 1
? t('channel.buttons.disable')
: t('channel.buttons.enable')}
</Button>
<Button
size={'small'}
as={Link}
to={'/channel/edit/' + channel.id}
>
编辑
{t('channel.buttons.edit')}
</Button>
</div>
</Table.Cell>
@@ -664,7 +669,7 @@ const ChannelsTable = () => {
to='/channel/add'
loading={loading}
>
添加新的渠道
{t('channel.buttons.add')}
</Button>
<Button
size='small'
@@ -673,7 +678,7 @@ const ChannelsTable = () => {
testChannels('all');
}}
>
测试所有渠道
{t('channel.buttons.test_all')}
</Button>
<Button
size='small'
@@ -682,14 +687,12 @@ const ChannelsTable = () => {
testChannels('disabled');
}}
>
测试禁用渠道
{t('channel.buttons.test_disabled')}
</Button>
{/*<Button size='small' onClick={updateAllChannelsBalance}*/}
{/* loading={loading || updatingBalance}>更新已启用渠道余额</Button>*/}
<Popup
trigger={
<Button size='small' loading={loading}>
删除禁用渠道
{t('channel.buttons.delete_disabled')}
</Button>
}
on='click'
@@ -702,7 +705,7 @@ const ChannelsTable = () => {
negative
onClick={deleteAllDisabledChannels}
>
确认删除
{t('channel.buttons.confirm_delete_disabled')}
</Button>
</Popup>
<Pagination
@@ -717,10 +720,12 @@ const ChannelsTable = () => {
}
/>
<Button size='small' onClick={refresh} loading={loading}>
刷新
{t('channel.buttons.refresh')}
</Button>
<Button size='small' onClick={toggleShowDetail}>
{showDetail ? '隐藏详情' : '详情'}
{showDetail
? t('channel.buttons.hide_detail')
: t('channel.buttons.show_detail')}
</Button>
</Table.HeaderCell>
</Table.Row>

View File

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

View File

@@ -1,72 +1,93 @@
import React, { useContext, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { UserContext } from '../context/User';
import { useTranslation } from 'react-i18next';
import { Button, Container, Dropdown, Icon, Menu, Segment } from 'semantic-ui-react';
import { API, getLogo, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers';
import {
Button,
Container,
Dropdown,
Icon,
Menu,
Segment,
} from 'semantic-ui-react';
import {
API,
getLogo,
getSystemName,
isAdmin,
isMobile,
showSuccess,
} from '../helpers';
import '../index.css';
// Header Buttons
let headerButtons = [
{
name: '首页',
name: 'header.home',
to: '/',
icon: 'home'
icon: 'home',
},
{
name: '渠道',
name: 'header.channel',
to: '/channel',
icon: 'sitemap',
admin: true
admin: true,
},
{
name: '令牌',
name: 'header.token',
to: '/token',
icon: 'key'
icon: 'key',
},
{
name: '兑换',
name: 'header.redemption',
to: '/redemption',
icon: 'dollar sign',
admin: true
admin: true,
},
{
name: '充值',
name: 'header.topup',
to: '/topup',
icon: 'cart'
icon: 'cart',
},
{
name: '用户',
name: 'header.user',
to: '/user',
icon: 'user',
admin: true
admin: true,
},
{
name: '日志',
name: 'header.dashboard',
to: '/dashboard',
icon: 'chart bar',
},
{
name: 'header.log',
to: '/log',
icon: 'book'
icon: 'book',
},
{
name: '设置',
name: 'header.setting',
to: '/setting',
icon: 'setting'
icon: 'setting',
},
{
name: '关于',
name: 'header.about',
to: '/about',
icon: 'info circle'
}
icon: 'info circle',
},
];
if (localStorage.getItem('chat_link')) {
headerButtons.splice(1, 0, {
name: '聊天',
name: 'header.chat',
to: '/chat',
icon: 'comments'
icon: 'comments',
});
}
const Header = () => {
const { t, i18n } = useTranslation();
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
@@ -93,24 +114,45 @@ const Header = () => {
if (isMobile) {
return (
<Menu.Item
key={button.name}
onClick={() => {
navigate(button.to);
setShowSidebar(false);
}}
style={{ fontSize: '15px' }}
>
{button.name}
{t(button.name)}
</Menu.Item>
);
}
return (
<Menu.Item key={button.name} as={Link} to={button.to}>
<Icon name={button.icon} />
{button.name}
<Menu.Item
key={button.name}
as={Link}
to={button.to}
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
>
<Icon name={button.icon} style={{ marginRight: '4px' }} />
{t(button.name)}
</Menu.Item>
);
});
};
// Add language switcher dropdown
const languageOptions = [
{ key: 'zh', text: '中文', value: 'zh' },
{ key: 'en', text: 'English', value: 'en' },
];
const changeLanguage = (language) => {
i18n.changeLanguage(language);
};
if (isMobile()) {
return (
<>
@@ -120,21 +162,17 @@ const Header = () => {
style={
showSidebar
? {
borderBottom: 'none',
marginBottom: '0',
borderTop: 'none',
height: '51px'
}
borderBottom: 'none',
marginBottom: '0',
borderTop: 'none',
height: '51px',
}
: { borderTop: 'none', height: '52px' }
}
>
<Container>
<Menu.Item as={Link} to='/'>
<img
src={logo}
alt='logo'
style={{ marginRight: '0.75em' }}
/>
<img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
<div style={{ fontSize: '20px' }}>
<b>{systemName}</b>
</div>
@@ -150,9 +188,19 @@ const Header = () => {
<Segment style={{ marginTop: 0, borderTop: '0' }}>
<Menu secondary vertical style={{ width: '100%', margin: 0 }}>
{renderButtons(true)}
<Menu.Item>
<Dropdown
selection
options={languageOptions}
value={i18n.language}
onChange={(_, { value }) => changeLanguage(value)}
/>
</Menu.Item>
<Menu.Item>
{userState.user ? (
<Button onClick={logout}>注销</Button>
<Button onClick={logout} style={{ color: '#666666' }}>
{t('header.logout')}
</Button>
) : (
<>
<Button
@@ -161,7 +209,7 @@ const Header = () => {
navigate('/login');
}}
>
登录
{t('header.login')}
</Button>
<Button
onClick={() => {
@@ -169,7 +217,7 @@ const Header = () => {
navigate('/register');
}}
>
注册
{t('header.register')}
</Button>
</>
)}
@@ -185,32 +233,75 @@ const Header = () => {
return (
<>
<Menu borderless style={{ borderTop: 'none' }}>
<Menu
borderless
style={{
borderTop: 'none',
boxShadow: 'rgba(0, 0, 0, 0.04) 0px 2px 12px 0px',
border: 'none',
}}
>
<Container>
<Menu.Item as={Link} to='/' className={'hide-on-mobile'}>
<img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
<div style={{ fontSize: '20px' }}>
<b>{systemName}</b>
<div
style={{
fontSize: '18px',
fontWeight: '500',
color: '#333',
}}
>
{systemName}
</div>
</Menu.Item>
{renderButtons(false)}
<Menu.Menu position='right'>
<Dropdown
item
options={languageOptions}
value={i18n.language}
onChange={(_, { value }) => changeLanguage(value)}
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
/>
{userState.user ? (
<Dropdown
text={userState.user.username}
pointing
className='link item'
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
>
<Dropdown.Menu>
<Dropdown.Item onClick={logout}>注销</Dropdown.Item>
<Dropdown.Item
onClick={logout}
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
>
{t('header.logout')}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
) : (
<Menu.Item
name='登录'
name={t('header.login')}
as={Link}
to='/login'
className='btn btn-link'
style={{
fontSize: '15px',
fontWeight: '400',
color: '#666',
}}
/>
)}
</Menu.Menu>

View File

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

View File

@@ -307,7 +307,7 @@ const LogsTable = () => {
return (
<>
<Segment>
<>
<Header as='h3'>
使用明细总消耗额度
{showStat && renderQuota(stat.quota)}
@@ -388,7 +388,7 @@ const LogsTable = () => {
</>
)}
</Form>
<Table basic compact size='small'>
<Table basic={'very'} compact size='small'>
<Table.Header>
<Table.Row>
<Table.HeaderCell
@@ -596,7 +596,7 @@ const LogsTable = () => {
</Table.Row>
</Table.Footer>
</Table>
</Segment>
</>
</>
);
};

View File

@@ -1,6 +1,21 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers';
import {
Button,
Form,
Grid,
Header,
Image,
Card,
Message,
} from 'semantic-ui-react';
import {
API,
copy,
showError,
showInfo,
showNotice,
showSuccess,
} from '../helpers';
import { useSearchParams } from 'react-router-dom';
const PasswordResetConfirm = () => {
@@ -37,7 +52,7 @@ const PasswordResetConfirm = () => {
setDisableButton(false);
setCountdown(30);
}
return () => clearInterval(countdownInterval);
return () => clearInterval(countdownInterval);
}, [disableButton, countdown]);
async function handleSubmit(e) {
@@ -59,55 +74,86 @@ const PasswordResetConfirm = () => {
}
setLoading(false);
}
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'>
<Image src='/logo.png' /> 密码重置确认
</Header>
<Form size='large'>
<Segment>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder='邮箱地址'
name='email'
value={email}
readOnly
/>
{newPassword && (
<Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
<Card.Content>
<Card.Header>
<Header
as='h2'
textAlign='center'
style={{ marginBottom: '1.5em' }}
>
<Image src='/logo.png' style={{ marginBottom: '10px' }} />
<Header.Content>密码重置确认</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='新密码'
name='newPassword'
value={newPassword}
readOnly
onClick={(e) => {
e.target.select();
navigator.clipboard.writeText(newPassword);
showNotice(`密码已复制到剪贴板:${newPassword}`);
}}
/>
fluid
icon='mail'
iconPosition='left'
placeholder='邮箱地址'
name='email'
value={email}
readOnly
style={{ marginBottom: '1em' }}
/>
{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
color='green'
fluid
size='large'
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
>
{disableButton ? `密码重置完成` : '提交'}
</Button>
</Segment>
</Form>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
);
);
};
export default PasswordResetConfirm;

View File

@@ -1,11 +1,19 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
import {
Button,
Form,
Grid,
Header,
Image,
Card,
Message,
} from 'semantic-ui-react';
import { API, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile';
const PasswordResetForm = () => {
const [inputs, setInputs] = useState({
email: ''
email: '',
});
const { email } = inputs;
@@ -42,7 +50,7 @@ const PasswordResetForm = () => {
function handleChange(e) {
const { name, value } = e.target;
setInputs(inputs => ({ ...inputs, [name]: value }));
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
async function handleSubmit(e) {
@@ -69,42 +77,72 @@ const PasswordResetForm = () => {
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'>
<Image src='/logo.png' /> 密码重置
</Header>
<Form size='large'>
<Segment>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder='邮箱地址'
name='email'
value={email}
onChange={handleChange}
/>
{turnstileEnabled ? (
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
<Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
<Card.Content>
<Card.Header>
<Header
as='h2'
textAlign='center'
style={{ marginBottom: '1.5em' }}
>
<Image src='/logo.png' style={{ marginBottom: '10px' }} />
<Header.Content>密码重置</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder='邮箱地址'
name='email'
value={email}
onChange={handleChange}
style={{ marginBottom: '1em' }}
/>
) : (
<></>
)}
<Button
color='green'
fluid
size='large'
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
>
{disableButton ? `重试 (${countdown})` : '提交'}
</Button>
</Segment>
</Form>
{turnstileEnabled && (
<div
style={{
marginBottom: '1em',
display: 'flex',
justifyContent: 'center',
}}
>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</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>
);

View File

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

View File

@@ -1,5 +1,15 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Image, Message, Segment } from 'semantic-ui-react';
import {
Button,
Form,
Grid,
Header,
Image,
Message,
Segment,
Card,
Divider,
} from 'semantic-ui-react';
import { Link, useNavigate } from 'react-router-dom';
import { API, getLogo, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile';
@@ -10,7 +20,7 @@ const RegisterForm = () => {
password: '',
password2: '',
email: '',
verification_code: ''
verification_code: '',
});
const { username, password, password2 } = inputs;
const [showEmailVerification, setShowEmailVerification] = useState(false);
@@ -100,92 +110,135 @@ const RegisterForm = () => {
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'>
<Image src={logo} /> 新用户注册
</Header>
<Form size='large'>
<Segment>
<Form.Input
fluid
icon='user'
iconPosition='left'
placeholder='输入用户名,最长 12 位'
onChange={handleChange}
name='username'
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='输入密码,最短 8 位,最长 20 位'
onChange={handleChange}
name='password'
type='password'
/>
<Form.Input
fluid
icon='lock'
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);
}}
<Card
fluid
className='chart-card'
style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }}
>
<Card.Content>
<Card.Header>
<Header
as='h2'
textAlign='center'
style={{ marginBottom: '1.5em' }}
>
<Image src={logo} style={{ marginBottom: '10px' }} />
<Header.Content>新用户注册</Header.Content>
</Header>
</Card.Header>
<Form size='large'>
<Form.Input
fluid
icon='user'
iconPosition='left'
placeholder='输入用户名,最长 12 位'
onChange={handleChange}
name='username'
style={{ marginBottom: '1em' }}
/>
) : (
<></>
)}
<Button
color='green'
fluid
size='large'
onClick={handleSubmit}
loading={loading}
>
注册
</Button>
</Segment>
</Form>
<Message>
已有账户
<Link to='/login' className='btn btn-link'>
点击登录
</Link>
</Message>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='输入密码,最短 8 位,最长 20 位'
onChange={handleChange}
name='password'
type='password'
style={{ marginBottom: '1em' }}
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='再次输入密码'
onChange={handleChange}
name='password2'
type='password'
style={{ marginBottom: '1em' }}
/>
{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>
);

View File

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

View File

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

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

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

View File

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

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Header, Segment } from 'semantic-ui-react';
import { Card, Header, Segment } from 'semantic-ui-react';
import { API, showError } from '../../helpers';
import { marked } from 'marked';
@@ -28,31 +28,38 @@ const About = () => {
useEffect(() => {
displayAbout().then();
}, []);
return (
<>
{
aboutLoaded && about === '' ? <>
<Segment>
<Header as='h3'>关于</Header>
<p>可在设置页面设置关于内容支持 HTML & Markdown</p>
项目仓库地址
<a href='https://github.com/songquanpeng/one-api'>
https://github.com/songquanpeng/one-api
</a>
</Segment>
</> : <>
{
about.startsWith('https://') ? <iframe
{aboutLoaded && about === '' ? (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>关于系统</Card.Header>
<p>可在设置页面设置关于内容支持 HTML & Markdown</p>
项目仓库地址
<a href='https://github.com/songquanpeng/one-api'>
https://github.com/songquanpeng/one-api
</a>
</Card.Content>
</Card>
</div>
) : (
<>
{about.startsWith('https://') ? (
<iframe
src={about}
style={{ width: '100%', height: '100vh', border: 'none' }}
/> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div>
}
/>
) : (
<div
style={{ fontSize: 'larger' }}
dangerouslySetInnerHTML={{ __html: about }}
></div>
)}
</>
}
)}
</>
);
};
export default About;

View File

@@ -1,32 +1,49 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Input, Message, Segment } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';
import {
Button,
Form,
Header,
Input,
Message,
Segment,
Card,
} from 'semantic-ui-react';
import { useNavigate, useParams } from 'react-router-dom';
import { API, copy, getChannelModels, showError, showInfo, showSuccess, verifyJSON } from '../../helpers';
import {
API,
copy,
getChannelModels,
showError,
showInfo,
showSuccess,
verifyJSON,
} from '../../helpers';
import { CHANNEL_OPTIONS } from '../../constants';
const MODEL_MAPPING_EXAMPLE = {
'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',
'gpt-4-0314': 'gpt-4',
'gpt-4-32k-0314': 'gpt-4-32k'
'gpt-4-32k-0314': 'gpt-4-32k',
};
function type2secretPrompt(type) {
// inputs.type === 15 ? '按照如下格式输入APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')
function type2secretPrompt(type, t) {
switch (type) {
case 15:
return '按照如下格式输入APIKey|SecretKey';
return t('channel.edit.key_prompts.zhipu');
case 18:
return '按照如下格式输入APPID|APISecret|APIKey';
return t('channel.edit.key_prompts.spark');
case 22:
return '按照如下格式输入APIKey-AppId例如fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041';
return t('channel.edit.key_prompts.fastgpt');
case 23:
return '按照如下格式输入AppId|SecretId|SecretKey';
return t('channel.edit.key_prompts.tencent');
default:
return '请输入渠道对应的鉴权密钥';
return t('channel.edit.key_prompts.default');
}
}
const EditChannel = () => {
const { t } = useTranslation();
const params = useParams();
const navigate = useNavigate();
const channelId = params.id;
@@ -45,7 +62,7 @@ const EditChannel = () => {
model_mapping: '',
system_prompt: '',
models: [],
groups: ['default']
groups: ['default'],
};
const [batch, setBatch] = useState(false);
const [inputs, setInputs] = useState(originInputs);
@@ -61,7 +78,7 @@ const EditChannel = () => {
ak: '',
user_id: '',
vertex_ai_project_id: '',
vertex_ai_adc: ''
vertex_ai_adc: '',
});
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
@@ -93,7 +110,11 @@ const EditChannel = () => {
data.groups = data.group.split(',');
}
if (data.model_mapping !== '') {
data.model_mapping = JSON.stringify(JSON.parse(data.model_mapping), null, 2);
data.model_mapping = JSON.stringify(
JSON.parse(data.model_mapping),
null,
2
);
}
setInputs(data);
if (data.config !== '') {
@@ -112,7 +133,7 @@ const EditChannel = () => {
let localModelOptions = res.data.data.map((model) => ({
key: model.id,
text: model.id,
value: model.id
value: model.id,
}));
setOriginModelOptions(localModelOptions);
setFullModels(res.data.data.map((model) => model.id));
@@ -124,11 +145,13 @@ const EditChannel = () => {
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
setGroupOptions(res.data.data.map((group) => ({
key: group,
text: group,
value: group
})));
setGroupOptions(
res.data.data.map((group) => ({
key: group,
text: group,
value: group,
}))
);
} catch (error) {
showError(error.message);
}
@@ -141,7 +164,7 @@ const EditChannel = () => {
localModelOptions.push({
key: model,
text: model,
value: model
value: model,
});
}
});
@@ -163,25 +186,32 @@ const EditChannel = () => {
if (inputs.key === '') {
if (config.ak !== '' && config.sk !== '' && config.region !== '') {
inputs.key = `${config.ak}|${config.sk}|${config.region}`;
} else if (config.region !== '' && config.vertex_ai_project_id !== '' && config.vertex_ai_adc !== '') {
} else if (
config.region !== '' &&
config.vertex_ai_project_id !== '' &&
config.vertex_ai_adc !== ''
) {
inputs.key = `${config.region}|${config.vertex_ai_project_id}|${config.vertex_ai_adc}`;
}
}
if (!isEdit && (inputs.name === '' || inputs.key === '')) {
showInfo('请填写渠道名称和渠道密钥!');
showInfo(t('channel.edit.messages.name_required'));
return;
}
if (inputs.type !== 43 && inputs.models.length === 0) {
showInfo('请至少选择一个模型!');
showInfo(t('channel.edit.messages.models_required'));
return;
}
if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {
showInfo('模型映射必须是合法的 JSON 格式!');
showInfo(t('channel.edit.messages.model_mapping_invalid'));
return;
}
let localInputs = {...inputs};
let localInputs = { ...inputs };
if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1);
localInputs.base_url = localInputs.base_url.slice(
0,
localInputs.base_url.length - 1
);
}
if (localInputs.type === 3 && localInputs.other === '') {
localInputs.other = '2024-03-01-preview';
@@ -191,16 +221,19 @@ const EditChannel = () => {
localInputs.group = localInputs.groups.join(',');
localInputs.config = JSON.stringify(config);
if (isEdit) {
res = await API.put(`/api/channel/`, { ...localInputs, id: parseInt(channelId) });
res = await API.put(`/api/channel/`, {
...localInputs,
id: parseInt(channelId),
});
} else {
res = await API.post(`/api/channel/`, localInputs);
}
const { success, message } = res.data;
if (success) {
if (isEdit) {
showSuccess('渠道更新成功!');
showSuccess(t('channel.edit.messages.update_success'));
} else {
showSuccess('渠道创建成功!');
showSuccess(t('channel.edit.messages.create_success'));
setInputs(originInputs);
}
} else {
@@ -217,9 +250,9 @@ const EditChannel = () => {
localModelOptions.push({
key: customModel,
text: customModel,
value: customModel
value: customModel,
});
setModelOptions(modelOptions => {
setModelOptions((modelOptions) => {
return [...modelOptions, ...localModelOptions];
});
setCustomModel('');
@@ -227,34 +260,74 @@ const EditChannel = () => {
};
return (
<>
<Segment loading={loading}>
<Header as='h3'>{isEdit ? '更新渠道信息' : '创建新的渠道'}</Header>
<Form autoComplete='new-password'>
<Form.Field>
<Form.Select
label='类型'
name='type'
required
search
options={CHANNEL_OPTIONS}
value={inputs.type}
onChange={handleInputChange}
/>
</Form.Field>
{
inputs.type === 3 && (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>
{isEdit
? t('channel.edit.title_edit')
: t('channel.edit.title_create')}
</Card.Header>
<Form loading={loading} autoComplete='new-password'>
<Form.Field>
<Form.Select
label={t('channel.edit.type')}
name='type'
required
search
options={CHANNEL_OPTIONS}
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>
注意<strong>模型部署名称必须和模型名称保持一致</strong> One API model
参数替换为你的部署名称模型名称中的点会被剔除<a target='_blank'
href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'>图片演示</a>
注意<strong>模型部署名称必须和模型名称保持一致</strong>
因为 One API 会把请求体中的 model
参数替换为你的部署名称模型名称中的点会被剔除
<a
target='_blank'
href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'
>
图片演示
</a>
</Message>
<Form.Field>
<Form.Input
label='AZURE_OPENAI_ENDPOINT'
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}
value={inputs.base_url}
autoComplete='new-password'
@@ -264,119 +337,85 @@ const EditChannel = () => {
<Form.Input
label='默认 API 版本'
name='other'
placeholder={'请输入默认 API 版本例如2024-03-01-preview该配置可以被实际的请求查询参数所覆盖'}
placeholder='请输入默认 API 版本例如2024-03-01-preview该配置可以被实际的请求查询参数所覆盖'
onChange={handleInputChange}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
</>
)
}
{
inputs.type === 8 && (
)}
{/* Custom base URL field */}
{inputs.type === 8 && (
<Form.Field>
<Form.Input
label='Base URL'
label={t('channel.edit.base_url')}
name='base_url'
placeholder={'请输入自定义渠道的 Base URL例如https://openai.justsong.cn'}
placeholder={t('channel.edit.base_url_placeholder')}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)
}
<Form.Field>
<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 && (
)}
{inputs.type === 18 && (
<Form.Field>
<Form.Input
label='模型版本'
label={t('channel.edit.spark_version')}
name='other'
placeholder={'请输入星火大模型版本注意是接口地址中的版本号例如v2.1'}
placeholder={t('channel.edit.spark_version_placeholder')}
onChange={handleInputChange}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
)
}
{
inputs.type === 21 && (
)}
{inputs.type === 21 && (
<Form.Field>
<Form.Input
label='知识库 ID'
label={t('channel.edit.knowledge_id')}
name='other'
placeholder={'请输入知识库 ID例如123456'}
placeholder={t('channel.edit.knowledge_id_placeholder')}
onChange={handleInputChange}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
)
}
{
inputs.type === 17 && (
)}
{inputs.type === 17 && (
<Form.Field>
<Form.Input
label='插件参数'
label={t('channel.edit.plugin_param')}
name='other'
placeholder={'请输入插件参数,即 X-DashScope-Plugin 请求头的取值'}
placeholder={t('channel.edit.plugin_param_placeholder')}
onChange={handleInputChange}
value={inputs.other}
autoComplete='new-password'
/>
</Form.Field>
)
}
{
inputs.type === 34 && (
)}
{inputs.type === 34 && (
<Message>{t('channel.edit.coze_notice')}</Message>
)}
{inputs.type === 40 && (
<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>
)
}
{
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 && (
)}
{inputs.type !== 43 && (
<Form.Field>
<Form.Dropdown
label='模型'
placeholder={'请选择该渠道所支持的模型'}
label={t('channel.edit.models')}
placeholder={t('channel.edit.models_placeholder')}
name='models'
required
fluid
@@ -392,25 +431,46 @@ const EditChannel = () => {
options={modelOptions}
/>
</Form.Field>
)
}
{
inputs.type !== 43 && (
)}
{inputs.type !== 43 && (
<div style={{ lineHeight: '40px', marginBottom: '12px' }}>
<Button type={'button'} onClick={() => {
handleInputChange(null, { name: 'models', value: basicModels });
}}>填入相关模型</Button>
<Button type={'button'} onClick={() => {
handleInputChange(null, { name: 'models', value: fullModels });
}}>填入所有模型</Button>
<Button type={'button'} onClick={() => {
handleInputChange(null, { name: 'models', value: [] });
}}>清除所有模型</Button>
<Button
type={'button'}
onClick={() => {
handleInputChange(null, {
name: 'models',
value: basicModels,
});
}}
>
{t('channel.edit.buttons.fill_models')}
</Button>
<Button
type={'button'}
onClick={() => {
handleInputChange(null, {
name: 'models',
value: fullModels,
});
}}
>
{t('channel.edit.buttons.fill_all')}
</Button>
<Button
type={'button'}
onClick={() => {
handleInputChange(null, { name: 'models', value: [] });
}}
>
{t('channel.edit.buttons.clear')}
</Button>
<Input
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}
onChange={(e, { value }) => {
setCustomModel(value);
@@ -423,43 +483,48 @@ const EditChannel = () => {
}}
/>
</div>
)
}
{
inputs.type !== 43 && (<>
<Form.Field>
<Form.TextArea
label='模型重定向'
placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
name='model_mapping'
onChange={handleInputChange}
value={inputs.model_mapping}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.TextArea
label='系统提示词'
placeholder={`此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型`}
name='system_prompt'
onChange={handleInputChange}
value={inputs.system_prompt}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
autoComplete='new-password'
/>
</Form.Field>
)}
{inputs.type !== 43 && (
<>
<Form.Field>
<Form.TextArea
label={t('channel.edit.model_mapping')}
placeholder={`${t(
'channel.edit.model_mapping_placeholder'
)}\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
name='model_mapping'
onChange={handleInputChange}
value={inputs.model_mapping}
style={{
minHeight: 150,
fontFamily: 'JetBrains Mono, Consolas',
}}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.TextArea
label={t('channel.edit.system_prompt')}
placeholder={t('channel.edit.system_prompt_placeholder')}
name='system_prompt'
onChange={handleInputChange}
value={inputs.system_prompt}
style={{
minHeight: 150,
fontFamily: 'JetBrains Mono, Consolas',
}}
autoComplete='new-password'
/>
</Form.Field>
</>
)
}
{
inputs.type === 33 && (
)}
{inputs.type === 33 && (
<Form.Field>
<Form.Input
label='Region'
name='region'
required
placeholder={'regione.g. us-west-2'}
placeholder={t('channel.edit.aws_region_placeholder')}
onChange={handleConfigChange}
value={config.region}
autoComplete=''
@@ -468,7 +533,7 @@ const EditChannel = () => {
label='AK'
name='ak'
required
placeholder={'AWS IAM Access Key'}
placeholder={t('channel.edit.aws_ak_placeholder')}
onChange={handleConfigChange}
value={config.ak}
autoComplete=''
@@ -477,141 +542,137 @@ const EditChannel = () => {
label='SK'
name='sk'
required
placeholder={'AWS IAM Secret Key'}
placeholder={t('channel.edit.aws_sk_placeholder')}
onChange={handleConfigChange}
value={config.sk}
autoComplete=''
/>
</Form.Field>
)
}
{
inputs.type === 42 && (
)}
{inputs.type === 42 && (
<Form.Field>
<Form.Input
label='Region'
name='region'
required
placeholder={'Vertex AI Region.g. us-east5'}
placeholder={t('channel.edit.vertex_region_placeholder')}
onChange={handleConfigChange}
value={config.region}
autoComplete=''
/>
<Form.Input
label='Vertex AI Project ID'
label={t('channel.edit.vertex_project_id')}
name='vertex_ai_project_id'
required
placeholder={'Vertex AI Project ID'}
placeholder={t('channel.edit.vertex_project_id_placeholder')}
onChange={handleConfigChange}
value={config.vertex_ai_project_id}
autoComplete=''
/>
<Form.Input
label='Google Cloud Application Default Credentials JSON'
label={t('channel.edit.vertex_credentials')}
name='vertex_ai_adc'
required
placeholder={'Google Cloud Application Default Credentials JSON'}
placeholder={t('channel.edit.vertex_credentials_placeholder')}
onChange={handleConfigChange}
value={config.vertex_ai_adc}
autoComplete=''
/>
</Form.Field>
)
}
{
inputs.type === 34 && (
)}
{inputs.type === 34 && (
<Form.Input
label='User ID'
label={t('channel.edit.user_id')}
name='user_id'
required
placeholder={'生成该密钥的用户 ID'}
placeholder={t('channel.edit.user_id_placeholder')}
onChange={handleConfigChange}
value={config.user_id}
autoComplete=''
/>)
}
{
inputs.type !== 33 && inputs.type !== 42 && (batch ? <Form.Field>
<Form.TextArea
label='密钥'
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
label='密钥'
name='key'
required
placeholder={type2secretPrompt(inputs.type)}
onChange={handleInputChange}
value={inputs.key}
autoComplete='new-password'
/>
</Form.Field>)
}
{
inputs.type === 37 && (
<Form.Field>
<Form.Input
label='Account ID'
name='user_id'
required
placeholder={'请输入 Account ID例如d8d7c61dbc334c32d3ced580e4bf42b4'}
onChange={handleConfigChange}
value={config.user_id}
autoComplete=''
/>
</Form.Field>
)
}
{
inputs.type !== 33 && !isEdit && (
)}
{inputs.type !== 33 &&
inputs.type !== 42 &&
(batch ? (
<Form.Field>
<Form.TextArea
label={t('channel.edit.key')}
name='key'
required
placeholder={t('channel.edit.batch_placeholder')}
onChange={handleInputChange}
value={inputs.key}
style={{
minHeight: 150,
fontFamily: 'JetBrains Mono, Consolas',
}}
autoComplete='new-password'
/>
</Form.Field>
) : (
<Form.Field>
<Form.Input
label={t('channel.edit.key')}
name='key'
required
placeholder={type2secretPrompt(inputs.type, t)}
onChange={handleInputChange}
value={inputs.key}
autoComplete='new-password'
/>
</Form.Field>
))}
{inputs.type !== 33 && !isEdit && (
<Form.Checkbox
checked={batch}
label='批量创建'
label={t('channel.edit.batch')}
name='batch'
onChange={() => setBatch(!batch)}
/>
)
}
{
inputs.type !== 3 && inputs.type !== 33 && inputs.type !== 8 && inputs.type !== 22 && (
<Form.Field>
<Form.Input
label='代理'
name='base_url'
placeholder={'此项可选,用于通过代理站来进行 API 调用请输入代理站地址格式为https://domain.com'}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)
}
{
inputs.type === 22 && (
)}
{inputs.type !== 3 &&
inputs.type !== 33 &&
inputs.type !== 8 &&
inputs.type !== 22 && (
<Form.Field>
<Form.Input
label={t('channel.edit.base_url')}
name='base_url'
placeholder={t('channel.edit.base_url_placeholder')}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)}
{inputs.type === 22 && (
<Form.Field>
<Form.Input
label='私有部署地址'
name='base_url'
placeholder={'请输入私有部署地址格式为https://fastgpt.run/api/openapi'}
placeholder={
'请输入私有部署地址格式为https://fastgpt.run/api/openapi'
}
onChange={handleInputChange}
value={inputs.base_url}
autoComplete='new-password'
/>
</Form.Field>
)
}
<Button onClick={handleCancel}>取消</Button>
<Button type={isEdit ? 'button' : 'submit'} positive onClick={submit}>提交</Button>
</Form>
</Segment>
</>
)}
<Button onClick={handleCancel}>
{t('channel.edit.buttons.cancel')}
</Button>
<Button
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 { Header, Segment } from 'semantic-ui-react';
import { Card } from 'semantic-ui-react';
import ChannelsTable from '../../components/ChannelsTable';
import { useTranslation } from 'react-i18next';
const Channel = () => (
<>
<Segment>
<Header as='h3'>管理渠道</Header>
<ChannelsTable />
</Segment>
</>
);
const Channel = () => {
const { t } = useTranslation();
return (
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>{t('channel.title')}</Card.Header>
<ChannelsTable />
</Card.Content>
</Card>
</div>
);
};
export default Channel;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,19 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Segment, Statistic } from 'semantic-ui-react';
import {
Button,
Form,
Grid,
Header,
Card,
Statistic,
Divider,
} from 'semantic-ui-react';
import { API, showError, showInfo, showSuccess } from '../../helpers';
import { renderQuota } from '../../helpers/render';
import { useTranslation } from 'react-i18next';
const TopUp = () => {
const { t } = useTranslation();
const [redemptionCode, setRedemptionCode] = useState('');
const [topUpLink, setTopUpLink] = useState('');
const [userQuota, setUserQuota] = useState(0);
@@ -12,17 +22,17 @@ const TopUp = () => {
const topUp = async () => {
if (redemptionCode === '') {
showInfo('请输入充值码!')
showInfo(t('topup.redeem_code.empty_code'));
return;
}
setIsSubmitting(true);
try {
const res = await API.post('/api/user/topup', {
key: redemptionCode
key: redemptionCode,
});
const { success, message, data } = res.data;
if (success) {
showSuccess('充值成功!');
showSuccess(t('topup.redeem_code.success'));
setUserQuota((quota) => {
return quota + data;
});
@@ -31,37 +41,36 @@ const TopUp = () => {
showError(message);
}
} catch (err) {
showError('请求失败');
showError(t('topup.redeem_code.request_failed'));
} finally {
setIsSubmitting(false);
setIsSubmitting(false);
}
};
const openTopUpLink = () => {
if (!topUpLink) {
showError('超级管理员未设置充值链接!');
showError(t('topup.redeem_code.no_link'));
return;
}
let url = new URL(topUpLink);
let username = user.username;
let user_id = user.id;
// add username and user_id to the topup link
url.searchParams.append('username', username);
url.searchParams.append('user_id', user_id);
url.searchParams.append('transaction_id', crypto.randomUUID());
window.open(url.toString(), '_blank');
};
const getUserQuota = async ()=>{
let res = await API.get(`/api/user/self`);
const {success, message, data} = res.data;
const getUserQuota = async () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
if (success) {
setUserQuota(data.quota);
setUser(data);
} else {
showError(message);
}
}
};
useEffect(() => {
let status = localStorage.getItem('status');
@@ -75,38 +84,170 @@ const TopUp = () => {
}, []);
return (
<Segment>
<Header as='h3'>充值额度</Header>
<Grid columns={2} stackable>
<Grid.Column>
<Form>
<Form.Input
placeholder='兑换码'
name='redemptionCode'
value={redemptionCode}
onChange={(e) => {
setRedemptionCode(e.target.value);
}}
/>
<Button color='green' onClick={openTopUpLink}>
充值
</Button>
<Button color='yellow' onClick={topUp} disabled={isSubmitting}>
{isSubmitting ? '兑换中...' : '兑换'}
</Button>
</Form>
</Grid.Column>
<Grid.Column>
<Statistic.Group widths='one'>
<Statistic>
<Statistic.Value>{renderQuota(userQuota)}</Statistic.Value>
<Statistic.Label>剩余额度</Statistic.Label>
</Statistic>
</Statistic.Group>
</Grid.Column>
</Grid>
</Segment>
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>
<Header as='h2'>{t('topup.title')}</Header>
</Card.Header>
<Grid columns={2} stackable>
<Grid.Column>
<Card
fluid
style={{
height: '100%',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
<Card.Content
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<Card.Header>
<Header as='h3' style={{ color: '#2185d0', margin: '1em' }}>
<i className='credit card icon'></i>
{t('topup.get_code.title')}
</Header>
</Card.Header>
<Card.Description
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}}
>
<div style={{ textAlign: 'center', paddingTop: '1em' }}>
<Statistic>
<Statistic.Value style={{ color: '#2185d0' }}>
{renderQuota(userQuota)}
</Statistic.Value>
<Statistic.Label>
{t('topup.get_code.current_quota')}
</Statistic.Label>
</Statistic>
</div>
<div
style={{ textAlign: 'center', paddingBottom: '1em' }}
>
<Button
primary
size='large'
onClick={openTopUpLink}
style={{ width: '80%' }}
>
{t('topup.get_code.button')}
</Button>
</div>
</div>
</Card.Description>
</Card.Content>
</Card>
</Grid.Column>
<Grid.Column>
<Card
fluid
style={{
height: '100%',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
<Card.Content
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<Card.Header>
<Header as='h3' style={{ color: '#21ba45', margin: '1em' }}>
<i className='ticket alternate icon'></i>
{t('topup.redeem_code.title')}
</Header>
</Card.Header>
<Card.Description
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}}
>
<Form.Input
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,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Segment } from 'semantic-ui-react';
import { Button, Form, Card } from 'semantic-ui-react';
import { useParams, useNavigate } from 'react-router-dom';
import { API, showError, showSuccess } from '../../helpers';
import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
@@ -16,30 +16,40 @@ const EditUser = () => {
wechat_id: '',
email: '',
quota: 0,
group: 'default'
group: 'default',
});
const [groupOptions, setGroupOptions] = useState([]);
const { username, display_name, password, github_id, wechat_id, email, quota, group } =
inputs;
const {
username,
display_name,
password,
github_id,
wechat_id,
email,
quota,
group,
} = inputs;
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
setGroupOptions(res.data.data.map((group) => ({
key: group,
text: group,
value: group,
})));
setGroupOptions(
res.data.data.map((group) => ({
key: group,
text: group,
value: group,
}))
);
} catch (error) {
showError(error.message);
}
};
const navigate = useNavigate();
const handleCancel = () => {
navigate("/setting");
}
navigate('/setting');
};
const loadUser = async () => {
let res = undefined;
if (userId) {
@@ -83,107 +93,113 @@ const EditUser = () => {
};
return (
<>
<Segment loading={loading}>
<Header as='h3'>更新用户信息</Header>
<Form autoComplete='new-password'>
<Form.Field>
<Form.Input
label='用户名'
name='username'
placeholder={'请输入新的用户名'}
onChange={handleInputChange}
value={username}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.Input
label='密码'
name='password'
type={'password'}
placeholder={'请输入新的密码,最短 8 位'}
onChange={handleInputChange}
value={password}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.Input
label='显示名称'
name='display_name'
placeholder={'请输入新的显示名称'}
onChange={handleInputChange}
value={display_name}
autoComplete='new-password'
/>
</Form.Field>
{
userId && <>
<Form.Field>
<Form.Dropdown
label='分组'
placeholder={'请选择分组'}
name='group'
fluid
search
selection
allowAdditions
additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
onChange={handleInputChange}
value={inputs.group}
autoComplete='new-password'
options={groupOptions}
/>
</Form.Field>
<Form.Field>
<Form.Input
label={`剩余额度${renderQuotaWithPrompt(quota)}`}
name='quota'
placeholder={'请输入新的剩余额度'}
onChange={handleInputChange}
value={quota}
type={'number'}
autoComplete='new-password'
/>
</Form.Field>
</>
}
<Form.Field>
<Form.Input
label='已绑定的 GitHub 账户'
name='github_id'
value={github_id}
autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly
/>
</Form.Field>
<Form.Field>
<Form.Input
label='已绑定的微信账户'
name='wechat_id'
value={wechat_id}
autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly
/>
</Form.Field>
<Form.Field>
<Form.Input
label='已绑定的邮箱账户'
name='email'
value={email}
autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly
/>
</Form.Field>
<Button onClick={handleCancel}>取消</Button>
<Button positive onClick={submit}>提交</Button>
</Form>
</Segment>
</>
<div className='dashboard-container'>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header className='header'>更新用户信息</Card.Header>
<Form loading={loading} autoComplete='new-password'>
<Form.Field>
<Form.Input
label='用户名'
name='username'
placeholder={'请输入新的用户名'}
onChange={handleInputChange}
value={username}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.Input
label='密码'
name='password'
type={'password'}
placeholder={'请输入新的密码,最短 8 位'}
onChange={handleInputChange}
value={password}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.Input
label='显示名称'
name='display_name'
placeholder={'请输入新的显示名称'}
onChange={handleInputChange}
value={display_name}
autoComplete='new-password'
/>
</Form.Field>
{userId && (
<>
<Form.Field>
<Form.Dropdown
label='分组'
placeholder={'请选择分组'}
name='group'
fluid
search
selection
allowAdditions
additionLabel={
'请在系统设置页面编辑分组倍率以添加新的分组:'
}
onChange={handleInputChange}
value={inputs.group}
autoComplete='new-password'
options={groupOptions}
/>
</Form.Field>
<Form.Field>
<Form.Input
label={`剩余额度${renderQuotaWithPrompt(quota)}`}
name='quota'
placeholder={'请输入新的剩余额度'}
onChange={handleInputChange}
value={quota}
type={'number'}
autoComplete='new-password'
/>
</Form.Field>
</>
)}
<Form.Field>
<Form.Input
label='已绑定的 GitHub 账户'
name='github_id'
value={github_id}
autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly
/>
</Form.Field>
<Form.Field>
<Form.Input
label='已绑定的微信账户'
name='wechat_id'
value={wechat_id}
autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly
/>
</Form.Field>
<Form.Field>
<Form.Input
label='已绑定的邮箱账户'
name='email'
value={email}
autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly
/>
</Form.Field>
<Button onClick={handleCancel}>取消</Button>
<Button positive onClick={submit}>
提交
</Button>
</Form>
</Card.Content>
</Card>
</div>
);
};

View File

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