mirror of
				https://github.com/songquanpeng/one-api.git
				synced 2025-10-31 05:43:42 +08:00 
			
		
		
		
	Compare commits
	
		
			42 Commits
		
	
	
		
			v0.6.11-al
			...
			v0.6.11-al
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 562964238c | ||
|  | 46b1d35d83 | ||
|  | 6a197ceb69 | ||
|  | bd7d0d1e96 | ||
|  | f325f58edf | ||
|  | aef2100be1 | ||
|  | 4685c52a5d | ||
|  | 174dea7763 | ||
|  | 9b81c88250 | ||
|  | 72d911986c | ||
|  | a01d769a83 | ||
|  | d0965050a9 | ||
|  | e7ea7c866f | ||
|  | b1fe81a84f | ||
|  | e183e3b9b0 | ||
|  | b7f008cd72 | ||
|  | 33102c4586 | ||
|  | ee3ed65356 | ||
|  | 958f2f4ea8 | ||
|  | 4a5f872dce | ||
|  | 6ca6a3ea74 | ||
|  | 2c8c29bfc7 | ||
|  | ae20aea555 | ||
|  | 60f2776795 | ||
|  | 93ce6c4cd7 | ||
|  | 4fe5ab8d09 | ||
|  | afbbfbbf83 | ||
|  | 75d9d9d560 | ||
|  | d1af30ee5a | ||
|  | a3924a2353 | ||
|  | 9af5a1d11d | ||
|  | 57f9f7dfbb | ||
|  | d9f2df2baf | ||
|  | bdf312e5dc | ||
|  | 1521df6551 | ||
|  | c67b167f4f | ||
|  | c351e196e6 | ||
|  | a316ed7abc | ||
|  | 0895d8660e | ||
|  | be1ed114f4 | ||
|  | eb6da573a3 | ||
|  | 0a6273fc08 | 
							
								
								
									
										64
									
								
								.github/workflows/docker-image-en.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										64
									
								
								.github/workflows/docker-image-en.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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 }} | ||||
							
								
								
									
										7
									
								
								.github/workflows/docker-image.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/docker-image.yml
									
									
									
									
										vendored
									
									
								
							| @@ -55,14 +55,15 @@ jobs: | ||||
|         uses: docker/metadata-action@v4 | ||||
|         with: | ||||
|           images: | | ||||
|             justsong/one-api | ||||
|             ghcr.io/${{ github.repository }} | ||||
|             ${{ contains(github.ref, 'alpha') && 'justsong/one-api-alpha' || 'justsong/one-api' }} | ||||
|             ${{ contains(github.ref, 'alpha') && format('ghcr.io/{0}-alpha', github.repository) || format('ghcr.io/{0}', github.repository) }} | ||||
|  | ||||
|       - name: Build and push Docker images | ||||
|         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 }} | ||||
							
								
								
									
										45
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										45
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -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
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								go.mod
									
									
									
									
									
								
							| @@ -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
									
									
									
									
									
								
							
							
						
						
									
										8
									
								
								go.sum
									
									
									
									
									
								
							| @@ -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= | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import ( | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
|  | ||||
| 	"github.com/songquanpeng/one-api/common" | ||||
| 	"github.com/songquanpeng/one-api/common/config" | ||||
| ) | ||||
| @@ -71,7 +72,7 @@ func memoryRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark s | ||||
| } | ||||
|  | ||||
| func rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gin.Context) { | ||||
| 	if maxRequestNum == 0 { | ||||
| 	if maxRequestNum == 0 || config.DebugEnabled { | ||||
| 		return func(c *gin.Context) { | ||||
| 			c.Next() | ||||
| 		} | ||||
|   | ||||
| @@ -5,10 +5,15 @@ | ||||
|   "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", | ||||
|     "moment": "^2.30.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", | ||||
|   | ||||
							
								
								
									
										758
									
								
								web/default/public/locales/en/translation.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										758
									
								
								web/default/public/locales/en/translation.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,758 @@ | ||||
| { | ||||
|   "header": { | ||||
|     "home": "Home", | ||||
|     "channel": "Channel", | ||||
|     "token": "Token", | ||||
|     "redemption": "Redemption", | ||||
|     "topup": "Top Up", | ||||
|     "user": "User", | ||||
|     "dashboard": "Dashboard", | ||||
|     "log": "Log", | ||||
|     "setting": "Settings", | ||||
|     "about": "About", | ||||
|     "chat": "Chat", | ||||
|     "login": "Login", | ||||
|     "logout": "Logout", | ||||
|     "register": "Register" | ||||
|   }, | ||||
|   "topup": { | ||||
|     "title": "Top Up Center", | ||||
|     "get_code": { | ||||
|       "title": "Get Redemption Code", | ||||
|       "current_quota": "Current Available Quota", | ||||
|       "button": "Get Code Now" | ||||
|     }, | ||||
|     "redeem_code": { | ||||
|       "title": "Redeem Code", | ||||
|       "placeholder": "Please enter redemption code", | ||||
|       "paste": "Paste", | ||||
|       "paste_error": "Cannot access clipboard, please paste manually", | ||||
|       "submit": "Redeem Now", | ||||
|       "submitting": "Redeeming...", | ||||
|       "empty_code": "Please enter the redemption code!", | ||||
|       "success": "Top up successful!", | ||||
|       "request_failed": "Request failed", | ||||
|       "no_link": "Admin has not set up the top-up link!" | ||||
|     } | ||||
|   }, | ||||
|   "channel": { | ||||
|     "title": "Channel Management", | ||||
|     "search": "Search channels by ID, name and key...", | ||||
|     "balance_notice": "OpenAI channels no longer support getting balance via key, so balance shows as 0. For supported channel types, click balance to refresh.", | ||||
|     "test_notice": "Channel testing only supports chat models, preferring gpt-3.5-turbo. If unavailable, uses the first model in your configured list.", | ||||
|     "detail_notice": "Click the detail button below to show balance and set additional test models.", | ||||
|     "table": { | ||||
|       "id": "ID", | ||||
|       "name": "Name", | ||||
|       "group": "Group", | ||||
|       "type": "Type", | ||||
|       "status": "Status", | ||||
|       "response_time": "Response Time", | ||||
|       "balance": "Balance", | ||||
|       "priority": "Priority", | ||||
|       "test_model": "Test Model", | ||||
|       "actions": "Actions", | ||||
|       "no_name": "None", | ||||
|       "status_enabled": "Enabled", | ||||
|       "status_disabled": "Disabled", | ||||
|       "status_auto_disabled": "Disabled", | ||||
|       "status_disabled_tip": "This channel is manually disabled", | ||||
|       "status_auto_disabled_tip": "This channel is automatically disabled", | ||||
|       "status_unknown": "Unknown Status", | ||||
|       "not_tested": "Not Tested", | ||||
|       "priority_tip": "Channel selection priority, higher is preferred", | ||||
|       "select_test_model": "Please select test model", | ||||
|       "click_to_update": "Click to update" | ||||
|     }, | ||||
|     "buttons": { | ||||
|       "test": "Test", | ||||
|       "delete": "Delete", | ||||
|       "confirm_delete": "Delete Channel", | ||||
|       "enable": "Enable", | ||||
|       "disable": "Disable", | ||||
|       "edit": "Edit", | ||||
|       "add": "Add New Channel", | ||||
|       "test_all": "Test All Channels", | ||||
|       "test_disabled": "Test Disabled Channels", | ||||
|       "delete_disabled": "Delete Disabled Channels", | ||||
|       "confirm_delete_disabled": "Confirm Delete", | ||||
|       "refresh": "Refresh", | ||||
|       "show_detail": "Details", | ||||
|       "hide_detail": "Hide Details" | ||||
|     }, | ||||
|     "messages": { | ||||
|       "test_success": "Channel {{name}} test successful, model {{model}}, time {{time}}s, output: {{message}}", | ||||
|       "test_all_started": "Channel testing started successfully, please refresh page to see results.", | ||||
|       "delete_disabled_success": "Deleted all disabled channels, total: {{count}}", | ||||
|       "balance_update_success": "Channel {{name}} balance updated successfully!", | ||||
|       "all_balance_updated": "All enabled channel balances have been updated!" | ||||
|     }, | ||||
|     "edit": { | ||||
|       "title_edit": "Update Channel Information", | ||||
|       "title_create": "Create New Channel", | ||||
|       "type": "Type", | ||||
|       "name": "Name", | ||||
|       "name_placeholder": "Please enter name", | ||||
|       "group": "Group", | ||||
|       "group_placeholder": "Please select groups that can use this channel", | ||||
|       "group_addition": "Please edit group multipliers in system settings to add new group:", | ||||
|       "models": "Models", | ||||
|       "models_placeholder": "Please select models supported by this channel", | ||||
|       "model_mapping": "Model Mapping", | ||||
|       "model_mapping_placeholder": "Optional, used to modify model names in request body. A JSON string where keys are request model names and values are target model names", | ||||
|       "system_prompt": "System Prompt", | ||||
|       "system_prompt_placeholder": "Optional, used to force set system prompt. Use with custom model & model mapping. First create a unique custom model name above, then map it to a natively supported model", | ||||
|       "base_url": "Proxy", | ||||
|       "base_url_placeholder": "Optional, used for API calls through proxy. Enter proxy address in format: https://domain.com", | ||||
|       "key": "Key", | ||||
|       "key_placeholder": "Please enter key", | ||||
|       "batch": "Batch Create", | ||||
|       "batch_placeholder": "Please enter keys, one per line", | ||||
|       "buttons": { | ||||
|         "cancel": "Cancel", | ||||
|         "submit": "Submit", | ||||
|         "fill_models": "Fill Related Models", | ||||
|         "fill_all": "Fill All Models", | ||||
|         "clear": "Clear All Models", | ||||
|         "add_custom": "Add", | ||||
|         "custom_placeholder": "Enter custom model name" | ||||
|       }, | ||||
|       "messages": { | ||||
|         "name_required": "Please enter channel name and key!", | ||||
|         "models_required": "Please select at least one model!", | ||||
|         "model_mapping_invalid": "Model mapping must be valid JSON format!", | ||||
|         "update_success": "Channel updated successfully!", | ||||
|         "create_success": "Channel created successfully!" | ||||
|       }, | ||||
|       "spark_version": "Model Version", | ||||
|       "spark_version_placeholder": "Please enter Spark model version from API URL, e.g.: v2.1", | ||||
|       "knowledge_id": "Knowledge Base ID", | ||||
|       "knowledge_id_placeholder": "Please enter knowledge base ID, e.g.: 123456", | ||||
|       "plugin_param": "Plugin Parameter", | ||||
|       "plugin_param_placeholder": "Please enter plugin parameter (X-DashScope-Plugin header value)", | ||||
|       "coze_notice": "For Coze, model name is the Bot ID. You can add prefix `bot-`, e.g.: `bot-123456`.", | ||||
|       "douban_notice": "For Douban, you need to go to", | ||||
|       "douban_notice_link": "Model Inference Page", | ||||
|       "douban_notice_2": "to create an inference endpoint, and use the endpoint name as model name, e.g.: `ep-20240608051426-tkxvl`.", | ||||
|       "aws_region_placeholder": "region, e.g.: us-west-2", | ||||
|       "aws_ak_placeholder": "AWS IAM Access Key", | ||||
|       "aws_sk_placeholder": "AWS IAM Secret Key", | ||||
|       "vertex_region_placeholder": "Vertex AI Region, e.g.: us-east5", | ||||
|       "vertex_project_id": "Vertex AI Project ID", | ||||
|       "vertex_project_id_placeholder": "Vertex AI Project ID", | ||||
|       "vertex_credentials": "Google Cloud Application Default Credentials JSON", | ||||
|       "vertex_credentials_placeholder": "Google Cloud Application Default Credentials JSON", | ||||
|       "user_id": "User ID", | ||||
|       "user_id_placeholder": "User ID who generated this key", | ||||
|       "key_prompts": { | ||||
|         "default": "Please enter the authentication key for this channel", | ||||
|         "zhipu": "Enter in format: APIKey|SecretKey", | ||||
|         "spark": "Enter in format: APPID|APISecret|APIKey", | ||||
|         "fastgpt": "Enter in format: APIKey-AppId, e.g.: fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041", | ||||
|         "tencent": "Enter in format: AppId|SecretId|SecretKey" | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   "token": { | ||||
|     "title": "Token Management", | ||||
|     "search": "Search tokens by name ...", | ||||
|     "table": { | ||||
|       "name": "Name", | ||||
|       "status": "Status", | ||||
|       "used_quota": "Used Quota", | ||||
|       "remain_quota": "Remaining Quota", | ||||
|       "created_time": "Created Time", | ||||
|       "expired_time": "Expiry Time", | ||||
|       "actions": "Actions", | ||||
|       "no_name": "None", | ||||
|       "never_expire": "Never Expires", | ||||
|       "unlimited": "Unlimited", | ||||
|       "status_enabled": "Enabled", | ||||
|       "status_disabled": "Disabled", | ||||
|       "status_expired": "Expired", | ||||
|       "status_depleted": "Depleted", | ||||
|       "status_unknown": "Unknown Status" | ||||
|     }, | ||||
|     "buttons": { | ||||
|       "copy": "Copy", | ||||
|       "chat": "Chat", | ||||
|       "delete": "Delete", | ||||
|       "confirm_delete": "Delete Token", | ||||
|       "enable": "Enable", | ||||
|       "disable": "Disable", | ||||
|       "edit": "Edit", | ||||
|       "add": "Add New Token", | ||||
|       "refresh": "Refresh" | ||||
|     }, | ||||
|     "edit": { | ||||
|       "title_edit": "Update Token Information", | ||||
|       "title_create": "Create New Token", | ||||
|       "name": "Name", | ||||
|       "name_placeholder": "Please enter name", | ||||
|       "models": "Model Scope", | ||||
|       "models_placeholder": "Please select allowed models, leave empty for no restrictions", | ||||
|       "ip_limit": "IP Restriction", | ||||
|       "ip_limit_placeholder": "Please enter allowed subnets, e.g.: 192.168.0.0/24, use commas to separate multiple subnets", | ||||
|       "expire_time": "Expiry Time", | ||||
|       "expire_time_placeholder": "Please enter expiry time in yyyy-MM-dd HH:mm:ss format, -1 for no limit", | ||||
|       "quota_notice": "Note: Token quota only limits the maximum usage of the token itself, actual usage is subject to account remaining quota.", | ||||
|       "quota": "Quota", | ||||
|       "quota_placeholder": "Please enter quota", | ||||
|       "buttons": { | ||||
|         "never_expire": "Never Expire", | ||||
|         "expire_1_month": "Expire in 1 Month", | ||||
|         "expire_1_day": "Expire in 1 Day", | ||||
|         "expire_1_hour": "Expire in 1 Hour", | ||||
|         "expire_1_minute": "Expire in 1 Minute", | ||||
|         "unlimited_quota": "Set Unlimited Quota", | ||||
|         "cancel_unlimited": "Cancel Unlimited Quota", | ||||
|         "submit": "Submit", | ||||
|         "cancel": "Cancel" | ||||
|       }, | ||||
|       "messages": { | ||||
|         "update_success": "Token updated successfully!", | ||||
|         "create_success": "Token created successfully, please copy it from the list page!", | ||||
|         "expire_time_invalid": "Invalid expiry time format!" | ||||
|       } | ||||
|     }, | ||||
|     "copy_options": { | ||||
|       "raw": "Copy Raw Token", | ||||
|       "ama": "Copy AMA Link", | ||||
|       "opencat": "Copy OpenCat Link", | ||||
|       "next": "Copy NextChat Link", | ||||
|       "lobe": "Copy LobeChat Link" | ||||
|     }, | ||||
|     "messages": { | ||||
|       "copy_success": "Copied to clipboard!", | ||||
|       "copy_failed": "Unable to copy to clipboard, please copy manually. Token has been filled in the search box.", | ||||
|       "operation_success": "Operation completed successfully!" | ||||
|     }, | ||||
|     "sort": { | ||||
|       "placeholder": "Sort By", | ||||
|       "default": "Default Order", | ||||
|       "by_remain": "Sort by Remaining Quota", | ||||
|       "by_used": "Sort by Used Quota" | ||||
|     } | ||||
|   }, | ||||
|   "common": { | ||||
|     "quota": { | ||||
|       "display": "Equivalent: ${{amount}}", | ||||
|       "display_short": "${{amount}}", | ||||
|       "unit": "$" | ||||
|     } | ||||
|   }, | ||||
|   "redemption": { | ||||
|     "title": "Redemption Management", | ||||
|     "search": "Search redemption codes by ID and name ...", | ||||
|     "table": { | ||||
|       "id": "ID", | ||||
|       "name": "Name", | ||||
|       "status": "Status", | ||||
|       "quota": "Quota", | ||||
|       "created_time": "Created Time", | ||||
|       "redeemed_time": "Redeemed Time", | ||||
|       "actions": "Actions", | ||||
|       "no_name": "None", | ||||
|       "not_redeemed": "Not Redeemed" | ||||
|     }, | ||||
|     "buttons": { | ||||
|       "copy": "Copy", | ||||
|       "delete": "Delete", | ||||
|       "confirm_delete": "Confirm Delete", | ||||
|       "enable": "Enable", | ||||
|       "disable": "Disable", | ||||
|       "edit": "Edit", | ||||
|       "add": "Add New Code", | ||||
|       "refresh": "Refresh" | ||||
|     }, | ||||
|     "status": { | ||||
|       "unused": "Unused", | ||||
|       "disabled": "Disabled", | ||||
|       "used": "Used", | ||||
|       "unknown": "Unknown" | ||||
|     }, | ||||
|     "edit": { | ||||
|       "title_edit": "Update Redemption Code", | ||||
|       "title_create": "Create New Redemption Code", | ||||
|       "name": "Name", | ||||
|       "name_placeholder": "Please enter name", | ||||
|       "quota": "Quota", | ||||
|       "quota_placeholder": "Please enter quota per redemption code", | ||||
|       "count": "Generate Count", | ||||
|       "count_placeholder": "Please enter number of codes to generate", | ||||
|       "buttons": { | ||||
|         "submit": "Submit", | ||||
|         "cancel": "Cancel" | ||||
|       } | ||||
|     }, | ||||
|     "messages": { | ||||
|       "update_success": "Redemption code updated successfully!", | ||||
|       "create_success": "Redemption code created successfully!" | ||||
|     } | ||||
|   }, | ||||
|   "log": { | ||||
|     "title": "Operation Log", | ||||
|     "search": "Search logs...", | ||||
|     "usage_details": "Usage Details", | ||||
|     "total_quota": "Total Quota Used", | ||||
|     "click_to_view": "Click to View", | ||||
|     "type": { | ||||
|       "select": "Select Log Type", | ||||
|       "all": "All", | ||||
|       "topup": "Top Up", | ||||
|       "usage": "Usage", | ||||
|       "admin": "Admin", | ||||
|       "system": "System", | ||||
|       "test": "Test" | ||||
|     }, | ||||
|     "table": { | ||||
|       "time": "Time", | ||||
|       "channel": "Channel", | ||||
|       "type": "Type", | ||||
|       "model": "Model", | ||||
|       "username": "Username", | ||||
|       "token_name": "Token Name", | ||||
|       "token_name_placeholder": "Optional", | ||||
|       "model_name": "Model Name", | ||||
|       "model_name_placeholder": "Optional", | ||||
|       "start_time": "Start Time", | ||||
|       "end_time": "End Time", | ||||
|       "channel_id": "Channel ID", | ||||
|       "channel_id_placeholder": "Optional", | ||||
|       "username_placeholder": "Optional", | ||||
|       "prompt_tokens": "Prompt Tokens", | ||||
|       "completion_tokens": "Completion Tokens", | ||||
|       "quota": "Quota", | ||||
|       "detail": "Detail" | ||||
|     }, | ||||
|     "buttons": { | ||||
|       "query": "Action", | ||||
|       "submit": "Query", | ||||
|       "refresh": "Refresh" | ||||
|     } | ||||
|   }, | ||||
|   "user": { | ||||
|     "title": "User Management", | ||||
|     "edit": { | ||||
|       "title": "Update User Information", | ||||
|       "username": "Username", | ||||
|       "username_placeholder": "Please enter new username", | ||||
|       "password": "Password", | ||||
|       "password_placeholder": "Please enter new password, minimum 8 characters", | ||||
|       "display_name": "Display Name", | ||||
|       "display_name_placeholder": "Please enter new display name", | ||||
|       "group": "Group", | ||||
|       "group_placeholder": "Please select group", | ||||
|       "group_addition": "Please edit group multipliers in system settings to add new group:", | ||||
|       "quota": "Remaining Quota", | ||||
|       "quota_placeholder": "Please enter new remaining quota", | ||||
|       "github_id": "Linked GitHub Account", | ||||
|       "github_id_placeholder": "Read-only, user must link through personal settings page, cannot be modified directly", | ||||
|       "wechat_id": "Linked WeChat Account", | ||||
|       "wechat_id_placeholder": "Read-only, user must link through personal settings page, cannot be modified directly", | ||||
|       "email": "Linked Email Account", | ||||
|       "email_placeholder": "Read-only, user must link through personal settings page, cannot be modified directly", | ||||
|       "buttons": { | ||||
|         "submit": "Submit", | ||||
|         "cancel": "Cancel" | ||||
|       } | ||||
|     }, | ||||
|     "add": { | ||||
|       "title": "Create New User Account" | ||||
|     }, | ||||
|     "messages": { | ||||
|       "update_success": "User information updated successfully!", | ||||
|       "create_success": "User account created successfully!", | ||||
|       "operation_success": "Operation completed successfully!" | ||||
|     }, | ||||
|     "search": "Search users...", | ||||
|     "table": { | ||||
|       "id": "ID", | ||||
|       "username": "Username", | ||||
|       "group": "Group", | ||||
|       "quota": "Quota", | ||||
|       "role_text": "Role", | ||||
|       "status_text": "Status", | ||||
|       "actions": "Actions", | ||||
|       "remaining_quota": "Remaining Quota", | ||||
|       "used_quota": "Used Quota", | ||||
|       "request_count": "Request Count", | ||||
|       "role_types": { | ||||
|         "normal": "Normal User", | ||||
|         "admin": "Admin", | ||||
|         "super_admin": "Super Admin", | ||||
|         "unknown": "Unknown Role" | ||||
|       }, | ||||
|       "status_types": { | ||||
|         "activated": "Activated", | ||||
|         "banned": "Banned", | ||||
|         "unknown": "Unknown Status" | ||||
|       }, | ||||
|       "sort": { | ||||
|         "default": "Default Order", | ||||
|         "by_quota": "Sort by Remaining Quota", | ||||
|         "by_used_quota": "Sort by Used Quota", | ||||
|         "by_request_count": "Sort by Request Count" | ||||
|       }, | ||||
|       "sort_by": "Sort By" | ||||
|     }, | ||||
|     "buttons": { | ||||
|       "add": "Add New User", | ||||
|       "delete": "Delete", | ||||
|       "delete_user": "Delete User", | ||||
|       "enable": "Enable", | ||||
|       "disable": "Disable", | ||||
|       "edit": "Edit", | ||||
|       "promote": "Promote", | ||||
|       "demote": "Demote" | ||||
|     } | ||||
|   }, | ||||
|   "dashboard": { | ||||
|     "charts": { | ||||
|       "requests": { | ||||
|         "title": "Model Request Trend", | ||||
|         "tooltip": "Request Count" | ||||
|       }, | ||||
|       "quota": { | ||||
|         "title": "Quota Usage Trend", | ||||
|         "tooltip": "Quota Used" | ||||
|       }, | ||||
|       "tokens": { | ||||
|         "title": "Token Usage Trend", | ||||
|         "tooltip": "Token Count" | ||||
|       } | ||||
|     }, | ||||
|     "statistics": { | ||||
|       "title": "Statistics", | ||||
|       "tooltip": { | ||||
|         "date": "Date", | ||||
|         "value": "Value" | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   "setting": { | ||||
|     "title": "System Settings", | ||||
|     "tabs": { | ||||
|       "personal": "Personal Settings", | ||||
|       "operation": "Operation Settings", | ||||
|       "system": "System Settings", | ||||
|       "other": "Other Settings" | ||||
|     }, | ||||
|     "personal": { | ||||
|       "general": { | ||||
|         "title": "General Settings", | ||||
|         "system_token_notice": "Note: The token generated here is for system management, not for requesting OpenAI related services.", | ||||
|         "buttons": { | ||||
|           "update_profile": "Update Profile", | ||||
|           "generate_token": "Generate System Token", | ||||
|           "copy_invite": "Copy Invite Link", | ||||
|           "delete_account": "Delete Account" | ||||
|         } | ||||
|       }, | ||||
|       "binding": { | ||||
|         "title": "Account Binding", | ||||
|         "buttons": { | ||||
|           "bind_wechat": "Bind WeChat Account", | ||||
|           "bind_github": "Bind GitHub Account", | ||||
|           "bind_email": "Bind Email Address", | ||||
|           "bind_lark": "Bind Lark Account" | ||||
|         }, | ||||
|         "wechat": { | ||||
|           "title": "WeChat Binding", | ||||
|           "description": "Scan QR code to follow the official account, enter 'verification code' to get the code (valid for 3 minutes)", | ||||
|           "verification_code": "Verification Code", | ||||
|           "bind": "Bind" | ||||
|         }, | ||||
|         "email": { | ||||
|           "title": "Bind Email Address", | ||||
|           "email_placeholder": "Enter email address", | ||||
|           "code_placeholder": "Verification code", | ||||
|           "get_code": "Get Code", | ||||
|           "get_code_retry": "Resend({{countdown}})", | ||||
|           "bind": "Confirm Binding", | ||||
|           "cancel": "Cancel" | ||||
|         } | ||||
|       }, | ||||
|       "delete_account": { | ||||
|         "title": "Dangerous Operation", | ||||
|         "warning": "You are deleting your account. All data will be cleared and cannot be recovered", | ||||
|         "confirm_placeholder": "Enter your username {{username}} to confirm deletion", | ||||
|         "buttons": { | ||||
|           "confirm": "Confirm Delete", | ||||
|           "cancel": "Cancel" | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "system": { | ||||
|       "general": { | ||||
|         "title": "General Settings", | ||||
|         "server_address": "Server Address", | ||||
|         "server_address_placeholder": "e.g.: https://yourdomain.com", | ||||
|         "buttons": { | ||||
|           "update": "Update Server Address" | ||||
|         } | ||||
|       }, | ||||
|       "login": { | ||||
|         "title": "Login & Registration Settings", | ||||
|         "password_login": "Allow Password Login", | ||||
|         "password_register": "Allow Password Registration", | ||||
|         "email_verification": "Require Email Verification for Password Registration", | ||||
|         "github_oauth": "Allow GitHub OAuth Login & Registration", | ||||
|         "wechat_login": "Allow WeChat Login & Registration", | ||||
|         "registration": "Allow New User Registration (When disabled, new users cannot register by any means)", | ||||
|         "turnstile": "Enable Turnstile User Verification" | ||||
|       }, | ||||
|       "email_restriction": { | ||||
|         "title": "Email Domain Whitelist", | ||||
|         "subtitle": "Used to prevent malicious users from batch registering using temporary emails", | ||||
|         "enable": "Enable Email Domain Whitelist", | ||||
|         "allowed_domains": "Allowed Email Domains", | ||||
|         "add_domain": "Add New Allowed Email Domain", | ||||
|         "add_domain_placeholder": "Enter new allowed email domain", | ||||
|         "buttons": { | ||||
|           "fill": "Fill", | ||||
|           "save": "Save Email Domain Whitelist Settings" | ||||
|         } | ||||
|       }, | ||||
|       "smtp": { | ||||
|         "title": "SMTP Configuration", | ||||
|         "subtitle": "Used to support system email sending", | ||||
|         "server": "SMTP Server Address", | ||||
|         "server_placeholder": "e.g.: smtp.gmail.com", | ||||
|         "port": "SMTP Port", | ||||
|         "port_placeholder": "Default: 587", | ||||
|         "account": "SMTP Account", | ||||
|         "account_placeholder": "Usually your email address", | ||||
|         "from": "SMTP Sender Email", | ||||
|         "from_placeholder": "Usually same as email address", | ||||
|         "token": "SMTP Access Token", | ||||
|         "token_placeholder": "Sensitive information will not be sent to frontend", | ||||
|         "buttons": { | ||||
|           "save": "Save SMTP Settings" | ||||
|         } | ||||
|       }, | ||||
|       "github": { | ||||
|         "title": "GitHub OAuth App Configuration", | ||||
|         "subtitle": "Used to support GitHub login and registration", | ||||
|         "manage_link": "Click here", | ||||
|         "manage_text": "to manage your GitHub OAuth Apps", | ||||
|         "url_notice": "Set Homepage URL to {{server_url}}, and Authorization callback URL to {{callback_url}}", | ||||
|         "client_id": "GitHub Client ID", | ||||
|         "client_id_placeholder": "Enter your registered GitHub OAuth APP ID", | ||||
|         "client_secret": "GitHub Client Secret", | ||||
|         "client_secret_placeholder": "Sensitive information will not be sent to frontend", | ||||
|         "buttons": { | ||||
|           "save": "Save GitHub OAuth Settings" | ||||
|         } | ||||
|       }, | ||||
|       "lark": { | ||||
|         "title": "Lark OAuth Configuration", | ||||
|         "subtitle": "Used to support Lark login and registration", | ||||
|         "manage_link": "Click here", | ||||
|         "manage_text": "to manage your Lark applications", | ||||
|         "url_notice": "Set Homepage URL to {{server_url}}, and Redirect URL to {{callback_url}}", | ||||
|         "client_id": "App ID", | ||||
|         "client_id_placeholder": "Enter App ID", | ||||
|         "client_secret": "App Secret", | ||||
|         "client_secret_placeholder": "Sensitive information will not be sent to frontend", | ||||
|         "buttons": { | ||||
|           "save": "Save Lark OAuth Settings" | ||||
|         } | ||||
|       }, | ||||
|       "wechat": { | ||||
|         "title": "WeChat Server Configuration", | ||||
|         "subtitle": "Used to support WeChat login and registration", | ||||
|         "learn_more": "Learn about WeChat Server", | ||||
|         "server_address": "WeChat Server Address", | ||||
|         "server_address_placeholder": "e.g.: https://yourdomain.com", | ||||
|         "token": "WeChat Server Access Token", | ||||
|         "token_placeholder": "Sensitive information will not be sent to frontend", | ||||
|         "qrcode": "WeChat Official Account QR Code Image URL", | ||||
|         "qrcode_placeholder": "Enter an image URL", | ||||
|         "buttons": { | ||||
|           "save": "Save WeChat Server Settings" | ||||
|         } | ||||
|       }, | ||||
|       "turnstile": { | ||||
|         "title": "Turnstile Configuration", | ||||
|         "subtitle": "Used to support user verification", | ||||
|         "manage_link": "Click here", | ||||
|         "manage_text": "to manage your Turnstile Sites, Invisible Widget Type recommended", | ||||
|         "site_key": "Turnstile Site Key", | ||||
|         "site_key_placeholder": "Enter your registered Turnstile Site Key", | ||||
|         "secret_key": "Turnstile Secret Key", | ||||
|         "secret_key_placeholder": "Sensitive information will not be sent to frontend", | ||||
|         "buttons": { | ||||
|           "save": "Save Turnstile Settings" | ||||
|         } | ||||
|       }, | ||||
|       "password_login": { | ||||
|         "warning": { | ||||
|           "title": "Warning", | ||||
|           "content": "Disabling password login will prevent all users (including administrators) who haven't bound other login methods from logging in via password. Confirm disable?", | ||||
|           "buttons": { | ||||
|             "confirm": "Confirm", | ||||
|             "cancel": "Cancel" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "operation": { | ||||
|       "quota": { | ||||
|         "title": "Quota Settings", | ||||
|         "new_user": "Initial Quota for New Users", | ||||
|         "new_user_placeholder": "e.g.: 100", | ||||
|         "pre_consume": "Pre-consumed Quota per Request", | ||||
|         "pre_consume_placeholder": "Refund or charge difference after request", | ||||
|         "inviter_reward": "Reward Quota for Inviter", | ||||
|         "inviter_reward_placeholder": "e.g.: 2000", | ||||
|         "invitee_reward": "Reward Quota for Using Invite Code", | ||||
|         "invitee_reward_placeholder": "e.g.: 1000", | ||||
|         "buttons": { | ||||
|           "save": "Save Quota Settings" | ||||
|         } | ||||
|       }, | ||||
|       "ratio": { | ||||
|         "title": "Ratio Settings", | ||||
|         "model": { | ||||
|           "title": "Model Ratio", | ||||
|           "placeholder": "A JSON text where keys are model names and values are ratios" | ||||
|         }, | ||||
|         "completion": { | ||||
|           "title": "Completion Ratio", | ||||
|           "placeholder": "A JSON text where keys are model names and values are ratios. These ratios are the proportion of completion to prompt ratio, which can override One API's internal ratios" | ||||
|         }, | ||||
|         "group": { | ||||
|           "title": "Group Ratio", | ||||
|           "placeholder": "A JSON text where keys are group names and values are ratios" | ||||
|         }, | ||||
|         "buttons": { | ||||
|           "save": "Save Ratio Settings" | ||||
|         } | ||||
|       }, | ||||
|       "log": { | ||||
|         "title": "Log Settings", | ||||
|         "enable_consume": "Enable Quota Consumption Logging", | ||||
|         "target_time": "Target Time", | ||||
|         "buttons": { | ||||
|           "clean": "Clean Historical Logs" | ||||
|         } | ||||
|       }, | ||||
|       "monitor": { | ||||
|         "title": "Monitor Settings", | ||||
|         "max_response_time": "Maximum Response Time", | ||||
|         "max_response_time_placeholder": "In seconds, channels exceeding this time during testing will be automatically disabled", | ||||
|         "quota_reminder": "Quota Reminder Threshold", | ||||
|         "quota_reminder_placeholder": "Users will receive email reminders when quota falls below this value", | ||||
|         "auto_disable": "Automatically Disable Channel on Failure", | ||||
|         "auto_enable": "Automatically Enable Channel on Success", | ||||
|         "buttons": { | ||||
|           "save": "Save Monitor Settings" | ||||
|         } | ||||
|       }, | ||||
|       "general": { | ||||
|         "title": "General Settings", | ||||
|         "topup_link": "Top-up Link", | ||||
|         "topup_link_placeholder": "e.g.: Card selling website purchase link", | ||||
|         "chat_link": "Chat Page Link", | ||||
|         "chat_link_placeholder": "e.g.: ChatGPT Next Web deployment address", | ||||
|         "quota_per_unit": "Quota per Dollar", | ||||
|         "quota_per_unit_placeholder": "Quota exchangeable per unit of currency", | ||||
|         "retry_times": "Retry Times on Failure", | ||||
|         "retry_times_placeholder": "Number of retry attempts on failure", | ||||
|         "display_in_currency": "Display Quota in Currency Format", | ||||
|         "display_token_stat": "Show Token Quota Instead of User Quota in Billing APIs", | ||||
|         "approximate_token": "Use Approximate Method to Estimate Token Count", | ||||
|         "buttons": { | ||||
|           "save": "Save General Settings" | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "other": { | ||||
|       "notice": { | ||||
|         "title": "Notice Settings", | ||||
|         "content": "Notice Content", | ||||
|         "content_placeholder": "Enter new notice content here, supports Markdown & HTML code", | ||||
|         "buttons": { | ||||
|           "save": "Save Notice" | ||||
|         } | ||||
|       }, | ||||
|       "system": { | ||||
|         "title": "System Settings", | ||||
|         "name": "System Name", | ||||
|         "name_placeholder": "Please enter system name", | ||||
|         "logo": "Logo Image URL", | ||||
|         "logo_placeholder": "Enter Logo image URL here", | ||||
|         "theme": { | ||||
|           "title": "Theme Name", | ||||
|           "link": "Available Themes", | ||||
|           "placeholder": "Please enter theme name" | ||||
|         }, | ||||
|         "buttons": { | ||||
|           "save_name": "Set System Name", | ||||
|           "save_logo": "Set Logo", | ||||
|           "save_theme": "Set Theme (Restart Required)" | ||||
|         } | ||||
|       }, | ||||
|       "content": { | ||||
|         "title": "Content Settings", | ||||
|         "homepage": { | ||||
|           "title": "Homepage Content", | ||||
|           "placeholder": "Enter homepage content here, supports Markdown & HTML code. Status information will not be shown after setting. If a link is entered, it will be used as the src attribute of an iframe, allowing you to set any webpage as homepage." | ||||
|         }, | ||||
|         "about": { | ||||
|           "title": "About System", | ||||
|           "description": "You can set about content in settings page, supports HTML & Markdown", | ||||
|           "repository": "Project Repository:", | ||||
|           "loading_failed": "Failed to load about content..." | ||||
|         }, | ||||
|         "footer": { | ||||
|           "title": "Footer", | ||||
|           "placeholder": "Enter new footer here, leave empty to use default footer, supports HTML code" | ||||
|         }, | ||||
|         "buttons": { | ||||
|           "save_homepage": "Save Homepage Content", | ||||
|           "save_about": "Save About", | ||||
|           "save_footer": "Set Footer" | ||||
|         } | ||||
|       }, | ||||
|       "copyright": { | ||||
|         "notice": "Removing One API's copyright notice requires authorization. Project maintenance requires significant effort, if this project is meaningful to you, please actively support it." | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   "footer": { | ||||
|     "built_by": "built by", | ||||
|     "built_by_name": "JustSong", | ||||
|     "license": ", source code is licensed under the", | ||||
|     "mit": "MIT License" | ||||
|   }, | ||||
|   "home": { | ||||
|     "welcome": { | ||||
|       "title": "Welcome to One API", | ||||
|       "description": "One API is a LLM API management and distribution system that helps you better manage and use LLM APIs from various providers.", | ||||
|       "login_notice": "To use the service, please login or register first." | ||||
|     }, | ||||
|     "system_status": { | ||||
|       "title": "System Status", | ||||
|       "info": { | ||||
|         "title": "System Information", | ||||
|         "name": "Name: ", | ||||
|         "version": "Version: ", | ||||
|         "source": "Source: ", | ||||
|         "source_link": "GitHub Repository", | ||||
|         "start_time": "Start Time: " | ||||
|       }, | ||||
|       "config": { | ||||
|         "title": "System Configuration", | ||||
|         "email_verify": "Email Verification: ", | ||||
|         "github_oauth": "GitHub OAuth: ", | ||||
|         "wechat_login": "WeChat Login: ", | ||||
|         "turnstile": "Turnstile Check: ", | ||||
|         "enabled": "Enabled", | ||||
|         "disabled": "Disabled" | ||||
|       } | ||||
|     }, | ||||
|     "loading_failed": "Failed to load homepage content..." | ||||
|   } | ||||
| } | ||||
							
								
								
									
										762
									
								
								web/default/public/locales/zh/translation.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										762
									
								
								web/default/public/locales/zh/translation.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,762 @@ | ||||
| { | ||||
|   "header": { | ||||
|     "home": "首页", | ||||
|     "channel": "渠道", | ||||
|     "token": "令牌", | ||||
|     "redemption": "兑换", | ||||
|     "topup": "充值", | ||||
|     "user": "用户", | ||||
|     "dashboard": "总览", | ||||
|     "log": "日志", | ||||
|     "setting": "设置", | ||||
|     "about": "关于", | ||||
|     "chat": "聊天", | ||||
|     "login": "登录", | ||||
|     "logout": "注销", | ||||
|     "register": "注册" | ||||
|   }, | ||||
|   "topup": { | ||||
|     "title": "充值中心", | ||||
|     "get_code": { | ||||
|       "title": "获取兑换码", | ||||
|       "current_quota": "当前可用额度", | ||||
|       "button": "立即获取兑换码" | ||||
|     }, | ||||
|     "redeem_code": { | ||||
|       "title": "兑换码充值", | ||||
|       "placeholder": "请输入兑换码", | ||||
|       "paste": "粘贴", | ||||
|       "paste_error": "无法访问剪贴板,请手动粘贴", | ||||
|       "submit": "立即兑换", | ||||
|       "submitting": "兑换中...", | ||||
|       "empty_code": "请输入兑换码!", | ||||
|       "success": "充值成功!", | ||||
|       "request_failed": "请求失败", | ||||
|       "no_link": "超级管理员未设置充值链接!" | ||||
|     } | ||||
|   }, | ||||
|   "channel": { | ||||
|     "title": "管理渠道", | ||||
|     "search": "搜索渠道的 ID,名称和密钥 ...", | ||||
|     "balance_notice": "OpenAI 渠道已经不再支持通过 key 获取余额,因此余额显示为 0。对于支持的渠道类型,请点击余额进行刷新。", | ||||
|     "test_notice": "渠道测试仅支持 chat 模型,优先使用 gpt-3.5-turbo,如果该模型不可用则使用你所配置的模型列表中的第一个模型。", | ||||
|     "detail_notice": "点击下方详情按钮可以显示余额以及设置额外的测试模型。", | ||||
|     "table": { | ||||
|       "id": "ID", | ||||
|       "name": "名称", | ||||
|       "group": "分组", | ||||
|       "type": "类型", | ||||
|       "status": "状态", | ||||
|       "response_time": "响应时间", | ||||
|       "balance": "余额", | ||||
|       "priority": "优先级", | ||||
|       "test_model": "测试模型", | ||||
|       "actions": "操作", | ||||
|       "no_name": "无", | ||||
|       "status_enabled": "已启用", | ||||
|       "status_disabled": "已禁用", | ||||
|       "status_auto_disabled": "已禁用", | ||||
|       "status_disabled_tip": "本渠道被手动禁用", | ||||
|       "status_auto_disabled_tip": "本渠道被程序自动禁用", | ||||
|       "status_unknown": "未知状态", | ||||
|       "not_tested": "未测试", | ||||
|       "priority_tip": "渠道选择优先级,越高越优先", | ||||
|       "select_test_model": "请选择测试模型", | ||||
|       "click_to_update": "点击更新" | ||||
|     }, | ||||
|     "buttons": { | ||||
|       "test": "测试", | ||||
|       "delete": "删除", | ||||
|       "confirm_delete": "删除渠道", | ||||
|       "enable": "启用", | ||||
|       "disable": "禁用", | ||||
|       "edit": "编辑", | ||||
|       "add": "添加新的渠道", | ||||
|       "test_all": "测试所有渠道", | ||||
|       "test_disabled": "测试禁用渠道", | ||||
|       "delete_disabled": "删除禁用渠道", | ||||
|       "confirm_delete_disabled": "确认删除", | ||||
|       "refresh": "刷新", | ||||
|       "show_detail": "详情", | ||||
|       "hide_detail": "隐藏详情" | ||||
|     }, | ||||
|     "messages": { | ||||
|       "test_success": "渠道 {{name}} 测试成功,模型 {{model}},耗时 {{time}} 秒,模型输出:{{message}}", | ||||
|       "test_all_started": "已成功开始测试渠道,请刷新页面查看结果。", | ||||
|       "delete_disabled_success": "已删除所有禁用渠道,共计 {{count}} 个", | ||||
|       "balance_update_success": "渠道 {{name}} 余额更新成功!", | ||||
|       "all_balance_updated": "已更新完毕所有已启用渠道余额!" | ||||
|     }, | ||||
|     "edit": { | ||||
|       "title_edit": "更新渠道信息", | ||||
|       "title_create": "创建新的渠道", | ||||
|       "type": "类型", | ||||
|       "name": "名称", | ||||
|       "name_placeholder": "请输入名称", | ||||
|       "group": "分组", | ||||
|       "group_placeholder": "请选择可以使用该渠道的分组", | ||||
|       "group_addition": "请在系统设置页面编辑分组倍率以添加新的分组:", | ||||
|       "models": "模型", | ||||
|       "models_placeholder": "请选择该渠道所支持的模型", | ||||
|       "model_mapping": "模型重定向", | ||||
|       "model_mapping_placeholder": "此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称", | ||||
|       "system_prompt": "系统提示词", | ||||
|       "system_prompt_placeholder": "此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型", | ||||
|       "base_url": "代理", | ||||
|       "base_url_placeholder": "此项可选,用于通过代理站来进行 API 调用,请输入代理站地址,格式为:https://domain.com", | ||||
|       "key": "密钥", | ||||
|       "key_placeholder": "请输入密钥", | ||||
|       "batch": "批量创建", | ||||
|       "batch_placeholder": "请输入密钥,一行一个", | ||||
|       "buttons": { | ||||
|         "cancel": "取消", | ||||
|         "submit": "提交", | ||||
|         "fill_models": "填入相关模型", | ||||
|         "fill_all": "填入所有模型", | ||||
|         "clear": "清除所有模型", | ||||
|         "add_custom": "填入", | ||||
|         "custom_placeholder": "输入自定义模型名称" | ||||
|       }, | ||||
|       "messages": { | ||||
|         "name_required": "请填写渠道名称和渠道密钥!", | ||||
|         "models_required": "请至少选择一个模型!", | ||||
|         "model_mapping_invalid": "模型映射必须是合法的 JSON 格式!", | ||||
|         "update_success": "渠道更新成功!", | ||||
|         "create_success": "渠道创建成功!" | ||||
|       }, | ||||
|       "spark_version": "模型版本", | ||||
|       "spark_version_placeholder": "请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1", | ||||
|       "knowledge_id": "知识库 ID", | ||||
|       "knowledge_id_placeholder": "请输入知识库 ID,例如:123456", | ||||
|       "plugin_param": "插件参数", | ||||
|       "plugin_param_placeholder": "请输入插件参数,即 X-DashScope-Plugin 请求头的取值", | ||||
|       "coze_notice": "对于 Coze 而言,模型名称即 Bot ID,你可以添加一个前缀 `bot-`,例如:`bot-123456`。", | ||||
|       "douban_notice": "对于豆包而言,需要手动去", | ||||
|       "douban_notice_link": "模型推理页面", | ||||
|       "douban_notice_2": "创建推理接入点,以接入点名称作为模型名称,例如:`ep-20240608051426-tkxvl`。", | ||||
|       "aws_region_placeholder": "region,例如:us-west-2", | ||||
|       "aws_ak_placeholder": "AWS IAM Access Key", | ||||
|       "aws_sk_placeholder": "AWS IAM Secret Key", | ||||
|       "vertex_region_placeholder": "Vertex AI Region,例如:us-east5", | ||||
|       "vertex_project_id": "Vertex AI Project ID", | ||||
|       "vertex_project_id_placeholder": "Vertex AI Project ID", | ||||
|       "vertex_credentials": "Google Cloud Application Default Credentials JSON", | ||||
|       "vertex_credentials_placeholder": "Google Cloud Application Default Credentials JSON", | ||||
|       "user_id": "User ID", | ||||
|       "user_id_placeholder": "生成该密钥的用户 ID", | ||||
|       "key_prompts": { | ||||
|         "default": "请输入渠道对应的鉴权密钥", | ||||
|         "zhipu": "按照如下格式输入:APIKey|SecretKey", | ||||
|         "spark": "按照如下格式输入:APPID|APISecret|APIKey", | ||||
|         "fastgpt": "按照如下格式输入:APIKey-AppId,例如:fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041", | ||||
|         "tencent": "按照如下格式输入:AppId|SecretId|SecretKey" | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   "token": { | ||||
|     "title": "令牌管理", | ||||
|     "search": "搜索令牌的名称 ...", | ||||
|     "table": { | ||||
|       "name": "名称", | ||||
|       "status": "状态", | ||||
|       "used_quota": "已用额度", | ||||
|       "remain_quota": "剩余额度", | ||||
|       "created_time": "创建时间", | ||||
|       "expired_time": "过期时间", | ||||
|       "actions": "操作", | ||||
|       "no_name": "无", | ||||
|       "never_expire": "永不过期", | ||||
|       "unlimited": "无限制", | ||||
|       "status_enabled": "已启用", | ||||
|       "status_disabled": "已禁用", | ||||
|       "status_expired": "已过期", | ||||
|       "status_depleted": "已耗尽", | ||||
|       "status_unknown": "未知状态" | ||||
|     }, | ||||
|     "buttons": { | ||||
|       "copy": "复制", | ||||
|       "chat": "聊天", | ||||
|       "delete": "删除", | ||||
|       "confirm_delete": "删除令牌", | ||||
|       "enable": "启用", | ||||
|       "disable": "禁用", | ||||
|       "edit": "编辑", | ||||
|       "add": "添加新的令牌", | ||||
|       "refresh": "刷新" | ||||
|     }, | ||||
|     "edit": { | ||||
|       "title_edit": "更新令牌信息", | ||||
|       "title_create": "创建新的令牌", | ||||
|       "name": "名称", | ||||
|       "name_placeholder": "请输入名称", | ||||
|       "models": "模型范围", | ||||
|       "models_placeholder": "请选择允许使用的模型,留空则不进行限制", | ||||
|       "ip_limit": "IP 限制", | ||||
|       "ip_limit_placeholder": "请输入允许访问的网段,例如:192.168.0.0/24,请使用英文逗号分隔多个网段", | ||||
|       "expire_time": "过期时间", | ||||
|       "expire_time_placeholder": "请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss,-1 表示无限制", | ||||
|       "quota_notice": "注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。", | ||||
|       "quota": "额度", | ||||
|       "quota_placeholder": "请输入额度", | ||||
|       "buttons": { | ||||
|         "never_expire": "永不过期", | ||||
|         "expire_1_month": "一个月后过期", | ||||
|         "expire_1_day": "一天后过期", | ||||
|         "expire_1_hour": "一小时后过期", | ||||
|         "expire_1_minute": "一分钟后过期", | ||||
|         "unlimited_quota": "设为无限额度", | ||||
|         "cancel_unlimited": "取消无限额度", | ||||
|         "submit": "提交", | ||||
|         "cancel": "取消" | ||||
|       }, | ||||
|       "messages": { | ||||
|         "update_success": "令牌更新成功!", | ||||
|         "create_success": "令牌创建成功,请在列表页面点击复制获取令牌!", | ||||
|         "expire_time_invalid": "过期时间格式错误!" | ||||
|       } | ||||
|     }, | ||||
|     "copy_options": { | ||||
|       "raw": "复制原始令牌", | ||||
|       "ama": "复制 AMA 链接", | ||||
|       "opencat": "复制 OpenCat 链接", | ||||
|       "next": "复制 NextChat 链接", | ||||
|       "lobe": "复制 LobeChat 链接" | ||||
|     }, | ||||
|     "messages": { | ||||
|       "copy_success": "已复制到剪贴板!", | ||||
|       "copy_failed": "无法复制到剪贴板,请手动复制,已将令牌填入搜索框。", | ||||
|       "operation_success": "操作成功完成!" | ||||
|     }, | ||||
|     "sort": { | ||||
|       "placeholder": "排序方式", | ||||
|       "default": "默认排序", | ||||
|       "by_remain": "按剩余额度排序", | ||||
|       "by_used": "按已用额度排序" | ||||
|     } | ||||
|   }, | ||||
|   "common": { | ||||
|     "quota": { | ||||
|       "display": "等价金额:${{amount}}", | ||||
|       "display_short": "${{amount}}", | ||||
|       "unit": "$" | ||||
|     } | ||||
|   }, | ||||
|   "redemption": { | ||||
|     "title": "兑换管理", | ||||
|     "search": "搜索兑换码的 ID 和名称 ...", | ||||
|     "table": { | ||||
|       "id": "ID", | ||||
|       "name": "名称", | ||||
|       "status": "状态", | ||||
|       "quota": "额度", | ||||
|       "created_time": "创建时间", | ||||
|       "redeemed_time": "兑换时间", | ||||
|       "actions": "操作", | ||||
|       "no_name": "无", | ||||
|       "not_redeemed": "尚未兑换" | ||||
|     }, | ||||
|     "buttons": { | ||||
|       "copy": "复制", | ||||
|       "delete": "删除", | ||||
|       "confirm_delete": "确认删除", | ||||
|       "enable": "启用", | ||||
|       "disable": "禁用", | ||||
|       "edit": "编辑", | ||||
|       "add": "添加新的兑换码", | ||||
|       "refresh": "刷新" | ||||
|     }, | ||||
|     "status": { | ||||
|       "unused": "未使用", | ||||
|       "disabled": "已禁用", | ||||
|       "used": "已使用", | ||||
|       "unknown": "未知状态" | ||||
|     }, | ||||
|     "edit": { | ||||
|       "title_edit": "更新兑换码信息", | ||||
|       "title_create": "创建新的兑换码", | ||||
|       "name": "名称", | ||||
|       "name_placeholder": "请输入名称", | ||||
|       "quota": "额度", | ||||
|       "quota_placeholder": "请输入单个兑换码中包含的额度", | ||||
|       "count": "生成数量", | ||||
|       "count_placeholder": "请输入生成数量", | ||||
|       "buttons": { | ||||
|         "submit": "提交", | ||||
|         "cancel": "取消" | ||||
|       } | ||||
|     }, | ||||
|     "messages": { | ||||
|       "update_success": "兑换码更新成功!", | ||||
|       "create_success": "兑换码创建成功!" | ||||
|     } | ||||
|   }, | ||||
|   "log": { | ||||
|     "title": "操作日志", | ||||
|     "search": "搜索日志...", | ||||
|     "usage_details": "使用明细", | ||||
|     "total_quota": "总消耗额度", | ||||
|     "click_to_view": "点击查看", | ||||
|     "type": { | ||||
|       "select": "选择明细分类", | ||||
|       "all": "全部", | ||||
|       "topup": "充值", | ||||
|       "usage": "消费", | ||||
|       "admin": "管理", | ||||
|       "system": "系统", | ||||
|       "test": "测试" | ||||
|     }, | ||||
|     "table": { | ||||
|       "time": "时间", | ||||
|       "channel": "渠道", | ||||
|       "type": "类型", | ||||
|       "model": "模型", | ||||
|       "username": "用户名", | ||||
|       "token_name": "令牌名称", | ||||
|       "token_name_placeholder": "可选值", | ||||
|       "model_name": "模型名称", | ||||
|       "model_name_placeholder": "可选值", | ||||
|       "start_time": "起始时间", | ||||
|       "end_time": "结束时间", | ||||
|       "channel_id": "渠道 ID", | ||||
|       "channel_id_placeholder": "可选值", | ||||
|       "username_placeholder": "可选值", | ||||
|       "prompt_tokens": "提示词消耗", | ||||
|       "completion_tokens": "补全消耗", | ||||
|       "quota": "额度", | ||||
|       "detail": "详情" | ||||
|     }, | ||||
|     "buttons": { | ||||
|       "query": "操作", | ||||
|       "submit": "查询", | ||||
|       "refresh": "刷新" | ||||
|     } | ||||
|   }, | ||||
|   "user": { | ||||
|     "title": "用户管理", | ||||
|     "edit": { | ||||
|       "title": "更新用户信息", | ||||
|       "username": "用户名", | ||||
|       "username_placeholder": "请输入新的用户名", | ||||
|       "password": "密码", | ||||
|       "password_placeholder": "请输入新的密码,最短 8 位", | ||||
|       "display_name": "显示名称", | ||||
|       "display_name_placeholder": "请输入新的显示名称", | ||||
|       "group": "分组", | ||||
|       "group_placeholder": "请选择分组", | ||||
|       "group_addition": "请在系统设置页面编辑分组倍率以添加新的分组:", | ||||
|       "quota": "剩余额度", | ||||
|       "quota_placeholder": "请输入新的剩余额度", | ||||
|       "github_id": "已绑定的 GitHub 账户", | ||||
|       "github_id_placeholder": "此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改", | ||||
|       "wechat_id": "已绑定的微信账户", | ||||
|       "wechat_id_placeholder": "此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改", | ||||
|       "email": "已绑定的邮箱账户", | ||||
|       "email_placeholder": "此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改", | ||||
|       "buttons": { | ||||
|         "submit": "提交", | ||||
|         "cancel": "取消" | ||||
|       } | ||||
|     }, | ||||
|     "add": { | ||||
|       "title": "创建新用户账户" | ||||
|     }, | ||||
|     "messages": { | ||||
|       "update_success": "用户信息更新成功!", | ||||
|       "create_success": "用户账户创建成功!", | ||||
|       "operation_success": "操作成功完成!" | ||||
|     }, | ||||
|     "search": "搜索用户...", | ||||
|     "table": { | ||||
|       "id": "ID", | ||||
|       "username": "用户名", | ||||
|       "group": "分组", | ||||
|       "quota": "额度", | ||||
|       "role_text": "角色", | ||||
|       "status_text": "状态", | ||||
|       "actions": "操作", | ||||
|       "remaining_quota": "剩余额度", | ||||
|       "used_quota": "已用额度", | ||||
|       "request_count": "请求次数", | ||||
|       "role_types": { | ||||
|         "normal": "普通用户", | ||||
|         "admin": "管理员", | ||||
|         "super_admin": "超级管理员", | ||||
|         "unknown": "未知身份" | ||||
|       }, | ||||
|       "status_types": { | ||||
|         "activated": "已激活", | ||||
|         "banned": "已封禁", | ||||
|         "unknown": "未知状态" | ||||
|       }, | ||||
|       "sort": { | ||||
|         "default": "默认排序", | ||||
|         "by_quota": "按剩余额度排序", | ||||
|         "by_used_quota": "按已用额度排序", | ||||
|         "by_request_count": "按请求次数排序" | ||||
|       }, | ||||
|       "sort_by": "排序方式" | ||||
|     }, | ||||
|     "buttons": { | ||||
|       "add": "添加新的用户", | ||||
|       "delete": "删除", | ||||
|       "delete_user": "删除用户", | ||||
|       "enable": "启用", | ||||
|       "disable": "禁用", | ||||
|       "edit": "编辑", | ||||
|       "promote": "提升", | ||||
|       "demote": "降级" | ||||
|     } | ||||
|   }, | ||||
|   "dashboard": { | ||||
|     "charts": { | ||||
|       "requests": { | ||||
|         "title": "模型请求趋势", | ||||
|         "tooltip": "请求次数" | ||||
|       }, | ||||
|       "quota": { | ||||
|         "title": "额度消费趋势", | ||||
|         "tooltip": "消费额度" | ||||
|       }, | ||||
|       "tokens": { | ||||
|         "title": "Token 消费趋势", | ||||
|         "tooltip": "Token 数量" | ||||
|       } | ||||
|     }, | ||||
|     "statistics": { | ||||
|       "title": "统计", | ||||
|       "tooltip": { | ||||
|         "date": "日期", | ||||
|         "value": "数值" | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   "setting": { | ||||
|     "title": "系统设置", | ||||
|     "tabs": { | ||||
|       "personal": "个人设置", | ||||
|       "operation": "运营设置", | ||||
|       "system": "系统设置", | ||||
|       "other": "其他设置" | ||||
|     }, | ||||
|     "personal": { | ||||
|       "general": { | ||||
|         "title": "通用设置", | ||||
|         "system_token_notice": "注意,此处生成的令牌用于系统管理,而非用于请求 OpenAI 相关的服务,请知悉。", | ||||
|         "buttons": { | ||||
|           "update_profile": "更新个人信息", | ||||
|           "generate_token": "生成系统访问令牌", | ||||
|           "copy_invite": "复制邀请链接", | ||||
|           "delete_account": "删除个人账户" | ||||
|         } | ||||
|       }, | ||||
|       "binding": { | ||||
|         "title": "账号绑定", | ||||
|         "buttons": { | ||||
|           "bind_wechat": "绑定微信账号", | ||||
|           "bind_github": "绑定 GitHub 账号", | ||||
|           "bind_email": "绑定邮箱地址", | ||||
|           "bind_lark": "绑定飞书账号" | ||||
|         }, | ||||
|         "wechat": { | ||||
|           "title": "微信绑定", | ||||
|           "description": "微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)", | ||||
|           "verification_code": "验证码", | ||||
|           "bind": "绑定" | ||||
|         }, | ||||
|         "email": { | ||||
|           "title": "绑定邮箱地址", | ||||
|           "email_placeholder": "输入邮箱地址", | ||||
|           "code_placeholder": "验证码", | ||||
|           "get_code": "获取验证码", | ||||
|           "get_code_retry": "重新发送({{countdown}})", | ||||
|           "bind": "确认绑定", | ||||
|           "cancel": "取消" | ||||
|         } | ||||
|       }, | ||||
|       "delete_account": { | ||||
|         "title": "危险操作", | ||||
|         "warning": "您正在删除自己的帐户,将清空所有数据且不可恢复", | ||||
|         "confirm_placeholder": "输入你的账户名 {{username}} 以确认删除", | ||||
|         "buttons": { | ||||
|           "confirm": "确认删除", | ||||
|           "cancel": "取消" | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "system": { | ||||
|       "general": { | ||||
|         "title": "通用设置", | ||||
|         "server_address": "服务器地址", | ||||
|         "server_address_placeholder": "例如:https://yourdomain.com", | ||||
|         "buttons": { | ||||
|           "update": "更新服务器地址" | ||||
|         } | ||||
|       }, | ||||
|       "login": { | ||||
|         "title": "配置登录注册", | ||||
|         "password_login": "允许通过密码进行登录", | ||||
|         "password_register": "允许通过密码进行注册", | ||||
|         "email_verification": "通过密码注册时需要进行邮箱验证", | ||||
|         "github_oauth": "允许通过 GitHub 账户登录 & 注册", | ||||
|         "wechat_login": "允许通过微信登录 & 注册", | ||||
|         "registration": "允许新用户注册(此项为否时,新用户将无法以任何方式进行注册)", | ||||
|         "turnstile": "启用 Turnstile 用户校验" | ||||
|       }, | ||||
|       "email_restriction": { | ||||
|         "title": "配置邮箱域名白名单", | ||||
|         "subtitle": "用以防止恶意用户利用临时邮箱批量注册", | ||||
|         "enable": "启用邮箱域名白名单", | ||||
|         "allowed_domains": "允许的邮箱域名", | ||||
|         "add_domain": "添加新的允许的邮箱域名", | ||||
|         "add_domain_placeholder": "输入新的允许的邮箱域名", | ||||
|         "buttons": { | ||||
|           "fill": "填入", | ||||
|           "save": "保存邮箱域名白名单设置" | ||||
|         } | ||||
|       }, | ||||
|       "smtp": { | ||||
|         "title": "配置 SMTP", | ||||
|         "subtitle": "用以支持系统的邮件发送", | ||||
|         "server": "SMTP 服务器地址", | ||||
|         "server_placeholder": "例如:smtp.qq.com", | ||||
|         "port": "SMTP 端口", | ||||
|         "port_placeholder": "默认: 587", | ||||
|         "account": "SMTP 账户", | ||||
|         "account_placeholder": "通常是邮箱地址", | ||||
|         "from": "SMTP 发送者邮箱", | ||||
|         "from_placeholder": "通常和邮箱地址保持一致", | ||||
|         "token": "SMTP 访问凭证", | ||||
|         "token_placeholder": "敏感信息不会发送到前端显示", | ||||
|         "buttons": { | ||||
|           "save": "保存 SMTP 设置" | ||||
|         } | ||||
|       }, | ||||
|       "github": { | ||||
|         "title": "配置 GitHub OAuth App", | ||||
|         "subtitle": "用以支持通过 GitHub 进行登录注册", | ||||
|         "manage_link": "点击此处", | ||||
|         "manage_text": "管理你的 GitHub OAuth App", | ||||
|         "url_notice": "Homepage URL 填 {{server_url}},Authorization callback URL 填 {{callback_url}}", | ||||
|         "client_id": "GitHub Client ID", | ||||
|         "client_id_placeholder": "输入你注册的 GitHub OAuth APP 的 ID", | ||||
|         "client_secret": "GitHub Client Secret", | ||||
|         "client_secret_placeholder": "敏感信息不会发送到前端显示", | ||||
|         "buttons": { | ||||
|           "save": "保存 GitHub OAuth 设置" | ||||
|         } | ||||
|       }, | ||||
|       "lark": { | ||||
|         "title": "配置飞书授权登录", | ||||
|         "subtitle": "用以支持通过飞书进行登录注册", | ||||
|         "manage_link": "点击此处", | ||||
|         "manage_text": "管理你的飞书应用", | ||||
|         "url_notice": "主页链接填 {{server_url}},重定向 URL 填 {{callback_url}}", | ||||
|         "client_id": "App ID", | ||||
|         "client_id_placeholder": "输入 App ID", | ||||
|         "client_secret": "App Secret", | ||||
|         "client_secret_placeholder": "敏感信息不会发送到前端显示", | ||||
|         "buttons": { | ||||
|           "save": "保存飞书 OAuth 设置" | ||||
|         } | ||||
|       }, | ||||
|       "wechat": { | ||||
|         "title": "配置 WeChat Server", | ||||
|         "subtitle": "用以支持通过微信进行登录注册", | ||||
|         "learn_more": "了解 WeChat Server", | ||||
|         "server_address": "WeChat Server 服务器地址", | ||||
|         "server_address_placeholder": "例如:https://yourdomain.com", | ||||
|         "token": "WeChat Server 访问凭证", | ||||
|         "token_placeholder": "敏感信息不会发送到前端显示", | ||||
|         "qrcode": "微信公众号二维码图片链接", | ||||
|         "qrcode_placeholder": "输入一个图片链接", | ||||
|         "buttons": { | ||||
|           "save": "保存 WeChat Server 设置" | ||||
|         } | ||||
|       }, | ||||
|       "turnstile": { | ||||
|         "title": "配置 Turnstile", | ||||
|         "subtitle": "用以支持用户校验", | ||||
|         "manage_link": "点击此处", | ||||
|         "manage_text": "管理你的 Turnstile Sites,推荐选择 Invisible Widget Type", | ||||
|         "site_key": "Turnstile Site Key", | ||||
|         "site_key_placeholder": "输入你注册的 Turnstile Site Key", | ||||
|         "secret_key": "Turnstile Secret Key", | ||||
|         "secret_key_placeholder": "敏感信息不会发送到前端显示", | ||||
|         "buttons": { | ||||
|           "save": "保存 Turnstile 设置" | ||||
|         } | ||||
|       }, | ||||
|       "password_login": { | ||||
|         "warning": { | ||||
|           "title": "警告", | ||||
|           "content": "取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?", | ||||
|           "buttons": { | ||||
|             "confirm": "确定", | ||||
|             "cancel": "取消" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "operation": { | ||||
|       "quota": { | ||||
|         "title": "额度设置", | ||||
|         "new_user": "新用户初始额度", | ||||
|         "new_user_placeholder": "例如:100", | ||||
|         "pre_consume": "请求预扣费额度", | ||||
|         "pre_consume_placeholder": "请求结束后多退少补", | ||||
|         "inviter_reward": "邀请新用户奖励额度", | ||||
|         "inviter_reward_placeholder": "例如:2000", | ||||
|         "invitee_reward": "新用户使用邀请码奖励额度", | ||||
|         "invitee_reward_placeholder": "例如:1000", | ||||
|         "buttons": { | ||||
|           "save": "保存额度设置" | ||||
|         } | ||||
|       }, | ||||
|       "ratio": { | ||||
|         "title": "倍率设置", | ||||
|         "model": { | ||||
|           "title": "模型倍率", | ||||
|           "placeholder": "为一个 JSON 文本,键为模型名称,值为倍率" | ||||
|         }, | ||||
|         "completion": { | ||||
|           "title": "补全倍率", | ||||
|           "placeholder": "为一个 JSON 文本,键为模型名称,值为倍率,此处的倍率设置是模型补全倍率相较于提示倍率的比例,使用该设置可强制覆盖 One API 的内部比例" | ||||
|         }, | ||||
|         "group": { | ||||
|           "title": "分组倍率", | ||||
|           "placeholder": "为一个 JSON 文本,键为分组名称,值为倍率" | ||||
|         }, | ||||
|         "buttons": { | ||||
|           "save": "保存倍率设置" | ||||
|         } | ||||
|       }, | ||||
|       "log": { | ||||
|         "title": "日志设置", | ||||
|         "enable_consume": "启用额度消费日志记录", | ||||
|         "target_time": "目标时间", | ||||
|         "buttons": { | ||||
|           "clean": "清理历史日志" | ||||
|         } | ||||
|       }, | ||||
|       "monitor": { | ||||
|         "title": "监控设置", | ||||
|         "max_response_time": "最长响应时间", | ||||
|         "max_response_time_placeholder": "单位秒,当运行渠道全部测试时,超过此时间将自动禁用渠道", | ||||
|         "quota_reminder": "额度提醒阈值", | ||||
|         "quota_reminder_placeholder": "低于此额度时将发送邮件提醒用户", | ||||
|         "auto_disable": "失败时自动禁用渠道", | ||||
|         "auto_enable": "成功时自动启用渠道", | ||||
|         "buttons": { | ||||
|           "save": "保存监控设置" | ||||
|         } | ||||
|       }, | ||||
|       "general": { | ||||
|         "title": "通用设置", | ||||
|         "topup_link": "充值链接", | ||||
|         "topup_link_placeholder": "例如发卡网站的购买链接", | ||||
|         "chat_link": "聊天页面链接", | ||||
|         "chat_link_placeholder": "例如 ChatGPT Next Web 的部署地址", | ||||
|         "quota_per_unit": "单位美元额度", | ||||
|         "quota_per_unit_placeholder": "一单位货币能兑换的额度", | ||||
|         "retry_times": "失败重试次数", | ||||
|         "retry_times_placeholder": "失败重试次数", | ||||
|         "display_in_currency": "以货币形式显示额度", | ||||
|         "display_token_stat": "Billing 相关 API 显示令牌额度而非用户额度", | ||||
|         "approximate_token": "使用近似的方式估算 token 数以减少计算量", | ||||
|         "buttons": { | ||||
|           "save": "保存通用设置" | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "other": { | ||||
|       "notice": { | ||||
|         "title": "公告设置", | ||||
|         "content": "公告内容", | ||||
|         "content_placeholder": "在此输入新的公告内容,支持 Markdown & HTML 代码", | ||||
|         "buttons": { | ||||
|           "save": "保存公告" | ||||
|         } | ||||
|       }, | ||||
|       "system": { | ||||
|         "title": "系统设置", | ||||
|         "name": "系统名称", | ||||
|         "name_placeholder": "请输入系统名称", | ||||
|         "logo": "Logo 图片地址", | ||||
|         "logo_placeholder": "在此输入 Logo 图片地址", | ||||
|         "theme": { | ||||
|           "title": "主题名称", | ||||
|           "link": "当前可用主题", | ||||
|           "placeholder": "请输入主题名称" | ||||
|         }, | ||||
|         "buttons": { | ||||
|           "save_name": "设置系统名称", | ||||
|           "save_logo": "设置 Logo", | ||||
|           "save_theme": "设置主题(重启生效)" | ||||
|         } | ||||
|       }, | ||||
|       "content": { | ||||
|         "title": "内容设置", | ||||
|         "homepage": { | ||||
|           "title": "首页内容", | ||||
|           "placeholder": "在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。" | ||||
|         }, | ||||
|         "about": { | ||||
|           "title": "关于", | ||||
|           "placeholder": "在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面。" | ||||
|         }, | ||||
|         "footer": { | ||||
|           "title": "页脚", | ||||
|           "placeholder": "在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码" | ||||
|         }, | ||||
|         "buttons": { | ||||
|           "save_homepage": "保存首页内容", | ||||
|           "save_about": "保存关于", | ||||
|           "save_footer": "设置页脚" | ||||
|         } | ||||
|       }, | ||||
|       "copyright": { | ||||
|         "notice": "移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目。" | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   "about": { | ||||
|     "title": "关于系统", | ||||
|     "description": "可在设置页面设置关于内容,支持 HTML & Markdown", | ||||
|     "repository": "项目仓库地址:", | ||||
|     "loading_failed": "加载关于内容失败..." | ||||
|   }, | ||||
|   "footer": { | ||||
|     "built_by": "由", | ||||
|     "built_by_name": "JustSong", | ||||
|     "license": "构建,源代码遵循", | ||||
|     "mit": "MIT 协议" | ||||
|   }, | ||||
|   "home": { | ||||
|     "welcome": { | ||||
|       "title": "欢迎使用 One API", | ||||
|       "description": "One API 是一个 LLM API 接口管理和分发系统,可以帮助您更好地管理和使用各大厂商的 LLM API。", | ||||
|       "login_notice": "如需使用,请先登录或注册。" | ||||
|     }, | ||||
|     "system_status": { | ||||
|       "title": "系统状况", | ||||
|       "info": { | ||||
|         "title": "系统信息", | ||||
|         "name": "名称:", | ||||
|         "version": "版本:", | ||||
|         "source": "源码:", | ||||
|         "source_link": "GitHub 仓库", | ||||
|         "start_time": "启动时间:" | ||||
|       }, | ||||
|       "config": { | ||||
|         "title": "系统配置", | ||||
|         "email_verify": "邮箱验证:", | ||||
|         "github_oauth": "GitHub 身份验证:", | ||||
|         "wechat_login": "微信身份验证:", | ||||
|         "turnstile": "Turnstile 校验:", | ||||
|         "enabled": "已启用", | ||||
|         "disabled": "未启用" | ||||
|       } | ||||
|     }, | ||||
|     "loading_failed": "加载首页内容失败..." | ||||
|   } | ||||
| } | ||||
| @@ -42,32 +42,37 @@ function App() { | ||||
|     } | ||||
|   }; | ||||
|   const loadStatus = async () => { | ||||
|     const res = await API.get('/api/status'); | ||||
|     const { success, data } = res.data; | ||||
|     if (success) { | ||||
|       localStorage.setItem('status', JSON.stringify(data)); | ||||
|       statusDispatch({ type: 'set', payload: data }); | ||||
|       localStorage.setItem('system_name', data.system_name); | ||||
|       localStorage.setItem('logo', data.logo); | ||||
|       localStorage.setItem('footer_html', data.footer_html); | ||||
|       localStorage.setItem('quota_per_unit', data.quota_per_unit); | ||||
|       localStorage.setItem('display_in_currency', data.display_in_currency); | ||||
|       if (data.chat_link) { | ||||
|         localStorage.setItem('chat_link', data.chat_link); | ||||
|     try { | ||||
|       const res = await API.get('/api/status'); | ||||
|       const { success, message, data } = res.data || {}; // Add default empty object | ||||
|       if (success && data) { | ||||
|         // Check data exists | ||||
|         localStorage.setItem('status', JSON.stringify(data)); | ||||
|         statusDispatch({ type: 'set', payload: data }); | ||||
|         localStorage.setItem('system_name', data.system_name); | ||||
|         localStorage.setItem('logo', data.logo); | ||||
|         localStorage.setItem('footer_html', data.footer_html); | ||||
|         localStorage.setItem('quota_per_unit', data.quota_per_unit); | ||||
|         localStorage.setItem('display_in_currency', data.display_in_currency); | ||||
|         if (data.chat_link) { | ||||
|           localStorage.setItem('chat_link', data.chat_link); | ||||
|         } else { | ||||
|           localStorage.removeItem('chat_link'); | ||||
|         } | ||||
|         if ( | ||||
|           data.version !== process.env.REACT_APP_VERSION && | ||||
|           data.version !== 'v0.0.0' && | ||||
|           process.env.REACT_APP_VERSION !== '' | ||||
|         ) { | ||||
|           showNotice( | ||||
|             `新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面` | ||||
|           ); | ||||
|         } | ||||
|       } else { | ||||
|         localStorage.removeItem('chat_link'); | ||||
|         showError(message || '无法正常连接至服务器!'); | ||||
|       } | ||||
|       if ( | ||||
|         data.version !== process.env.REACT_APP_VERSION && | ||||
|         data.version !== 'v0.0.0' && | ||||
|         process.env.REACT_APP_VERSION !== '' | ||||
|       ) { | ||||
|         showNotice( | ||||
|           `新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面` | ||||
|         ); | ||||
|       } | ||||
|     } else { | ||||
|       showError('无法正常连接至服务器!'); | ||||
|     } catch (error) { | ||||
|       showError(error.message || '无法正常连接至服务器!'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   | ||||
| @@ -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); | ||||
| @@ -192,7 +198,7 @@ const ChannelsTable = () => { | ||||
|     } | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
|       showSuccess('操作成功完成!'); | ||||
|       showSuccess(t('channel.messages.operation_success')); | ||||
|       let channel = res.data.data; | ||||
|       let newChannels = [...channels]; | ||||
|       let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; | ||||
| @@ -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) { | ||||
| @@ -319,10 +325,8 @@ const ChannelsTable = () => { | ||||
|       newChannels[realIdx].response_time = time * 1000; | ||||
|       newChannels[realIdx].test_time = Date.now() / 1000; | ||||
|       setChannels(newChannels); | ||||
|       showInfo( | ||||
|         `渠道 ${name} 测试成功,模型 ${model},耗时 ${time.toFixed( | ||||
|           2 | ||||
|         )} 秒,模型输出:${message}` | ||||
|       showSuccess( | ||||
|         t('channel.messages.test_success', { name, model, time, message }) | ||||
|       ); | ||||
|     } else { | ||||
|       showError(message); | ||||
| @@ -338,7 +342,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 +352,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 +370,7 @@ const ChannelsTable = () => { | ||||
|       newChannels[realIdx].balance = balance; | ||||
|       newChannels[realIdx].balance_updated_time = Date.now() / 1000; | ||||
|       setChannels(newChannels); | ||||
|       showInfo(`渠道 ${name} 余额更新成功!`); | ||||
|       showSuccess(t('channel.messages.balance_update_success', { name })); | ||||
|     } else { | ||||
|       showError(message); | ||||
|     } | ||||
| @@ -375,7 +381,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 +419,7 @@ const ChannelsTable = () => { | ||||
|           icon='search' | ||||
|           fluid | ||||
|           iconPosition='left' | ||||
|           placeholder='搜索渠道的 ID,名称和密钥 ...' | ||||
|           placeholder={t('channel.search')} | ||||
|           value={searchKeyword} | ||||
|           loading={searching} | ||||
|           onChange={handleKeywordChange} | ||||
| @@ -426,13 +432,11 @@ 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={'very'} compact size='small'> | ||||
| @@ -444,7 +448,7 @@ const ChannelsTable = () => { | ||||
|                 sortChannel('id'); | ||||
|               }} | ||||
|             > | ||||
|               ID | ||||
|               {t('channel.table.id')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -452,7 +456,7 @@ const ChannelsTable = () => { | ||||
|                 sortChannel('name'); | ||||
|               }} | ||||
|             > | ||||
|               名称 | ||||
|               {t('channel.table.name')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -460,7 +464,7 @@ const ChannelsTable = () => { | ||||
|                 sortChannel('group'); | ||||
|               }} | ||||
|             > | ||||
|               分组 | ||||
|               {t('channel.table.group')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -468,7 +472,7 @@ const ChannelsTable = () => { | ||||
|                 sortChannel('type'); | ||||
|               }} | ||||
|             > | ||||
|               类型 | ||||
|               {t('channel.table.type')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -476,7 +480,7 @@ const ChannelsTable = () => { | ||||
|                 sortChannel('status'); | ||||
|               }} | ||||
|             > | ||||
|               状态 | ||||
|               {t('channel.table.status')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -484,7 +488,7 @@ const ChannelsTable = () => { | ||||
|                 sortChannel('response_time'); | ||||
|               }} | ||||
|             > | ||||
|               响应时间 | ||||
|               {t('channel.table.response_time')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -493,7 +497,7 @@ const ChannelsTable = () => { | ||||
|               }} | ||||
|               hidden={!showDetail} | ||||
|             > | ||||
|               余额 | ||||
|               {t('channel.table.balance')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -501,10 +505,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 +525,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 +552,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 +577,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} | ||||
| @@ -587,7 +595,7 @@ const ChannelsTable = () => { | ||||
|                   <Table.Cell> | ||||
|                     <div> | ||||
|                       <Button | ||||
|                         size={'small'} | ||||
|                         size={'tiny'} | ||||
|                         positive | ||||
|                         onClick={() => { | ||||
|                           testChannel( | ||||
| @@ -598,22 +606,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> | ||||
|                             删除 | ||||
|                           <Button size='tiny' negative> | ||||
|                             {t('channel.buttons.delete')} | ||||
|                           </Button> | ||||
|                         } | ||||
|                         on='click' | ||||
| @@ -621,16 +619,17 @@ const ChannelsTable = () => { | ||||
|                         hoverable | ||||
|                       > | ||||
|                         <Button | ||||
|                           size={'tiny'} | ||||
|                           negative | ||||
|                           onClick={() => { | ||||
|                             manageChannel(channel.id, 'delete', idx); | ||||
|                           }} | ||||
|                         > | ||||
|                           删除渠道 {channel.name} | ||||
|                           {t('channel.buttons.confirm_delete')} {channel.name} | ||||
|                         </Button> | ||||
|                       </Popup> | ||||
|                       <Button | ||||
|                         size={'small'} | ||||
|                         size={'tiny'} | ||||
|                         onClick={() => { | ||||
|                           manageChannel( | ||||
|                             channel.id, | ||||
| @@ -639,14 +638,16 @@ const ChannelsTable = () => { | ||||
|                           ); | ||||
|                         }} | ||||
|                       > | ||||
|                         {channel.status === 1 ? '禁用' : '启用'} | ||||
|                         {channel.status === 1 | ||||
|                           ? t('channel.buttons.disable') | ||||
|                           : t('channel.buttons.enable')} | ||||
|                       </Button> | ||||
|                       <Button | ||||
|                         size={'small'} | ||||
|                         size={'tiny'} | ||||
|                         as={Link} | ||||
|                         to={'/channel/edit/' + channel.id} | ||||
|                       > | ||||
|                         编辑 | ||||
|                         {t('channel.buttons.edit')} | ||||
|                       </Button> | ||||
|                     </div> | ||||
|                   </Table.Cell> | ||||
| @@ -658,38 +659,31 @@ const ChannelsTable = () => { | ||||
|         <Table.Footer> | ||||
|           <Table.Row> | ||||
|             <Table.HeaderCell colSpan={showDetail ? '10' : '8'}> | ||||
|               <Button | ||||
|                 size='small' | ||||
|                 as={Link} | ||||
|                 to='/channel/add' | ||||
|                 loading={loading} | ||||
|               > | ||||
|                 添加新的渠道 | ||||
|               <Button size='tiny' as={Link} to='/channel/add' loading={loading}> | ||||
|                 {t('channel.buttons.add')} | ||||
|               </Button> | ||||
|               <Button | ||||
|                 size='small' | ||||
|                 size='tiny' | ||||
|                 loading={loading} | ||||
|                 onClick={() => { | ||||
|                   testChannels('all'); | ||||
|                 }} | ||||
|               > | ||||
|                 测试所有渠道 | ||||
|                 {t('channel.buttons.test_all')} | ||||
|               </Button> | ||||
|               <Button | ||||
|                 size='small' | ||||
|                 size='tiny' | ||||
|                 loading={loading} | ||||
|                 onClick={() => { | ||||
|                   testChannels('disabled'); | ||||
|                 }} | ||||
|               > | ||||
|                 测试禁用渠道 | ||||
|                 {t('channel.buttons.test_disabled')} | ||||
|               </Button> | ||||
|               {/*<Button size='small' onClick={updateAllChannelsBalance}*/} | ||||
|               {/*        loading={loading || updatingBalance}>更新已启用渠道余额</Button>*/} | ||||
|               <Popup | ||||
|                 trigger={ | ||||
|                   <Button size='small' loading={loading}> | ||||
|                     删除禁用渠道 | ||||
|                   <Button size='tiny' loading={loading}> | ||||
|                     {t('channel.buttons.delete_disabled')} | ||||
|                   </Button> | ||||
|                 } | ||||
|                 on='click' | ||||
| @@ -697,30 +691,32 @@ const ChannelsTable = () => { | ||||
|                 hoverable | ||||
|               > | ||||
|                 <Button | ||||
|                   size='small' | ||||
|                   size='tiny' | ||||
|                   loading={loading} | ||||
|                   negative | ||||
|                   onClick={deleteAllDisabledChannels} | ||||
|                 > | ||||
|                   确认删除 | ||||
|                   {t('channel.buttons.confirm_delete_disabled')} | ||||
|                 </Button> | ||||
|               </Popup> | ||||
|               <Pagination | ||||
|                 floated='right' | ||||
|                 activePage={activePage} | ||||
|                 onPageChange={onPaginationChange} | ||||
|                 size='small' | ||||
|                 size='tiny' | ||||
|                 siblingRange={1} | ||||
|                 totalPages={ | ||||
|                   Math.ceil(channels.length / ITEMS_PER_PAGE) + | ||||
|                   (channels.length % ITEMS_PER_PAGE === 0 ? 1 : 0) | ||||
|                 } | ||||
|               /> | ||||
|               <Button size='small' onClick={refresh} loading={loading}> | ||||
|                 刷新 | ||||
|               <Button size='tiny' onClick={refresh} loading={loading}> | ||||
|                 {t('channel.buttons.refresh')} | ||||
|               </Button> | ||||
|               <Button size='small' onClick={toggleShowDetail}> | ||||
|                 {showDetail ? '隐藏详情' : '详情'} | ||||
|               <Button size='tiny' onClick={toggleShowDetail}> | ||||
|                 {showDetail | ||||
|                   ? t('channel.buttons.hide_detail') | ||||
|                   : t('channel.buttons.show_detail')} | ||||
|               </Button> | ||||
|             </Table.HeaderCell> | ||||
|           </Table.Row> | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
|  | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Container, Segment } from 'semantic-ui-react'; | ||||
| import { getFooterHTML, getSystemName } from '../helpers'; | ||||
|  | ||||
| const Footer = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const systemName = getSystemName(); | ||||
|   const [footer, setFooter] = useState(getFooterHTML()); | ||||
|   let remainCheckTimes = 5; | ||||
| @@ -40,13 +41,13 @@ const Footer = () => { | ||||
|             <a href='https://github.com/songquanpeng/one-api' target='_blank'> | ||||
|               {systemName} {process.env.REACT_APP_VERSION}{' '} | ||||
|             </a> | ||||
|             由{' '} | ||||
|             {t('footer.built_by')}{' '} | ||||
|             <a href='https://github.com/songquanpeng' target='_blank'> | ||||
|               JustSong | ||||
|               {t('footer.built_by_name')} | ||||
|             </a>{' '} | ||||
|             构建,源代码遵循{' '} | ||||
|             {t('footer.license')}{' '} | ||||
|             <a href='https://opensource.org/licenses/mit-license.php'> | ||||
|               MIT 协议 | ||||
|               {t('footer.mit')} | ||||
|             </a> | ||||
|           </div> | ||||
|         )} | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| 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, | ||||
| @@ -23,55 +24,50 @@ import '../index.css'; | ||||
| // Header Buttons | ||||
| let headerButtons = [ | ||||
|   { | ||||
|     name: '首页', | ||||
|     to: '/', | ||||
|     icon: 'home', | ||||
|   }, | ||||
|   { | ||||
|     name: '渠道', | ||||
|     name: 'header.channel', | ||||
|     to: '/channel', | ||||
|     icon: 'sitemap', | ||||
|     admin: true, | ||||
|   }, | ||||
|   { | ||||
|     name: '令牌', | ||||
|     name: 'header.token', | ||||
|     to: '/token', | ||||
|     icon: 'key', | ||||
|   }, | ||||
|   { | ||||
|     name: '兑换', | ||||
|     name: 'header.redemption', | ||||
|     to: '/redemption', | ||||
|     icon: 'dollar sign', | ||||
|     admin: true, | ||||
|   }, | ||||
|   { | ||||
|     name: '充值', | ||||
|     name: 'header.topup', | ||||
|     to: '/topup', | ||||
|     icon: 'cart', | ||||
|   }, | ||||
|   { | ||||
|     name: '用户', | ||||
|     name: 'header.user', | ||||
|     to: '/user', | ||||
|     icon: 'user', | ||||
|     admin: true, | ||||
|   }, | ||||
|   { | ||||
|     name: '总览', | ||||
|     name: 'header.dashboard', | ||||
|     to: '/dashboard', | ||||
|     icon: 'chart bar', | ||||
|   }, | ||||
|   { | ||||
|     name: '日志', | ||||
|     name: 'header.log', | ||||
|     to: '/log', | ||||
|     icon: 'book', | ||||
|   }, | ||||
|   { | ||||
|     name: '设置', | ||||
|     name: 'header.setting', | ||||
|     to: '/setting', | ||||
|     icon: 'setting', | ||||
|   }, | ||||
|   { | ||||
|     name: '关于', | ||||
|     name: 'header.about', | ||||
|     to: '/about', | ||||
|     icon: 'info circle', | ||||
|   }, | ||||
| @@ -79,13 +75,14 @@ let headerButtons = [ | ||||
|  | ||||
| if (localStorage.getItem('chat_link')) { | ||||
|   headerButtons.splice(1, 0, { | ||||
|     name: '聊天', | ||||
|     name: 'header.chat', | ||||
|     to: '/chat', | ||||
|     icon: 'comments', | ||||
|   }); | ||||
| } | ||||
|  | ||||
| const Header = () => { | ||||
|   const { t, i18n } = useTranslation(); | ||||
|   const [userState, userDispatch] = useContext(UserContext); | ||||
|   let navigate = useNavigate(); | ||||
|  | ||||
| @@ -112,13 +109,14 @@ 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> | ||||
|         ); | ||||
|       } | ||||
| @@ -134,12 +132,22 @@ const Header = () => { | ||||
|           }} | ||||
|         > | ||||
|           <Icon name={button.icon} style={{ marginRight: '4px' }} /> | ||||
|           {button.name} | ||||
|           {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 ( | ||||
|       <> | ||||
| @@ -157,7 +165,13 @@ const Header = () => { | ||||
|               : { borderTop: 'none', height: '52px' } | ||||
|           } | ||||
|         > | ||||
|           <Container> | ||||
|           <Container | ||||
|             style={{ | ||||
|               width: '100%', | ||||
|               maxWidth: isMobile() ? '100%' : '1200px', | ||||
|               padding: isMobile() ? '0 10px' : '0 20px', | ||||
|             }} | ||||
|           > | ||||
|             <Menu.Item as={Link} to='/'> | ||||
|               <img src={logo} alt='logo' style={{ marginRight: '0.75em' }} /> | ||||
|               <div style={{ fontSize: '20px' }}> | ||||
| @@ -175,10 +189,24 @@ const Header = () => { | ||||
|           <Segment style={{ marginTop: 0, borderTop: '0' }}> | ||||
|             <Menu secondary vertical style={{ width: '100%', margin: 0 }}> | ||||
|               {renderButtons(true)} | ||||
|               <Menu.Item> | ||||
|                 <Dropdown | ||||
|                   selection | ||||
|                   trigger={ | ||||
|                     <Icon | ||||
|                       name='language' | ||||
|                       style={{ margin: 0, fontSize: '18px' }} | ||||
|                     /> | ||||
|                   } | ||||
|                   options={languageOptions} | ||||
|                   value={i18n.language} | ||||
|                   onChange={(_, { value }) => changeLanguage(value)} | ||||
|                 /> | ||||
|               </Menu.Item> | ||||
|               <Menu.Item> | ||||
|                 {userState.user ? ( | ||||
|                   <Button onClick={logout} style={{ color: '#666666' }}> | ||||
|                     注销 | ||||
|                     {t('header.logout')} | ||||
|                   </Button> | ||||
|                 ) : ( | ||||
|                   <> | ||||
| @@ -188,7 +216,7 @@ const Header = () => { | ||||
|                         navigate('/login'); | ||||
|                       }} | ||||
|                     > | ||||
|                       登录 | ||||
|                       {t('header.login')} | ||||
|                     </Button> | ||||
|                     <Button | ||||
|                       onClick={() => { | ||||
| @@ -196,7 +224,7 @@ const Header = () => { | ||||
|                         navigate('/register'); | ||||
|                       }} | ||||
|                     > | ||||
|                       注册 | ||||
|                       {t('header.register')} | ||||
|                     </Button> | ||||
|                   </> | ||||
|                 )} | ||||
| @@ -220,7 +248,13 @@ const Header = () => { | ||||
|           border: 'none', | ||||
|         }} | ||||
|       > | ||||
|         <Container> | ||||
|         <Container | ||||
|           style={{ | ||||
|             width: '100%', | ||||
|             maxWidth: isMobile() ? '100%' : '1200px', | ||||
|             padding: isMobile() ? '0 10px' : '0 20px', | ||||
|           }} | ||||
|         > | ||||
|           <Menu.Item as={Link} to='/' className={'hide-on-mobile'}> | ||||
|             <img src={logo} alt='logo' style={{ marginRight: '0.75em' }} /> | ||||
|             <div | ||||
| @@ -235,6 +269,21 @@ const Header = () => { | ||||
|           </Menu.Item> | ||||
|           {renderButtons(false)} | ||||
|           <Menu.Menu position='right'> | ||||
|             <Dropdown | ||||
|               item | ||||
|               trigger={ | ||||
|                 <Icon name='language' style={{ margin: 0, fontSize: '18px' }} /> | ||||
|               } | ||||
|               options={languageOptions} | ||||
|               value={i18n.language} | ||||
|               onChange={(_, { value }) => changeLanguage(value)} | ||||
|               style={{ | ||||
|                 fontSize: '16px', | ||||
|                 fontWeight: '400', | ||||
|                 color: '#666', | ||||
|                 padding: '0 10px', | ||||
|               }} | ||||
|             /> | ||||
|             {userState.user ? ( | ||||
|               <Dropdown | ||||
|                 text={userState.user.username} | ||||
| @@ -255,13 +304,13 @@ const Header = () => { | ||||
|                       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' | ||||
|   | ||||
| @@ -240,6 +240,44 @@ const LoginForm = () => { | ||||
|             )} | ||||
|           </Card.Content> | ||||
|         </Card> | ||||
|         <Modal | ||||
|           onClose={() => setShowWeChatLoginModal(false)} | ||||
|           onOpen={() => setShowWeChatLoginModal(true)} | ||||
|           open={showWeChatLoginModal} | ||||
|           size={'mini'} | ||||
|         > | ||||
|           <Modal.Content> | ||||
|             <Modal.Description> | ||||
|               <Image src={status.wechat_qrcode} fluid /> | ||||
|               <div style={{ textAlign: 'center' }}> | ||||
|                 <p> | ||||
|                   微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效) | ||||
|                 </p> | ||||
|               </div> | ||||
|               <Form size='large'> | ||||
|                 <Form.Input | ||||
|                   fluid | ||||
|                   placeholder='验证码' | ||||
|                   name='wechat_verification_code' | ||||
|                   value={inputs.wechat_verification_code} | ||||
|                   onChange={handleChange} | ||||
|                 /> | ||||
|                 <Button | ||||
|                   fluid | ||||
|                   size='large' | ||||
|                   style={{ | ||||
|                     background: '#2F73FF', // 使用更现代的蓝色 | ||||
|                     color: 'white', | ||||
|                     marginBottom: '1.5em', | ||||
|                   }} | ||||
|                   onClick={onSubmitWeChatVerificationCode} | ||||
|                 > | ||||
|                   登录 | ||||
|                 </Button> | ||||
|               </Form> | ||||
|             </Modal.Description> | ||||
|           </Modal.Content> | ||||
|         </Modal> | ||||
|       </Grid.Column> | ||||
|     </Grid> | ||||
|   ); | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import { | ||||
|   Segment, | ||||
|   Select, | ||||
|   Table, | ||||
|   Popup, | ||||
| } from 'semantic-ui-react'; | ||||
| import { | ||||
|   API, | ||||
| @@ -18,6 +19,7 @@ import { | ||||
|   showWarning, | ||||
|   timestamp2string, | ||||
| } from '../helpers'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
|  | ||||
| import { ITEMS_PER_PAGE } from '../constants'; | ||||
| import { renderColorLabel, renderQuota } from '../helpers/render'; | ||||
| @@ -45,15 +47,6 @@ const MODE_OPTIONS = [ | ||||
|   { key: 'self', text: '当前用户', value: 'self' }, | ||||
| ]; | ||||
|  | ||||
| const LOG_OPTIONS = [ | ||||
|   { key: '0', text: '全部', value: 0 }, | ||||
|   { key: '1', text: '充值', value: 1 }, | ||||
|   { key: '2', text: '消费', value: 2 }, | ||||
|   { key: '3', text: '管理', value: 3 }, | ||||
|   { key: '4', text: '系统', value: 4 }, | ||||
|   { key: '5', text: '测试', value: 5 }, | ||||
| ]; | ||||
|  | ||||
| function renderType(type) { | ||||
|   switch (type) { | ||||
|     case 1: | ||||
| @@ -137,6 +130,7 @@ function renderDetail(log) { | ||||
| } | ||||
|  | ||||
| const LogsTable = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const [logs, setLogs] = useState([]); | ||||
|   const [showStat, setShowStat] = useState(false); | ||||
|   const [loading, setLoading] = useState(true); | ||||
| @@ -168,6 +162,15 @@ const LogsTable = () => { | ||||
|     token: 0, | ||||
|   }); | ||||
|  | ||||
|   const LOG_OPTIONS = [ | ||||
|     { key: '0', text: t('log.type.all'), value: 0 }, | ||||
|     { key: '1', text: t('log.type.topup'), value: 1 }, | ||||
|     { key: '2', text: t('log.type.usage'), value: 2 }, | ||||
|     { key: '3', text: t('log.type.admin'), value: 3 }, | ||||
|     { key: '4', text: t('log.type.system'), value: 4 }, | ||||
|     { key: '5', text: t('log.type.test'), value: 5 }, | ||||
|   ]; | ||||
|  | ||||
|   const handleInputChange = (e, { name, value }) => { | ||||
|     setInputs((inputs) => ({ ...inputs, [name]: value })); | ||||
|   }; | ||||
| @@ -307,296 +310,295 @@ const LogsTable = () => { | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <> | ||||
|         <Header as='h3'> | ||||
|           使用明细(总消耗额度: | ||||
|           {showStat && renderQuota(stat.quota)} | ||||
|           {!showStat && ( | ||||
|             <span | ||||
|               onClick={handleEyeClick} | ||||
|               style={{ cursor: 'pointer', color: 'gray' }} | ||||
|             > | ||||
|               点击查看 | ||||
|             </span> | ||||
|           )} | ||||
|           ) | ||||
|         </Header> | ||||
|         <Form> | ||||
|           <Form.Group> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               label={'令牌名称'} | ||||
|               width={3} | ||||
|               value={token_name} | ||||
|               placeholder={'可选值'} | ||||
|               name='token_name' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               label='模型名称' | ||||
|               width={3} | ||||
|               value={model_name} | ||||
|               placeholder='可选值' | ||||
|               name='model_name' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               label='起始时间' | ||||
|               width={4} | ||||
|               value={start_timestamp} | ||||
|               type='datetime-local' | ||||
|               name='start_timestamp' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               fluid | ||||
|               label='结束时间' | ||||
|               width={4} | ||||
|               value={end_timestamp} | ||||
|               type='datetime-local' | ||||
|               name='end_timestamp' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Button fluid label='操作' width={2} onClick={refresh}> | ||||
|               查询 | ||||
|             </Form.Button> | ||||
|           </Form.Group> | ||||
|           {isAdminUser && ( | ||||
|             <> | ||||
|               <Form.Group> | ||||
|                 <Form.Input | ||||
|                   fluid | ||||
|                   label={'渠道 ID'} | ||||
|                   width={3} | ||||
|                   value={channel} | ||||
|                   placeholder='可选值' | ||||
|                   name='channel' | ||||
|                   onChange={handleInputChange} | ||||
|                 /> | ||||
|                 <Form.Input | ||||
|                   fluid | ||||
|                   label={'用户名称'} | ||||
|                   width={3} | ||||
|                   value={username} | ||||
|                   placeholder={'可选值'} | ||||
|                   name='username' | ||||
|                   onChange={handleInputChange} | ||||
|                 /> | ||||
|               </Form.Group> | ||||
|             </> | ||||
|           )} | ||||
|         </Form> | ||||
|         <Table basic={'very'} compact size='small'> | ||||
|           <Table.Header> | ||||
|             <Table.Row> | ||||
|               <Table.HeaderCell | ||||
|                 style={{ cursor: 'pointer' }} | ||||
|                 onClick={() => { | ||||
|                   sortLog('created_time'); | ||||
|                 }} | ||||
|       <Header as='h3'> | ||||
|         {t('log.usage_details')}({t('log.total_quota')}: | ||||
|         {showStat && renderQuota(stat.quota, t)} | ||||
|         {!showStat && ( | ||||
|           <span | ||||
|             onClick={handleEyeClick} | ||||
|             style={{ cursor: 'pointer', color: 'gray' }} | ||||
|           > | ||||
|             {t('log.click_to_view')} | ||||
|           </span> | ||||
|         )} | ||||
|         ) | ||||
|       </Header> | ||||
|       <Form> | ||||
|         <Form.Group> | ||||
|           <Form.Input | ||||
|             fluid | ||||
|             label={t('log.table.token_name')} | ||||
|             width={3} | ||||
|             value={token_name} | ||||
|             placeholder={t('log.table.token_name_placeholder')} | ||||
|             name='token_name' | ||||
|             onChange={handleInputChange} | ||||
|           /> | ||||
|           <Form.Input | ||||
|             fluid | ||||
|             label={t('log.table.model_name')} | ||||
|             width={3} | ||||
|             value={model_name} | ||||
|             placeholder={t('log.table.model_name_placeholder')} | ||||
|             name='model_name' | ||||
|             onChange={handleInputChange} | ||||
|           /> | ||||
|           <Form.Input | ||||
|             fluid | ||||
|             label={t('log.table.start_time')} | ||||
|             width={4} | ||||
|             value={start_timestamp} | ||||
|             type='datetime-local' | ||||
|             name='start_timestamp' | ||||
|             onChange={handleInputChange} | ||||
|           /> | ||||
|           <Form.Input | ||||
|             fluid | ||||
|             label={t('log.table.end_time')} | ||||
|             width={4} | ||||
|             value={end_timestamp} | ||||
|             type='datetime-local' | ||||
|             name='end_timestamp' | ||||
|             onChange={handleInputChange} | ||||
|           /> | ||||
|           <Form.Button | ||||
|             fluid | ||||
|             label={t('log.buttons.query')} | ||||
|             width={2} | ||||
|             onClick={refresh} | ||||
|           > | ||||
|             {t('log.buttons.submit')} | ||||
|           </Form.Button> | ||||
|         </Form.Group> | ||||
|         {isAdminUser && ( | ||||
|           <> | ||||
|             <Form.Group> | ||||
|               <Form.Input | ||||
|                 fluid | ||||
|                 label={t('log.table.channel_id')} | ||||
|                 width={3} | ||||
|               > | ||||
|                 时间 | ||||
|               </Table.HeaderCell> | ||||
|               {isAdminUser && ( | ||||
|                 <Table.HeaderCell | ||||
|                   style={{ cursor: 'pointer' }} | ||||
|                   onClick={() => { | ||||
|                     sortLog('channel'); | ||||
|                   }} | ||||
|                   width={1} | ||||
|                 > | ||||
|                   渠道 | ||||
|                 </Table.HeaderCell> | ||||
|               )} | ||||
|                 value={channel} | ||||
|                 placeholder={t('log.table.channel_id_placeholder')} | ||||
|                 name='channel' | ||||
|                 onChange={handleInputChange} | ||||
|               /> | ||||
|               <Form.Input | ||||
|                 fluid | ||||
|                 label={t('log.table.username')} | ||||
|                 width={3} | ||||
|                 value={username} | ||||
|                 placeholder={t('log.table.username_placeholder')} | ||||
|                 name='username' | ||||
|                 onChange={handleInputChange} | ||||
|               /> | ||||
|             </Form.Group> | ||||
|           </> | ||||
|         )} | ||||
|         <Form.Input | ||||
|           icon='search' | ||||
|           placeholder={t('log.search')} | ||||
|           value={searchKeyword} | ||||
|           onChange={(e, { value }) => setSearchKeyword(value)} | ||||
|         /> | ||||
|       </Form> | ||||
|       <Table basic={'very'} compact size='small'> | ||||
|         <Table.Header> | ||||
|           <Table.Row> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
|               onClick={() => { | ||||
|                 sortLog('created_time'); | ||||
|               }} | ||||
|               width={3} | ||||
|             > | ||||
|               {t('log.table.time')} | ||||
|             </Table.HeaderCell> | ||||
|             {isAdminUser && ( | ||||
|               <Table.HeaderCell | ||||
|                 style={{ cursor: 'pointer' }} | ||||
|                 onClick={() => { | ||||
|                   sortLog('type'); | ||||
|                   sortLog('channel'); | ||||
|                 }} | ||||
|                 width={1} | ||||
|               > | ||||
|                 类型 | ||||
|                 {t('log.table.channel')} | ||||
|               </Table.HeaderCell> | ||||
|               <Table.HeaderCell | ||||
|                 style={{ cursor: 'pointer' }} | ||||
|                 onClick={() => { | ||||
|                   sortLog('model_name'); | ||||
|                 }} | ||||
|                 width={2} | ||||
|               > | ||||
|                 模型 | ||||
|               </Table.HeaderCell> | ||||
|               {showUserTokenQuota() && ( | ||||
|                 <> | ||||
|                   {isAdminUser && ( | ||||
|                     <Table.HeaderCell | ||||
|                       style={{ cursor: 'pointer' }} | ||||
|                       onClick={() => { | ||||
|                         sortLog('username'); | ||||
|                       }} | ||||
|                       width={1} | ||||
|                     > | ||||
|                       用户 | ||||
|                     </Table.HeaderCell> | ||||
|                   )} | ||||
|             )} | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
|               onClick={() => { | ||||
|                 sortLog('type'); | ||||
|               }} | ||||
|               width={1} | ||||
|             > | ||||
|               {t('log.table.type')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
|               onClick={() => { | ||||
|                 sortLog('model_name'); | ||||
|               }} | ||||
|               width={2} | ||||
|             > | ||||
|               {t('log.table.model')} | ||||
|             </Table.HeaderCell> | ||||
|             {showUserTokenQuota() && ( | ||||
|               <> | ||||
|                 {isAdminUser && ( | ||||
|                   <Table.HeaderCell | ||||
|                     style={{ cursor: 'pointer' }} | ||||
|                     onClick={() => { | ||||
|                       sortLog('token_name'); | ||||
|                       sortLog('username'); | ||||
|                     }} | ||||
|                     width={1} | ||||
|                     width={2} | ||||
|                   > | ||||
|                     令牌 | ||||
|                     {t('log.table.username')} | ||||
|                   </Table.HeaderCell> | ||||
|                   <Table.HeaderCell | ||||
|                     style={{ cursor: 'pointer' }} | ||||
|                     onClick={() => { | ||||
|                       sortLog('prompt_tokens'); | ||||
|                     }} | ||||
|                     width={1} | ||||
|                   > | ||||
|                     提示 | ||||
|                   </Table.HeaderCell> | ||||
|                   <Table.HeaderCell | ||||
|                     style={{ cursor: 'pointer' }} | ||||
|                     onClick={() => { | ||||
|                       sortLog('completion_tokens'); | ||||
|                     }} | ||||
|                     width={1} | ||||
|                   > | ||||
|                     补全 | ||||
|                   </Table.HeaderCell> | ||||
|                   <Table.HeaderCell | ||||
|                     style={{ cursor: 'pointer' }} | ||||
|                     onClick={() => { | ||||
|                       sortLog('quota'); | ||||
|                     }} | ||||
|                     width={1} | ||||
|                   > | ||||
|                     额度 | ||||
|                   </Table.HeaderCell> | ||||
|                 </> | ||||
|               )} | ||||
|               <Table.HeaderCell | ||||
|                 style={{ cursor: 'pointer' }} | ||||
|                 onClick={() => { | ||||
|                   sortLog('content'); | ||||
|                 }} | ||||
|                 width={isAdminUser ? 4 : 6} | ||||
|               > | ||||
|                 详情 | ||||
|               </Table.HeaderCell> | ||||
|             </Table.Row> | ||||
|           </Table.Header> | ||||
|  | ||||
|           <Table.Body> | ||||
|             {logs | ||||
|               .slice( | ||||
|                 (activePage - 1) * ITEMS_PER_PAGE, | ||||
|                 activePage * ITEMS_PER_PAGE | ||||
|               ) | ||||
|               .map((log, idx) => { | ||||
|                 if (log.deleted) return <></>; | ||||
|                 return ( | ||||
|                   <Table.Row key={log.id}> | ||||
|                     <Table.Cell> | ||||
|                       {renderTimestamp(log.created_at, log.request_id)} | ||||
|                     </Table.Cell> | ||||
|                     {isAdminUser && ( | ||||
|                       <Table.Cell> | ||||
|                         {log.channel ? ( | ||||
|                           <Label | ||||
|                             basic | ||||
|                             as={Link} | ||||
|                             to={`/channel/edit/${log.channel}`} | ||||
|                           > | ||||
|                             {log.channel} | ||||
|                           </Label> | ||||
|                         ) : ( | ||||
|                           '' | ||||
|                         )} | ||||
|                       </Table.Cell> | ||||
|                     )} | ||||
|                     <Table.Cell>{renderType(log.type)}</Table.Cell> | ||||
|                     <Table.Cell> | ||||
|                       {log.model_name ? renderColorLabel(log.model_name) : ''} | ||||
|                     </Table.Cell> | ||||
|                     {showUserTokenQuota() && ( | ||||
|                       <> | ||||
|                         {isAdminUser && ( | ||||
|                           <Table.Cell> | ||||
|                             {log.username ? ( | ||||
|                               <Label | ||||
|                                 basic | ||||
|                                 as={Link} | ||||
|                                 to={`/user/edit/${log.user_id}`} | ||||
|                               > | ||||
|                                 {log.username} | ||||
|                               </Label> | ||||
|                             ) : ( | ||||
|                               '' | ||||
|                             )} | ||||
|                           </Table.Cell> | ||||
|                         )} | ||||
|                         <Table.Cell> | ||||
|                           {log.token_name | ||||
|                             ? renderColorLabel(log.token_name) | ||||
|                             : ''} | ||||
|                         </Table.Cell> | ||||
|  | ||||
|                         <Table.Cell> | ||||
|                           {log.prompt_tokens ? log.prompt_tokens : ''} | ||||
|                         </Table.Cell> | ||||
|                         <Table.Cell> | ||||
|                           {log.completion_tokens ? log.completion_tokens : ''} | ||||
|                         </Table.Cell> | ||||
|                         <Table.Cell> | ||||
|                           {log.quota ? renderQuota(log.quota, 6) : ''} | ||||
|                         </Table.Cell> | ||||
|                       </> | ||||
|                     )} | ||||
|  | ||||
|                     <Table.Cell>{renderDetail(log)}</Table.Cell> | ||||
|                   </Table.Row> | ||||
|                 ); | ||||
|               })} | ||||
|           </Table.Body> | ||||
|  | ||||
|           <Table.Footer> | ||||
|             <Table.Row> | ||||
|               <Table.HeaderCell colSpan={'10'}> | ||||
|                 <Select | ||||
|                   placeholder='选择明细分类' | ||||
|                   options={LOG_OPTIONS} | ||||
|                   style={{ marginRight: '8px' }} | ||||
|                   name='logType' | ||||
|                   value={logType} | ||||
|                   onChange={(e, { name, value }) => { | ||||
|                     setLogType(value); | ||||
|                 )} | ||||
|                 <Table.HeaderCell | ||||
|                   style={{ cursor: 'pointer' }} | ||||
|                   onClick={() => { | ||||
|                     sortLog('token_name'); | ||||
|                   }} | ||||
|                 /> | ||||
|                 <Button size='small' onClick={refresh} loading={loading}> | ||||
|                   刷新 | ||||
|                 </Button> | ||||
|                 <Pagination | ||||
|                   floated='right' | ||||
|                   activePage={activePage} | ||||
|                   onPageChange={onPaginationChange} | ||||
|                   size='small' | ||||
|                   siblingRange={1} | ||||
|                   totalPages={ | ||||
|                     Math.ceil(logs.length / ITEMS_PER_PAGE) + | ||||
|                     (logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0) | ||||
|                   } | ||||
|                 /> | ||||
|               </Table.HeaderCell> | ||||
|             </Table.Row> | ||||
|           </Table.Footer> | ||||
|         </Table> | ||||
|       </> | ||||
|                   width={2} | ||||
|                 > | ||||
|                   {t('log.table.token_name')} | ||||
|                 </Table.HeaderCell> | ||||
|                 <Table.HeaderCell | ||||
|                   style={{ cursor: 'pointer' }} | ||||
|                   onClick={() => { | ||||
|                     sortLog('prompt_tokens'); | ||||
|                   }} | ||||
|                   width={1} | ||||
|                 > | ||||
|                   {t('log.table.prompt_tokens')} | ||||
|                 </Table.HeaderCell> | ||||
|                 <Table.HeaderCell | ||||
|                   style={{ cursor: 'pointer' }} | ||||
|                   onClick={() => { | ||||
|                     sortLog('completion_tokens'); | ||||
|                   }} | ||||
|                   width={1} | ||||
|                 > | ||||
|                   {t('log.table.completion_tokens')} | ||||
|                 </Table.HeaderCell> | ||||
|                 <Table.HeaderCell | ||||
|                   style={{ cursor: 'pointer' }} | ||||
|                   onClick={() => { | ||||
|                     sortLog('quota'); | ||||
|                   }} | ||||
|                   width={1} | ||||
|                 > | ||||
|                   {t('log.table.quota')} | ||||
|                 </Table.HeaderCell> | ||||
|               </> | ||||
|             )} | ||||
|             <Table.HeaderCell>{t('log.table.detail')}</Table.HeaderCell> | ||||
|           </Table.Row> | ||||
|         </Table.Header> | ||||
|  | ||||
|         <Table.Body> | ||||
|           {logs | ||||
|             .slice( | ||||
|               (activePage - 1) * ITEMS_PER_PAGE, | ||||
|               activePage * ITEMS_PER_PAGE | ||||
|             ) | ||||
|             .map((log, idx) => { | ||||
|               if (log.deleted) return <></>; | ||||
|               return ( | ||||
|                 <Table.Row key={log.id}> | ||||
|                   <Table.Cell> | ||||
|                     {renderTimestamp(log.created_at, log.request_id)} | ||||
|                   </Table.Cell> | ||||
|                   {isAdminUser && ( | ||||
|                     <Table.Cell> | ||||
|                       {log.channel ? ( | ||||
|                         <Label | ||||
|                           basic | ||||
|                           as={Link} | ||||
|                           to={`/channel/edit/${log.channel}`} | ||||
|                         > | ||||
|                           {log.channel} | ||||
|                         </Label> | ||||
|                       ) : ( | ||||
|                         '' | ||||
|                       )} | ||||
|                     </Table.Cell> | ||||
|                   )} | ||||
|                   <Table.Cell>{renderType(log.type)}</Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     {log.model_name ? renderColorLabel(log.model_name) : ''} | ||||
|                   </Table.Cell> | ||||
|                   {showUserTokenQuota() && ( | ||||
|                     <> | ||||
|                       {isAdminUser && ( | ||||
|                         <Table.Cell> | ||||
|                           {log.username ? ( | ||||
|                             <Label | ||||
|                               basic | ||||
|                               as={Link} | ||||
|                               to={`/user/edit/${log.user_id}`} | ||||
|                             > | ||||
|                               {log.username} | ||||
|                             </Label> | ||||
|                           ) : ( | ||||
|                             '' | ||||
|                           )} | ||||
|                         </Table.Cell> | ||||
|                       )} | ||||
|                       <Table.Cell> | ||||
|                         {log.token_name ? renderColorLabel(log.token_name) : ''} | ||||
|                       </Table.Cell> | ||||
|  | ||||
|                       <Table.Cell> | ||||
|                         {log.prompt_tokens ? log.prompt_tokens : ''} | ||||
|                       </Table.Cell> | ||||
|                       <Table.Cell> | ||||
|                         {log.completion_tokens ? log.completion_tokens : ''} | ||||
|                       </Table.Cell> | ||||
|                       <Table.Cell> | ||||
|                         {log.quota ? renderQuota(log.quota, t, 6) : ''} | ||||
|                       </Table.Cell> | ||||
|                     </> | ||||
|                   )} | ||||
|  | ||||
|                   <Table.Cell>{renderDetail(log)}</Table.Cell> | ||||
|                 </Table.Row> | ||||
|               ); | ||||
|             })} | ||||
|         </Table.Body> | ||||
|  | ||||
|         <Table.Footer> | ||||
|           <Table.Row> | ||||
|             <Table.HeaderCell colSpan={'10'}> | ||||
|               <Select | ||||
|                 placeholder={t('log.type.select')} | ||||
|                 options={LOG_OPTIONS} | ||||
|                 style={{ marginRight: '8px' }} | ||||
|                 name='logType' | ||||
|                 value={logType} | ||||
|                 onChange={(e, { name, value }) => { | ||||
|                   setLogType(value); | ||||
|                 }} | ||||
|               /> | ||||
|               <Button size='small' onClick={refresh} loading={loading}> | ||||
|                 {t('log.buttons.refresh')} | ||||
|               </Button> | ||||
|               <Pagination | ||||
|                 floated='right' | ||||
|                 activePage={activePage} | ||||
|                 onPageChange={onPaginationChange} | ||||
|                 size='small' | ||||
|                 siblingRange={1} | ||||
|                 totalPages={ | ||||
|                   Math.ceil(logs.length / ITEMS_PER_PAGE) + | ||||
|                   (logs.length % ITEMS_PER_PAGE === 0 ? 1 : 0) | ||||
|                 } | ||||
|               /> | ||||
|             </Table.HeaderCell> | ||||
|           </Table.Row> | ||||
|         </Table.Footer> | ||||
|       </Table> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -1,8 +1,16 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Divider, Form, Grid, Header } from 'semantic-ui-react'; | ||||
| import { API, showError, showSuccess, timestamp2string, verifyJSON } from '../helpers'; | ||||
| import { | ||||
|   API, | ||||
|   showError, | ||||
|   showSuccess, | ||||
|   timestamp2string, | ||||
|   verifyJSON, | ||||
| } from '../helpers'; | ||||
|  | ||||
| const OperationSetting = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   let now = new Date(); | ||||
|   let [inputs, setInputs] = useState({ | ||||
|     QuotaForNewUser: 0, | ||||
| @@ -23,11 +31,13 @@ const OperationSetting = () => { | ||||
|     DisplayInCurrencyEnabled: '', | ||||
|     DisplayTokenStatEnabled: '', | ||||
|     ApproximateTokenEnabled: '', | ||||
|     RetryTimes: 0 | ||||
|     RetryTimes: 0, | ||||
|   }); | ||||
|   const [originInputs, setOriginInputs] = useState({}); | ||||
|   let [loading, setLoading] = useState(false); | ||||
|   let [historyTimestamp, setHistoryTimestamp] = useState(timestamp2string(now.getTime() / 1000 - 30 * 24 * 3600)); // a month ago | ||||
|   let [historyTimestamp, setHistoryTimestamp] = useState( | ||||
|     timestamp2string(now.getTime() / 1000 - 30 * 24 * 3600) | ||||
|   ); // a month ago | ||||
|  | ||||
|   const getOptions = async () => { | ||||
|     const res = await API.get('/api/option/'); | ||||
| @@ -35,7 +45,11 @@ const OperationSetting = () => { | ||||
|     if (success) { | ||||
|       let newInputs = {}; | ||||
|       data.forEach((item) => { | ||||
|         if (item.key === 'ModelRatio' || item.key === 'GroupRatio' || item.key === 'CompletionRatio') { | ||||
|         if ( | ||||
|           item.key === 'ModelRatio' || | ||||
|           item.key === 'GroupRatio' || | ||||
|           item.key === 'CompletionRatio' | ||||
|         ) { | ||||
|           item.value = JSON.stringify(JSON.parse(item.value), null, 2); | ||||
|         } | ||||
|         if (item.value === '{}') { | ||||
| @@ -61,7 +75,7 @@ const OperationSetting = () => { | ||||
|     } | ||||
|     const res = await API.put('/api/option/', { | ||||
|       key, | ||||
|       value | ||||
|       value, | ||||
|     }); | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
| @@ -83,11 +97,22 @@ const OperationSetting = () => { | ||||
|   const submitConfig = async (group) => { | ||||
|     switch (group) { | ||||
|       case 'monitor': | ||||
|         if (originInputs['ChannelDisableThreshold'] !== inputs.ChannelDisableThreshold) { | ||||
|           await updateOption('ChannelDisableThreshold', inputs.ChannelDisableThreshold); | ||||
|         if ( | ||||
|           originInputs['ChannelDisableThreshold'] !== | ||||
|           inputs.ChannelDisableThreshold | ||||
|         ) { | ||||
|           await updateOption( | ||||
|             'ChannelDisableThreshold', | ||||
|             inputs.ChannelDisableThreshold | ||||
|           ); | ||||
|         } | ||||
|         if (originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold) { | ||||
|           await updateOption('QuotaRemindThreshold', inputs.QuotaRemindThreshold); | ||||
|         if ( | ||||
|           originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold | ||||
|         ) { | ||||
|           await updateOption( | ||||
|             'QuotaRemindThreshold', | ||||
|             inputs.QuotaRemindThreshold | ||||
|           ); | ||||
|         } | ||||
|         break; | ||||
|       case 'ratio': | ||||
| @@ -146,7 +171,9 @@ const OperationSetting = () => { | ||||
|  | ||||
|   const deleteHistoryLogs = async () => { | ||||
|     console.log(inputs); | ||||
|     const res = await API.delete(`/api/log/?target_timestamp=${Date.parse(historyTimestamp) / 1000}`); | ||||
|     const res = await API.delete( | ||||
|       `/api/log/?target_timestamp=${Date.parse(historyTimestamp) / 1000}` | ||||
|     ); | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       showSuccess(`${data} 条日志已清理!`); | ||||
| @@ -159,40 +186,218 @@ const OperationSetting = () => { | ||||
|     <Grid columns={1}> | ||||
|       <Grid.Column> | ||||
|         <Form loading={loading}> | ||||
|           <Header as='h3'> | ||||
|             通用设置 | ||||
|           </Header> | ||||
|           <Header as='h3'>{t('setting.operation.quota.title')}</Header> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.Input | ||||
|               label={t('setting.operation.quota.new_user')} | ||||
|               name='QuotaForNewUser' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.QuotaForNewUser} | ||||
|               type='number' | ||||
|               min='0' | ||||
|               placeholder={t('setting.operation.quota.new_user_placeholder')} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label={t('setting.operation.quota.pre_consume')} | ||||
|               name='PreConsumedQuota' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.PreConsumedQuota} | ||||
|               type='number' | ||||
|               min='0' | ||||
|               placeholder={t('setting.operation.quota.pre_consume_placeholder')} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label={t('setting.operation.quota.inviter_reward')} | ||||
|               name='QuotaForInviter' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.QuotaForInviter} | ||||
|               type='number' | ||||
|               min='0' | ||||
|               placeholder={t( | ||||
|                 'setting.operation.quota.inviter_reward_placeholder' | ||||
|               )} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label={t('setting.operation.quota.invitee_reward')} | ||||
|               name='QuotaForInvitee' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.QuotaForInvitee} | ||||
|               type='number' | ||||
|               min='0' | ||||
|               placeholder={t( | ||||
|                 'setting.operation.quota.invitee_reward_placeholder' | ||||
|               )} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button | ||||
|             onClick={() => { | ||||
|               submitConfig('quota').then(); | ||||
|             }} | ||||
|           > | ||||
|             {t('setting.operation.quota.buttons.save')} | ||||
|           </Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'>{t('setting.operation.ratio.title')}</Header> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.TextArea | ||||
|               label={t('setting.operation.ratio.model.title')} | ||||
|               name='ModelRatio' | ||||
|               onChange={handleInputChange} | ||||
|               style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.ModelRatio} | ||||
|               placeholder={t('setting.operation.ratio.model.placeholder')} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.TextArea | ||||
|               label={t('setting.operation.ratio.completion.title')} | ||||
|               name='CompletionRatio' | ||||
|               onChange={handleInputChange} | ||||
|               style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.CompletionRatio} | ||||
|               placeholder={t('setting.operation.ratio.completion.placeholder')} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.TextArea | ||||
|               label={t('setting.operation.ratio.group.title')} | ||||
|               name='GroupRatio' | ||||
|               onChange={handleInputChange} | ||||
|               style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.GroupRatio} | ||||
|               placeholder={t('setting.operation.ratio.group.placeholder')} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button | ||||
|             onClick={() => { | ||||
|               submitConfig('ratio').then(); | ||||
|             }} | ||||
|           > | ||||
|             {t('setting.operation.ratio.buttons.save')} | ||||
|           </Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'>{t('setting.operation.log.title')}</Header> | ||||
|           <Form.Group inline> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.LogConsumeEnabled === 'true'} | ||||
|               label={t('setting.operation.log.enable_consume')} | ||||
|               name='LogConsumeEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Group widths={4}> | ||||
|             <Form.Input | ||||
|               label='充值链接' | ||||
|               label={t('setting.operation.log.target_time')} | ||||
|               value={historyTimestamp} | ||||
|               type='datetime-local' | ||||
|               name='history_timestamp' | ||||
|               onChange={(e, { name, value }) => { | ||||
|                 setHistoryTimestamp(value); | ||||
|               }} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button | ||||
|             onClick={() => { | ||||
|               deleteHistoryLogs().then(); | ||||
|             }} | ||||
|           > | ||||
|             {t('setting.operation.log.buttons.clean')} | ||||
|           </Form.Button> | ||||
|  | ||||
|           <Divider /> | ||||
|           <Header as='h3'>{t('setting.operation.monitor.title')}</Header> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label={t('setting.operation.monitor.max_response_time')} | ||||
|               name='ChannelDisableThreshold' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.ChannelDisableThreshold} | ||||
|               type='number' | ||||
|               min='0' | ||||
|               placeholder={t( | ||||
|                 'setting.operation.monitor.max_response_time_placeholder' | ||||
|               )} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label={t('setting.operation.monitor.quota_reminder')} | ||||
|               name='QuotaRemindThreshold' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.QuotaRemindThreshold} | ||||
|               type='number' | ||||
|               min='0' | ||||
|               placeholder={t( | ||||
|                 'setting.operation.monitor.quota_reminder_placeholder' | ||||
|               )} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Group inline> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.AutomaticDisableChannelEnabled === 'true'} | ||||
|               label={t('setting.operation.monitor.auto_disable')} | ||||
|               name='AutomaticDisableChannelEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.AutomaticEnableChannelEnabled === 'true'} | ||||
|               label={t('setting.operation.monitor.auto_enable')} | ||||
|               name='AutomaticEnableChannelEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button | ||||
|             onClick={() => { | ||||
|               submitConfig('monitor').then(); | ||||
|             }} | ||||
|           > | ||||
|             {t('setting.operation.monitor.buttons.save')} | ||||
|           </Form.Button> | ||||
|  | ||||
|           <Divider /> | ||||
|           <Header as='h3'>{t('setting.operation.general.title')}</Header> | ||||
|           <Form.Group widths={4}> | ||||
|             <Form.Input | ||||
|               label={t('setting.operation.general.topup_link')} | ||||
|               name='TopUpLink' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.TopUpLink} | ||||
|               type='link' | ||||
|               placeholder='例如发卡网站的购买链接' | ||||
|               placeholder={t( | ||||
|                 'setting.operation.general.topup_link_placeholder' | ||||
|               )} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='聊天页面链接' | ||||
|               label={t('setting.operation.general.chat_link')} | ||||
|               name='ChatLink' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.ChatLink} | ||||
|               type='link' | ||||
|               placeholder='例如 ChatGPT Next Web 的部署地址' | ||||
|               placeholder={t('setting.operation.general.chat_link_placeholder')} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='单位美元额度' | ||||
|               label={t('setting.operation.general.quota_per_unit')} | ||||
|               name='QuotaPerUnit' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.QuotaPerUnit} | ||||
|               type='number' | ||||
|               step='0.01' | ||||
|               placeholder='一单位货币能兑换的额度' | ||||
|               placeholder={t( | ||||
|                 'setting.operation.general.quota_per_unit_placeholder' | ||||
|               )} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='失败重试次数' | ||||
|               label={t('setting.operation.general.retry_times')} | ||||
|               name='RetryTimes' | ||||
|               type={'number'} | ||||
|               step='1' | ||||
| @@ -200,186 +405,38 @@ const OperationSetting = () => { | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.RetryTimes} | ||||
|               placeholder='失败重试次数' | ||||
|               placeholder={t( | ||||
|                 'setting.operation.general.retry_times_placeholder' | ||||
|               )} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Group inline> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.DisplayInCurrencyEnabled === 'true'} | ||||
|               label='以货币形式显示额度' | ||||
|               label={t('setting.operation.general.display_in_currency')} | ||||
|               name='DisplayInCurrencyEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.DisplayTokenStatEnabled === 'true'} | ||||
|               label='Billing 相关 API 显示令牌额度而非用户额度' | ||||
|               label={t('setting.operation.general.display_token_stat')} | ||||
|               name='DisplayTokenStatEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.ApproximateTokenEnabled === 'true'} | ||||
|               label='使用近似的方式估算 token 数以减少计算量' | ||||
|               label={t('setting.operation.general.approximate_token')} | ||||
|               name='ApproximateTokenEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={() => { | ||||
|             submitConfig('general').then(); | ||||
|           }}>保存通用设置</Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             日志设置 | ||||
|           </Header> | ||||
|           <Form.Group inline> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.LogConsumeEnabled === 'true'} | ||||
|               label='启用额度消费日志记录' | ||||
|               name='LogConsumeEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Group widths={4}> | ||||
|             <Form.Input label='目标时间' value={historyTimestamp} type='datetime-local' | ||||
|                         name='history_timestamp' | ||||
|                         onChange={(e, { name, value }) => { | ||||
|                           setHistoryTimestamp(value); | ||||
|                         }} /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={() => { | ||||
|             deleteHistoryLogs().then(); | ||||
|           }}>清理历史日志</Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             监控设置 | ||||
|           </Header> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label='最长响应时间' | ||||
|               name='ChannelDisableThreshold' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.ChannelDisableThreshold} | ||||
|               type='number' | ||||
|               min='0' | ||||
|               placeholder='单位秒,当运行渠道全部测试时,超过此时间将自动禁用渠道' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='额度提醒阈值' | ||||
|               name='QuotaRemindThreshold' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.QuotaRemindThreshold} | ||||
|               type='number' | ||||
|               min='0' | ||||
|               placeholder='低于此额度时将发送邮件提醒用户' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Group inline> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.AutomaticDisableChannelEnabled === 'true'} | ||||
|               label='失败时自动禁用渠道' | ||||
|               name='AutomaticDisableChannelEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.AutomaticEnableChannelEnabled === 'true'} | ||||
|               label='成功时自动启用渠道' | ||||
|               name='AutomaticEnableChannelEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={() => { | ||||
|             submitConfig('monitor').then(); | ||||
|           }}>保存监控设置</Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             额度设置 | ||||
|           </Header> | ||||
|           <Form.Group widths={4}> | ||||
|             <Form.Input | ||||
|               label='新用户初始额度' | ||||
|               name='QuotaForNewUser' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.QuotaForNewUser} | ||||
|               type='number' | ||||
|               min='0' | ||||
|               placeholder='例如:100' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='请求预扣费额度' | ||||
|               name='PreConsumedQuota' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.PreConsumedQuota} | ||||
|               type='number' | ||||
|               min='0' | ||||
|               placeholder='请求结束后多退少补' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='邀请新用户奖励额度' | ||||
|               name='QuotaForInviter' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.QuotaForInviter} | ||||
|               type='number' | ||||
|               min='0' | ||||
|               placeholder='例如:2000' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='新用户使用邀请码奖励额度' | ||||
|               name='QuotaForInvitee' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.QuotaForInvitee} | ||||
|               type='number' | ||||
|               min='0' | ||||
|               placeholder='例如:1000' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={() => { | ||||
|             submitConfig('quota').then(); | ||||
|           }}>保存额度设置</Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             倍率设置 | ||||
|           </Header> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.TextArea | ||||
|               label='模型倍率' | ||||
|               name='ModelRatio' | ||||
|               onChange={handleInputChange} | ||||
|               style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.ModelRatio} | ||||
|               placeholder='为一个 JSON 文本,键为模型名称,值为倍率' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.TextArea | ||||
|               label='补全倍率' | ||||
|               name='CompletionRatio' | ||||
|               onChange={handleInputChange} | ||||
|               style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.CompletionRatio} | ||||
|               placeholder='为一个 JSON 文本,键为模型名称,值为倍率,此处的倍率设置是模型补全倍率相较于提示倍率的比例,使用该设置可强制覆盖 One API 的内部比例' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.TextArea | ||||
|               label='分组倍率' | ||||
|               name='GroupRatio' | ||||
|               onChange={handleInputChange} | ||||
|               style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.GroupRatio} | ||||
|               placeholder='为一个 JSON 文本,键为分组名称,值为倍率' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={() => { | ||||
|             submitConfig('ratio').then(); | ||||
|           }}>保存倍率设置</Form.Button> | ||||
|           <Form.Button | ||||
|             onClick={() => { | ||||
|               submitConfig('general').then(); | ||||
|             }} | ||||
|           > | ||||
|             {t('setting.operation.general.buttons.save')} | ||||
|           </Form.Button> | ||||
|         </Form> | ||||
|       </Grid.Column> | ||||
|     </Grid> | ||||
|   | ||||
| @@ -1,10 +1,20 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Divider, Form, Grid, Header, Message, Modal } from 'semantic-ui-react'; | ||||
| import { API, showError, showSuccess } from '../helpers'; | ||||
| import { marked } from 'marked'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { | ||||
|   Button, | ||||
|   Divider, | ||||
|   Form, | ||||
|   Grid, | ||||
|   Header, | ||||
|   Message, | ||||
|   Modal, | ||||
| } from 'semantic-ui-react'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import { API, showError, showSuccess, verifyJSON } from '../helpers'; | ||||
| import { marked } from 'marked'; | ||||
|  | ||||
| const OtherSetting = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   let [inputs, setInputs] = useState({ | ||||
|     Footer: '', | ||||
|     Notice: '', | ||||
| @@ -12,13 +22,13 @@ const OtherSetting = () => { | ||||
|     SystemName: '', | ||||
|     Logo: '', | ||||
|     HomePageContent: '', | ||||
|     Theme: '' | ||||
|     Theme: '', | ||||
|   }); | ||||
|   let [loading, setLoading] = useState(false); | ||||
|   const [showUpdateModal, setShowUpdateModal] = useState(false); | ||||
|   const [updateData, setUpdateData] = useState({ | ||||
|     tag_name: '', | ||||
|     content: '' | ||||
|     content: '', | ||||
|   }); | ||||
|  | ||||
|   const getOptions = async () => { | ||||
| @@ -45,7 +55,7 @@ const OtherSetting = () => { | ||||
|     setLoading(true); | ||||
|     const res = await API.put('/api/option/', { | ||||
|       key, | ||||
|       value | ||||
|       value, | ||||
|     }); | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
| @@ -64,10 +74,6 @@ const OtherSetting = () => { | ||||
|     await updateOption('Notice', inputs.Notice); | ||||
|   }; | ||||
|  | ||||
|   const submitFooter = async () => { | ||||
|     await updateOption('Footer', inputs.Footer); | ||||
|   }; | ||||
|  | ||||
|   const submitSystemName = async () => { | ||||
|     await updateOption('SystemName', inputs.SystemName); | ||||
|   }; | ||||
| @@ -89,8 +95,7 @@ const OtherSetting = () => { | ||||
|   }; | ||||
|  | ||||
|   const openGitHubRelease = () => { | ||||
|     window.location = | ||||
|       'https://github.com/songquanpeng/one-api/releases/latest'; | ||||
|     window.location = 'https://github.com/songquanpeng/one-api/releases/latest'; | ||||
|   }; | ||||
|  | ||||
|   const checkUpdate = async () => { | ||||
| @@ -103,7 +108,7 @@ const OtherSetting = () => { | ||||
|     } else { | ||||
|       setUpdateData({ | ||||
|         tag_name: tag_name, | ||||
|         content: marked.parse(body) | ||||
|         content: marked.parse(body), | ||||
|       }); | ||||
|       setShowUpdateModal(true); | ||||
|     } | ||||
| @@ -113,87 +118,110 @@ const OtherSetting = () => { | ||||
|     <Grid columns={1}> | ||||
|       <Grid.Column> | ||||
|         <Form loading={loading}> | ||||
|           <Header as='h3'>通用设置</Header> | ||||
|           <Form.Button onClick={checkUpdate}>检查更新</Form.Button> | ||||
|           <Header as='h3'>{t('setting.other.notice.title')}</Header> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.TextArea | ||||
|               label='公告' | ||||
|               placeholder='在此输入新的公告内容,支持 Markdown & HTML 代码' | ||||
|               label={t('setting.other.notice.content')} | ||||
|               placeholder={t('setting.other.notice.content_placeholder')} | ||||
|               value={inputs.Notice} | ||||
|               name='Notice' | ||||
|               onChange={handleInputChange} | ||||
|               style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} | ||||
|               style={{ minHeight: 100, fontFamily: 'JetBrains Mono, Consolas' }} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitNotice}>保存公告</Form.Button> | ||||
|           <Form.Button onClick={submitNotice}> | ||||
|             {t('setting.other.notice.buttons.save')} | ||||
|           </Form.Button> | ||||
|  | ||||
|           <Divider /> | ||||
|           <Header as='h3'>个性化设置</Header> | ||||
|           <Header as='h3'>{t('setting.other.system.title')}</Header> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.Input | ||||
|               label='系统名称' | ||||
|               placeholder='在此输入系统名称' | ||||
|               label={t('setting.other.system.name')} | ||||
|               placeholder={t('setting.other.system.name_placeholder')} | ||||
|               value={inputs.SystemName} | ||||
|               name='SystemName' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitSystemName}>设置系统名称</Form.Button> | ||||
|           <Form.Button onClick={submitSystemName}> | ||||
|             {t('setting.other.system.buttons.save_name')} | ||||
|           </Form.Button> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.Input | ||||
|               label={<label>主题名称(<Link | ||||
|                 to='https://github.com/songquanpeng/one-api/blob/main/web/README.md'>当前可用主题</Link>)</label>} | ||||
|               placeholder='请输入主题名称' | ||||
|               label={ | ||||
|                 <label> | ||||
|                   {t('setting.other.system.theme.title')}( | ||||
|                   <Link to='https://github.com/songquanpeng/one-api/blob/main/web/README.md'> | ||||
|                     {t('setting.other.system.theme.link')} | ||||
|                   </Link> | ||||
|                   ) | ||||
|                 </label> | ||||
|               } | ||||
|               placeholder={t('setting.other.system.theme.placeholder')} | ||||
|               value={inputs.Theme} | ||||
|               name='Theme' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitTheme}>设置主题(重启生效)</Form.Button> | ||||
|           <Form.Button onClick={submitTheme}> | ||||
|             {t('setting.other.system.buttons.save_theme')} | ||||
|           </Form.Button> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.Input | ||||
|               label='Logo 图片地址' | ||||
|               placeholder='在此输入 Logo 图片地址' | ||||
|               label={t('setting.other.system.logo')} | ||||
|               placeholder={t('setting.other.system.logo_placeholder')} | ||||
|               value={inputs.Logo} | ||||
|               name='Logo' | ||||
|               type='url' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitLogo}>设置 Logo</Form.Button> | ||||
|           <Form.Button onClick={submitLogo}> | ||||
|             {t('setting.other.system.buttons.save_logo')} | ||||
|           </Form.Button> | ||||
|  | ||||
|           <Divider /> | ||||
|           <Header as='h3'>{t('setting.other.content.title')}</Header> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.TextArea | ||||
|               label='首页内容' | ||||
|               placeholder='在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。' | ||||
|               label={t('setting.other.content.homepage.title')} | ||||
|               placeholder={t('setting.other.content.homepage.placeholder')} | ||||
|               value={inputs.HomePageContent} | ||||
|               name='HomePageContent' | ||||
|               onChange={handleInputChange} | ||||
|               style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={() => submitOption('HomePageContent')}>保存首页内容</Form.Button> | ||||
|           <Form.Button onClick={() => submitOption('HomePageContent')}> | ||||
|             {t('setting.other.content.buttons.save_homepage')} | ||||
|           </Form.Button> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.TextArea | ||||
|               label='关于' | ||||
|               placeholder='在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面。' | ||||
|               label={t('setting.other.content.about.title')} | ||||
|               placeholder={t('setting.other.content.about.placeholder')} | ||||
|               value={inputs.About} | ||||
|               name='About' | ||||
|               onChange={handleInputChange} | ||||
|               style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitAbout}>保存关于</Form.Button> | ||||
|           <Message>移除 One API | ||||
|             的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目。</Message> | ||||
|           <Form.Button onClick={submitAbout}> | ||||
|             {t('setting.other.content.buttons.save_about')} | ||||
|           </Form.Button> | ||||
|           <Message>{t('setting.other.copyright.notice')}</Message> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.Input | ||||
|               label='页脚' | ||||
|               placeholder='在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码' | ||||
|               label={t('setting.other.content.footer.title')} | ||||
|               placeholder={t('setting.other.content.footer.placeholder')} | ||||
|               value={inputs.Footer} | ||||
|               name='Footer' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitFooter}>设置页脚</Form.Button> | ||||
|           <Form.Button onClick={() => submitOption('Footer')}> | ||||
|             {t('setting.other.content.buttons.save_footer')} | ||||
|           </Form.Button> | ||||
|         </Form> | ||||
|       </Grid.Column> | ||||
|       <Modal | ||||
|   | ||||
| @@ -1,12 +1,29 @@ | ||||
| import React, { useContext, useEffect, useState } from 'react'; | ||||
| import { Button, Divider, Form, Header, Image, Message, Modal } from 'semantic-ui-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { | ||||
|   Button, | ||||
|   Divider, | ||||
|   Form, | ||||
|   Header, | ||||
|   Image, | ||||
|   Message, | ||||
|   Modal, | ||||
| } from 'semantic-ui-react'; | ||||
| import { Link, useNavigate } from 'react-router-dom'; | ||||
| import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers'; | ||||
| import { | ||||
|   API, | ||||
|   copy, | ||||
|   showError, | ||||
|   showInfo, | ||||
|   showNotice, | ||||
|   showSuccess, | ||||
| } from '../helpers'; | ||||
| import Turnstile from 'react-turnstile'; | ||||
| import { UserContext } from '../context/User'; | ||||
| import { onGitHubOAuthClicked, onLarkOAuthClicked } from './utils'; | ||||
|  | ||||
| const PersonalSetting = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const [userState, userDispatch] = useContext(UserContext); | ||||
|   let navigate = useNavigate(); | ||||
|  | ||||
| @@ -14,7 +31,7 @@ const PersonalSetting = () => { | ||||
|     wechat_verification_code: '', | ||||
|     email_verification_code: '', | ||||
|     email: '', | ||||
|     self_account_deletion_confirmation: '' | ||||
|     self_account_deletion_confirmation: '', | ||||
|   }); | ||||
|   const [status, setStatus] = useState({}); | ||||
|   const [showWeChatBindModal, setShowWeChatBindModal] = useState(false); | ||||
| @@ -26,8 +43,8 @@ const PersonalSetting = () => { | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [disableButton, setDisableButton] = useState(false); | ||||
|   const [countdown, setCountdown] = useState(30); | ||||
|   const [affLink, setAffLink] = useState(""); | ||||
|   const [systemToken, setSystemToken] = useState(""); | ||||
|   const [affLink, setAffLink] = useState(''); | ||||
|   const [systemToken, setSystemToken] = useState(''); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     let status = localStorage.getItem('status'); | ||||
| @@ -63,7 +80,7 @@ const PersonalSetting = () => { | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       setSystemToken(data); | ||||
|       setAffLink("");  | ||||
|       setAffLink(''); | ||||
|       await copy(data); | ||||
|       showSuccess(`令牌已重置并已复制到剪贴板`); | ||||
|     } else { | ||||
| @@ -77,7 +94,7 @@ const PersonalSetting = () => { | ||||
|     if (success) { | ||||
|       let link = `${window.location.origin}/register?aff=${data}`; | ||||
|       setAffLink(link); | ||||
|       setSystemToken(""); | ||||
|       setSystemToken(''); | ||||
|       await copy(link); | ||||
|       showSuccess(`邀请链接已复制到剪切板`); | ||||
|     } else { | ||||
| @@ -169,50 +186,50 @@ const PersonalSetting = () => { | ||||
|  | ||||
|   return ( | ||||
|     <div style={{ lineHeight: '40px' }}> | ||||
|       <Header as='h3'>通用设置</Header> | ||||
|       <Message> | ||||
|         注意,此处生成的令牌用于系统管理,而非用于请求 OpenAI 相关的服务,请知悉。 | ||||
|       </Message> | ||||
|       <Header as='h3'>{t('setting.personal.general.title')}</Header> | ||||
|       <Message>{t('setting.personal.general.system_token_notice')}</Message> | ||||
|       <Button as={Link} to={`/user/edit/`}> | ||||
|         更新个人信息 | ||||
|         {t('setting.personal.general.buttons.update_profile')} | ||||
|       </Button> | ||||
|       <Button onClick={generateAccessToken}>生成系统访问令牌</Button> | ||||
|       <Button onClick={getAffLink}>复制邀请链接</Button> | ||||
|       <Button onClick={() => { | ||||
|         setShowAccountDeleteModal(true); | ||||
|       }}>删除个人账户</Button> | ||||
|        | ||||
|       <Button onClick={generateAccessToken}> | ||||
|         {t('setting.personal.general.buttons.generate_token')} | ||||
|       </Button> | ||||
|       <Button onClick={getAffLink}> | ||||
|         {t('setting.personal.general.buttons.copy_invite')} | ||||
|       </Button> | ||||
|       <Button | ||||
|         onClick={() => { | ||||
|           setShowAccountDeleteModal(true); | ||||
|         }} | ||||
|       > | ||||
|         {t('setting.personal.general.buttons.delete_account')} | ||||
|       </Button> | ||||
|  | ||||
|       {systemToken && ( | ||||
|         <Form.Input  | ||||
|           fluid  | ||||
|           readOnly  | ||||
|           value={systemToken}  | ||||
|         <Form.Input | ||||
|           fluid | ||||
|           readOnly | ||||
|           value={systemToken} | ||||
|           onClick={handleSystemTokenClick} | ||||
|           style={{ marginTop: '10px' }} | ||||
|         /> | ||||
|       )} | ||||
|       {affLink && ( | ||||
|         <Form.Input  | ||||
|           fluid  | ||||
|           readOnly  | ||||
|           value={affLink}  | ||||
|         <Form.Input | ||||
|           fluid | ||||
|           readOnly | ||||
|           value={affLink} | ||||
|           onClick={handleAffLinkClick} | ||||
|           style={{ marginTop: '10px' }} | ||||
|         /> | ||||
|       )} | ||||
|       <Divider /> | ||||
|       <Header as='h3'>账号绑定</Header> | ||||
|       { | ||||
|         status.wechat_login && ( | ||||
|           <Button | ||||
|             onClick={() => { | ||||
|               setShowWeChatBindModal(true); | ||||
|             }} | ||||
|           > | ||||
|             绑定微信账号 | ||||
|           </Button> | ||||
|         ) | ||||
|       } | ||||
|       <Header as='h3'>{t('setting.personal.binding.title')}</Header> | ||||
|       {status.wechat_login && ( | ||||
|         <Button onClick={() => setShowWeChatBindModal(true)}> | ||||
|           {t('setting.personal.binding.buttons.bind_wechat')} | ||||
|         </Button> | ||||
|       )} | ||||
|       <Modal | ||||
|         onClose={() => setShowWeChatBindModal(false)} | ||||
|         onOpen={() => setShowWeChatBindModal(true)} | ||||
| @@ -223,41 +240,37 @@ const PersonalSetting = () => { | ||||
|           <Modal.Description> | ||||
|             <Image src={status.wechat_qrcode} fluid /> | ||||
|             <div style={{ textAlign: 'center' }}> | ||||
|               <p> | ||||
|                 微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效) | ||||
|               </p> | ||||
|               <p>{t('setting.personal.binding.wechat.description')}</p> | ||||
|             </div> | ||||
|             <Form size='large'> | ||||
|               <Form.Input | ||||
|                 fluid | ||||
|                 placeholder='验证码' | ||||
|                 placeholder={t( | ||||
|                   'setting.personal.binding.wechat.verification_code' | ||||
|                 )} | ||||
|                 name='wechat_verification_code' | ||||
|                 value={inputs.wechat_verification_code} | ||||
|                 onChange={handleInputChange} | ||||
|               /> | ||||
|               <Button color='' fluid size='large' onClick={bindWeChat}> | ||||
|                 绑定 | ||||
|                 {t('setting.personal.binding.wechat.bind')} | ||||
|               </Button> | ||||
|             </Form> | ||||
|           </Modal.Description> | ||||
|         </Modal.Content> | ||||
|       </Modal> | ||||
|       { | ||||
|         status.github_oauth && ( | ||||
|           <Button onClick={()=>{onGitHubOAuthClicked(status.github_client_id)}}>绑定 GitHub 账号</Button> | ||||
|         ) | ||||
|       } | ||||
|       { | ||||
|         status.lark_client_id && ( | ||||
|           <Button onClick={()=>{onLarkOAuthClicked(status.lark_client_id)}}>绑定飞书账号</Button> | ||||
|         ) | ||||
|       } | ||||
|       <Button | ||||
|         onClick={() => { | ||||
|           setShowEmailBindModal(true); | ||||
|         }} | ||||
|       > | ||||
|         绑定邮箱地址 | ||||
|       {status.github_oauth && ( | ||||
|         <Button onClick={() => onGitHubOAuthClicked(status.github_client_id)}> | ||||
|           {t('setting.personal.binding.buttons.bind_github')} | ||||
|         </Button> | ||||
|       )} | ||||
|       {status.lark_client_id && ( | ||||
|         <Button onClick={() => onLarkOAuthClicked(status.lark_client_id)}> | ||||
|           {t('setting.personal.binding.buttons.bind_lark')} | ||||
|         </Button> | ||||
|       )} | ||||
|       <Button onClick={() => setShowEmailBindModal(true)}> | ||||
|         {t('setting.personal.binding.buttons.bind_email')} | ||||
|       </Button> | ||||
|       <Modal | ||||
|         onClose={() => setShowEmailBindModal(false)} | ||||
| @@ -266,57 +279,72 @@ const PersonalSetting = () => { | ||||
|         size={'tiny'} | ||||
|         style={{ maxWidth: '450px' }} | ||||
|       > | ||||
|         <Modal.Header>绑定邮箱地址</Modal.Header> | ||||
|         <Modal.Header>{t('setting.personal.binding.email.title')}</Modal.Header> | ||||
|         <Modal.Content> | ||||
|           <Modal.Description> | ||||
|             <Form size='large'> | ||||
|               <Form.Input | ||||
|                 fluid | ||||
|                 placeholder='输入邮箱地址' | ||||
|                 placeholder={t( | ||||
|                   'setting.personal.binding.email.email_placeholder' | ||||
|                 )} | ||||
|                 onChange={handleInputChange} | ||||
|                 name='email' | ||||
|                 type='email' | ||||
|                 action={ | ||||
|                   <Button onClick={sendVerificationCode} disabled={disableButton || loading}> | ||||
|                     {disableButton ? `重新发送(${countdown})` : '获取验证码'} | ||||
|                   <Button | ||||
|                     onClick={sendVerificationCode} | ||||
|                     disabled={disableButton || loading} | ||||
|                   > | ||||
|                     {disableButton | ||||
|                       ? t('setting.personal.binding.email.get_code_retry', { | ||||
|                           countdown, | ||||
|                         }) | ||||
|                       : t('setting.personal.binding.email.get_code')} | ||||
|                   </Button> | ||||
|                 } | ||||
|               /> | ||||
|               <Form.Input | ||||
|                 fluid | ||||
|                 placeholder='验证码' | ||||
|                 placeholder={t( | ||||
|                   'setting.personal.binding.email.code_placeholder' | ||||
|                 )} | ||||
|                 name='email_verification_code' | ||||
|                 value={inputs.email_verification_code} | ||||
|                 onChange={handleInputChange} | ||||
|               /> | ||||
|               {turnstileEnabled ? ( | ||||
|               {turnstileEnabled && ( | ||||
|                 <Turnstile | ||||
|                   sitekey={turnstileSiteKey} | ||||
|                   onVerify={(token) => { | ||||
|                     setTurnstileToken(token); | ||||
|                   }} | ||||
|                 /> | ||||
|               ) : ( | ||||
|                 <></> | ||||
|               )} | ||||
|               <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}> | ||||
|               <Button | ||||
|                 color='' | ||||
|                 fluid | ||||
|                 size='large' | ||||
|                 onClick={bindEmail} | ||||
|                 loading={loading} | ||||
|               <div | ||||
|                 style={{ | ||||
|                   display: 'flex', | ||||
|                   justifyContent: 'space-between', | ||||
|                   marginTop: '1rem', | ||||
|                 }} | ||||
|               > | ||||
|                 确认绑定 | ||||
|               </Button> | ||||
|               <div style={{ width: '1rem' }}></div>  | ||||
|               <Button | ||||
|                 fluid | ||||
|                 size='large' | ||||
|                 onClick={() => setShowEmailBindModal(false)} | ||||
|               > | ||||
|                 取消 | ||||
|               </Button> | ||||
|                 <Button | ||||
|                   color='' | ||||
|                   fluid | ||||
|                   size='large' | ||||
|                   onClick={bindEmail} | ||||
|                   loading={loading} | ||||
|                 > | ||||
|                   {t('setting.personal.binding.email.bind')} | ||||
|                 </Button> | ||||
|                 <div style={{ width: '1rem' }}></div> | ||||
|                 <Button | ||||
|                   fluid | ||||
|                   size='large' | ||||
|                   onClick={() => setShowEmailBindModal(false)} | ||||
|                 > | ||||
|                   {t('setting.personal.binding.email.cancel')} | ||||
|                 </Button> | ||||
|               </div> | ||||
|             </Form> | ||||
|           </Modal.Description> | ||||
| @@ -329,29 +357,40 @@ const PersonalSetting = () => { | ||||
|         size={'tiny'} | ||||
|         style={{ maxWidth: '450px' }} | ||||
|       > | ||||
|         <Modal.Header>危险操作</Modal.Header> | ||||
|         <Modal.Header> | ||||
|           {t('setting.personal.delete_account.title')} | ||||
|         </Modal.Header> | ||||
|         <Modal.Content> | ||||
|         <Message>您正在删除自己的帐户,将清空所有数据且不可恢复</Message> | ||||
|           <Message>{t('setting.personal.delete_account.warning')}</Message> | ||||
|           <Modal.Description> | ||||
|             <Form size='large'> | ||||
|               <Form.Input | ||||
|                 fluid | ||||
|                 placeholder={`输入你的账户名 ${userState?.user?.username} 以确认删除`} | ||||
|                 placeholder={t( | ||||
|                   'setting.personal.delete_account.confirm_placeholder', | ||||
|                   { | ||||
|                     username: userState?.user?.username, | ||||
|                   } | ||||
|                 )} | ||||
|                 name='self_account_deletion_confirmation' | ||||
|                 value={inputs.self_account_deletion_confirmation} | ||||
|                 onChange={handleInputChange} | ||||
|               /> | ||||
|               {turnstileEnabled ? ( | ||||
|               {turnstileEnabled && ( | ||||
|                 <Turnstile | ||||
|                   sitekey={turnstileSiteKey} | ||||
|                   onVerify={(token) => { | ||||
|                     setTurnstileToken(token); | ||||
|                   }} | ||||
|                 /> | ||||
|               ) : ( | ||||
|                 <></> | ||||
|               )} | ||||
|               <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}> | ||||
|               <div | ||||
|                 style={{ | ||||
|                   display: 'flex', | ||||
|                   justifyContent: 'space-between', | ||||
|                   marginTop: '1rem', | ||||
|                 }} | ||||
|               > | ||||
|                 <Button | ||||
|                   color='red' | ||||
|                   fluid | ||||
| @@ -359,7 +398,7 @@ const PersonalSetting = () => { | ||||
|                   onClick={deleteAccount} | ||||
|                   loading={loading} | ||||
|                 > | ||||
|                   确认删除 | ||||
|                   {t('setting.personal.delete_account.buttons.confirm')} | ||||
|                 </Button> | ||||
|                 <div style={{ width: '1rem' }}></div> | ||||
|                 <Button | ||||
| @@ -367,7 +406,7 @@ const PersonalSetting = () => { | ||||
|                   size='large' | ||||
|                   onClick={() => setShowAccountDeleteModal(false)} | ||||
|                 > | ||||
|                   取消 | ||||
|                   {t('setting.personal.delete_account.buttons.cancel')} | ||||
|                 </Button> | ||||
|               </div> | ||||
|             </Form> | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { | ||||
|   Button, | ||||
|   Form, | ||||
| @@ -25,39 +26,37 @@ function renderTimestamp(timestamp) { | ||||
|   return <>{timestamp2string(timestamp)}</>; | ||||
| } | ||||
|  | ||||
| function renderStatus(status) { | ||||
| function renderStatus(status, t) { | ||||
|   switch (status) { | ||||
|     case 1: | ||||
|       return ( | ||||
|         <Label basic color='green'> | ||||
|           未使用 | ||||
|           {t('redemption.status.unused')} | ||||
|         </Label> | ||||
|       ); | ||||
|     case 2: | ||||
|       return ( | ||||
|         <Label basic color='red'> | ||||
|           {' '} | ||||
|           已禁用{' '} | ||||
|           {t('redemption.status.disabled')} | ||||
|         </Label> | ||||
|       ); | ||||
|     case 3: | ||||
|       return ( | ||||
|         <Label basic color='grey'> | ||||
|           {' '} | ||||
|           已使用{' '} | ||||
|           {t('redemption.status.used')} | ||||
|         </Label> | ||||
|       ); | ||||
|     default: | ||||
|       return ( | ||||
|         <Label basic color='black'> | ||||
|           {' '} | ||||
|           未知状态{' '} | ||||
|           {t('redemption.status.unknown')} | ||||
|         </Label> | ||||
|       ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| const RedemptionsTable = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const [redemptions, setRedemptions] = useState([]); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [activePage, setActivePage] = useState(1); | ||||
| @@ -117,7 +116,7 @@ const RedemptionsTable = () => { | ||||
|     } | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
|       showSuccess('操作成功完成!'); | ||||
|       showSuccess(t('token.messages.operation_success')); | ||||
|       let redemption = res.data.data; | ||||
|       let newRedemptions = [...redemptions]; | ||||
|       let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; | ||||
| @@ -177,6 +176,12 @@ const RedemptionsTable = () => { | ||||
|     setLoading(false); | ||||
|   }; | ||||
|  | ||||
|   const refresh = async () => { | ||||
|     setLoading(true); | ||||
|     await loadRedemptions(0); | ||||
|     setActivePage(1); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Form onSubmit={searchRedemptions}> | ||||
| @@ -184,7 +189,7 @@ const RedemptionsTable = () => { | ||||
|           icon='search' | ||||
|           fluid | ||||
|           iconPosition='left' | ||||
|           placeholder='搜索兑换码的 ID 和名称 ...' | ||||
|           placeholder={t('redemption.search')} | ||||
|           value={searchKeyword} | ||||
|           loading={searching} | ||||
|           onChange={handleKeywordChange} | ||||
| @@ -200,7 +205,7 @@ const RedemptionsTable = () => { | ||||
|                 sortRedemption('id'); | ||||
|               }} | ||||
|             > | ||||
|               ID | ||||
|               {t('redemption.table.id')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -208,7 +213,7 @@ const RedemptionsTable = () => { | ||||
|                 sortRedemption('name'); | ||||
|               }} | ||||
|             > | ||||
|               名称 | ||||
|               {t('redemption.table.name')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -216,7 +221,7 @@ const RedemptionsTable = () => { | ||||
|                 sortRedemption('status'); | ||||
|               }} | ||||
|             > | ||||
|               状态 | ||||
|               {t('redemption.table.status')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -224,7 +229,7 @@ const RedemptionsTable = () => { | ||||
|                 sortRedemption('quota'); | ||||
|               }} | ||||
|             > | ||||
|               额度 | ||||
|               {t('redemption.table.quota')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -232,7 +237,7 @@ const RedemptionsTable = () => { | ||||
|                 sortRedemption('created_time'); | ||||
|               }} | ||||
|             > | ||||
|               创建时间 | ||||
|               {t('redemption.table.created_time')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -240,9 +245,9 @@ const RedemptionsTable = () => { | ||||
|                 sortRedemption('redeemed_time'); | ||||
|               }} | ||||
|             > | ||||
|               兑换时间 | ||||
|               {t('redemption.table.redeemed_time')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell>操作</Table.HeaderCell> | ||||
|             <Table.HeaderCell>{t('redemption.table.actions')}</Table.HeaderCell> | ||||
|           </Table.Row> | ||||
|         </Table.Header> | ||||
|  | ||||
| @@ -258,40 +263,38 @@ const RedemptionsTable = () => { | ||||
|                 <Table.Row key={redemption.id}> | ||||
|                   <Table.Cell>{redemption.id}</Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     {redemption.name ? redemption.name : '无'} | ||||
|                     {redemption.name ? redemption.name : t('redemption.table.no_name')} | ||||
|                   </Table.Cell> | ||||
|                   <Table.Cell>{renderStatus(redemption.status)}</Table.Cell> | ||||
|                   <Table.Cell>{renderQuota(redemption.quota)}</Table.Cell> | ||||
|                   <Table.Cell>{renderStatus(redemption.status, t)}</Table.Cell> | ||||
|                   <Table.Cell>{renderQuota(redemption.quota, t)}</Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     {renderTimestamp(redemption.created_time)} | ||||
|                   </Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     {redemption.redeemed_time | ||||
|                       ? renderTimestamp(redemption.redeemed_time) | ||||
|                       : '尚未兑换'}{' '} | ||||
|                       : t('redemption.table.not_redeemed')}{' '} | ||||
|                   </Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     <div> | ||||
|                       <Button | ||||
|                         size={'small'} | ||||
|                         size={'tiny'} | ||||
|                         positive | ||||
|                         onClick={async () => { | ||||
|                           if (await copy(redemption.key)) { | ||||
|                             showSuccess('已复制到剪贴板!'); | ||||
|                             showSuccess(t('token.messages.copy_success')); | ||||
|                           } else { | ||||
|                             showWarning( | ||||
|                               '无法复制到剪贴板,请手动复制,已将兑换码填入搜索框。' | ||||
|                             ); | ||||
|                             showWarning(t('token.messages.copy_failed')); | ||||
|                             setSearchKeyword(redemption.key); | ||||
|                           } | ||||
|                         }} | ||||
|                       > | ||||
|                         复制 | ||||
|                         {t('redemption.buttons.copy')} | ||||
|                       </Button> | ||||
|                       <Popup | ||||
|                         trigger={ | ||||
|                           <Button size='small' negative> | ||||
|                             删除 | ||||
|                           <Button size='tiny' negative> | ||||
|                             {t('redemption.buttons.delete')} | ||||
|                           </Button> | ||||
|                         } | ||||
|                         on='click' | ||||
| @@ -304,11 +307,11 @@ const RedemptionsTable = () => { | ||||
|                             manageRedemption(redemption.id, 'delete', idx); | ||||
|                           }} | ||||
|                         > | ||||
|                           确认删除 | ||||
|                           {t('redemption.buttons.confirm_delete')} | ||||
|                         </Button> | ||||
|                       </Popup> | ||||
|                       <Button | ||||
|                         size={'small'} | ||||
|                         size={'tiny'} | ||||
|                         disabled={redemption.status === 3} // used | ||||
|                         onClick={() => { | ||||
|                           manageRedemption( | ||||
| @@ -318,14 +321,16 @@ const RedemptionsTable = () => { | ||||
|                           ); | ||||
|                         }} | ||||
|                       > | ||||
|                         {redemption.status === 1 ? '禁用' : '启用'} | ||||
|                         {redemption.status === 1 | ||||
|                           ? t('redemption.buttons.disable') | ||||
|                           : t('redemption.buttons.enable')} | ||||
|                       </Button> | ||||
|                       <Button | ||||
|                         size={'small'} | ||||
|                         size={'tiny'} | ||||
|                         as={Link} | ||||
|                         to={'/redemption/edit/' + redemption.id} | ||||
|                       > | ||||
|                         编辑 | ||||
|                         {t('redemption.buttons.edit')} | ||||
|                       </Button> | ||||
|                     </div> | ||||
|                   </Table.Cell> | ||||
| @@ -336,14 +341,17 @@ const RedemptionsTable = () => { | ||||
|  | ||||
|         <Table.Footer> | ||||
|           <Table.Row> | ||||
|             <Table.HeaderCell colSpan='8'> | ||||
|             <Table.HeaderCell colSpan='7'> | ||||
|               <Button | ||||
|                 size='small' | ||||
|                 as={Link} | ||||
|                 to='/redemption/add' | ||||
|                 loading={loading} | ||||
|               > | ||||
|                 添加新的兑换码 | ||||
|                 {t('redemption.buttons.add')} | ||||
|               </Button> | ||||
|               <Button size='small' onClick={refresh} loading={loading}> | ||||
|                 {t('redemption.buttons.refresh')} | ||||
|               </Button> | ||||
|               <Pagination | ||||
|                 floated='right' | ||||
|   | ||||
| @@ -171,7 +171,7 @@ const RegisterForm = () => { | ||||
|                       <Button | ||||
|                         onClick={sendVerificationCode} | ||||
|                         disabled={loading} | ||||
|                         style={{ backgroundColor: '#2185d0', color: 'white' }} | ||||
|                         // style={{ backgroundColor: '#2F73FF', color: 'white' }} | ||||
|                       > | ||||
|                         获取验证码 | ||||
|                       </Button> | ||||
|   | ||||
| @@ -1,8 +1,18 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Button, Divider, Form, Grid, Header, Modal, Message } from 'semantic-ui-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { | ||||
|   Button, | ||||
|   Divider, | ||||
|   Form, | ||||
|   Grid, | ||||
|   Header, | ||||
|   Modal, | ||||
|   Message, | ||||
| } from 'semantic-ui-react'; | ||||
| import { API, removeTrailingSlash, showError } from '../helpers'; | ||||
|  | ||||
| const SystemSetting = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   let [inputs, setInputs] = useState({ | ||||
|     PasswordLoginEnabled: '', | ||||
|     PasswordRegisterEnabled: '', | ||||
| @@ -31,13 +41,14 @@ const SystemSetting = () => { | ||||
|     TurnstileSecretKey: '', | ||||
|     RegisterEnabled: '', | ||||
|     EmailDomainRestrictionEnabled: '', | ||||
|     EmailDomainWhitelist: '' | ||||
|     EmailDomainWhitelist: '', | ||||
|   }); | ||||
|   const [originInputs, setOriginInputs] = useState({}); | ||||
|   let [loading, setLoading] = useState(false); | ||||
|   const [EmailDomainWhitelist, setEmailDomainWhitelist] = useState([]); | ||||
|   const [restrictedDomainInput, setRestrictedDomainInput] = useState(''); | ||||
|   const [showPasswordWarningModal, setShowPasswordWarningModal] = useState(false); | ||||
|   const [showPasswordWarningModal, setShowPasswordWarningModal] = | ||||
|     useState(false); | ||||
|  | ||||
|   const getOptions = async () => { | ||||
|     const res = await API.get('/api/option/'); | ||||
| @@ -49,13 +60,15 @@ const SystemSetting = () => { | ||||
|       }); | ||||
|       setInputs({ | ||||
|         ...newInputs, | ||||
|         EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(',') | ||||
|         EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(','), | ||||
|       }); | ||||
|       setOriginInputs(newInputs); | ||||
|  | ||||
|       setEmailDomainWhitelist(newInputs.EmailDomainWhitelist.split(',').map((item) => { | ||||
|         return { key: item, text: item, value: item }; | ||||
|       })); | ||||
|       setEmailDomainWhitelist( | ||||
|         newInputs.EmailDomainWhitelist.split(',').map((item) => { | ||||
|           return { key: item, text: item, value: item }; | ||||
|         }) | ||||
|       ); | ||||
|     } else { | ||||
|       showError(message); | ||||
|     } | ||||
| @@ -83,7 +96,7 @@ const SystemSetting = () => { | ||||
|     } | ||||
|     const res = await API.put('/api/option/', { | ||||
|       key, | ||||
|       value | ||||
|       value, | ||||
|     }); | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
| @@ -91,7 +104,8 @@ const SystemSetting = () => { | ||||
|         value = value.split(','); | ||||
|       } | ||||
|       setInputs((inputs) => ({ | ||||
|         ...inputs, [key]: value | ||||
|         ...inputs, | ||||
|         [key]: value, | ||||
|       })); | ||||
|     } else { | ||||
|       showError(message); | ||||
| @@ -155,13 +169,16 @@ const SystemSetting = () => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|  | ||||
|   const submitEmailDomainWhitelist = async () => { | ||||
|     if ( | ||||
|       originInputs['EmailDomainWhitelist'] !== inputs.EmailDomainWhitelist.join(',') && | ||||
|       originInputs['EmailDomainWhitelist'] !== | ||||
|         inputs.EmailDomainWhitelist.join(',') && | ||||
|       inputs.SMTPToken !== '' | ||||
|     ) { | ||||
|       await updateOption('EmailDomainWhitelist', inputs.EmailDomainWhitelist.join(',')); | ||||
|       await updateOption( | ||||
|         'EmailDomainWhitelist', | ||||
|         inputs.EmailDomainWhitelist.join(',') | ||||
|       ); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
| @@ -216,7 +233,7 @@ const SystemSetting = () => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|    const submitLarkOAuth = async () => { | ||||
|   const submitLarkOAuth = async () => { | ||||
|     if (originInputs['LarkClientId'] !== inputs.LarkClientId) { | ||||
|       await updateOption('LarkClientId', inputs.LarkClientId); | ||||
|     } | ||||
| @@ -242,60 +259,71 @@ const SystemSetting = () => { | ||||
|  | ||||
|   const submitNewRestrictedDomain = () => { | ||||
|     const localDomainList = inputs.EmailDomainWhitelist; | ||||
|     if (restrictedDomainInput !== '' && !localDomainList.includes(restrictedDomainInput)) { | ||||
|     if ( | ||||
|       restrictedDomainInput !== '' && | ||||
|       !localDomainList.includes(restrictedDomainInput) | ||||
|     ) { | ||||
|       setRestrictedDomainInput(''); | ||||
|       setInputs({ | ||||
|         ...inputs, | ||||
|         EmailDomainWhitelist: [...localDomainList, restrictedDomainInput], | ||||
|       }); | ||||
|       setEmailDomainWhitelist([...EmailDomainWhitelist, { | ||||
|         key: restrictedDomainInput, | ||||
|         text: restrictedDomainInput, | ||||
|         value: restrictedDomainInput, | ||||
|       }]); | ||||
|       setEmailDomainWhitelist([ | ||||
|         ...EmailDomainWhitelist, | ||||
|         { | ||||
|           key: restrictedDomainInput, | ||||
|           text: restrictedDomainInput, | ||||
|           value: restrictedDomainInput, | ||||
|         }, | ||||
|       ]); | ||||
|     } | ||||
|   } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Grid columns={1}> | ||||
|       <Grid.Column> | ||||
|         <Form loading={loading}> | ||||
|           <Header as='h3'>通用设置</Header> | ||||
|           <Header as='h3'>{t('setting.system.general.title')}</Header> | ||||
|           <Form.Group widths='equal'> | ||||
|             <Form.Input | ||||
|               label='服务器地址' | ||||
|               placeholder='例如:https://yourdomain.com' | ||||
|               label={t('setting.system.general.server_address')} | ||||
|               placeholder={t( | ||||
|                 'setting.system.general.server_address_placeholder' | ||||
|               )} | ||||
|               value={inputs.ServerAddress} | ||||
|               name='ServerAddress' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitServerAddress}> | ||||
|             更新服务器地址 | ||||
|             {t('setting.system.general.buttons.update')} | ||||
|           </Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'>配置登录注册</Header> | ||||
|           <Header as='h3'>{t('setting.system.login.title')}</Header> | ||||
|           <Form.Group inline> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.PasswordLoginEnabled === 'true'} | ||||
|               label='允许通过密码进行登录' | ||||
|               label={t('setting.system.login.password_login')} | ||||
|               name='PasswordLoginEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             { | ||||
|               showPasswordWarningModal && | ||||
|             {showPasswordWarningModal && ( | ||||
|               <Modal | ||||
|                 open={showPasswordWarningModal} | ||||
|                 onClose={() => setShowPasswordWarningModal(false)} | ||||
|                 size={'tiny'} | ||||
|                 style={{ maxWidth: '450px' }} | ||||
|               > | ||||
|                 <Modal.Header>警告</Modal.Header> | ||||
|                 <Modal.Header> | ||||
|                   {t('setting.system.password_login.warning.title')} | ||||
|                 </Modal.Header> | ||||
|                 <Modal.Content> | ||||
|                   <p>取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?</p> | ||||
|                   <p>{t('setting.system.password_login.warning.content')}</p> | ||||
|                 </Modal.Content> | ||||
|                 <Modal.Actions> | ||||
|                   <Button onClick={() => setShowPasswordWarningModal(false)}>取消</Button> | ||||
|                   <Button onClick={() => setShowPasswordWarningModal(false)}> | ||||
|                     {t('setting.system.password_login.warning.buttons.cancel')} | ||||
|                   </Button> | ||||
|                   <Button | ||||
|                     color='yellow' | ||||
|                     onClick={async () => { | ||||
| @@ -303,32 +331,32 @@ const SystemSetting = () => { | ||||
|                       await updateOption('PasswordLoginEnabled', 'false'); | ||||
|                     }} | ||||
|                   > | ||||
|                     确定 | ||||
|                     {t('setting.system.password_login.warning.buttons.confirm')} | ||||
|                   </Button> | ||||
|                 </Modal.Actions> | ||||
|               </Modal> | ||||
|             } | ||||
|             )} | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.PasswordRegisterEnabled === 'true'} | ||||
|               label='允许通过密码进行注册' | ||||
|               label={t('setting.system.login.password_register')} | ||||
|               name='PasswordRegisterEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.EmailVerificationEnabled === 'true'} | ||||
|               label='通过密码注册时需要进行邮箱验证' | ||||
|               label={t('setting.system.login.email_verification')} | ||||
|               name='EmailVerificationEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.GitHubOAuthEnabled === 'true'} | ||||
|               label='允许通过 GitHub 账户登录 & 注册' | ||||
|               label={t('setting.system.login.github_oauth')} | ||||
|               name='GitHubOAuthEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.WeChatAuthEnabled === 'true'} | ||||
|               label='允许通过微信登录 & 注册' | ||||
|               label={t('setting.system.login.wechat_login')} | ||||
|               name='WeChatAuthEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
| @@ -336,304 +364,295 @@ const SystemSetting = () => { | ||||
|           <Form.Group inline> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.RegisterEnabled === 'true'} | ||||
|               label='允许新用户注册(此项为否时,新用户将无法以任何方式进行注册)' | ||||
|               label={t('setting.system.login.registration')} | ||||
|               name='RegisterEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|             <Form.Checkbox | ||||
|               checked={inputs.TurnstileCheckEnabled === 'true'} | ||||
|               label='启用 Turnstile 用户校验' | ||||
|               label={t('setting.system.login.turnstile')} | ||||
|               name='TurnstileCheckEnabled' | ||||
|               onChange={handleInputChange} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             配置邮箱域名白名单 | ||||
|             <Header.Subheader>用以防止恶意用户利用临时邮箱批量注册</Header.Subheader> | ||||
|           </Header> | ||||
|           <Form.Group widths={3}> | ||||
|           <Header as='h3'>{t('setting.system.email_restriction.title')}</Header> | ||||
|           <Message>{t('setting.system.email_restriction.subtitle')}</Message> | ||||
|           <Form.Group inline> | ||||
|             <Form.Checkbox | ||||
|               label='启用邮箱域名白名单' | ||||
|               checked={inputs.EmailDomainRestrictionEnabled === 'true'} | ||||
|               label={t('setting.system.email_restriction.enable')} | ||||
|               name='EmailDomainRestrictionEnabled' | ||||
|               onChange={handleInputChange} | ||||
|               checked={inputs.EmailDomainRestrictionEnabled === 'true'} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Group widths={2}> | ||||
|             <Form.Dropdown | ||||
|               label='允许的邮箱域名' | ||||
|               placeholder='允许的邮箱域名' | ||||
|               name='EmailDomainWhitelist' | ||||
|               required | ||||
|               fluid | ||||
|               multiple | ||||
|               selection | ||||
|               onChange={handleInputChange} | ||||
|               value={inputs.EmailDomainWhitelist} | ||||
|               autoComplete='new-password' | ||||
|               options={EmailDomainWhitelist} | ||||
|             /> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label='添加新的允许的邮箱域名' | ||||
|               action={ | ||||
|                 <Button type='button' onClick={() => { | ||||
|                   submitNewRestrictedDomain(); | ||||
|                 }}>填入</Button> | ||||
|               } | ||||
|               onKeyDown={(e) => { | ||||
|                 if (e.key === 'Enter') { | ||||
|                   submitNewRestrictedDomain(); | ||||
|                 } | ||||
|               }} | ||||
|               autoComplete='new-password' | ||||
|               placeholder='输入新的允许的邮箱域名' | ||||
|               label={t('setting.system.email_restriction.add_domain')} | ||||
|               placeholder={t( | ||||
|                 'setting.system.email_restriction.add_domain_placeholder' | ||||
|               )} | ||||
|               value={restrictedDomainInput} | ||||
|               onChange={(e, { value }) => { | ||||
|                 setRestrictedDomainInput(value); | ||||
|               }} | ||||
|               action={ | ||||
|                 <Button | ||||
|                   onClick={() => { | ||||
|                     if (restrictedDomainInput === '') return; | ||||
|                     setEmailDomainWhitelist([ | ||||
|                       ...EmailDomainWhitelist, | ||||
|                       { | ||||
|                         key: restrictedDomainInput, | ||||
|                         text: restrictedDomainInput, | ||||
|                         value: restrictedDomainInput, | ||||
|                       }, | ||||
|                     ]); | ||||
|                     setRestrictedDomainInput(''); | ||||
|                   }} | ||||
|                 > | ||||
|                   {t('setting.system.email_restriction.buttons.fill')} | ||||
|                 </Button> | ||||
|               } | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitEmailDomainWhitelist}>保存邮箱域名白名单设置</Form.Button> | ||||
|           <Form.Dropdown | ||||
|             label={t('setting.system.email_restriction.allowed_domains')} | ||||
|             placeholder={t('setting.system.email_restriction.allowed_domains')} | ||||
|             fluid | ||||
|             multiple | ||||
|             search | ||||
|             selection | ||||
|             allowAdditions | ||||
|             value={EmailDomainWhitelist.map((item) => item.value)} | ||||
|             options={EmailDomainWhitelist} | ||||
|             onAddItem={(e, { value }) => { | ||||
|               setEmailDomainWhitelist([ | ||||
|                 ...EmailDomainWhitelist, | ||||
|                 { | ||||
|                   key: value, | ||||
|                   text: value, | ||||
|                   value: value, | ||||
|                 }, | ||||
|               ]); | ||||
|             }} | ||||
|             onChange={(e, { value }) => { | ||||
|               let newEmailDomainWhitelist = []; | ||||
|               value.forEach((item) => { | ||||
|                 newEmailDomainWhitelist.push({ | ||||
|                   key: item, | ||||
|                   text: item, | ||||
|                   value: item, | ||||
|                 }); | ||||
|               }); | ||||
|               setEmailDomainWhitelist(newEmailDomainWhitelist); | ||||
|             }} | ||||
|           /> | ||||
|           <Form.Button onClick={submitEmailDomainWhitelist}> | ||||
|             {t('setting.system.email_restriction.buttons.save')} | ||||
|           </Form.Button> | ||||
|  | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             配置 SMTP | ||||
|             <Header.Subheader>用以支持系统的邮件发送</Header.Subheader> | ||||
|           </Header> | ||||
|           <Header as='h3'>{t('setting.system.smtp.title')}</Header> | ||||
|           <Message>{t('setting.system.smtp.subtitle')}</Message> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label='SMTP 服务器地址' | ||||
|               label={t('setting.system.smtp.server')} | ||||
|               placeholder={t('setting.system.smtp.server_placeholder')} | ||||
|               name='SMTPServer' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.SMTPServer} | ||||
|               placeholder='例如:smtp.qq.com' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='SMTP 端口' | ||||
|               label={t('setting.system.smtp.port')} | ||||
|               placeholder={t('setting.system.smtp.port_placeholder')} | ||||
|               name='SMTPPort' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.SMTPPort} | ||||
|               placeholder='默认: 587' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='SMTP 账户' | ||||
|               label={t('setting.system.smtp.account')} | ||||
|               placeholder={t('setting.system.smtp.account_placeholder')} | ||||
|               name='SMTPAccount' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.SMTPAccount} | ||||
|               placeholder='通常是邮箱地址' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label='SMTP 发送者邮箱' | ||||
|               label={t('setting.system.smtp.from')} | ||||
|               placeholder={t('setting.system.smtp.from_placeholder')} | ||||
|               name='SMTPFrom' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.SMTPFrom} | ||||
|               placeholder='通常和邮箱地址保持一致' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='SMTP 访问凭证' | ||||
|               label={t('setting.system.smtp.token')} | ||||
|               placeholder={t('setting.system.smtp.token_placeholder')} | ||||
|               name='SMTPToken' | ||||
|               onChange={handleInputChange} | ||||
|               type='password' | ||||
|               autoComplete='new-password' | ||||
|               checked={inputs.RegisterEnabled === 'true'} | ||||
|               placeholder='敏感信息不会发送到前端显示' | ||||
|               value={inputs.SMTPToken} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitSMTP}>保存 SMTP 设置</Form.Button> | ||||
|           <Form.Button onClick={submitSMTP}> | ||||
|             {t('setting.system.smtp.buttons.save')} | ||||
|           </Form.Button> | ||||
|  | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             配置 GitHub OAuth App | ||||
|             <Header.Subheader> | ||||
|               用以支持通过 GitHub 进行登录注册, | ||||
|               <a href='https://github.com/settings/developers' target='_blank'> | ||||
|                 点击此处 | ||||
|               </a> | ||||
|               管理你的 GitHub OAuth App | ||||
|             </Header.Subheader> | ||||
|           </Header> | ||||
|           <Header as='h3'>{t('setting.system.github.title')}</Header> | ||||
|           <Message> | ||||
|             Homepage URL 填 <code>{inputs.ServerAddress}</code> | ||||
|             ,Authorization callback URL 填{' '} | ||||
|             <code>{`${inputs.ServerAddress}/oauth/github`}</code> | ||||
|             {t('setting.system.github.subtitle')} | ||||
|             <a href='https://github.com/settings/developers' target='_blank'> | ||||
|               {t('setting.system.github.manage_link')} | ||||
|             </a> | ||||
|             {t('setting.system.github.manage_text')} | ||||
|           </Message> | ||||
|           <Message> | ||||
|             {t('setting.system.github.url_notice', { | ||||
|               server_url: originInputs.ServerAddress, | ||||
|               callback_url: `${originInputs.ServerAddress}/oauth/github`, | ||||
|             })} | ||||
|           </Message> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label='GitHub Client ID' | ||||
|               label={t('setting.system.github.client_id')} | ||||
|               placeholder={t('setting.system.github.client_id_placeholder')} | ||||
|               name='GitHubClientId' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.GitHubClientId} | ||||
|               placeholder='输入你注册的 GitHub OAuth APP 的 ID' | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='GitHub Client Secret' | ||||
|               label={t('setting.system.github.client_secret')} | ||||
|               placeholder={t('setting.system.github.client_secret_placeholder')} | ||||
|               name='GitHubClientSecret' | ||||
|               onChange={handleInputChange} | ||||
|               type='password' | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.GitHubClientSecret} | ||||
|               placeholder='敏感信息不会发送到前端显示' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitGitHubOAuth}> | ||||
|             保存 GitHub OAuth 设置 | ||||
|             {t('setting.system.github.buttons.save')} | ||||
|           </Form.Button> | ||||
|  | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             配置飞书授权登录 | ||||
|             {t('setting.system.lark.title')} | ||||
|             <Header.Subheader> | ||||
|               用以支持通过飞书进行登录注册, | ||||
|               {t('setting.system.lark.subtitle')} | ||||
|               <a href='https://open.feishu.cn/app' target='_blank'> | ||||
|                 点击此处 | ||||
|                 {t('setting.system.lark.manage_link')} | ||||
|               </a> | ||||
|               管理你的飞书应用 | ||||
|               {t('setting.system.lark.manage_text')} | ||||
|             </Header.Subheader> | ||||
|           </Header> | ||||
|           <Message> | ||||
|             主页链接填 <code>{inputs.ServerAddress}</code> | ||||
|             ,重定向 URL 填{' '} | ||||
|             <code>{`${inputs.ServerAddress}/oauth/lark`}</code> | ||||
|             {t('setting.system.lark.url_notice', { | ||||
|               server_url: inputs.ServerAddress, | ||||
|               callback_url: `${inputs.ServerAddress}/oauth/lark`, | ||||
|             })} | ||||
|           </Message> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label='App ID' | ||||
|               label={t('setting.system.lark.client_id')} | ||||
|               name='LarkClientId' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.LarkClientId} | ||||
|               placeholder='输入 App ID' | ||||
|               placeholder={t('setting.system.lark.client_id_placeholder')} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='App Secret' | ||||
|               label={t('setting.system.lark.client_secret')} | ||||
|               name='LarkClientSecret' | ||||
|               onChange={handleInputChange} | ||||
|               type='password' | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.LarkClientSecret} | ||||
|               placeholder='敏感信息不会发送到前端显示' | ||||
|               placeholder={t('setting.system.lark.client_secret_placeholder')} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitLarkOAuth}> | ||||
|             保存飞书 OAuth 设置 | ||||
|             {t('setting.system.lark.buttons.save')} | ||||
|           </Form.Button> | ||||
|  | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             配置 WeChat Server | ||||
|             {t('setting.system.wechat.title')} | ||||
|             <Header.Subheader> | ||||
|               用以支持通过微信进行登录注册, | ||||
|               {t('setting.system.wechat.subtitle')} | ||||
|               <a | ||||
|                 href='https://github.com/songquanpeng/wechat-server' | ||||
|                 target='_blank' | ||||
|               > | ||||
|                 点击此处 | ||||
|                 {t('setting.system.wechat.learn_more')} | ||||
|               </a> | ||||
|               了解 WeChat Server | ||||
|             </Header.Subheader> | ||||
|           </Header> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label='WeChat Server 服务器地址' | ||||
|               label={t('setting.system.wechat.server_address')} | ||||
|               name='WeChatServerAddress' | ||||
|               placeholder='例如:https://yourdomain.com' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.WeChatServerAddress} | ||||
|               placeholder={t( | ||||
|                 'setting.system.wechat.server_address_placeholder' | ||||
|               )} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='WeChat Server 访问凭证' | ||||
|               label={t('setting.system.wechat.token')} | ||||
|               name='WeChatServerToken' | ||||
|               type='password' | ||||
|               onChange={handleInputChange} | ||||
|               type='password' | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.WeChatServerToken} | ||||
|               placeholder='敏感信息不会发送到前端显示' | ||||
|               placeholder={t('setting.system.wechat.token_placeholder')} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='微信公众号二维码图片链接' | ||||
|               label={t('setting.system.wechat.qrcode')} | ||||
|               name='WeChatAccountQRCodeImageURL' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.WeChatAccountQRCodeImageURL} | ||||
|               placeholder='输入一个图片链接' | ||||
|               placeholder={t('setting.system.wechat.qrcode_placeholder')} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitWeChat}> | ||||
|             保存 WeChat Server 设置 | ||||
|             {t('setting.system.wechat.buttons.save')} | ||||
|           </Form.Button> | ||||
|  | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             配置 Message Pusher | ||||
|             {t('setting.system.turnstile.title')} | ||||
|             <Header.Subheader> | ||||
|               用以推送报警信息, | ||||
|               <a | ||||
|                 href='https://github.com/songquanpeng/message-pusher' | ||||
|                 target='_blank' | ||||
|               > | ||||
|                 点击此处 | ||||
|               </a> | ||||
|               了解 Message Pusher | ||||
|             </Header.Subheader> | ||||
|           </Header> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label='Message Pusher 推送地址' | ||||
|               name='MessagePusherAddress' | ||||
|               placeholder='例如:https://msgpusher.com/push/your_username' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.MessagePusherAddress} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='Message Pusher 访问凭证' | ||||
|               name='MessagePusherToken' | ||||
|               type='password' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.MessagePusherToken} | ||||
|               placeholder='敏感信息不会发送到前端显示' | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitMessagePusher}> | ||||
|             保存 Message Pusher 设置 | ||||
|           </Form.Button> | ||||
|           <Divider /> | ||||
|           <Header as='h3'> | ||||
|             配置 Turnstile | ||||
|             <Header.Subheader> | ||||
|               用以支持用户校验, | ||||
|               {t('setting.system.turnstile.subtitle')} | ||||
|               <a href='https://dash.cloudflare.com/' target='_blank'> | ||||
|                 点击此处 | ||||
|                 {t('setting.system.turnstile.manage_link')} | ||||
|               </a> | ||||
|               管理你的 Turnstile Sites,推荐选择 Invisible Widget Type | ||||
|               {t('setting.system.turnstile.manage_text')} | ||||
|             </Header.Subheader> | ||||
|           </Header> | ||||
|           <Form.Group widths={3}> | ||||
|             <Form.Input | ||||
|               label='Turnstile Site Key' | ||||
|               label={t('setting.system.turnstile.site_key')} | ||||
|               name='TurnstileSiteKey' | ||||
|               onChange={handleInputChange} | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.TurnstileSiteKey} | ||||
|               placeholder='输入你注册的 Turnstile Site Key' | ||||
|               placeholder={t('setting.system.turnstile.site_key_placeholder')} | ||||
|             /> | ||||
|             <Form.Input | ||||
|               label='Turnstile Secret Key' | ||||
|               label={t('setting.system.turnstile.secret_key')} | ||||
|               name='TurnstileSecretKey' | ||||
|               onChange={handleInputChange} | ||||
|               type='password' | ||||
|               autoComplete='new-password' | ||||
|               value={inputs.TurnstileSecretKey} | ||||
|               placeholder='敏感信息不会发送到前端显示' | ||||
|               placeholder={t('setting.system.turnstile.secret_key_placeholder')} | ||||
|             /> | ||||
|           </Form.Group> | ||||
|           <Form.Button onClick={submitTurnstile}> | ||||
|             保存 Turnstile 设置 | ||||
|             {t('setting.system.turnstile.buttons.save')} | ||||
|           </Form.Button> | ||||
|         </Form> | ||||
|       </Grid.Column> | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { | ||||
|   Button, | ||||
|   Dropdown, | ||||
| @@ -21,64 +22,63 @@ import { | ||||
| import { ITEMS_PER_PAGE } from '../constants'; | ||||
| import { renderQuota } from '../helpers/render'; | ||||
|  | ||||
| const COPY_OPTIONS = [ | ||||
|   { key: 'next', text: 'ChatGPT Next Web', value: 'next' }, | ||||
|   { key: 'ama', text: 'BotGem', value: 'ama' }, | ||||
|   { key: 'opencat', text: 'OpenCat', value: 'opencat' }, | ||||
|   { key: 'lobechat', text: 'LobeChat', value: 'lobechat' }, | ||||
| ]; | ||||
|  | ||||
| const OPEN_LINK_OPTIONS = [ | ||||
|   { key: 'next', text: 'ChatGPT Next Web', value: 'next' }, | ||||
|   { key: 'ama', text: 'BotGem', value: 'ama' }, | ||||
|   { key: 'opencat', text: 'OpenCat', value: 'opencat' }, | ||||
|   { key: 'lobechat', text: 'LobeChat', value: 'lobechat' }, | ||||
| ]; | ||||
|  | ||||
| function renderTimestamp(timestamp) { | ||||
|   return <>{timestamp2string(timestamp)}</>; | ||||
| } | ||||
|  | ||||
| function renderStatus(status) { | ||||
| function renderStatus(status, t) { | ||||
|   switch (status) { | ||||
|     case 1: | ||||
|       return ( | ||||
|         <Label basic color='green'> | ||||
|           已启用 | ||||
|           {t('token.table.status_enabled')} | ||||
|         </Label> | ||||
|       ); | ||||
|     case 2: | ||||
|       return ( | ||||
|         <Label basic color='red'> | ||||
|           {' '} | ||||
|           已禁用{' '} | ||||
|           {t('token.table.status_disabled')} | ||||
|         </Label> | ||||
|       ); | ||||
|     case 3: | ||||
|       return ( | ||||
|         <Label basic color='yellow'> | ||||
|           {' '} | ||||
|           已过期{' '} | ||||
|           {t('token.table.status_expired')} | ||||
|         </Label> | ||||
|       ); | ||||
|     case 4: | ||||
|       return ( | ||||
|         <Label basic color='grey'> | ||||
|           {' '} | ||||
|           已耗尽{' '} | ||||
|           {t('token.table.status_depleted')} | ||||
|         </Label> | ||||
|       ); | ||||
|     default: | ||||
|       return ( | ||||
|         <Label basic color='black'> | ||||
|           {' '} | ||||
|           未知状态{' '} | ||||
|           {t('token.table.status_unknown')} | ||||
|         </Label> | ||||
|       ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| const TokensTable = () => { | ||||
|   const { t } = useTranslation(); | ||||
|  | ||||
|   const COPY_OPTIONS = [ | ||||
|     { key: 'raw', text: t('token.copy_options.raw'), value: '' }, | ||||
|     { key: 'next', text: t('token.copy_options.next'), value: 'next' }, | ||||
|     { key: 'ama', text: t('token.copy_options.ama'), value: 'ama' }, | ||||
|     { key: 'opencat', text: t('token.copy_options.opencat'), value: 'opencat' }, | ||||
|     { key: 'lobe', text: t('token.copy_options.lobe'), value: 'lobechat' }, | ||||
|   ]; | ||||
|  | ||||
|   const OPEN_LINK_OPTIONS = [ | ||||
|     { key: 'next', text: t('token.copy_options.next'), value: 'next' }, | ||||
|     { key: 'ama', text: t('token.copy_options.ama'), value: 'ama' }, | ||||
|     { key: 'opencat', text: t('token.copy_options.opencat'), value: 'opencat' }, | ||||
|     { key: 'lobe', text: t('token.copy_options.lobe'), value: 'lobechat' }, | ||||
|   ]; | ||||
|  | ||||
|   const [tokens, setTokens] = useState([]); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [activePage, setActivePage] = useState(1); | ||||
| @@ -161,9 +161,9 @@ const TokensTable = () => { | ||||
|         url = `sk-${key}`; | ||||
|     } | ||||
|     if (await copy(url)) { | ||||
|       showSuccess('已复制到剪贴板!'); | ||||
|       showSuccess(t('token.messages.copy_success')); | ||||
|     } else { | ||||
|       showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。'); | ||||
|       showWarning(t('token.messages.copy_failed')); | ||||
|       setSearchKeyword(url); | ||||
|     } | ||||
|   }; | ||||
| @@ -237,7 +237,7 @@ const TokensTable = () => { | ||||
|     } | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
|       showSuccess('操作成功完成!'); | ||||
|       showSuccess(t('token.messages.operation_success')); | ||||
|       let token = res.data.data; | ||||
|       let newTokens = [...tokens]; | ||||
|       let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; | ||||
| @@ -308,7 +308,7 @@ const TokensTable = () => { | ||||
|           icon='search' | ||||
|           fluid | ||||
|           iconPosition='left' | ||||
|           placeholder='搜索令牌的名称 ...' | ||||
|           placeholder={t('token.search')} | ||||
|           value={searchKeyword} | ||||
|           loading={searching} | ||||
|           onChange={handleKeywordChange} | ||||
| @@ -324,7 +324,7 @@ const TokensTable = () => { | ||||
|                 sortToken('name'); | ||||
|               }} | ||||
|             > | ||||
|               名称 | ||||
|               {t('token.table.name')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -332,7 +332,7 @@ const TokensTable = () => { | ||||
|                 sortToken('status'); | ||||
|               }} | ||||
|             > | ||||
|               状态 | ||||
|               {t('token.table.status')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -340,7 +340,7 @@ const TokensTable = () => { | ||||
|                 sortToken('used_quota'); | ||||
|               }} | ||||
|             > | ||||
|               已用额度 | ||||
|               {t('token.table.used_quota')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -348,7 +348,7 @@ const TokensTable = () => { | ||||
|                 sortToken('remain_quota'); | ||||
|               }} | ||||
|             > | ||||
|               剩余额度 | ||||
|               {t('token.table.remain_quota')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -356,7 +356,7 @@ const TokensTable = () => { | ||||
|                 sortToken('created_time'); | ||||
|               }} | ||||
|             > | ||||
|               创建时间 | ||||
|               {t('token.table.created_time')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -364,9 +364,9 @@ const TokensTable = () => { | ||||
|                 sortToken('expired_time'); | ||||
|               }} | ||||
|             > | ||||
|               过期时间 | ||||
|               {t('token.table.expired_time')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell>操作</Table.HeaderCell> | ||||
|             <Table.HeaderCell>{t('token.table.actions')}</Table.HeaderCell> | ||||
|           </Table.Row> | ||||
|         </Table.Header> | ||||
|  | ||||
| @@ -378,72 +378,77 @@ const TokensTable = () => { | ||||
|             ) | ||||
|             .map((token, idx) => { | ||||
|               if (token.deleted) return <></>; | ||||
|  | ||||
|               const copyOptionsWithHandlers = COPY_OPTIONS.map((option) => ({ | ||||
|                 ...option, | ||||
|                 onClick: async () => { | ||||
|                   await onCopy(option.value, token.key); | ||||
|                 }, | ||||
|               })); | ||||
|  | ||||
|               const openLinkOptionsWithHandlers = OPEN_LINK_OPTIONS.map( | ||||
|                 (option) => ({ | ||||
|                   ...option, | ||||
|                   onClick: async () => { | ||||
|                     await onOpenLink(option.value, token.key); | ||||
|                   }, | ||||
|                 }) | ||||
|               ); | ||||
|  | ||||
|               return ( | ||||
|                 <Table.Row key={token.id}> | ||||
|                   <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.name ? token.name : t('token.table.no_name')} | ||||
|                   </Table.Cell> | ||||
|                   <Table.Cell>{renderStatus(token.status, t)}</Table.Cell> | ||||
|                   <Table.Cell>{renderQuota(token.used_quota, t)}</Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     {token.unlimited_quota | ||||
|                       ? '无限制' | ||||
|                       : renderQuota(token.remain_quota, 2)} | ||||
|                       ? t('token.table.unlimited') | ||||
|                       : renderQuota(token.remain_quota, t, 2)} | ||||
|                   </Table.Cell> | ||||
|                   <Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     {token.expired_time === -1 | ||||
|                       ? '永不过期' | ||||
|                       ? t('token.table.never_expire') | ||||
|                       : renderTimestamp(token.expired_time)} | ||||
|                   </Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     <div> | ||||
|                       <Button.Group color='green' size={'small'}> | ||||
|                       <Button.Group color='green' size={'mini'}> | ||||
|                         <Button | ||||
|                           size={'small'} | ||||
|                           size={'mini'} | ||||
|                           positive | ||||
|                           onClick={async () => { | ||||
|                             await onCopy('', token.key); | ||||
|                           }} | ||||
|                           onClick={async () => await onCopy('', token.key)} | ||||
|                         > | ||||
|                           复制 | ||||
|                           {t('token.buttons.copy')} | ||||
|                         </Button> | ||||
|                         <Dropdown | ||||
|                           className='button icon' | ||||
|                           floating | ||||
|                           options={COPY_OPTIONS.map((option) => ({ | ||||
|                             ...option, | ||||
|                             onClick: async () => { | ||||
|                               await onCopy(option.value, token.key); | ||||
|                             }, | ||||
|                           }))} | ||||
|                           options={copyOptionsWithHandlers} | ||||
|                           trigger={<></>} | ||||
|                         /> | ||||
|                       </Button.Group>{' '} | ||||
|                       <Button.Group color='blue' size={'small'}> | ||||
|                       <Button.Group color='blue' size={'mini'}> | ||||
|                         <Button | ||||
|                           size={'small'} | ||||
|                           size={'mini'} | ||||
|                           positive | ||||
|                           onClick={() => { | ||||
|                             onOpenLink('', token.key); | ||||
|                           }} | ||||
|                           onClick={() => onOpenLink('', token.key)} | ||||
|                         > | ||||
|                           聊天 | ||||
|                           {t('token.buttons.chat')} | ||||
|                         </Button> | ||||
|                         <Dropdown | ||||
|                           className='button icon' | ||||
|                           floating | ||||
|                           options={OPEN_LINK_OPTIONS.map((option) => ({ | ||||
|                             ...option, | ||||
|                             onClick: async () => { | ||||
|                               await onOpenLink(option.value, token.key); | ||||
|                             }, | ||||
|                           }))} | ||||
|                           options={openLinkOptionsWithHandlers} | ||||
|                           trigger={<></>} | ||||
|                         /> | ||||
|                       </Button.Group>{' '} | ||||
|                       <Popup | ||||
|                         trigger={ | ||||
|                           <Button size='small' negative> | ||||
|                             删除 | ||||
|                           <Button size='mini' negative> | ||||
|                             {t('token.buttons.delete')} | ||||
|                           </Button> | ||||
|                         } | ||||
|                         on='click' | ||||
| @@ -451,16 +456,17 @@ const TokensTable = () => { | ||||
|                         hoverable | ||||
|                       > | ||||
|                         <Button | ||||
|                           size={'mini'} | ||||
|                           negative | ||||
|                           onClick={() => { | ||||
|                             manageToken(token.id, 'delete', idx); | ||||
|                           }} | ||||
|                         > | ||||
|                           删除令牌 {token.name} | ||||
|                           {t('token.buttons.confirm_delete')} {token.name} | ||||
|                         </Button> | ||||
|                       </Popup> | ||||
|                       <Button | ||||
|                         size={'small'} | ||||
|                         size={'mini'} | ||||
|                         onClick={() => { | ||||
|                           manageToken( | ||||
|                             token.id, | ||||
| @@ -469,14 +475,16 @@ const TokensTable = () => { | ||||
|                           ); | ||||
|                         }} | ||||
|                       > | ||||
|                         {token.status === 1 ? '禁用' : '启用'} | ||||
|                         {token.status === 1 | ||||
|                           ? t('token.buttons.disable') | ||||
|                           : t('token.buttons.enable')} | ||||
|                       </Button> | ||||
|                       <Button | ||||
|                         size={'small'} | ||||
|                         size={'mini'} | ||||
|                         as={Link} | ||||
|                         to={'/token/edit/' + token.id} | ||||
|                       > | ||||
|                         编辑 | ||||
|                         {t('token.buttons.edit')} | ||||
|                       </Button> | ||||
|                     </div> | ||||
|                   </Table.Cell> | ||||
| @@ -489,24 +497,24 @@ const TokensTable = () => { | ||||
|           <Table.Row> | ||||
|             <Table.HeaderCell colSpan='7'> | ||||
|               <Button size='small' as={Link} to='/token/add' loading={loading}> | ||||
|                 添加新的令牌 | ||||
|                 {t('token.buttons.add')} | ||||
|               </Button> | ||||
|               <Button size='small' onClick={refresh} loading={loading}> | ||||
|                 刷新 | ||||
|                 {t('token.buttons.refresh')} | ||||
|               </Button> | ||||
|               <Dropdown | ||||
|                 placeholder='排序方式' | ||||
|                 placeholder={t('token.sort.placeholder')} | ||||
|                 selection | ||||
|                 options={[ | ||||
|                   { key: '', text: '默认排序', value: '' }, | ||||
|                   { key: '', text: t('token.sort.default'), value: '' }, | ||||
|                   { | ||||
|                     key: 'remain_quota', | ||||
|                     text: '按剩余额度排序', | ||||
|                     text: t('token.sort.by_remain'), | ||||
|                     value: 'remain_quota', | ||||
|                   }, | ||||
|                   { | ||||
|                     key: 'used_quota', | ||||
|                     text: '按已用额度排序', | ||||
|                     text: t('token.sort.by_used'), | ||||
|                     value: 'used_quota', | ||||
|                   }, | ||||
|                 ]} | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import { | ||||
| } from 'semantic-ui-react'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import { API, showError, showSuccess } from '../helpers'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
|  | ||||
| import { ITEMS_PER_PAGE } from '../constants'; | ||||
| import { | ||||
| @@ -19,20 +20,23 @@ import { | ||||
|   renderText, | ||||
| } from '../helpers/render'; | ||||
|  | ||||
| function renderRole(role) { | ||||
| function renderRole(role, t) { | ||||
|   switch (role) { | ||||
|     case 1: | ||||
|       return <Label>普通用户</Label>; | ||||
|       return <Label>{t('user.table.role_types.normal')}</Label>; | ||||
|     case 10: | ||||
|       return <Label color='yellow'>管理员</Label>; | ||||
|       return <Label color='yellow'>{t('user.table.role_types.admin')}</Label>; | ||||
|     case 100: | ||||
|       return <Label color='orange'>超级管理员</Label>; | ||||
|       return ( | ||||
|         <Label color='orange'>{t('user.table.role_types.super_admin')}</Label> | ||||
|       ); | ||||
|     default: | ||||
|       return <Label color='red'>未知身份</Label>; | ||||
|       return <Label color='red'>{t('user.table.role_types.unknown')}</Label>; | ||||
|   } | ||||
| } | ||||
|  | ||||
| const UsersTable = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const [users, setUsers] = useState([]); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [activePage, setActivePage] = useState(1); | ||||
| @@ -83,7 +87,7 @@ const UsersTable = () => { | ||||
|       }); | ||||
|       const { success, message } = res.data; | ||||
|       if (success) { | ||||
|         showSuccess('操作成功完成!'); | ||||
|         showSuccess(t('user.messages.operation_success')); | ||||
|         let user = res.data.data; | ||||
|         let newUsers = [...users]; | ||||
|         let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx; | ||||
| @@ -103,17 +107,17 @@ const UsersTable = () => { | ||||
|   const renderStatus = (status) => { | ||||
|     switch (status) { | ||||
|       case 1: | ||||
|         return <Label basic>已激活</Label>; | ||||
|         return <Label basic>{t('user.table.status_types.activated')}</Label>; | ||||
|       case 2: | ||||
|         return ( | ||||
|           <Label basic color='red'> | ||||
|             已封禁 | ||||
|             {t('user.table.status_types.banned')} | ||||
|           </Label> | ||||
|         ); | ||||
|       default: | ||||
|         return ( | ||||
|           <Label basic color='grey'> | ||||
|             未知状态 | ||||
|             {t('user.table.status_types.unknown')} | ||||
|           </Label> | ||||
|         ); | ||||
|     } | ||||
| @@ -175,7 +179,7 @@ const UsersTable = () => { | ||||
|           icon='search' | ||||
|           fluid | ||||
|           iconPosition='left' | ||||
|           placeholder='搜索用户的 ID,用户名,显示名称,以及邮箱地址 ...' | ||||
|           placeholder={t('user.search')} | ||||
|           value={searchKeyword} | ||||
|           loading={searching} | ||||
|           onChange={handleKeywordChange} | ||||
| @@ -191,7 +195,7 @@ const UsersTable = () => { | ||||
|                 sortUser('id'); | ||||
|               }} | ||||
|             > | ||||
|               ID | ||||
|               {t('user.table.id')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -199,7 +203,7 @@ const UsersTable = () => { | ||||
|                 sortUser('username'); | ||||
|               }} | ||||
|             > | ||||
|               用户名 | ||||
|               {t('user.table.username')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -207,7 +211,7 @@ const UsersTable = () => { | ||||
|                 sortUser('group'); | ||||
|               }} | ||||
|             > | ||||
|               分组 | ||||
|               {t('user.table.group')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -215,7 +219,7 @@ const UsersTable = () => { | ||||
|                 sortUser('quota'); | ||||
|               }} | ||||
|             > | ||||
|               统计信息 | ||||
|               {t('user.table.quota')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -223,7 +227,7 @@ const UsersTable = () => { | ||||
|                 sortUser('role'); | ||||
|               }} | ||||
|             > | ||||
|               用户角色 | ||||
|               {t('user.table.role_text')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell | ||||
|               style={{ cursor: 'pointer' }} | ||||
| @@ -231,9 +235,9 @@ const UsersTable = () => { | ||||
|                 sortUser('status'); | ||||
|               }} | ||||
|             > | ||||
|               状态 | ||||
|               {t('user.table.status_text')} | ||||
|             </Table.HeaderCell> | ||||
|             <Table.HeaderCell>操作</Table.HeaderCell> | ||||
|             <Table.HeaderCell>{t('user.table.actions')}</Table.HeaderCell> | ||||
|           </Table.Row> | ||||
|         </Table.Header> | ||||
|  | ||||
| @@ -265,54 +269,56 @@ const UsersTable = () => { | ||||
|                   {/*</Table.Cell>*/} | ||||
|                   <Table.Cell> | ||||
|                     <Popup | ||||
|                       content='剩余额度' | ||||
|                       trigger={<Label basic>{renderQuota(user.quota)}</Label>} | ||||
|                     /> | ||||
|                     <Popup | ||||
|                       content='已用额度' | ||||
|                       content={t('user.table.remaining_quota')} | ||||
|                       trigger={ | ||||
|                         <Label basic>{renderQuota(user.used_quota)}</Label> | ||||
|                         <Label basic>{renderQuota(user.quota, t)}</Label> | ||||
|                       } | ||||
|                     /> | ||||
|                     <Popup | ||||
|                       content='请求次数' | ||||
|                       content={t('user.table.used_quota')} | ||||
|                       trigger={ | ||||
|                         <Label basic>{renderQuota(user.used_quota, t)}</Label> | ||||
|                       } | ||||
|                     /> | ||||
|                     <Popup | ||||
|                       content={t('user.table.request_count')} | ||||
|                       trigger={ | ||||
|                         <Label basic>{renderNumber(user.request_count)}</Label> | ||||
|                       } | ||||
|                     /> | ||||
|                   </Table.Cell> | ||||
|                   <Table.Cell>{renderRole(user.role)}</Table.Cell> | ||||
|                   <Table.Cell>{renderRole(user.role, t)}</Table.Cell> | ||||
|                   <Table.Cell>{renderStatus(user.status)}</Table.Cell> | ||||
|                   <Table.Cell> | ||||
|                     <div> | ||||
|                       <Button | ||||
|                         size={'small'} | ||||
|                         size={'tiny'} | ||||
|                         positive | ||||
|                         onClick={() => { | ||||
|                           manageUser(user.username, 'promote', idx); | ||||
|                         }} | ||||
|                         disabled={user.role === 100} | ||||
|                       > | ||||
|                         提升 | ||||
|                         {t('user.buttons.promote')} | ||||
|                       </Button> | ||||
|                       <Button | ||||
|                         size={'small'} | ||||
|                         size={'tiny'} | ||||
|                         color={'yellow'} | ||||
|                         onClick={() => { | ||||
|                           manageUser(user.username, 'demote', idx); | ||||
|                         }} | ||||
|                         disabled={user.role === 100} | ||||
|                       > | ||||
|                         降级 | ||||
|                         {t('user.buttons.demote')} | ||||
|                       </Button> | ||||
|                       <Popup | ||||
|                         trigger={ | ||||
|                           <Button | ||||
|                             size='small' | ||||
|                             size='tiny' | ||||
|                             negative | ||||
|                             disabled={user.role === 100} | ||||
|                           > | ||||
|                             删除 | ||||
|                             {t('user.buttons.delete')} | ||||
|                           </Button> | ||||
|                         } | ||||
|                         on='click' | ||||
| @@ -321,15 +327,16 @@ const UsersTable = () => { | ||||
|                       > | ||||
|                         <Button | ||||
|                           negative | ||||
|                           size={'tiny'} | ||||
|                           onClick={() => { | ||||
|                             manageUser(user.username, 'delete', idx); | ||||
|                           }} | ||||
|                         > | ||||
|                           删除用户 {user.username} | ||||
|                           {t('user.buttons.delete_user')} {user.username} | ||||
|                         </Button> | ||||
|                       </Popup> | ||||
|                       <Button | ||||
|                         size={'small'} | ||||
|                         size={'tiny'} | ||||
|                         onClick={() => { | ||||
|                           manageUser( | ||||
|                             user.username, | ||||
| @@ -339,14 +346,16 @@ const UsersTable = () => { | ||||
|                         }} | ||||
|                         disabled={user.role === 100} | ||||
|                       > | ||||
|                         {user.status === 1 ? '禁用' : '启用'} | ||||
|                         {user.status === 1 | ||||
|                           ? t('user.buttons.disable') | ||||
|                           : t('user.buttons.enable')} | ||||
|                       </Button> | ||||
|                       <Button | ||||
|                         size={'small'} | ||||
|                         size={'tiny'} | ||||
|                         as={Link} | ||||
|                         to={'/user/edit/' + user.id} | ||||
|                       > | ||||
|                         编辑 | ||||
|                         {t('user.buttons.edit')} | ||||
|                       </Button> | ||||
|                     </div> | ||||
|                   </Table.Cell> | ||||
| @@ -359,22 +368,26 @@ const UsersTable = () => { | ||||
|           <Table.Row> | ||||
|             <Table.HeaderCell colSpan='7'> | ||||
|               <Button size='small' as={Link} to='/user/add' loading={loading}> | ||||
|                 添加新的用户 | ||||
|                 {t('user.buttons.add')} | ||||
|               </Button> | ||||
|               <Dropdown | ||||
|                 placeholder='排序方式' | ||||
|                 placeholder={t('user.table.sort_by')} | ||||
|                 selection | ||||
|                 options={[ | ||||
|                   { key: '', text: '默认排序', value: '' }, | ||||
|                   { key: 'quota', text: '按剩余额度排序', value: 'quota' }, | ||||
|                   { key: '', text: t('user.table.sort.default'), value: '' }, | ||||
|                   { | ||||
|                     key: 'quota', | ||||
|                     text: t('user.table.sort.by_quota'), | ||||
|                     value: 'quota', | ||||
|                   }, | ||||
|                   { | ||||
|                     key: 'used_quota', | ||||
|                     text: '按已用额度排序', | ||||
|                     text: t('user.table.sort.by_used_quota'), | ||||
|                     value: 'used_quota', | ||||
|                   }, | ||||
|                   { | ||||
|                     key: 'request_count', | ||||
|                     text: '按请求次数排序', | ||||
|                     text: t('user.table.sort.by_request_count'), | ||||
|                     value: 'request_count', | ||||
|                   }, | ||||
|                 ]} | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { Label } from 'semantic-ui-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
|  | ||||
| export function renderText(text, limit) { | ||||
|   if (text.length > limit) { | ||||
| @@ -39,23 +40,33 @@ export function renderNumber(num) { | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function renderQuota(quota, digits = 2) { | ||||
|   let quotaPerUnit = localStorage.getItem('quota_per_unit'); | ||||
|   let displayInCurrency = localStorage.getItem('display_in_currency'); | ||||
|   quotaPerUnit = parseFloat(quotaPerUnit); | ||||
|   displayInCurrency = displayInCurrency === 'true'; | ||||
| export function renderQuota(quota, t, precision = 2) { | ||||
|   const displayInCurrency = | ||||
|     localStorage.getItem('display_in_currency') === 'true'; | ||||
|   const quotaPerUnit = parseFloat( | ||||
|     localStorage.getItem('quota_per_unit') || '1' | ||||
|   ); | ||||
|  | ||||
|   if (displayInCurrency) { | ||||
|     return '$' + (quota / quotaPerUnit).toFixed(digits); | ||||
|     const amount = (quota / quotaPerUnit).toFixed(precision); | ||||
|     return t('common.quota.display_short', { amount }); | ||||
|   } | ||||
|  | ||||
|   return renderNumber(quota); | ||||
| } | ||||
|  | ||||
| export function renderQuotaWithPrompt(quota, digits) { | ||||
|   let displayInCurrency = localStorage.getItem('display_in_currency'); | ||||
|   displayInCurrency = displayInCurrency === 'true'; | ||||
| export function renderQuotaWithPrompt(quota, t) { | ||||
|   const displayInCurrency = | ||||
|     localStorage.getItem('display_in_currency') === 'true'; | ||||
|   const quotaPerUnit = parseFloat( | ||||
|     localStorage.getItem('quota_per_unit') || '1' | ||||
|   ); | ||||
|  | ||||
|   if (displayInCurrency) { | ||||
|     return `(等价金额:${renderQuota(quota, digits)})`; | ||||
|     const amount = (quota / quotaPerUnit).toFixed(2); | ||||
|     return ` (${t('common.quota.display', { amount })})`; | ||||
|   } | ||||
|  | ||||
|   return ''; | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										23
									
								
								web/default/src/i18n.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								web/default/src/i18n.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import i18n from 'i18next'; | ||||
| import { initReactI18next } from 'react-i18next'; | ||||
| import Backend from 'i18next-http-backend'; | ||||
| import LanguageDetector from 'i18next-browser-languagedetector'; | ||||
|  | ||||
| i18n | ||||
|   .use(Backend) | ||||
|   .use(LanguageDetector) | ||||
|   .use(initReactI18next) | ||||
|   .init({ | ||||
|     fallbackLng: 'zh', | ||||
|     debug: process.env.NODE_ENV === 'development', | ||||
|  | ||||
|     interpolation: { | ||||
|       escapeValue: false, | ||||
|     }, | ||||
|  | ||||
|     backend: { | ||||
|       loadPath: '/locales/{{lng}}/{{ns}}.json', | ||||
|     }, | ||||
|   }); | ||||
|  | ||||
| export default i18n; | ||||
| @@ -33,3 +33,85 @@ code { | ||||
|         display: none !important; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @media screen and (max-width: 768px) { | ||||
|   .ui.container { | ||||
|     width: 100% !important; | ||||
|     margin-left: 0 !important; | ||||
|     margin-right: 0 !important; | ||||
|     padding: 0 10px !important; | ||||
|   } | ||||
|  | ||||
|   .ui.card,  | ||||
|   .ui.cards, | ||||
|   .ui.segment { | ||||
|     margin-left: 0 !important; | ||||
|     margin-right: 0 !important; | ||||
|   } | ||||
|  | ||||
|   .ui.table { | ||||
|     padding-left: 0 !important; | ||||
|     padding-right: 0 !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /* 小屏笔记本 (13-14寸) */ | ||||
| @media screen and (min-width: 769px) and (max-width: 1366px) { | ||||
|   .ui.container { | ||||
|     width: auto !important; | ||||
|     max-width: 100% !important; | ||||
|     margin-left: auto !important; | ||||
|     margin-right: auto !important; | ||||
|     padding: 0 24px !important; | ||||
|   } | ||||
|  | ||||
|   /* 调整表格显示 */ | ||||
|   .ui.table { | ||||
|     font-size: 0.9em; | ||||
|   } | ||||
|  | ||||
|   /* 调整卡片布局 */ | ||||
|   .ui.cards { | ||||
|     margin-left: -0.5em !important; | ||||
|     margin-right: -0.5em !important; | ||||
|   } | ||||
|  | ||||
|   .ui.cards > .card { | ||||
|     margin: 0.5em !important; | ||||
|     width: calc(50% - 1em) !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /* 大屏幕 */ | ||||
| @media screen and (min-width: 1367px) { | ||||
|   .ui.container { | ||||
|     width: 1200px !important; | ||||
|     margin-left: auto !important; | ||||
|     margin-right: auto !important; | ||||
|     padding: 0 !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /* 优化 Dashboard 网格布局 */ | ||||
| @media screen and (max-width: 1366px) { | ||||
|   .charts-grid { | ||||
|     margin: 0 -0.5em !important; | ||||
|   } | ||||
|  | ||||
|   .charts-grid .column { | ||||
|     padding: 0.5em !important; | ||||
|   } | ||||
|  | ||||
|   .chart-card { | ||||
|     margin: 0 !important; | ||||
|   } | ||||
|  | ||||
|   /* 调整字体大小 */ | ||||
|   .ui.header { | ||||
|     font-size: 1.1em !important; | ||||
|   } | ||||
|  | ||||
|   .stat-value { | ||||
|     font-size: 0.9em !important; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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( | ||||
|   | ||||
| @@ -1,45 +1,67 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Card } from 'semantic-ui-react'; | ||||
| import { API, showError } from '../../helpers'; | ||||
| import { marked } from 'marked'; | ||||
|  | ||||
| const About = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const [about, setAbout] = useState(''); | ||||
|   const [aboutLoaded, setAboutLoaded] = useState(false); | ||||
|  | ||||
|   // ... 其他函数保持不变 ... | ||||
|   const displayAbout = async () => { | ||||
|     setAbout(localStorage.getItem('about') || ''); | ||||
|     const res = await API.get('/api/about'); | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       let aboutContent = data; | ||||
|       if (!data.startsWith('https://')) { | ||||
|         aboutContent = marked.parse(data); | ||||
|       } | ||||
|       setAbout(aboutContent); | ||||
|       localStorage.setItem('about', aboutContent); | ||||
|     } else { | ||||
|       showError(message); | ||||
|       setAbout(t('about.loading_failed')); | ||||
|     } | ||||
|     setAboutLoaded(true); | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     displayAbout().then(); | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     <div className='dashboard-container'> | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header className='header'>关于系统</Card.Header> | ||||
|           {aboutLoaded && about === '' ? ( | ||||
|             <> | ||||
|               <p>可在设置页面设置关于内容,支持 HTML & Markdown</p> | ||||
|               项目仓库地址: | ||||
|     <> | ||||
|       {aboutLoaded && about === '' ? ( | ||||
|         <div className='dashboard-container'> | ||||
|           <Card fluid className='chart-card'> | ||||
|             <Card.Content> | ||||
|               <Card.Header className='header'>{t('about.title')}</Card.Header> | ||||
|               <p>{t('about.description')}</p> | ||||
|               {t('about.repository')} | ||||
|               <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' }} | ||||
|             /> | ||||
|           ) : ( | ||||
|             <> | ||||
|               {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> | ||||
|           )} | ||||
|         </Card.Content> | ||||
|       </Card> | ||||
|     </div> | ||||
|         </> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { | ||||
|   Button, | ||||
|   Form, | ||||
| @@ -26,23 +27,23 @@ const MODEL_MAPPING_EXAMPLE = { | ||||
|   '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; | ||||
| @@ -194,15 +195,15 @@ const EditChannel = () => { | ||||
|       } | ||||
|     } | ||||
|     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 }; | ||||
| @@ -230,9 +231,9 @@ const EditChannel = () => { | ||||
|     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 { | ||||
| @@ -263,12 +264,14 @@ const EditChannel = () => { | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header className='header'> | ||||
|             {isEdit ? '更新渠道信息' : '创建新的渠道'} | ||||
|             {isEdit | ||||
|               ? t('channel.edit.title_edit') | ||||
|               : t('channel.edit.title_create')} | ||||
|           </Card.Header> | ||||
|           <Form loading={loading} autoComplete='new-password'> | ||||
|             <Form.Field> | ||||
|               <Form.Select | ||||
|                 label='类型' | ||||
|                 label={t('channel.edit.type')} | ||||
|                 name='type' | ||||
|                 required | ||||
|                 search | ||||
| @@ -277,6 +280,35 @@ const EditChannel = () => { | ||||
|                 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> | ||||
| @@ -295,9 +327,7 @@ const EditChannel = () => { | ||||
|                   <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' | ||||
| @@ -307,9 +337,7 @@ 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' | ||||
| @@ -317,55 +345,27 @@ const EditChannel = () => { | ||||
|                 </Form.Field> | ||||
|               </> | ||||
|             )} | ||||
|  | ||||
|             {/* 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='名称' | ||||
|                 name='name' | ||||
|                 placeholder={'请输入名称'} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={inputs.name} | ||||
|                 required | ||||
|               /> | ||||
|             </Form.Field> | ||||
|             <Form.Field> | ||||
|               <Form.Dropdown | ||||
|                 label='分组' | ||||
|                 placeholder={'请选择可以使用该渠道的分组'} | ||||
|                 name='groups' | ||||
|                 required | ||||
|                 fluid | ||||
|                 multiple | ||||
|                 selection | ||||
|                 allowAdditions | ||||
|                 additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={inputs.groups} | ||||
|                 autoComplete='new-password' | ||||
|                 options={groupOptions} | ||||
|               /> | ||||
|             </Form.Field> | ||||
|  | ||||
|             {inputs.type === 18 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.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' | ||||
| @@ -375,9 +375,9 @@ const EditChannel = () => { | ||||
|             {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' | ||||
| @@ -387,11 +387,9 @@ const EditChannel = () => { | ||||
|             {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' | ||||
| @@ -399,28 +397,25 @@ const EditChannel = () => { | ||||
|               </Form.Field> | ||||
|             )} | ||||
|             {inputs.type === 34 && ( | ||||
|               <Message> | ||||
|                 对于 Coze 而言,模型名称即 Bot ID,你可以添加一个前缀 | ||||
|                 `bot-`,例如:`bot-123456`。 | ||||
|               </Message> | ||||
|               <Message>{t('channel.edit.coze_notice')}</Message> | ||||
|             )} | ||||
|             {inputs.type === 40 && ( | ||||
|               <Message> | ||||
|                 对于豆包而言,需要手动去{' '} | ||||
|                 {t('channel.edit.douban_notice')} | ||||
|                 <a | ||||
|                   target='_blank' | ||||
|                   href='https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint' | ||||
|                 > | ||||
|                   模型推理页面 | ||||
|                 </a>{' '} | ||||
|                 创建推理接入点,以接入点名称作为模型名称,例如:`ep-20240608051426-tkxvl`。 | ||||
|                   {t('channel.edit.douban_notice_link')} | ||||
|                 </a> | ||||
|                 {t('channel.edit.douban_notice_2')} | ||||
|               </Message> | ||||
|             )} | ||||
|             {inputs.type !== 43 && ( | ||||
|               <Form.Field> | ||||
|                 <Form.Dropdown | ||||
|                   label='模型' | ||||
|                   placeholder={'请选择该渠道所支持的模型'} | ||||
|                   label={t('channel.edit.models')} | ||||
|                   placeholder={t('channel.edit.models_placeholder')} | ||||
|                   name='models' | ||||
|                   required | ||||
|                   fluid | ||||
| @@ -448,7 +443,7 @@ const EditChannel = () => { | ||||
|                     }); | ||||
|                   }} | ||||
|                 > | ||||
|                   填入相关模型 | ||||
|                   {t('channel.edit.buttons.fill_models')} | ||||
|                 </Button> | ||||
|                 <Button | ||||
|                   type={'button'} | ||||
| @@ -459,7 +454,7 @@ const EditChannel = () => { | ||||
|                     }); | ||||
|                   }} | ||||
|                 > | ||||
|                   填入所有模型 | ||||
|                   {t('channel.edit.buttons.fill_all')} | ||||
|                 </Button> | ||||
|                 <Button | ||||
|                   type={'button'} | ||||
| @@ -467,15 +462,15 @@ const EditChannel = () => { | ||||
|                     handleInputChange(null, { name: 'models', value: [] }); | ||||
|                   }} | ||||
|                 > | ||||
|                   清除所有模型 | ||||
|                   {t('channel.edit.buttons.clear')} | ||||
|                 </Button> | ||||
|                 <Input | ||||
|                   action={ | ||||
|                     <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); | ||||
| @@ -493,12 +488,10 @@ const EditChannel = () => { | ||||
|               <> | ||||
|                 <Form.Field> | ||||
|                   <Form.TextArea | ||||
|                     label='模型重定向' | ||||
|                     placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:\n${JSON.stringify( | ||||
|                       MODEL_MAPPING_EXAMPLE, | ||||
|                       null, | ||||
|                       2 | ||||
|                     )}`} | ||||
|                     label={t('channel.edit.model_mapping')} | ||||
|                     placeholder={`${t( | ||||
|                       'channel.edit.model_mapping_placeholder' | ||||
|                     )}\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`} | ||||
|                     name='model_mapping' | ||||
|                     onChange={handleInputChange} | ||||
|                     value={inputs.model_mapping} | ||||
| @@ -511,8 +504,8 @@ const EditChannel = () => { | ||||
|                 </Form.Field> | ||||
|                 <Form.Field> | ||||
|                   <Form.TextArea | ||||
|                     label='系统提示词' | ||||
|                     placeholder={`此项可选,用于强制设置给定的系统提示词,请配合自定义模型 & 模型重定向使用,首先创建一个唯一的自定义模型名称并在上面填入,之后将该自定义模型重定向映射到该渠道一个原生支持的模型`} | ||||
|                     label={t('channel.edit.system_prompt')} | ||||
|                     placeholder={t('channel.edit.system_prompt_placeholder')} | ||||
|                     name='system_prompt' | ||||
|                     onChange={handleInputChange} | ||||
|                     value={inputs.system_prompt} | ||||
| @@ -531,7 +524,7 @@ const EditChannel = () => { | ||||
|                   label='Region' | ||||
|                   name='region' | ||||
|                   required | ||||
|                   placeholder={'region,e.g. us-west-2'} | ||||
|                   placeholder={t('channel.edit.aws_region_placeholder')} | ||||
|                   onChange={handleConfigChange} | ||||
|                   value={config.region} | ||||
|                   autoComplete='' | ||||
| @@ -540,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='' | ||||
| @@ -549,7 +542,7 @@ 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='' | ||||
| @@ -562,27 +555,25 @@ const EditChannel = () => { | ||||
|                   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='' | ||||
| @@ -591,10 +582,10 @@ const EditChannel = () => { | ||||
|             )} | ||||
|             {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='' | ||||
| @@ -605,10 +596,10 @@ const EditChannel = () => { | ||||
|               (batch ? ( | ||||
|                 <Form.Field> | ||||
|                   <Form.TextArea | ||||
|                     label='密钥' | ||||
|                     label={t('channel.edit.key')} | ||||
|                     name='key' | ||||
|                     required | ||||
|                     placeholder={'请输入密钥,一行一个'} | ||||
|                     placeholder={t('channel.edit.batch_placeholder')} | ||||
|                     onChange={handleInputChange} | ||||
|                     value={inputs.key} | ||||
|                     style={{ | ||||
| @@ -621,35 +612,20 @@ const EditChannel = () => { | ||||
|               ) : ( | ||||
|                 <Form.Field> | ||||
|                   <Form.Input | ||||
|                     label='密钥' | ||||
|                     label={t('channel.edit.key')} | ||||
|                     name='key' | ||||
|                     required | ||||
|                     placeholder={type2secretPrompt(inputs.type)} | ||||
|                     placeholder={type2secretPrompt(inputs.type, t)} | ||||
|                     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 && ( | ||||
|               <Form.Checkbox | ||||
|                 checked={batch} | ||||
|                 label='批量创建' | ||||
|                 label={t('channel.edit.batch')} | ||||
|                 name='batch' | ||||
|                 onChange={() => setBatch(!batch)} | ||||
|               /> | ||||
| @@ -660,11 +636,9 @@ const EditChannel = () => { | ||||
|               inputs.type !== 22 && ( | ||||
|                 <Form.Field> | ||||
|                   <Form.Input | ||||
|                     label='代理' | ||||
|                     label={t('channel.edit.base_url')} | ||||
|                     name='base_url' | ||||
|                     placeholder={ | ||||
|                       '此项可选,用于通过代理站来进行 API 调用,请输入代理站地址,格式为:https://domain.com' | ||||
|                     } | ||||
|                     placeholder={t('channel.edit.base_url_placeholder')} | ||||
|                     onChange={handleInputChange} | ||||
|                     value={inputs.base_url} | ||||
|                     autoComplete='new-password' | ||||
| @@ -685,13 +659,15 @@ const EditChannel = () => { | ||||
|                 /> | ||||
|               </Form.Field> | ||||
|             )} | ||||
|             <Button onClick={handleCancel}>取消</Button> | ||||
|             <Button onClick={handleCancel}> | ||||
|               {t('channel.edit.buttons.cancel')} | ||||
|             </Button> | ||||
|             <Button | ||||
|               type={isEdit ? 'button' : 'submit'} | ||||
|               positive | ||||
|               onClick={submit} | ||||
|             > | ||||
|               提交 | ||||
|               {t('channel.edit.buttons.submit')} | ||||
|             </Button> | ||||
|           </Form> | ||||
|         </Card.Content> | ||||
|   | ||||
| @@ -1,16 +1,21 @@ | ||||
| import React from 'react'; | ||||
| import { Card } from 'semantic-ui-react'; | ||||
| import ChannelsTable from '../../components/ChannelsTable'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
|  | ||||
| const Channel = () => ( | ||||
|   <div className='dashboard-container'> | ||||
|     <Card fluid className='chart-card'> | ||||
|       <Card.Content> | ||||
|         <Card.Header className='header'>管理渠道</Card.Header> | ||||
|         <ChannelsTable /> | ||||
|       </Card.Content> | ||||
|     </Card> | ||||
|   </div> | ||||
| ); | ||||
| 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; | ||||
|   | ||||
| @@ -1,5 +1,8 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { Card, Grid, Statistic } from 'semantic-ui-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Card, Grid, Header, Segment, Statistic } from 'semantic-ui-react'; | ||||
| import { API, showError } from '../../helpers'; | ||||
| import moment from 'moment'; | ||||
| import { | ||||
|   LineChart, | ||||
|   Line, | ||||
| @@ -53,6 +56,7 @@ const chartConfig = { | ||||
| }; | ||||
|  | ||||
| const Dashboard = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const [data, setData] = useState([]); | ||||
|   const [summaryData, setSummaryData] = useState({ | ||||
|     todayRequests: 0, | ||||
| @@ -68,16 +72,27 @@ const Dashboard = () => { | ||||
|     try { | ||||
|       const response = await axios.get('/api/user/dashboard'); | ||||
|       if (response.data.success) { | ||||
|         const dashboardData = response.data.data; | ||||
|         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); | ||||
|  | ||||
| @@ -87,7 +102,7 @@ const Dashboard = () => { | ||||
|         0 | ||||
|       ), | ||||
|       todayQuota: | ||||
|         todayData.reduce((sum, item) => sum + item.Quota, 0) / 1000000, // 转换为美元 | ||||
|         todayData.reduce((sum, item) => sum + item.Quota, 0) / 1000000, | ||||
|       todayTokens: todayData.reduce( | ||||
|         (sum, item) => sum + item.PromptTokens + item.CompletionTokens, | ||||
|         0 | ||||
| @@ -103,8 +118,18 @@ const Dashboard = () => { | ||||
|  | ||||
|     // 获取日期范围 | ||||
|     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)))); | ||||
|     const maxDate = new Date(); // 总是使用今天作为最后一天 | ||||
|     let minDate = | ||||
|       dates.length > 0 | ||||
|         ? new Date(Math.min(...dates.map((d) => new Date(d)))) | ||||
|         : new Date(); | ||||
|  | ||||
|     // 确保至少显示5天的数据 | ||||
|     const fiveDaysAgo = new Date(); | ||||
|     fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 4); // -4是因为包含今天 | ||||
|     if (minDate > fiveDaysAgo) { | ||||
|       minDate = fiveDaysAgo; | ||||
|     } | ||||
|  | ||||
|     // 生成所有日期 | ||||
|     for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) { | ||||
| @@ -135,8 +160,18 @@ const Dashboard = () => { | ||||
|  | ||||
|     // 获取日期范围 | ||||
|     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)))); | ||||
|     const maxDate = new Date(); // 总是使用今天作为最后一天 | ||||
|     let minDate = | ||||
|       dates.length > 0 | ||||
|         ? new Date(Math.min(...dates.map((d) => new Date(d)))) | ||||
|         : new Date(); | ||||
|  | ||||
|     // 确保至少显示5天的数据 | ||||
|     const fiveDaysAgo = new Date(); | ||||
|     fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 4); // -4是因为包含今天 | ||||
|     if (minDate > fiveDaysAgo) { | ||||
|       minDate = fiveDaysAgo; | ||||
|     } | ||||
|  | ||||
|     // 生成所有日期 | ||||
|     for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) { | ||||
| @@ -175,6 +210,31 @@ const Dashboard = () => { | ||||
|     return chartConfig.barColors[index % chartConfig.barColors.length]; | ||||
|   }; | ||||
|  | ||||
|   // 添加一个日期格式化函数 | ||||
|   const formatDate = (dateStr) => { | ||||
|     const date = new Date(dateStr); | ||||
|     return date.toLocaleDateString('zh-CN', { | ||||
|       month: 'numeric', | ||||
|       day: 'numeric', | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   // 修改所有 XAxis 配置 | ||||
|   const xAxisConfig = { | ||||
|     dataKey: 'date', | ||||
|     axisLine: false, | ||||
|     tickLine: false, | ||||
|     tick: { | ||||
|       fontSize: 12, | ||||
|       fill: '#A3AED0', | ||||
|       textAnchor: 'middle', // 文本居中对齐 | ||||
|     }, | ||||
|     tickFormatter: formatDate, | ||||
|     interval: 0, | ||||
|     minTickGap: 5, | ||||
|     padding: { left: 30, right: 30 }, // 增加两侧的内边距,确保首尾标签完整显示 | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div className='dashboard-container'> | ||||
|       {/* 三个并排的折线图 */} | ||||
| @@ -183,11 +243,15 @@ const Dashboard = () => { | ||||
|           <Card fluid className='chart-card'> | ||||
|             <Card.Content> | ||||
|               <Card.Header> | ||||
|                 模型请求趋势 | ||||
|                 {t('dashboard.charts.requests.title')} | ||||
|                 <span className='stat-value'>{summaryData.todayRequests}</span> | ||||
|               </Card.Header> | ||||
|               <div className='chart-container'> | ||||
|                 <ResponsiveContainer width='100%' height={120}> | ||||
|                 <ResponsiveContainer | ||||
|                   width='100%' | ||||
|                   height={120} | ||||
|                   margin={{ left: 10, right: 10 }} // 调整容器边距 | ||||
|                 > | ||||
|                   <LineChart data={timeSeriesData}> | ||||
|                     <CartesianGrid | ||||
|                       strokeDasharray='3 3' | ||||
| @@ -195,12 +259,7 @@ const Dashboard = () => { | ||||
|                       horizontal={chartConfig.lineChart.grid.horizontal} | ||||
|                       opacity={chartConfig.lineChart.grid.opacity} | ||||
|                     /> | ||||
|                     <XAxis | ||||
|                       dataKey='date' | ||||
|                       axisLine={false} | ||||
|                       tickLine={false} | ||||
|                       tick={{ fontSize: 12, fill: '#A3AED0' }} | ||||
|                     /> | ||||
|                     <XAxis {...xAxisConfig} /> | ||||
|                     <YAxis hide={true} /> | ||||
|                     <Tooltip | ||||
|                       contentStyle={{ | ||||
| @@ -209,6 +268,13 @@ const Dashboard = () => { | ||||
|                         borderRadius: '4px', | ||||
|                         boxShadow: '0 2px 8px rgba(0,0,0,0.1)', | ||||
|                       }} | ||||
|                       formatter={(value) => [ | ||||
|                         value, | ||||
|                         t('dashboard.charts.requests.tooltip'), | ||||
|                       ]} | ||||
|                       labelFormatter={(label) => | ||||
|                         `${t('dashboard.tooltip.date')}: ${formatDate(label)}` | ||||
|                       } | ||||
|                     /> | ||||
|                     <Line | ||||
|                       type='monotone' | ||||
| @@ -229,13 +295,17 @@ const Dashboard = () => { | ||||
|           <Card fluid className='chart-card'> | ||||
|             <Card.Content> | ||||
|               <Card.Header> | ||||
|                 额度消费趋势 | ||||
|                 {t('dashboard.charts.quota.title')} | ||||
|                 <span className='stat-value'> | ||||
|                   ${summaryData.todayQuota.toFixed(3)} | ||||
|                 </span> | ||||
|               </Card.Header> | ||||
|               <div className='chart-container'> | ||||
|                 <ResponsiveContainer width='100%' height={120}> | ||||
|                 <ResponsiveContainer | ||||
|                   width='100%' | ||||
|                   height={120} | ||||
|                   margin={{ left: 10, right: 10 }} // 调整容器边距 | ||||
|                 > | ||||
|                   <LineChart data={timeSeriesData}> | ||||
|                     <CartesianGrid | ||||
|                       strokeDasharray='3 3' | ||||
| @@ -243,12 +313,7 @@ const Dashboard = () => { | ||||
|                       horizontal={chartConfig.lineChart.grid.horizontal} | ||||
|                       opacity={chartConfig.lineChart.grid.opacity} | ||||
|                     /> | ||||
|                     <XAxis | ||||
|                       dataKey='date' | ||||
|                       axisLine={false} | ||||
|                       tickLine={false} | ||||
|                       tick={{ fontSize: 12, fill: '#A3AED0' }} | ||||
|                     /> | ||||
|                     <XAxis {...xAxisConfig} /> | ||||
|                     <YAxis hide={true} /> | ||||
|                     <Tooltip | ||||
|                       contentStyle={{ | ||||
| @@ -257,6 +322,13 @@ const Dashboard = () => { | ||||
|                         borderRadius: '4px', | ||||
|                         boxShadow: '0 2px 8px rgba(0,0,0,0.1)', | ||||
|                       }} | ||||
|                       formatter={(value) => [ | ||||
|                         value, | ||||
|                         t('dashboard.charts.quota.tooltip'), | ||||
|                       ]} | ||||
|                       labelFormatter={(label) => | ||||
|                         `${t('dashboard.tooltip.date')}: ${formatDate(label)}` | ||||
|                       } | ||||
|                     /> | ||||
|                     <Line | ||||
|                       type='monotone' | ||||
| @@ -277,11 +349,15 @@ const Dashboard = () => { | ||||
|           <Card fluid className='chart-card'> | ||||
|             <Card.Content> | ||||
|               <Card.Header> | ||||
|                 Token 消费趋势 | ||||
|                 {t('dashboard.charts.tokens.title')} | ||||
|                 <span className='stat-value'>{summaryData.todayTokens}</span> | ||||
|               </Card.Header> | ||||
|               <div className='chart-container'> | ||||
|                 <ResponsiveContainer width='100%' height={120}> | ||||
|                 <ResponsiveContainer | ||||
|                   width='100%' | ||||
|                   height={120} | ||||
|                   margin={{ left: 10, right: 10 }} // 调整容器边距 | ||||
|                 > | ||||
|                   <LineChart data={timeSeriesData}> | ||||
|                     <CartesianGrid | ||||
|                       strokeDasharray='3 3' | ||||
| @@ -289,12 +365,7 @@ const Dashboard = () => { | ||||
|                       horizontal={chartConfig.lineChart.grid.horizontal} | ||||
|                       opacity={chartConfig.lineChart.grid.opacity} | ||||
|                     /> | ||||
|                     <XAxis | ||||
|                       dataKey='date' | ||||
|                       axisLine={false} | ||||
|                       tickLine={false} | ||||
|                       tick={{ fontSize: 12, fill: '#A3AED0' }} | ||||
|                     /> | ||||
|                     <XAxis {...xAxisConfig} /> | ||||
|                     <YAxis hide={true} /> | ||||
|                     <Tooltip | ||||
|                       contentStyle={{ | ||||
| @@ -303,6 +374,13 @@ const Dashboard = () => { | ||||
|                         borderRadius: '4px', | ||||
|                         boxShadow: '0 2px 8px rgba(0,0,0,0.1)', | ||||
|                       }} | ||||
|                       formatter={(value) => [ | ||||
|                         value, | ||||
|                         t('dashboard.charts.tokens.tooltip'), | ||||
|                       ]} | ||||
|                       labelFormatter={(label) => | ||||
|                         `${t('dashboard.tooltip.date')}: ${formatDate(label)}` | ||||
|                       } | ||||
|                     /> | ||||
|                     <Line | ||||
|                       type='monotone' | ||||
| @@ -323,7 +401,7 @@ const Dashboard = () => { | ||||
|       {/* 模型使用统计 */} | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header>统计</Card.Header> | ||||
|           <Card.Header>{t('dashboard.statistics.title')}</Card.Header> | ||||
|           <div className='chart-container'> | ||||
|             <ResponsiveContainer width='100%' height={300}> | ||||
|               <BarChart data={modelData}> | ||||
| @@ -332,12 +410,7 @@ const Dashboard = () => { | ||||
|                   vertical={false} | ||||
|                   opacity={0.1} | ||||
|                 /> | ||||
|                 <XAxis | ||||
|                   dataKey='date' | ||||
|                   axisLine={false} | ||||
|                   tickLine={false} | ||||
|                   tick={{ fontSize: 12, fill: '#A3AED0' }} | ||||
|                 /> | ||||
|                 <XAxis {...xAxisConfig} /> | ||||
|                 <YAxis | ||||
|                   axisLine={false} | ||||
|                   tickLine={false} | ||||
| @@ -350,6 +423,9 @@ const Dashboard = () => { | ||||
|                     borderRadius: '4px', | ||||
|                     boxShadow: '0 2px 8px rgba(0,0,0,0.1)', | ||||
|                   }} | ||||
|                   labelFormatter={(label) => | ||||
|                     `${t('dashboard.tooltip.date')}: ${formatDate(label)}` | ||||
|                   } | ||||
|                 /> | ||||
|                 <Legend | ||||
|                   wrapperStyle={{ | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import React, { useContext, useEffect, useState } from 'react'; | ||||
| import { Card, Grid, Header, Segment } from 'semantic-ui-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Card, Grid, Header } from 'semantic-ui-react'; | ||||
| import { API, showError, showNotice, timestamp2string } from '../../helpers'; | ||||
| import { StatusContext } from '../../context/Status'; | ||||
| import { marked } from 'marked'; | ||||
| @@ -7,6 +8,7 @@ import { UserContext } from '../../context/User'; | ||||
| import { Link } from 'react-router-dom'; | ||||
|  | ||||
| const Home = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const [statusState, statusDispatch] = useContext(StatusContext); | ||||
|   const [homePageContentLoaded, setHomePageContentLoaded] = useState(false); | ||||
|   const [homePageContent, setHomePageContent] = useState(''); | ||||
| @@ -40,7 +42,7 @@ const Home = () => { | ||||
|       localStorage.setItem('home_page_content', content); | ||||
|     } else { | ||||
|       showError(message); | ||||
|       setHomePageContent('加载首页内容失败...'); | ||||
|       setHomePageContent(t('home.loading_failed')); | ||||
|     } | ||||
|     setHomePageContentLoaded(true); | ||||
|   }; | ||||
| @@ -56,218 +58,225 @@ const Home = () => { | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     <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> | ||||
|  | ||||
|     <> | ||||
|       {homePageContentLoaded && homePageContent === '' ? ( | ||||
|         <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', | ||||
|                         }} | ||||
|         <div className='dashboard-container'> | ||||
|           <Card fluid className='chart-card'> | ||||
|             <Card.Content> | ||||
|               <Card.Header className='header'> | ||||
|                 {t('home.welcome.title')} | ||||
|               </Card.Header> | ||||
|               <Card.Description style={{ lineHeight: '1.6' }}> | ||||
|                 <p>{t('home.welcome.description')}</p> | ||||
|                 {!userState.user && <p>{t('home.welcome.login_notice')}</p>} | ||||
|               </Card.Description> | ||||
|             </Card.Content> | ||||
|           </Card> | ||||
|           <Card fluid className='chart-card'> | ||||
|             <Card.Content> | ||||
|               <Card.Header> | ||||
|                 <Header as='h3'>{t('home.system_status.title')}</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' }}> | ||||
|                           {t('home.system_status.info.title')} | ||||
|                         </Header> | ||||
|                       </Card.Header> | ||||
|                       <Card.Description | ||||
|                         style={{ lineHeight: '2', marginTop: '1em' }} | ||||
|                       > | ||||
|                         <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' }} | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           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> | ||||
|                           <i className='info circle icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}> | ||||
|                             {t('home.system_status.info.name')} | ||||
|                           </span> | ||||
|                           <span>{statusState?.status?.system_name}</span> | ||||
|                         </p> | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           <i className='code branch icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}> | ||||
|                             {t('home.system_status.info.version')} | ||||
|                           </span> | ||||
|                           <span> | ||||
|                             {statusState?.status?.version || 'unknown'} | ||||
|                           </span> | ||||
|                         </p> | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           <i className='github icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}> | ||||
|                             {t('home.system_status.info.source')} | ||||
|                           </span> | ||||
|                           <a | ||||
|                             href='https://github.com/songquanpeng/one-api' | ||||
|                             target='_blank' | ||||
|                             style={{ color: '#2185d0' }} | ||||
|                           > | ||||
|                             {t('home.system_status.info.source_link')} | ||||
|                           </a> | ||||
|                         </p> | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           <i className='clock outline icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}> | ||||
|                             {t('home.system_status.info.start_time')} | ||||
|                           </span> | ||||
|                           <span>{getStartTimeString()}</span> | ||||
|                         </p> | ||||
|                       </Card.Description> | ||||
|                     </Card.Content> | ||||
|                   </Card> | ||||
|                 </Grid.Column> | ||||
|  | ||||
|               <Grid.Column> | ||||
|                 <Card | ||||
|                   fluid | ||||
|                   className='chart-card' | ||||
|                   style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }} | ||||
|                 > | ||||
|                   <Card.Content> | ||||
|                     <Card.Header> | ||||
|                       <Header as='h3' style={{ color: '#444' }}> | ||||
|                         系统配置 | ||||
|                       </Header> | ||||
|                     </Card.Header> | ||||
|                     <Card.Description | ||||
|                       style={{ lineHeight: '2', marginTop: '1em' }} | ||||
|                     > | ||||
|                       <p | ||||
|                         style={{ | ||||
|                           display: 'flex', | ||||
|                           alignItems: 'center', | ||||
|                           gap: '0.5em', | ||||
|                         }} | ||||
|                 <Grid.Column> | ||||
|                   <Card | ||||
|                     fluid | ||||
|                     className='chart-card' | ||||
|                     style={{ boxShadow: '0 1px 3px rgba(0,0,0,0.12)' }} | ||||
|                   > | ||||
|                     <Card.Content> | ||||
|                       <Card.Header> | ||||
|                         <Header as='h3' style={{ color: '#444' }}> | ||||
|                           {t('home.system_status.config.title')} | ||||
|                         </Header> | ||||
|                       </Card.Header> | ||||
|                       <Card.Description | ||||
|                         style={{ lineHeight: '2', marginTop: '1em' }} | ||||
|                       > | ||||
|                         <i className='envelope icon'></i> | ||||
|                         <span style={{ fontWeight: 'bold' }}>邮箱验证:</span> | ||||
|                         <span | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             color: statusState?.status?.email_verification | ||||
|                               ? '#21ba45' | ||||
|                               : '#db2828', | ||||
|                             fontWeight: '500', | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           {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 | ||||
|                           <i className='envelope icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}> | ||||
|                             {t('home.system_status.config.email_verify')} | ||||
|                           </span> | ||||
|                           <span | ||||
|                             style={{ | ||||
|                               color: statusState?.status?.email_verification | ||||
|                                 ? '#21ba45' | ||||
|                                 : '#db2828', | ||||
|                               fontWeight: '500', | ||||
|                             }} | ||||
|                           > | ||||
|                             {statusState?.status?.email_verification | ||||
|                               ? t('home.system_status.config.enabled') | ||||
|                               : t('home.system_status.config.disabled')} | ||||
|                           </span> | ||||
|                         </p> | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             color: statusState?.status?.github_oauth | ||||
|                               ? '#21ba45' | ||||
|                               : '#db2828', | ||||
|                             fontWeight: '500', | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           {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 | ||||
|                           <i className='github icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}> | ||||
|                             {t('home.system_status.config.github_oauth')} | ||||
|                           </span> | ||||
|                           <span | ||||
|                             style={{ | ||||
|                               color: statusState?.status?.github_oauth | ||||
|                                 ? '#21ba45' | ||||
|                                 : '#db2828', | ||||
|                               fontWeight: '500', | ||||
|                             }} | ||||
|                           > | ||||
|                             {statusState?.status?.github_oauth | ||||
|                               ? t('home.system_status.config.enabled') | ||||
|                               : t('home.system_status.config.disabled')} | ||||
|                           </span> | ||||
|                         </p> | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             color: statusState?.status?.wechat_login | ||||
|                               ? '#21ba45' | ||||
|                               : '#db2828', | ||||
|                             fontWeight: '500', | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           {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 | ||||
|                           <i className='wechat icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}> | ||||
|                             {t('home.system_status.config.wechat_login')} | ||||
|                           </span> | ||||
|                           <span | ||||
|                             style={{ | ||||
|                               color: statusState?.status?.wechat_login | ||||
|                                 ? '#21ba45' | ||||
|                                 : '#db2828', | ||||
|                               fontWeight: '500', | ||||
|                             }} | ||||
|                           > | ||||
|                             {statusState?.status?.wechat_login | ||||
|                               ? t('home.system_status.config.enabled') | ||||
|                               : t('home.system_status.config.disabled')} | ||||
|                           </span> | ||||
|                         </p> | ||||
|                         <p | ||||
|                           style={{ | ||||
|                             color: statusState?.status?.turnstile_check | ||||
|                               ? '#21ba45' | ||||
|                               : '#db2828', | ||||
|                             fontWeight: '500', | ||||
|                             display: 'flex', | ||||
|                             alignItems: 'center', | ||||
|                             gap: '0.5em', | ||||
|                           }} | ||||
|                         > | ||||
|                           {statusState?.status?.turnstile_check | ||||
|                             ? '已启用' | ||||
|                             : '未启用'} | ||||
|                         </span> | ||||
|                       </p> | ||||
|                     </Card.Description> | ||||
|                   </Card.Content> | ||||
|                 </Card> | ||||
|               </Grid.Column> | ||||
|             </Grid> | ||||
|           </Card.Content> | ||||
|         </Card> | ||||
|                           <i className='shield alternate icon'></i> | ||||
|                           <span style={{ fontWeight: 'bold' }}> | ||||
|                             {t('home.system_status.config.turnstile')} | ||||
|                           </span> | ||||
|                           <span | ||||
|                             style={{ | ||||
|                               color: statusState?.status?.turnstile_check | ||||
|                                 ? '#21ba45' | ||||
|                                 : '#db2828', | ||||
|                               fontWeight: '500', | ||||
|                             }} | ||||
|                           > | ||||
|                             {statusState?.status?.turnstile_check | ||||
|                               ? t('home.system_status.config.enabled') | ||||
|                               : t('home.system_status.config.disabled')} | ||||
|                           </span> | ||||
|                         </p> | ||||
|                       </Card.Description> | ||||
|                     </Card.Content> | ||||
|                   </Card> | ||||
|                 </Grid.Column> | ||||
|               </Grid> | ||||
|             </Card.Content> | ||||
|           </Card> | ||||
|         </div> | ||||
|       ) : ( | ||||
|         <> | ||||
|           {homePageContent.startsWith('https://') ? ( | ||||
| @@ -283,7 +292,7 @@ const Home = () => { | ||||
|           )} | ||||
|         </> | ||||
|       )} | ||||
|     </div> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -1,16 +1,21 @@ | ||||
| import React from 'react'; | ||||
| import { Card } from 'semantic-ui-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import LogsTable from '../../components/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> | ||||
| ); | ||||
| const Log = () => { | ||||
|   const { t } = useTranslation(); | ||||
|    | ||||
|   return ( | ||||
|     <div className='dashboard-container'> | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header className='header'>{t('log.title')}</Card.Header> | ||||
|           <LogsTable /> | ||||
|         </Card.Content> | ||||
|       </Card> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default Log; | ||||
|   | ||||
| @@ -1,10 +1,12 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| 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'; | ||||
|  | ||||
| const EditRedemption = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const params = useParams(); | ||||
|   const navigate = useNavigate(); | ||||
|   const redemptionId = params.id; | ||||
| @@ -61,9 +63,9 @@ const EditRedemption = () => { | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       if (isEdit) { | ||||
|         showSuccess('兑换码更新成功!'); | ||||
|         showSuccess(t('redemption.messages.update_success')); | ||||
|       } else { | ||||
|         showSuccess('兑换码创建成功!'); | ||||
|         showSuccess(t('redemption.messages.create_success')); | ||||
|         setInputs(originInputs); | ||||
|       } | ||||
|     } else { | ||||
| @@ -83,14 +85,14 @@ const EditRedemption = () => { | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header className='header'> | ||||
|             {isEdit ? '更新兑换码信息' : '创建新的兑换码'} | ||||
|             {isEdit ? t('redemption.edit.title_edit') : t('redemption.edit.title_create')} | ||||
|           </Card.Header> | ||||
|           <Form loading={loading} autoComplete='new-password'> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='名称' | ||||
|                 label={t('redemption.edit.name')} | ||||
|                 name='name' | ||||
|                 placeholder={'请输入名称'} | ||||
|                 placeholder={t('redemption.edit.name_placeholder')} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={name} | ||||
|                 autoComplete='new-password' | ||||
| @@ -99,9 +101,9 @@ const EditRedemption = () => { | ||||
|             </Form.Field> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label={`额度${renderQuotaWithPrompt(quota)}`} | ||||
|                 label={`${t('redemption.edit.quota')}${renderQuotaWithPrompt(quota, t)}`} | ||||
|                 name='quota' | ||||
|                 placeholder={'请输入单个兑换码中包含的额度'} | ||||
|                 placeholder={t('redemption.edit.quota_placeholder')} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={quota} | ||||
|                 autoComplete='new-password' | ||||
| @@ -112,9 +114,9 @@ const EditRedemption = () => { | ||||
|               <> | ||||
|                 <Form.Field> | ||||
|                   <Form.Input | ||||
|                     label='生成数量' | ||||
|                     label={t('redemption.edit.count')} | ||||
|                     name='count' | ||||
|                     placeholder={'请输入生成数量'} | ||||
|                     placeholder={t('redemption.edit.count_placeholder')} | ||||
|                     onChange={handleInputChange} | ||||
|                     value={count} | ||||
|                     autoComplete='new-password' | ||||
| @@ -124,9 +126,11 @@ const EditRedemption = () => { | ||||
|               </> | ||||
|             )} | ||||
|             <Button positive onClick={submit}> | ||||
|               提交 | ||||
|               {t('redemption.edit.buttons.submit')} | ||||
|             </Button> | ||||
|             <Button onClick={handleCancel}> | ||||
|               {t('redemption.edit.buttons.cancel')} | ||||
|             </Button> | ||||
|             <Button onClick={handleCancel}>取消</Button> | ||||
|           </Form> | ||||
|         </Card.Content> | ||||
|       </Card> | ||||
|   | ||||
| @@ -1,16 +1,21 @@ | ||||
| import React from 'react'; | ||||
| import { Card } from 'semantic-ui-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import RedemptionsTable from '../../components/RedemptionsTable'; | ||||
|  | ||||
| const Redemption = () => ( | ||||
|   <div className='dashboard-container'> | ||||
|     <Card fluid className='chart-card'> | ||||
|       <Card.Content> | ||||
|         <Card.Header className='header'>兑换管理</Card.Header> | ||||
|         <RedemptionsTable /> | ||||
|       </Card.Content> | ||||
|     </Card> | ||||
|   </div> | ||||
| ); | ||||
| const Redemption = () => { | ||||
|   const { t } = useTranslation(); | ||||
|    | ||||
|   return ( | ||||
|     <div className='dashboard-container'> | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header className='header'>{t('redemption.title')}</Card.Header> | ||||
|           <RedemptionsTable /> | ||||
|         </Card.Content> | ||||
|       </Card> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default Redemption; | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import React from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Card, Tab } from 'semantic-ui-react'; | ||||
| import SystemSetting from '../../components/SystemSetting'; | ||||
| import { isRoot } from '../../helpers'; | ||||
| @@ -7,9 +8,11 @@ import PersonalSetting from '../../components/PersonalSetting'; | ||||
| import OperationSetting from '../../components/OperationSetting'; | ||||
|  | ||||
| const Setting = () => { | ||||
|   const { t } = useTranslation(); | ||||
|  | ||||
|   let panes = [ | ||||
|     { | ||||
|       menuItem: '个人设置', | ||||
|       menuItem: t('setting.tabs.personal'), | ||||
|       render: () => ( | ||||
|         <Tab.Pane attached={false}> | ||||
|           <PersonalSetting /> | ||||
| @@ -20,7 +23,7 @@ const Setting = () => { | ||||
|  | ||||
|   if (isRoot()) { | ||||
|     panes.push({ | ||||
|       menuItem: '运营设置', | ||||
|       menuItem: t('setting.tabs.operation'), | ||||
|       render: () => ( | ||||
|         <Tab.Pane attached={false}> | ||||
|           <OperationSetting /> | ||||
| @@ -28,7 +31,7 @@ const Setting = () => { | ||||
|       ), | ||||
|     }); | ||||
|     panes.push({ | ||||
|       menuItem: '系统设置', | ||||
|       menuItem: t('setting.tabs.system'), | ||||
|       render: () => ( | ||||
|         <Tab.Pane attached={false}> | ||||
|           <SystemSetting /> | ||||
| @@ -36,7 +39,7 @@ const Setting = () => { | ||||
|       ), | ||||
|     }); | ||||
|     panes.push({ | ||||
|       menuItem: '其他设置', | ||||
|       menuItem: t('setting.tabs.other'), | ||||
|       render: () => ( | ||||
|         <Tab.Pane attached={false}> | ||||
|           <OtherSetting /> | ||||
| @@ -49,12 +52,12 @@ const Setting = () => { | ||||
|     <div className='dashboard-container'> | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header className='header'>系统设置</Card.Header> | ||||
|           <Card.Header className='header'>{t('setting.title')}</Card.Header> | ||||
|           <Tab | ||||
|             menu={{ | ||||
|               secondary: true, | ||||
|               pointing: true, | ||||
|               className: 'settings-tab', // 添加自定义类名以便样式化 | ||||
|               className: 'settings-tab', | ||||
|             }} | ||||
|             panes={panes} | ||||
|           /> | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { | ||||
|   Button, | ||||
|   Form, | ||||
| @@ -18,6 +19,7 @@ import { | ||||
| import { renderQuotaWithPrompt } from '../../helpers/render'; | ||||
|  | ||||
| const EditToken = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const params = useParams(); | ||||
|   const tokenId = params.id; | ||||
|   const isEdit = tokenId !== undefined; | ||||
| @@ -60,47 +62,61 @@ const EditToken = () => { | ||||
|   }; | ||||
|  | ||||
|   const loadToken = async () => { | ||||
|     let res = await API.get(`/api/token/${tokenId}`); | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       if (data.expired_time !== -1) { | ||||
|         data.expired_time = timestamp2string(data.expired_time); | ||||
|       } | ||||
|       if (data.models === '') { | ||||
|         data.models = []; | ||||
|     try { | ||||
|       let res = await API.get(`/api/token/${tokenId}`); | ||||
|       const { success, message, data } = res.data || {}; | ||||
|       if (success && data) { | ||||
|         if (data.expired_time !== -1) { | ||||
|           data.expired_time = timestamp2string(data.expired_time); | ||||
|         } | ||||
|         if (data.models === '') { | ||||
|           data.models = []; | ||||
|         } else { | ||||
|           data.models = data.models.split(','); | ||||
|         } | ||||
|         setInputs(data); | ||||
|       } else { | ||||
|         data.models = data.models.split(','); | ||||
|         showError(message || 'Failed to load token'); | ||||
|       } | ||||
|       setInputs(data); | ||||
|     } else { | ||||
|       showError(message); | ||||
|     } catch (error) { | ||||
|       showError(error.message || 'Network error'); | ||||
|     } | ||||
|     setLoading(false); | ||||
|   }; | ||||
|   useEffect(() => { | ||||
|     if (isEdit) { | ||||
|       loadToken().then(); | ||||
|     } | ||||
|     loadAvailableModels().then(); | ||||
|   }, []); | ||||
|  | ||||
|   const loadAvailableModels = async () => { | ||||
|     let res = await API.get(`/api/user/available_models`); | ||||
|     const { success, message, data } = res.data; | ||||
|     if (success) { | ||||
|       let options = data.map((model) => { | ||||
|         return { | ||||
|           key: model, | ||||
|           text: model, | ||||
|           value: model, | ||||
|         }; | ||||
|       }); | ||||
|       setModelOptions(options); | ||||
|     } else { | ||||
|       showError(message); | ||||
|     try { | ||||
|       let res = await API.get(`/api/user/available_models`); | ||||
|       const { success, message, data } = res.data || {}; | ||||
|       if (success && data) { | ||||
|         let options = data.map((model) => { | ||||
|           return { | ||||
|             key: model, | ||||
|             text: model, | ||||
|             value: model, | ||||
|           }; | ||||
|         }); | ||||
|         setModelOptions(options); | ||||
|       } else { | ||||
|         showError(message || 'Failed to load models'); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       showError(error.message || 'Network error'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (isEdit) { | ||||
|       loadToken().catch((error) => { | ||||
|         showError(error.message || 'Failed to load token'); | ||||
|         setLoading(false); | ||||
|       }); | ||||
|     } | ||||
|     loadAvailableModels().catch((error) => { | ||||
|       showError(error.message || 'Failed to load models'); | ||||
|     }); | ||||
|   }, []); | ||||
|  | ||||
|   const submit = async () => { | ||||
|     if (!isEdit && inputs.name === '') return; | ||||
|     let localInputs = inputs; | ||||
| @@ -108,7 +124,7 @@ const EditToken = () => { | ||||
|     if (localInputs.expired_time !== -1) { | ||||
|       let time = Date.parse(localInputs.expired_time); | ||||
|       if (isNaN(time)) { | ||||
|         showError('过期时间格式错误!'); | ||||
|         showError(t('token.edit.messages.expire_time_invalid')); | ||||
|         return; | ||||
|       } | ||||
|       localInputs.expired_time = Math.ceil(time / 1000); | ||||
| @@ -126,9 +142,9 @@ const EditToken = () => { | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
|       if (isEdit) { | ||||
|         showSuccess('令牌更新成功!'); | ||||
|         showSuccess(t('token.edit.messages.update_success')); | ||||
|       } else { | ||||
|         showSuccess('令牌创建成功,请在列表页面点击复制获取令牌!'); | ||||
|         showSuccess(t('token.edit.messages.create_success')); | ||||
|         setInputs(originInputs); | ||||
|       } | ||||
|     } else { | ||||
| @@ -141,14 +157,14 @@ const EditToken = () => { | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header className='header'> | ||||
|             {isEdit ? '更新令牌信息' : '创建新的令牌'} | ||||
|             {isEdit ? t('token.edit.title_edit') : t('token.edit.title_create')} | ||||
|           </Card.Header> | ||||
|           <Form loading={loading} autoComplete='new-password'> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='名称' | ||||
|                 label={t('token.edit.name')} | ||||
|                 name='name' | ||||
|                 placeholder={'请输入名称'} | ||||
|                 placeholder={t('token.edit.name_placeholder')} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={name} | ||||
|                 autoComplete='new-password' | ||||
| @@ -157,8 +173,8 @@ const EditToken = () => { | ||||
|             </Form.Field> | ||||
|             <Form.Field> | ||||
|               <Form.Dropdown | ||||
|                 label='模型范围' | ||||
|                 placeholder={'请选择允许使用的模型,留空则不进行限制'} | ||||
|                 label={t('token.edit.models')} | ||||
|                 placeholder={t('token.edit.models_placeholder')} | ||||
|                 name='models' | ||||
|                 fluid | ||||
|                 multiple | ||||
| @@ -175,11 +191,9 @@ const EditToken = () => { | ||||
|             </Form.Field> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='IP 限制' | ||||
|                 label={t('token.edit.ip_limit')} | ||||
|                 name='subnet' | ||||
|                 placeholder={ | ||||
|                   '请输入允许访问的网段,例如:192.168.0.0/24,请使用英文逗号分隔多个网段' | ||||
|                 } | ||||
|                 placeholder={t('token.edit.ip_limit_placeholder')} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={inputs.subnet} | ||||
|                 autoComplete='new-password' | ||||
| @@ -187,11 +201,9 @@ const EditToken = () => { | ||||
|             </Form.Field> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='过期时间' | ||||
|                 label={t('token.edit.expire_time')} | ||||
|                 name='expired_time' | ||||
|                 placeholder={ | ||||
|                   '请输入过期时间,格式为 yyyy-MM-dd HH:mm:ss,-1 表示无限制' | ||||
|                 } | ||||
|                 placeholder={t('token.edit.expire_time_placeholder')} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={expired_time} | ||||
|                 autoComplete='new-password' | ||||
| @@ -205,7 +217,7 @@ const EditToken = () => { | ||||
|                   setExpiredTime(0, 0, 0, 0); | ||||
|                 }} | ||||
|               > | ||||
|                 永不过期 | ||||
|                 {t('token.edit.buttons.never_expire')} | ||||
|               </Button> | ||||
|               <Button | ||||
|                 type={'button'} | ||||
| @@ -213,7 +225,7 @@ const EditToken = () => { | ||||
|                   setExpiredTime(1, 0, 0, 0); | ||||
|                 }} | ||||
|               > | ||||
|                 一个月后过期 | ||||
|                 {t('token.edit.buttons.expire_1_month')} | ||||
|               </Button> | ||||
|               <Button | ||||
|                 type={'button'} | ||||
| @@ -221,7 +233,7 @@ const EditToken = () => { | ||||
|                   setExpiredTime(0, 1, 0, 0); | ||||
|                 }} | ||||
|               > | ||||
|                 一天后过期 | ||||
|                 {t('token.edit.buttons.expire_1_day')} | ||||
|               </Button> | ||||
|               <Button | ||||
|                 type={'button'} | ||||
| @@ -229,7 +241,7 @@ const EditToken = () => { | ||||
|                   setExpiredTime(0, 0, 1, 0); | ||||
|                 }} | ||||
|               > | ||||
|                 一小时后过期 | ||||
|                 {t('token.edit.buttons.expire_1_hour')} | ||||
|               </Button> | ||||
|               <Button | ||||
|                 type={'button'} | ||||
| @@ -237,17 +249,18 @@ const EditToken = () => { | ||||
|                   setExpiredTime(0, 0, 0, 1); | ||||
|                 }} | ||||
|               > | ||||
|                 一分钟后过期 | ||||
|                 {t('token.edit.buttons.expire_1_minute')} | ||||
|               </Button> | ||||
|             </div> | ||||
|             <Message> | ||||
|               注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。 | ||||
|             </Message> | ||||
|             <Message>{t('token.edit.quota_notice')}</Message> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label={`额度${renderQuotaWithPrompt(remain_quota)}`} | ||||
|                 label={`${t('token.edit.quota')}${renderQuotaWithPrompt( | ||||
|                   remain_quota, | ||||
|                   t | ||||
|                 )}`} | ||||
|                 name='remain_quota' | ||||
|                 placeholder={'请输入额度'} | ||||
|                 placeholder={t('token.edit.quota_placeholder')} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={remain_quota} | ||||
|                 autoComplete='new-password' | ||||
| @@ -261,13 +274,15 @@ const EditToken = () => { | ||||
|                 setUnlimitedQuota(); | ||||
|               }} | ||||
|             > | ||||
|               {unlimited_quota ? '取消无限额度' : '设为无限额度'} | ||||
|               {unlimited_quota | ||||
|                 ? t('token.edit.buttons.cancel_unlimited') | ||||
|                 : t('token.edit.buttons.unlimited_quota')} | ||||
|             </Button> | ||||
|             <Button floated='right' positive onClick={submit}> | ||||
|               提交 | ||||
|               {t('token.edit.buttons.submit')} | ||||
|             </Button> | ||||
|             <Button floated='right' onClick={handleCancel}> | ||||
|               取消 | ||||
|               {t('token.edit.buttons.cancel')} | ||||
|             </Button> | ||||
|           </Form> | ||||
|         </Card.Content> | ||||
|   | ||||
| @@ -1,16 +1,21 @@ | ||||
| import React from 'react'; | ||||
| import { Card } from 'semantic-ui-react'; | ||||
| import TokensTable from '../../components/TokensTable'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
|  | ||||
| const Token = () => ( | ||||
|   <div className='dashboard-container'> | ||||
|     <Card fluid className='chart-card'> | ||||
|       <Card.Content> | ||||
|         <Card.Header className='header'>令牌管理</Card.Header> | ||||
|         <TokensTable /> | ||||
|       </Card.Content> | ||||
|     </Card> | ||||
|   </div> | ||||
| ); | ||||
| const Token = () => { | ||||
|   const { t } = useTranslation(); | ||||
|  | ||||
|   return ( | ||||
|     <div className='dashboard-container'> | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header className='header'>{t('token.title')}</Card.Header> | ||||
|           <TokensTable /> | ||||
|         </Card.Content> | ||||
|       </Card> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default Token; | ||||
|   | ||||
| @@ -10,8 +10,10 @@ import { | ||||
| } 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); | ||||
| @@ -20,7 +22,7 @@ const TopUp = () => { | ||||
|  | ||||
|   const topUp = async () => { | ||||
|     if (redemptionCode === '') { | ||||
|       showInfo('请输入兑换码!'); | ||||
|       showInfo(t('topup.redeem_code.empty_code')); | ||||
|       return; | ||||
|     } | ||||
|     setIsSubmitting(true); | ||||
| @@ -30,7 +32,7 @@ const TopUp = () => { | ||||
|       }); | ||||
|       const { success, message, data } = res.data; | ||||
|       if (success) { | ||||
|         showSuccess('充值成功!'); | ||||
|         showSuccess(t('topup.redeem_code.success')); | ||||
|         setUserQuota((quota) => { | ||||
|           return quota + data; | ||||
|         }); | ||||
| @@ -39,7 +41,7 @@ const TopUp = () => { | ||||
|         showError(message); | ||||
|       } | ||||
|     } catch (err) { | ||||
|       showError('请求失败'); | ||||
|       showError(t('topup.redeem_code.request_failed')); | ||||
|     } finally { | ||||
|       setIsSubmitting(false); | ||||
|     } | ||||
| @@ -47,13 +49,12 @@ const TopUp = () => { | ||||
|  | ||||
|   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()); | ||||
| @@ -87,7 +88,7 @@ const TopUp = () => { | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header> | ||||
|             <Header as='h2'>充值中心</Header> | ||||
|             <Header as='h2'>{t('topup.title')}</Header> | ||||
|           </Card.Header> | ||||
|  | ||||
|           <Grid columns={2} stackable> | ||||
| @@ -109,7 +110,7 @@ const TopUp = () => { | ||||
|                   <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 | ||||
| @@ -130,9 +131,11 @@ const TopUp = () => { | ||||
|                       <div style={{ textAlign: 'center', paddingTop: '1em' }}> | ||||
|                         <Statistic> | ||||
|                           <Statistic.Value style={{ color: '#2185d0' }}> | ||||
|                             {renderQuota(userQuota)} | ||||
|                             {renderQuota(userQuota, t)} | ||||
|                           </Statistic.Value> | ||||
|                           <Statistic.Label>当前可用额度</Statistic.Label> | ||||
|                           <Statistic.Label> | ||||
|                             {t('topup.get_code.current_quota')} | ||||
|                           </Statistic.Label> | ||||
|                         </Statistic> | ||||
|                       </div> | ||||
|  | ||||
| @@ -145,7 +148,7 @@ const TopUp = () => { | ||||
|                           onClick={openTopUpLink} | ||||
|                           style={{ width: '80%' }} | ||||
|                         > | ||||
|                           立即获取兑换码 | ||||
|                           {t('topup.get_code.button')} | ||||
|                         </Button> | ||||
|                       </div> | ||||
|                     </div> | ||||
| @@ -172,7 +175,7 @@ const TopUp = () => { | ||||
|                   <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 | ||||
| @@ -194,7 +197,7 @@ const TopUp = () => { | ||||
|                         fluid | ||||
|                         icon='key' | ||||
|                         iconPosition='left' | ||||
|                         placeholder='请输入兑换码' | ||||
|                         placeholder={t('topup.redeem_code.placeholder')} | ||||
|                         value={redemptionCode} | ||||
|                         onChange={(e) => { | ||||
|                           setRedemptionCode(e.target.value); | ||||
| @@ -207,14 +210,14 @@ const TopUp = () => { | ||||
|                         action={ | ||||
|                           <Button | ||||
|                             icon='paste' | ||||
|                             content='粘贴' | ||||
|                             content={t('topup.redeem_code.paste')} | ||||
|                             onClick={async () => { | ||||
|                               try { | ||||
|                                 const text = | ||||
|                                   await navigator.clipboard.readText(); | ||||
|                                 setRedemptionCode(text.trim()); | ||||
|                               } catch (err) { | ||||
|                                 showError('无法访问剪贴板,请手动粘贴'); | ||||
|                                 showError(t('topup.redeem_code.paste_error')); | ||||
|                               } | ||||
|                             }} | ||||
|                           /> | ||||
| @@ -230,7 +233,9 @@ const TopUp = () => { | ||||
|                           loading={isSubmitting} | ||||
|                           disabled={isSubmitting} | ||||
|                         > | ||||
|                           {isSubmitting ? '兑换中...' : '立即兑换'} | ||||
|                           {isSubmitting | ||||
|                             ? t('topup.redeem_code.submitting') | ||||
|                             : t('topup.redeem_code.submit')} | ||||
|                         </Button> | ||||
|                       </div> | ||||
|                     </div> | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| import React, { useState } from 'react'; | ||||
| import { Button, Form, Header, Segment } from 'semantic-ui-react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Button, Form, Card } from 'semantic-ui-react'; | ||||
| import { API, showError, showSuccess } from '../../helpers'; | ||||
|  | ||||
| const AddUser = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const originInputs = { | ||||
|     username: '', | ||||
|     display_name: '', | ||||
| @@ -20,7 +22,7 @@ const AddUser = () => { | ||||
|     const res = await API.post(`/api/user/`, inputs); | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
|       showSuccess('用户账户创建成功!'); | ||||
|       showSuccess(t('user.messages.create_success')); | ||||
|       setInputs(originInputs); | ||||
|     } else { | ||||
|       showError(message); | ||||
| @@ -28,49 +30,51 @@ const AddUser = () => { | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Segment> | ||||
|         <Header as="h3">创建新用户账户</Header> | ||||
|         <Form autoComplete="off"> | ||||
|           <Form.Field> | ||||
|             <Form.Input | ||||
|               label="用户名" | ||||
|               name="username" | ||||
|               placeholder={'请输入用户名'} | ||||
|               onChange={handleInputChange} | ||||
|               value={username} | ||||
|               autoComplete="off" | ||||
|               required | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <Form.Input | ||||
|               label="显示名称" | ||||
|               name="display_name" | ||||
|               placeholder={'请输入显示名称'} | ||||
|               onChange={handleInputChange} | ||||
|               value={display_name} | ||||
|               autoComplete="off" | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Form.Field> | ||||
|             <Form.Input | ||||
|               label="密码" | ||||
|               name="password" | ||||
|               type={'password'} | ||||
|               placeholder={'请输入密码'} | ||||
|               onChange={handleInputChange} | ||||
|               value={password} | ||||
|               autoComplete="off" | ||||
|               required | ||||
|             /> | ||||
|           </Form.Field> | ||||
|           <Button positive type={'submit'} onClick={submit}> | ||||
|             提交 | ||||
|           </Button> | ||||
|         </Form> | ||||
|       </Segment> | ||||
|     </> | ||||
|     <div className='dashboard-container'> | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header className='header'>{t('user.add.title')}</Card.Header> | ||||
|           <Form autoComplete='off'> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label={t('user.edit.username')} | ||||
|                 name='username' | ||||
|                 placeholder={t('user.edit.username_placeholder')} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={username} | ||||
|                 autoComplete='off' | ||||
|                 required | ||||
|               /> | ||||
|             </Form.Field> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label={t('user.edit.display_name')} | ||||
|                 name='display_name' | ||||
|                 placeholder={t('user.edit.display_name_placeholder')} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={display_name} | ||||
|                 autoComplete='off' | ||||
|               /> | ||||
|             </Form.Field> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label={t('user.edit.password')} | ||||
|                 name='password' | ||||
|                 type='password' | ||||
|                 placeholder={t('user.edit.password_placeholder')} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={password} | ||||
|                 autoComplete='off' | ||||
|                 required | ||||
|               /> | ||||
|             </Form.Field> | ||||
|             <Button positive type='submit' onClick={submit}> | ||||
|               {t('user.edit.buttons.submit')} | ||||
|             </Button> | ||||
|           </Form> | ||||
|         </Card.Content> | ||||
|       </Card> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -1,10 +1,12 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| 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'; | ||||
|  | ||||
| const EditUser = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const params = useParams(); | ||||
|   const userId = params.id; | ||||
|   const [loading, setLoading] = useState(true); | ||||
| @@ -86,7 +88,7 @@ const EditUser = () => { | ||||
|     } | ||||
|     const { success, message } = res.data; | ||||
|     if (success) { | ||||
|       showSuccess('用户信息更新成功!'); | ||||
|       showSuccess(t('user.messages.update_success')); | ||||
|     } else { | ||||
|       showError(message); | ||||
|     } | ||||
| @@ -96,13 +98,13 @@ const EditUser = () => { | ||||
|     <div className='dashboard-container'> | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header className='header'>更新用户信息</Card.Header> | ||||
|           <Card.Header className='header'>{t('user.edit.title')}</Card.Header> | ||||
|           <Form loading={loading} autoComplete='new-password'> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='用户名' | ||||
|                 label={t('user.edit.username')} | ||||
|                 name='username' | ||||
|                 placeholder={'请输入新的用户名'} | ||||
|                 placeholder={t('user.edit.username_placeholder')} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={username} | ||||
|                 autoComplete='new-password' | ||||
| @@ -110,10 +112,10 @@ const EditUser = () => { | ||||
|             </Form.Field> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='密码' | ||||
|                 label={t('user.edit.password')} | ||||
|                 name='password' | ||||
|                 type={'password'} | ||||
|                 placeholder={'请输入新的密码,最短 8 位'} | ||||
|                 placeholder={t('user.edit.password_placeholder')} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={password} | ||||
|                 autoComplete='new-password' | ||||
| @@ -121,9 +123,9 @@ const EditUser = () => { | ||||
|             </Form.Field> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='显示名称' | ||||
|                 label={t('user.edit.display_name')} | ||||
|                 name='display_name' | ||||
|                 placeholder={'请输入新的显示名称'} | ||||
|                 placeholder={t('user.edit.display_name_placeholder')} | ||||
|                 onChange={handleInputChange} | ||||
|                 value={display_name} | ||||
|                 autoComplete='new-password' | ||||
| @@ -133,16 +135,14 @@ const EditUser = () => { | ||||
|               <> | ||||
|                 <Form.Field> | ||||
|                   <Form.Dropdown | ||||
|                     label='分组' | ||||
|                     placeholder={'请选择分组'} | ||||
|                     label={t('user.edit.group')} | ||||
|                     placeholder={t('user.edit.group_placeholder')} | ||||
|                     name='group' | ||||
|                     fluid | ||||
|                     search | ||||
|                     selection | ||||
|                     allowAdditions | ||||
|                     additionLabel={ | ||||
|                       '请在系统设置页面编辑分组倍率以添加新的分组:' | ||||
|                     } | ||||
|                     additionLabel={t('user.edit.group_addition')} | ||||
|                     onChange={handleInputChange} | ||||
|                     value={inputs.group} | ||||
|                     autoComplete='new-password' | ||||
| @@ -151,9 +151,12 @@ const EditUser = () => { | ||||
|                 </Form.Field> | ||||
|                 <Form.Field> | ||||
|                   <Form.Input | ||||
|                     label={`剩余额度${renderQuotaWithPrompt(quota)}`} | ||||
|                     label={`${t('user.edit.quota')}${renderQuotaWithPrompt( | ||||
|                       quota, | ||||
|                       t | ||||
|                     )}`} | ||||
|                     name='quota' | ||||
|                     placeholder={'请输入新的剩余额度'} | ||||
|                     placeholder={t('user.edit.quota_placeholder')} | ||||
|                     onChange={handleInputChange} | ||||
|                     value={quota} | ||||
|                     type={'number'} | ||||
| @@ -164,37 +167,39 @@ const EditUser = () => { | ||||
|             )} | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='已绑定的 GitHub 账户' | ||||
|                 label={t('user.edit.github_id')} | ||||
|                 name='github_id' | ||||
|                 value={github_id} | ||||
|                 autoComplete='new-password' | ||||
|                 placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' | ||||
|                 placeholder={t('user.edit.github_id_placeholder')} | ||||
|                 readOnly | ||||
|               /> | ||||
|             </Form.Field> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='已绑定的微信账户' | ||||
|                 label={t('user.edit.wechat_id')} | ||||
|                 name='wechat_id' | ||||
|                 value={wechat_id} | ||||
|                 autoComplete='new-password' | ||||
|                 placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' | ||||
|                 placeholder={t('user.edit.wechat_id_placeholder')} | ||||
|                 readOnly | ||||
|               /> | ||||
|             </Form.Field> | ||||
|             <Form.Field> | ||||
|               <Form.Input | ||||
|                 label='已绑定的邮箱账户' | ||||
|                 label={t('user.edit.email')} | ||||
|                 name='email' | ||||
|                 value={email} | ||||
|                 autoComplete='new-password' | ||||
|                 placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改' | ||||
|                 placeholder={t('user.edit.email_placeholder')} | ||||
|                 readOnly | ||||
|               /> | ||||
|             </Form.Field> | ||||
|             <Button onClick={handleCancel}>取消</Button> | ||||
|             <Button onClick={handleCancel}> | ||||
|               {t('user.edit.buttons.cancel')} | ||||
|             </Button> | ||||
|             <Button positive onClick={submit}> | ||||
|               提交 | ||||
|               {t('user.edit.buttons.submit')} | ||||
|             </Button> | ||||
|           </Form> | ||||
|         </Card.Content> | ||||
|   | ||||
| @@ -1,16 +1,21 @@ | ||||
| import React from 'react'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
| import { Card } from 'semantic-ui-react'; | ||||
| import UsersTable from '../../components/UsersTable'; | ||||
|  | ||||
| const User = () => ( | ||||
|   <div className='dashboard-container'> | ||||
|     <Card fluid className='chart-card'> | ||||
|       <Card.Content> | ||||
|         <Card.Header className='header'>用户管理</Card.Header> | ||||
|         <UsersTable /> | ||||
|       </Card.Content> | ||||
|     </Card> | ||||
|   </div> | ||||
| ); | ||||
| const User = () => { | ||||
|   const { t } = useTranslation(); | ||||
|  | ||||
|   return ( | ||||
|     <div className='dashboard-container'> | ||||
|       <Card fluid className='chart-card'> | ||||
|         <Card.Content> | ||||
|           <Card.Header className='header'>{t('user.title')}</Card.Header> | ||||
|           <UsersTable /> | ||||
|         </Card.Content> | ||||
|       </Card> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default User; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user