Merge pull request #1172 from QuantumNous/alpha

feat: 0.8 version
This commit is contained in:
Calcium-Ion
2025-06-07 21:36:07 +08:00
committed by GitHub
134 changed files with 19928 additions and 10591 deletions
@@ -1,14 +1,15 @@
name: Publish Docker image (arm64)
name: Publish Docker image (alpha)
on:
push:
tags:
- '*'
branches:
- alpha
workflow_dispatch:
inputs:
name:
description: 'reason'
description: "reason"
required: false
jobs:
push_to_registries:
name: Push Docker image to multiple registries
@@ -22,13 +23,7 @@ jobs:
- name: Save version info
run: |
git describe --tags > VERSION
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
echo "alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" > VERSION
- name: Log in to Docker Hub
uses: docker/login-action@v3
@@ -43,6 +38,9 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
@@ -50,6 +48,9 @@ jobs:
images: |
calciumion/new-api
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=alpha
type=raw,value=alpha-{{date 'YYYYMMDD'}}-{{sha}}
- name: Build and push Docker images
uses: docker/build-push-action@v5
@@ -58,4 +59,4 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
-54
View File
@@ -1,54 +0,0 @@
name: Publish Docker image (amd64)
on:
push:
tags:
- '*'
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@v4
- name: Save version info
run: |
git describe --tags > VERSION
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
calciumion/new-api
ghcr.io/${{ github.repository }}
- name: Build and push Docker images
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-54
View File
@@ -1,54 +0,0 @@
name: Linux Release
permissions:
contents: write
on:
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Build Frontend
env:
CI: ""
run: |
cd web
npm install
REACT_APP_VERSION=$(git describe --tags) npm run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend (amd64)
run: |
go mod download
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api
- name: Build Backend (arm64)
run: |
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api-arm64
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
one-api
one-api-arm64
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-45
View File
@@ -1,45 +0,0 @@
name: macOS Release
permissions:
contents: write
on:
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Build Frontend
env:
CI: ""
run: |
cd web
npm install
REACT_APP_VERSION=$(git describe --tags) npm run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend
run: |
go mod download
go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o one-api-macos
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: one-api-macos
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-48
View File
@@ -1,48 +0,0 @@
name: Windows Release
permissions:
contents: write
on:
push:
tags:
- '*'
- '!*-alpha*'
jobs:
release:
runs-on: windows-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Build Frontend
env:
CI: ""
run: |
cd web
npm install
REACT_APP_VERSION=$(git describe --tags) npm run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '>=1.18.0'
- name: Build Backend
run: |
go mod download
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o one-api.exe
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: one-api.exe
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5
View File
@@ -189,6 +189,11 @@ func TokenAuth() func(c *gin.Context) {
if skKey != "" {
c.Request.Header.Set("Authorization", "Bearer "+skKey)
}
// 从x-goog-api-key header中获取key
xGoogKey := c.Request.Header.Get("x-goog-api-key")
if xGoogKey != "" {
c.Request.Header.Set("Authorization", "Bearer "+xGoogKey)
}
}
key := c.Request.Header.Get("Authorization")
parts := make([]string, 0)
+12 -11
View File
@@ -27,14 +27,9 @@ type FunctionCall struct {
Arguments any `json:"args"`
}
type GeminiFunctionResponseContent struct {
Name string `json:"name"`
Content any `json:"content"`
}
type FunctionResponse struct {
Name string `json:"name"`
Response GeminiFunctionResponseContent `json:"response"`
Name string `json:"name"`
Response map[string]interface{} `json:"response"`
}
type GeminiPartExecutableCode struct {
@@ -117,10 +112,16 @@ type GeminiChatResponse struct {
}
type GeminiUsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
}
type GeminiPromptTokensDetails struct {
Modality string `json:"modality"`
TokenCount int `json:"tokenCount"`
}
// Imagen related structs
+20 -3
View File
@@ -55,6 +55,16 @@ func GeminiTextGenerationHandler(c *gin.Context, resp *http.Response, info *rela
TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount,
}
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens = detail.TokenCount
}
}
// 直接返回 Gemini 原生格式的 JSON 响应
jsonResponse, err := json.Marshal(geminiResponse)
if err != nil {
@@ -100,6 +110,14 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens = detail.TokenCount
}
}
}
// 直接发送 GeminiChatResponse 响应
@@ -118,11 +136,10 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
}
// 计算最终使用量
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
// 结束流式响应
helper.Done(c)
// 移除流式响应结尾的[Done],因为Gemini API没有发送Done的行为
//helper.Done(c)
return usage, nil
}
+77 -29
View File
@@ -57,25 +57,63 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
}
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
if strings.HasSuffix(info.OriginModelName, "-thinking") {
// 如果模型名以 gemini-2.5-pro 开头,不设置 ThinkingBudget
if strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") {
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
IncludeThoughts: true,
}
} else {
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
if budgetTokens == 0 || budgetTokens > 24576 {
budgetTokens = 24576
}
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(int(budgetTokens)),
IncludeThoughts: true,
}
}
if strings.HasSuffix(info.OriginModelName, "-thinking") {
// 硬编码不支持 ThinkingBudget 的旧模型
unsupportedModels := []string{
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-pro-preview-03-25",
}
isUnsupported := false
for _, unsupportedModel := range unsupportedModels {
if strings.HasPrefix(info.OriginModelName, unsupportedModel) {
isUnsupported = true
break
}
}
if isUnsupported {
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
IncludeThoughts: true,
}
} else {
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
// 检查是否为新的2.5pro模型(支持ThinkingBudget但有特殊范围)
isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25")
if isNew25Pro {
// 新的2.5pro模型:ThinkingBudget范围为128-32768
if budgetTokens == 0 || budgetTokens < 128 {
budgetTokens = 128
} else if budgetTokens > 32768 {
budgetTokens = 32768
}
} else {
// 其他模型:ThinkingBudget范围为0-24576
if budgetTokens == 0 || budgetTokens > 24576 {
budgetTokens = 24576
}
}
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(int(budgetTokens)),
IncludeThoughts: true,
}
}
} else if strings.HasSuffix(info.OriginModelName, "-nothinking") {
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(0),
// 检查是否为新的2.5pro模型(不支持-nothinking,因为最低值只能为128
isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25")
if !isNew25Pro {
// 只有非新2.5pro模型才支持-nothinking
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
ThinkingBudget: common.GetPointer(0),
}
}
}
}
@@ -173,17 +211,12 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
} else if val, exists := tool_call_ids[message.ToolCallId]; exists {
name = val
}
content := common.StrToMap(message.StringContent())
contentMap := common.StrToMap(message.StringContent())
functionResp := &FunctionResponse{
Name: name,
Response: GeminiFunctionResponseContent{
Name: name,
Content: content,
},
}
if content == nil {
functionResp.Response.Content = message.StringContent()
Name: name,
Response: contentMap,
}
*parts = append(*parts, GeminiPart{
FunctionResponse: functionResp,
})
@@ -280,13 +313,13 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
if part.GetInputAudio().Data == "" {
return nil, fmt.Errorf("only base64 audio is supported in gemini")
}
format, base64String, err := service.DecodeBase64FileData(part.GetInputAudio().Data)
base64String, err := service.DecodeBase64AudioData(part.GetInputAudio().Data)
if err != nil {
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
}
parts = append(parts, GeminiPart{
InlineData: &GeminiInlineData{
MimeType: format,
MimeType: "audio/" + part.GetInputAudio().Format,
Data: base64String,
},
})
@@ -738,6 +771,13 @@ func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycom
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens = detail.TokenCount
}
}
}
err = helper.ObjectData(c, response)
if err != nil {
@@ -812,6 +852,14 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
if detail.Modality == "AUDIO" {
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
} else if detail.Modality == "TEXT" {
usage.PromptTokensDetails.TextTokens = detail.TokenCount
}
}
fullTextResponse.Usage = usage
jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil {
+38 -9
View File
@@ -352,6 +352,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
promptTokens := usage.PromptTokens
cacheTokens := usage.PromptTokensDetails.CachedTokens
imageTokens := usage.PromptTokensDetails.ImageTokens
audioTokens := usage.PromptTokensDetails.AudioTokens
completionTokens := usage.CompletionTokens
modelName := relayInfo.OriginModelName
@@ -367,6 +368,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
dPromptTokens := decimal.NewFromInt(int64(promptTokens))
dCacheTokens := decimal.NewFromInt(int64(cacheTokens))
dImageTokens := decimal.NewFromInt(int64(imageTokens))
dAudioTokens := decimal.NewFromInt(int64(audioTokens))
dCompletionTokens := decimal.NewFromInt(int64(completionTokens))
dCompletionRatio := decimal.NewFromFloat(completionRatio)
dCacheRatio := decimal.NewFromFloat(cacheRatio)
@@ -412,23 +414,43 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice).
Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))).
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 $%s",
extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 %s",
fileSearchTool.CallCount, dFileSearchQuota.String())
}
}
var quotaCalculateDecimal decimal.Decimal
if !priceData.UsePrice {
nonCachedTokens := dPromptTokens.Sub(dCacheTokens)
cachedTokensWithRatio := dCacheTokens.Mul(dCacheRatio)
promptQuota := nonCachedTokens.Add(cachedTokensWithRatio)
if imageTokens > 0 {
nonImageTokens := dPromptTokens.Sub(dImageTokens)
imageTokensWithRatio := dImageTokens.Mul(dImageRatio)
promptQuota = nonImageTokens.Add(imageTokensWithRatio)
var audioInputQuota decimal.Decimal
var audioInputPrice float64
if !priceData.UsePrice {
baseTokens := dPromptTokens
// 减去 cached tokens
var cachedTokensWithRatio decimal.Decimal
if !dCacheTokens.IsZero() {
baseTokens = baseTokens.Sub(dCacheTokens)
cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
}
// 减去 image tokens
var imageTokensWithRatio decimal.Decimal
if !dImageTokens.IsZero() {
baseTokens = baseTokens.Sub(dImageTokens)
imageTokensWithRatio = dImageTokens.Mul(dImageRatio)
}
// 减去 Gemini audio tokens
if !dAudioTokens.IsZero() {
audioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(modelName)
if audioInputPrice > 0 {
// 重新计算 base tokens
baseTokens = baseTokens.Sub(dAudioTokens)
audioInputQuota = decimal.NewFromFloat(audioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String())
}
}
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio)
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio)
@@ -442,6 +464,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
// 添加 responses tools call 调用的配额
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
// 添加 audio input 独立计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
quota := int(quotaCalculateDecimal.Round(0).IntPart())
totalTokens := promptTokens + completionTokens
@@ -512,6 +536,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
other["file_search_price"] = fileSearchPrice
}
}
if !audioInputQuota.IsZero() {
other["audio_input_seperate_price"] = true
other["audio_input_token_count"] = audioTokens
other["audio_input_price"] = audioInputPrice
}
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel,
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
}
+17
View File
@@ -3,6 +3,7 @@ package service
import (
"encoding/base64"
"fmt"
"strings"
)
func parseAudio(audioBase64 string, format string) (duration float64, err error) {
@@ -29,3 +30,19 @@ func parseAudio(audioBase64 string, format string) (duration float64, err error)
duration = float64(samplesCount) / float64(sampleRate)
return duration, nil
}
func DecodeBase64AudioData(audioBase64 string) (string, error) {
// 检查并移除 data:audio/xxx;base64, 前缀
idx := strings.Index(audioBase64, ",")
if idx != -1 {
audioBase64 = audioBase64[idx+1:]
}
// 解码 Base64 数据
_, err := base64.StdEncoding.DecodeString(audioBase64)
if err != nil {
return "", fmt.Errorf("base64 decode error: %v", err)
}
return audioBase64, nil
}
+18
View File
@@ -14,6 +14,13 @@ const (
FileSearchPrice = 2.5
)
const (
// Gemini Audio Input Price
Gemini25FlashPreviewInputAudioPrice = 1.00
Gemini25FlashNativeAudioInputAudioPrice = 3.00
Gemini20FlashInputAudioPrice = 0.70
)
func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 {
// 确定模型类型
// https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费
@@ -55,3 +62,14 @@ func GetWebSearchPricePerThousand(modelName string, contextSize string) float64
func GetFileSearchPricePerThousand() float64 {
return FileSearchPrice
}
func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") {
return Gemini25FlashPreviewInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") {
return Gemini25FlashNativeAudioInputAudioPrice
} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
return Gemini20FlashInputAudioPrice
}
return 0
}
+22 -3
View File
@@ -6,27 +6,42 @@
"dependencies": {
"@douyinfe/semi-icons": "^2.63.1",
"@douyinfe/semi-ui": "^2.69.1",
"@lobehub/icons": "^2.0.0",
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
"axios": "^0.27.2",
"clsx": "^2.1.1",
"country-flag-icons": "^1.5.19",
"dayjs": "^1.11.11",
"history": "^5.3.0",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^7.2.0",
"katex": "^0.16.22",
"lucide-react": "^0.511.0",
"marked": "^4.1.1",
"mermaid": "^11.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-fireworks": "^1.0.4",
"react-i18next": "^13.0.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.3.0",
"react-telegram-login": "^1.1.2",
"react-toastify": "^9.0.8",
"react-turnstile": "^1.0.5",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"semantic-ui-offline": "^2.5.0",
"semantic-ui-react": "^2.1.3",
"sse": "https://github.com/mpetazzoni/sse.js",
"i18next": "^23.16.8",
"react-i18next": "^13.0.0",
"i18next-browser-languagedetector": "^7.2.0"
"unist-util-visit": "^5.0.0",
"use-debounce": "^10.0.4"
},
"scripts": {
"dev": "vite",
@@ -54,9 +69,13 @@
]
},
"devDependencies": {
"@douyinfe/semi-webpack-plugin": "^2.78.0",
"@so1ve/prettier-config": "^3.1.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.3",
"prettier": "^3.0.0",
"tailwindcss": "^3",
"typescript": "4.4.2",
"vite": "^5.2.0"
},
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

+34 -31
View File
@@ -1,15 +1,15 @@
import React, { lazy, Suspense, useContext, useEffect } from 'react';
import React, { lazy, Suspense } from 'react';
import { Route, Routes, useLocation } from 'react-router-dom';
import Loading from './components/Loading';
import Loading from './components/common/Loading.js';
import User from './pages/User';
import { PrivateRoute } from './components/PrivateRoute';
import RegisterForm from './components/RegisterForm';
import LoginForm from './components/LoginForm';
import { AuthRedirect, PrivateRoute } from './helpers';
import RegisterForm from './components/auth/RegisterForm.js';
import LoginForm from './components/auth/LoginForm.js';
import NotFound from './pages/NotFound';
import Setting from './pages/Setting';
import EditUser from './pages/User/EditUser';
import PasswordResetForm from './components/PasswordResetForm';
import PasswordResetConfirm from './components/PasswordResetConfirm';
import PasswordResetForm from './components/auth/PasswordResetForm.js';
import PasswordResetConfirm from './components/auth/PasswordResetConfirm.js';
import Channel from './pages/Channel';
import Token from './pages/Token';
import EditChannel from './pages/Channel/EditChannel';
@@ -18,15 +18,14 @@ import TopUp from './pages/TopUp';
import Log from './pages/Log';
import Chat from './pages/Chat';
import Chat2Link from './pages/Chat2Link';
import { Layout } from '@douyinfe/semi-ui';
import Midjourney from './pages/Midjourney';
import Pricing from './pages/Pricing/index.js';
import Task from './pages/Task/index.js';
import Playground from './pages/Playground/Playground.js';
import OAuth2Callback from './components/OAuth2Callback.js';
import PersonalSetting from './components/PersonalSetting.js';
import Playground from './pages/Playground/index.js';
import OAuth2Callback from './components/auth/OAuth2Callback.js';
import PersonalSetting from './components/settings/PersonalSetting.js';
import Setup from './pages/Setup/index.js';
import SetupCheck from './components/SetupCheck';
import SetupCheck from './components/layout/SetupCheck.js';
const Home = lazy(() => import('./pages/Home'));
const Detail = lazy(() => import('./pages/Detail'));
@@ -55,7 +54,7 @@ function App() {
}
/>
<Route
path='/channel'
path='/console/channel'
element={
<PrivateRoute>
<Channel />
@@ -63,7 +62,7 @@ function App() {
}
/>
<Route
path='/channel/edit/:id'
path='/console/channel/edit/:id'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<EditChannel />
@@ -71,7 +70,7 @@ function App() {
}
/>
<Route
path='/channel/add'
path='/console/channel/add'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<EditChannel />
@@ -79,7 +78,7 @@ function App() {
}
/>
<Route
path='/token'
path='/console/token'
element={
<PrivateRoute>
<Token />
@@ -87,7 +86,7 @@ function App() {
}
/>
<Route
path='/playground'
path='/console/playground'
element={
<PrivateRoute>
<Playground />
@@ -95,7 +94,7 @@ function App() {
}
/>
<Route
path='/redemption'
path='/console/redemption'
element={
<PrivateRoute>
<Redemption />
@@ -103,7 +102,7 @@ function App() {
}
/>
<Route
path='/user'
path='/console/user'
element={
<PrivateRoute>
<User />
@@ -111,7 +110,7 @@ function App() {
}
/>
<Route
path='/user/edit/:id'
path='/console/user/edit/:id'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<EditUser />
@@ -119,7 +118,7 @@ function App() {
}
/>
<Route
path='/user/edit'
path='/console/user/edit'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<EditUser />
@@ -138,7 +137,9 @@ function App() {
path='/login'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<LoginForm />
<AuthRedirect>
<LoginForm />
</AuthRedirect>
</Suspense>
}
/>
@@ -146,7 +147,9 @@ function App() {
path='/register'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<RegisterForm />
<AuthRedirect>
<RegisterForm />
</AuthRedirect>
</Suspense>
}
/>
@@ -183,7 +186,7 @@ function App() {
}
/>
<Route
path='/setting'
path='/console/setting'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -193,7 +196,7 @@ function App() {
}
/>
<Route
path='/personal'
path='/console/personal'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -203,7 +206,7 @@ function App() {
}
/>
<Route
path='/topup'
path='/console/topup'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -213,7 +216,7 @@ function App() {
}
/>
<Route
path='/log'
path='/console/log'
element={
<PrivateRoute>
<Log />
@@ -221,7 +224,7 @@ function App() {
}
/>
<Route
path='/detail'
path='/console'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -231,7 +234,7 @@ function App() {
}
/>
<Route
path='/midjourney'
path='/console/midjourney'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -241,7 +244,7 @@ function App() {
}
/>
<Route
path='/task'
path='/console/task'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
@@ -267,7 +270,7 @@ function App() {
}
/>
<Route
path='/chat/:id?'
path='/console/chat/:id?'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Chat />
-76
View File
@@ -1,76 +0,0 @@
import React, { useEffect, useState, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { getFooterHTML, getSystemName } from '../helpers';
import { Layout, Tooltip } from '@douyinfe/semi-ui';
import { StyleContext } from '../context/Style/index.js';
const FooterBar = () => {
const { t } = useTranslation();
const systemName = getSystemName();
const [footer, setFooter] = useState(getFooterHTML());
const [styleState] = useContext(StyleContext);
let remainCheckTimes = 5;
const loadFooter = () => {
let footer_html = localStorage.getItem('footer_html');
if (footer_html) {
setFooter(footer_html);
}
};
const defaultFooter = (
<div className='custom-footer'>
<a
href='https://github.com/Calcium-Ion/new-api'
target='_blank'
rel='noreferrer'
>
New API {import.meta.env.VITE_REACT_APP_VERSION}{' '}
</a>
{t('由')}{' '}
<a href='https://github.com/Calcium-Ion' target='_blank' rel='noreferrer'>
Calcium-Ion
</a>{' '}
{t('开发,基于')}{' '}
<a
href='https://github.com/songquanpeng/one-api'
target='_blank'
rel='noreferrer'
>
One API
</a>
</div>
);
useEffect(() => {
const timer = setInterval(() => {
if (remainCheckTimes <= 0) {
clearInterval(timer);
return;
}
remainCheckTimes--;
loadFooter();
}, 200);
return () => clearTimeout(timer);
}, []);
return (
<div
style={{
textAlign: 'center',
paddingBottom: '5px',
}}
>
{footer ? (
<div
className='custom-footer'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
) : (
defaultFooter
)}
</div>
);
};
export default FooterBar;
-494
View File
@@ -1,494 +0,0 @@
import React, { useContext, useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { UserContext } from '../context/User';
import { useSetTheme, useTheme } from '../context/Theme';
import { useTranslation } from 'react-i18next';
import { API, getLogo, getSystemName, isMobile, showSuccess } from '../helpers';
import '../index.css';
import fireworks from 'react-fireworks';
import {
IconClose,
IconHelpCircle,
IconHome,
IconHomeStroked,
IconIndentLeft,
IconComment,
IconKey,
IconMenu,
IconNoteMoneyStroked,
IconPriceTag,
IconUser,
IconLanguage,
IconInfoCircle,
IconCreditCard,
IconTerminal,
} from '@douyinfe/semi-icons';
import {
Avatar,
Button,
Dropdown,
Layout,
Nav,
Switch,
Tag,
} from '@douyinfe/semi-ui';
import { stringToColor } from '../helpers/render';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import { StyleContext } from '../context/Style/index.js';
import { StatusContext } from '../context/Status/index.js';
// 自定义顶部栏样式
const headerStyle = {
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.1)',
borderBottom: '1px solid var(--semi-color-border)',
background: 'var(--semi-color-bg-0)',
transition: 'all 0.3s ease',
width: '100%',
};
// 自定义顶部栏按钮样式
const headerItemStyle = {
borderRadius: '4px',
margin: '0 4px',
transition: 'all 0.3s ease',
};
// 自定义顶部栏按钮悬停样式
const headerItemHoverStyle = {
backgroundColor: 'var(--semi-color-primary-light-default)',
color: 'var(--semi-color-primary)',
};
// 自定义顶部栏Logo样式
const logoStyle = {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '0 10px',
height: '100%',
};
// 自定义顶部栏系统名称样式
const systemNameStyle = {
fontWeight: 'bold',
fontSize: '18px',
background:
'linear-gradient(45deg, var(--semi-color-primary), var(--semi-color-secondary))',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
padding: '0 5px',
};
// 自定义顶部栏按钮图标样式
const headerIconStyle = {
fontSize: '18px',
transition: 'all 0.3s ease',
};
// 自定义头像样式
const avatarStyle = {
margin: '4px',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
transition: 'all 0.3s ease',
};
// 自定义下拉菜单样式
const dropdownStyle = {
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
overflow: 'hidden',
};
// 自定义主题切换开关样式
const switchStyle = {
margin: '0 8px',
};
const HeaderBar = () => {
const { t, i18n } = useTranslation();
const [userState, userDispatch] = useContext(UserContext);
const [styleState, styleDispatch] = useContext(StyleContext);
const [statusState, statusDispatch] = useContext(StatusContext);
let navigate = useNavigate();
const [currentLang, setCurrentLang] = useState(i18n.language);
const systemName = getSystemName();
const logo = getLogo();
const currentDate = new Date();
// enable fireworks on new year(1.1 and 2.9-2.24)
const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1;
// Check if self-use mode is enabled
const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false;
const docsLink = statusState?.status?.docs_link || '';
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
let buttons = [
{
text: t('首页'),
itemKey: 'home',
to: '/',
icon: <IconHome style={headerIconStyle} />,
},
{
text: t('控制台'),
itemKey: 'detail',
to: '/',
icon: <IconTerminal style={headerIconStyle} />,
},
{
text: t('定价'),
itemKey: 'pricing',
to: '/pricing',
icon: <IconPriceTag style={headerIconStyle} />,
},
// Only include the docs button if docsLink exists
...(docsLink
? [
{
text: t('文档'),
itemKey: 'docs',
isExternal: true,
externalLink: docsLink,
icon: <IconHelpCircle style={headerIconStyle} />,
},
]
: []),
{
text: t('关于'),
itemKey: 'about',
to: '/about',
icon: <IconInfoCircle style={headerIconStyle} />,
},
];
async function logout() {
await API.get('/api/user/logout');
showSuccess(t('注销成功!'));
userDispatch({ type: 'logout' });
localStorage.removeItem('user');
navigate('/login');
}
const handleNewYearClick = () => {
fireworks.init('root', {});
fireworks.start();
setTimeout(() => {
fireworks.stop();
setTimeout(() => {
window.location.reload();
}, 10000);
}, 3000);
};
const theme = useTheme();
const setTheme = useSetTheme();
useEffect(() => {
if (theme === 'dark') {
document.body.setAttribute('theme-mode', 'dark');
} else {
document.body.removeAttribute('theme-mode');
}
// 发送当前主题模式给子页面
const iframe = document.querySelector('iframe');
if (iframe) {
iframe.contentWindow.postMessage({ themeMode: theme }, '*');
}
if (isNewYear) {
console.log('Happy New Year!');
}
}, [theme]);
useEffect(() => {
const handleLanguageChanged = (lng) => {
setCurrentLang(lng);
const iframe = document.querySelector('iframe');
if (iframe) {
iframe.contentWindow.postMessage({ lang: lng }, '*');
}
};
i18n.on('languageChanged', handleLanguageChanged);
return () => {
i18n.off('languageChanged', handleLanguageChanged);
};
}, [i18n]);
const handleLanguageChange = (lang) => {
i18n.changeLanguage(lang);
};
return (
<>
<Layout>
<div style={{ width: '100%' }}>
<Nav
className={'topnav'}
mode={'horizontal'}
style={headerStyle}
itemStyle={headerItemStyle}
hoverStyle={headerItemHoverStyle}
renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
const routerMap = {
about: '/about',
login: '/login',
register: '/register',
pricing: '/pricing',
detail: '/detail',
home: '/',
chat: '/chat',
};
return (
<div
onClick={(e) => {
if (props.itemKey === 'home') {
styleDispatch({
type: 'SET_INNER_PADDING',
payload: false,
});
styleDispatch({ type: 'SET_SIDER', payload: false });
} else {
styleDispatch({
type: 'SET_INNER_PADDING',
payload: true,
});
if (!styleState.isMobile) {
styleDispatch({ type: 'SET_SIDER', payload: true });
}
}
}}
>
{props.isExternal ? (
<a
className='header-bar-text'
style={{ textDecoration: 'none' }}
href={props.externalLink}
target='_blank'
rel='noopener noreferrer'
>
{itemElement}
</a>
) : (
<Link
className='header-bar-text'
style={{ textDecoration: 'none' }}
to={routerMap[props.itemKey]}
>
{itemElement}
</Link>
)}
</div>
);
}}
selectedKeys={[]}
// items={headerButtons}
onSelect={(key) => {}}
header={
styleState.isMobile
? {
logo: (
<div
style={{
display: 'flex',
alignItems: 'center',
position: 'relative',
}}
>
{!styleState.showSider ? (
<Button
icon={<IconMenu />}
theme='light'
aria-label={t('展开侧边栏')}
onClick={() =>
styleDispatch({
type: 'SET_SIDER',
payload: true,
})
}
/>
) : (
<Button
icon={<IconIndentLeft />}
theme='light'
aria-label={t('闭侧边栏')}
onClick={() =>
styleDispatch({
type: 'SET_SIDER',
payload: false,
})
}
/>
)}
{(isSelfUseMode || isDemoSiteMode) && (
<Tag
color={isSelfUseMode ? 'purple' : 'blue'}
style={{
position: 'absolute',
top: '-8px',
right: '-15px',
fontSize: '0.7rem',
padding: '0 4px',
height: 'auto',
lineHeight: '1.2',
zIndex: 1,
pointerEvents: 'none',
}}
>
{isSelfUseMode ? t('自用模式') : t('演示站点')}
</Tag>
)}
</div>
),
}
: {
logo: (
<div style={logoStyle}>
<img src={logo} alt='logo' style={{ height: '28px' }} />
</div>
),
text: (
<div
style={{
position: 'relative',
display: 'inline-block',
}}
>
<span style={systemNameStyle}>{systemName}</span>
{(isSelfUseMode || isDemoSiteMode) && (
<Tag
color={isSelfUseMode ? 'purple' : 'blue'}
style={{
position: 'absolute',
top: '-10px',
right: '-25px',
fontSize: '0.7rem',
padding: '0 4px',
whiteSpace: 'nowrap',
zIndex: 1,
boxShadow: '0 0 3px rgba(255, 255, 255, 0.7)',
}}
>
{isSelfUseMode ? t('自用模式') : t('演示站点')}
</Tag>
)}
</div>
),
}
}
items={buttons}
footer={
<>
{isNewYear && (
// happy new year
<Dropdown
position='bottomRight'
render={
<Dropdown.Menu style={dropdownStyle}>
<Dropdown.Item onClick={handleNewYearClick}>
Happy New Year!!!
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Nav.Item itemKey={'new-year'} text={'🎉'} />
</Dropdown>
)}
{/* <Nav.Item itemKey={'about'} icon={<IconHelpCircle />} /> */}
<>
<Switch
checkedText='🌞'
size={styleState.isMobile ? 'default' : 'large'}
checked={theme === 'dark'}
uncheckedText='🌙'
style={switchStyle}
onChange={(checked) => {
setTheme(checked);
}}
/>
</>
<Dropdown
position='bottomRight'
render={
<Dropdown.Menu style={dropdownStyle}>
<Dropdown.Item
onClick={() => handleLanguageChange('zh')}
type={currentLang === 'zh' ? 'primary' : 'tertiary'}
>
中文
</Dropdown.Item>
<Dropdown.Item
onClick={() => handleLanguageChange('en')}
type={currentLang === 'en' ? 'primary' : 'tertiary'}
>
English
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Nav.Item
itemKey={'language'}
icon={<IconLanguage style={headerIconStyle} />}
/>
</Dropdown>
{userState.user ? (
<>
<Dropdown
position='bottomRight'
render={
<Dropdown.Menu style={dropdownStyle}>
<Dropdown.Item onClick={logout}>
{t('退出')}
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Avatar
size='small'
color={stringToColor(userState.user.username)}
style={avatarStyle}
>
{userState.user.username[0]}
</Avatar>
{styleState.isMobile ? null : (
<Text style={{ marginLeft: '4px', fontWeight: '500' }}>
{userState.user.username}
</Text>
)}
</Dropdown>
</>
) : (
<>
<Nav.Item
itemKey={'login'}
text={!styleState.isMobile ? t('登录') : null}
icon={<IconUser style={headerIconStyle} />}
/>
{
// Hide register option in self-use mode
!styleState.isMobile && !isSelfUseMode && (
<Nav.Item
itemKey={'register'}
text={t('注册')}
icon={<IconKey style={headerIconStyle} />}
/>
)
}
</>
)}
</>
}
></Nav>
</div>
</Layout>
</>
);
};
export default HeaderBar;
-12
View File
@@ -1,12 +0,0 @@
import React from 'react';
import { Spin } from '@douyinfe/semi-ui';
const Loading = ({ prompt: name = 'page' }) => {
return (
<Spin style={{ height: 100 }} spinning={true}>
加载{name}...
</Spin>
);
};
export default Loading;
-385
View File
@@ -1,385 +0,0 @@
import React, { useContext, useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { UserContext } from '../context/User';
import {
API,
getLogo,
showError,
showInfo,
showSuccess,
updateAPI,
} from '../helpers';
import {
onGitHubOAuthClicked,
onOIDCClicked,
onLinuxDOOAuthClicked,
} from './utils';
import Turnstile from 'react-turnstile';
import {
Button,
Card,
Divider,
Form,
Icon,
Layout,
Modal,
} from '@douyinfe/semi-ui';
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import TelegramLoginButton from 'react-telegram-login';
import { IconGithubLogo, IconAlarm } from '@douyinfe/semi-icons';
import OIDCIcon from './OIDCIcon.js';
import WeChatIcon from './WeChatIcon';
import { setUserData } from '../helpers/data.js';
import LinuxDoIcon from './LinuxDoIcon.js';
import { useTranslation } from 'react-i18next';
const LoginForm = () => {
const [inputs, setInputs] = useState({
username: '',
password: '',
wechat_verification_code: '',
});
const [searchParams, setSearchParams] = useSearchParams();
const [submitted, setSubmitted] = useState(false);
const { username, password } = inputs;
const [userState, userDispatch] = useContext(UserContext);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
let navigate = useNavigate();
const [status, setStatus] = useState({});
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
const { t } = useTranslation();
const logo = getLogo();
let affCode = new URLSearchParams(window.location.search).get('aff');
if (affCode) {
localStorage.setItem('aff', affCode);
}
useEffect(() => {
if (searchParams.get('expired')) {
showError(t('未登录或登录已过期,请重新登录'));
}
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
setStatus(status);
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
}
}, []);
const onWeChatLoginClicked = () => {
setShowWeChatLoginModal(true);
};
const onSubmitWeChatVerificationCode = async () => {
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
return;
}
const res = await API.get(
`/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
setUserData(data);
updateAPI();
navigate('/');
showSuccess('登录成功!');
setShowWeChatLoginModal(false);
} else {
showError(message);
}
};
function handleChange(name, value) {
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
async function handleSubmit(e) {
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
return;
}
setSubmitted(true);
if (username && password) {
const res = await API.post(
`/api/user/login?turnstile=${turnstileToken}`,
{
username,
password,
},
);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
setUserData(data);
updateAPI();
showSuccess('登录成功!');
if (username === 'root' && password === '123456') {
Modal.error({
title: '您正在使用默认密码!',
content: '请立刻修改默认密码!',
centered: true,
});
}
navigate('/token');
} else {
showError(message);
}
} else {
showError('请输入用户名和密码!');
}
}
// 添加Telegram登录处理函数
const onTelegramLoginClicked = async (response) => {
const fields = [
'id',
'first_name',
'last_name',
'username',
'photo_url',
'auth_date',
'hash',
'lang',
];
const params = {};
fields.forEach((field) => {
if (response[field]) {
params[field] = response[field];
}
});
const res = await API.get(`/api/oauth/telegram/login`, { params });
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
showSuccess('登录成功!');
setUserData(data);
updateAPI();
navigate('/');
} else {
showError(message);
}
};
return (
<div>
<Layout>
<Layout.Header></Layout.Header>
<Layout.Content>
<div
style={{
justifyContent: 'center',
display: 'flex',
marginTop: 120,
}}
>
<div style={{ width: 500 }}>
<Card>
<Title heading={2} style={{ textAlign: 'center' }}>
{t('用户登录')}
</Title>
<Form>
<Form.Input
field={'username'}
label={t('用户名/邮箱')}
placeholder={t('用户名/邮箱')}
name='username'
onChange={(value) => handleChange('username', value)}
/>
<Form.Input
field={'password'}
label={t('密码')}
placeholder={t('密码')}
name='password'
type='password'
onChange={(value) => handleChange('password', value)}
/>
<Button
theme='solid'
style={{ width: '100%' }}
type={'primary'}
size='large'
htmlType={'submit'}
onClick={handleSubmit}
>
{t('登录')}
</Button>
</Form>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: 20,
}}
>
<Text>
{t('没有账户?')}{' '}
<Link to='/register'>{t('点击注册')}</Link>
</Text>
<Text>
{t('忘记密码?')} <Link to='/reset'>{t('点击重置')}</Link>
</Text>
</div>
{status.github_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.telegram_oauth ||
status.linuxdo_oauth ? (
<>
<Divider margin='12px' align='center'>
{t('第三方登录')}
</Divider>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: 20,
}}
>
{status.github_oauth ? (
<Button
type='primary'
icon={<IconGithubLogo />}
onClick={() =>
onGitHubOAuthClicked(status.github_client_id)
}
/>
) : (
<></>
)}
{status.oidc_enabled ? (
<Button
type='primary'
icon={<OIDCIcon />}
onClick={() =>
onOIDCClicked(
status.oidc_authorization_endpoint,
status.oidc_client_id,
)
}
/>
) : (
<></>
)}
{status.linuxdo_oauth ? (
<Button
icon={<LinuxDoIcon />}
onClick={() =>
onLinuxDOOAuthClicked(status.linuxdo_client_id)
}
/>
) : (
<></>
)}
{status.wechat_login ? (
<Button
type='primary'
style={{ color: 'rgba(var(--semi-green-5), 1)' }}
icon={<Icon svg={<WeChatIcon />} />}
onClick={onWeChatLoginClicked}
/>
) : (
<></>
)}
</div>
{status.telegram_oauth ? (
<>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: 5,
}}
>
<TelegramLoginButton
dataOnauth={onTelegramLoginClicked}
botName={status.telegram_bot_name}
/>
</div>
</>
) : (
<></>
)}
</>
) : (
<></>
)}
<Modal
title={t('微信扫码登录')}
visible={showWeChatLoginModal}
maskClosable={true}
onOk={onSubmitWeChatVerificationCode}
onCancel={() => setShowWeChatLoginModal(false)}
okText={t('登录')}
size={'small'}
centered={true}
>
<div
style={{
display: 'flex',
alignItem: 'center',
flexDirection: 'column',
}}
>
<img src={status.wechat_qrcode} />
</div>
<div style={{ textAlign: 'center' }}>
<p>
{t(
'微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)',
)}
</p>
</div>
<Form size='large'>
<Form.Input
field={'wechat_verification_code'}
placeholder={t('验证码')}
label={t('验证码')}
value={inputs.wechat_verification_code}
onChange={(value) =>
handleChange('wechat_verification_code', value)
}
/>
</Form>
</Modal>
</Card>
{turnstileEnabled ? (
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: 20,
}}
>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
) : (
<></>
)}
</div>
</div>
</Layout.Content>
</Layout>
</div>
);
};
export default LoginForm;
-660
View File
@@ -1,660 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
API,
copy,
isAdmin,
showError,
showSuccess,
timestamp2string,
} from '../helpers';
import {
Banner,
Button,
Form,
ImagePreview,
Layout,
Modal,
Progress,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import { ITEMS_PER_PAGE } from '../constants';
import { useTranslation } from 'react-i18next';
const colors = [
'amber',
'blue',
'cyan',
'green',
'grey',
'indigo',
'light-blue',
'lime',
'orange',
'pink',
'purple',
'red',
'teal',
'violet',
'yellow',
];
const LogsTable = () => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalContent, setModalContent] = useState('');
function renderType(type) {
switch (type) {
case 'IMAGINE':
return (
<Tag color='blue' size='large'>
{t('绘图')}
</Tag>
);
case 'UPSCALE':
return (
<Tag color='orange' size='large'>
{t('放大')}
</Tag>
);
case 'VARIATION':
return (
<Tag color='purple' size='large'>
{t('变换')}
</Tag>
);
case 'HIGH_VARIATION':
return (
<Tag color='purple' size='large'>
{t('强变换')}
</Tag>
);
case 'LOW_VARIATION':
return (
<Tag color='purple' size='large'>
{t('弱变换')}
</Tag>
);
case 'PAN':
return (
<Tag color='cyan' size='large'>
{t('平移')}
</Tag>
);
case 'DESCRIBE':
return (
<Tag color='yellow' size='large'>
{t('图生文')}
</Tag>
);
case 'BLEND':
return (
<Tag color='lime' size='large'>
{t('图混合')}
</Tag>
);
case 'UPLOAD':
return (
<Tag color='blue' size='large'>
上传文件
</Tag>
);
case 'SHORTEN':
return (
<Tag color='pink' size='large'>
{t('缩词')}
</Tag>
);
case 'REROLL':
return (
<Tag color='indigo' size='large'>
{t('重绘')}
</Tag>
);
case 'INPAINT':
return (
<Tag color='violet' size='large'>
{t('局部重绘-提交')}
</Tag>
);
case 'ZOOM':
return (
<Tag color='teal' size='large'>
{t('变焦')}
</Tag>
);
case 'CUSTOM_ZOOM':
return (
<Tag color='teal' size='large'>
{t('自定义变焦-提交')}
</Tag>
);
case 'MODAL':
return (
<Tag color='green' size='large'>
{t('窗口处理')}
</Tag>
);
case 'SWAP_FACE':
return (
<Tag color='light-green' size='large'>
{t('换脸')}
</Tag>
);
default:
return (
<Tag color='white' size='large'>
{t('未知')}
</Tag>
);
}
}
function renderCode(code) {
switch (code) {
case 1:
return (
<Tag color='green' size='large'>
{t('已提交')}
</Tag>
);
case 21:
return (
<Tag color='lime' size='large'>
{t('等待中')}
</Tag>
);
case 22:
return (
<Tag color='orange' size='large'>
{t('重复提交')}
</Tag>
);
case 0:
return (
<Tag color='yellow' size='large'>
{t('未提交')}
</Tag>
);
default:
return (
<Tag color='white' size='large'>
{t('未知')}
</Tag>
);
}
}
function renderStatus(type) {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large'>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large'>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large'>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large'>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large'>
{t('失败')}
</Tag>
);
case 'MODAL':
return (
<Tag color='yellow' size='large'>
{t('窗口等待')}
</Tag>
);
default:
return (
<Tag color='white' size='large'>
{t('未知')}
</Tag>
);
}
}
const renderTimestamp = (timestampInSeconds) => {
const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
const year = date.getFullYear(); // 获取年份
const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
};
// 修改renderDuration函数以包含颜色逻辑
function renderDuration(submit_time, finishTime) {
if (!submit_time || !finishTime) return 'N/A';
const start = new Date(submit_time);
const finish = new Date(finishTime);
const durationMs = finish - start;
const durationSec = (durationMs / 1000).toFixed(1);
const color = durationSec > 60 ? 'red' : 'green';
return (
<Tag color={color} size='large'>
{durationSec} {t('秒')}
</Tag>
);
}
const columns = [
{
title: t('提交时间'),
dataIndex: 'submit_time',
render: (text, record, index) => {
return <div>{renderTimestamp(text / 1000)}</div>;
},
},
{
title: t('花费时间'),
dataIndex: 'finish_time', // 以finish_time作为dataIndex
key: 'finish_time',
render: (finish, record) => {
// 假设record.start_time是存在的,并且finish是完成时间的时间戳
return renderDuration(record.submit_time, finish);
},
},
{
title: t('渠道'),
dataIndex: 'channel_id',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return (
<div>
<Tag
color={colors[parseInt(text) % colors.length]}
size='large'
onClick={() => {
copyText(text); // 假设copyText是用于文本复制的函数
}}
>
{' '}
{text}{' '}
</Tag>
</div>
);
},
},
{
title: t('类型'),
dataIndex: 'action',
render: (text, record, index) => {
return <div>{renderType(text)}</div>;
},
},
{
title: t('任务ID'),
dataIndex: 'mj_id',
render: (text, record, index) => {
return <div>{text}</div>;
},
},
{
title: t('提交结果'),
dataIndex: 'code',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return <div>{renderCode(text)}</div>;
},
},
{
title: t('任务状态'),
dataIndex: 'status',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return <div>{renderStatus(text)}</div>;
},
},
{
title: t('进度'),
dataIndex: 'progress',
render: (text, record, index) => {
return (
<div>
{
// 转换例如100%为数字100,如果text未定义,返回0
<Progress
stroke={
record.status === 'FAILURE'
? 'var(--semi-color-warning)'
: null
}
percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true}
aria-label='drawing progress'
/>
}
</div>
);
},
},
{
title: t('结果图片'),
dataIndex: 'image_url',
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Button
onClick={() => {
setModalImageUrl(text); // 更新图片URL状态
setIsModalOpenurl(true); // 打开模态框
}}
>
{t('查看图片')}
</Button>
);
},
},
{
title: 'Prompt',
dataIndex: 'prompt',
render: (text, record, index) => {
// 如果text未定义,返回替代文本,例如空字符串''或其他
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.Text>
);
},
},
{
title: 'PromptEn',
dataIndex: 'prompt_en',
render: (text, record, index) => {
// 如果text未定义,返回替代文本,例如空字符串''或其他
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.Text>
);
},
},
{
title: t('失败原因'),
dataIndex: 'fail_reason',
render: (text, record, index) => {
// 如果text未定义,返回替代文本,例如空字符串''或其他
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.Text>
);
},
},
];
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
const [logType, setLogType] = useState(0);
const isAdminUser = isAdmin();
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [showBanner, setShowBanner] = useState(false);
// 定义模态框图片URL的状态和更新函数
const [modalImageUrl, setModalImageUrl] = useState('');
let now = new Date();
// 初始化start_timestamp为前一天
const [inputs, setInputs] = useState({
channel_id: '',
mj_id: '',
start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000),
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
});
const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs;
const [stat, setStat] = useState({
quota: 0,
token: 0,
});
const handleInputChange = (value, name) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const setLogsFormat = (logs) => {
for (let i = 0; i < logs.length; i++) {
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
logs[i].key = '' + logs[i].id;
}
// data.key = '' + data.id
setLogs(logs);
setLogCount(logs.length + ITEMS_PER_PAGE);
// console.log(logCount);
};
const loadLogs = async (startIdx) => {
setLoading(true);
let url = '';
let localStartTimestamp = Date.parse(start_timestamp);
let localEndTimestamp = Date.parse(end_timestamp);
if (isAdminUser) {
url = `/api/mj/?p=${startIdx}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
} else {
url = `/api/mj/self/?p=${startIdx}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
}
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setLogsFormat(data);
} else {
let newLogs = [...logs];
newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
setLogsFormat(newLogs);
}
} else {
showError(message);
}
setLoading(false);
};
const pageData = logs.slice(
(activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE,
);
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) {
// In this case we have to load more data and then append them.
loadLogs(page - 1).then((r) => {});
}
};
const refresh = async () => {
// setLoading(true);
setActivePage(1);
await loadLogs(0);
};
const copyText = async (text) => {
if (await copy(text)) {
showSuccess('已复制:' + text);
} else {
// setSearchKeyword(text);
Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
}
};
useEffect(() => {
refresh().then();
}, [logType]);
useEffect(() => {
const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');
if (mjNotifyEnabled !== 'true') {
setShowBanner(true);
}
}, []);
return (
<>
<Layout>
{isAdminUser && showBanner ? (
<Banner
type='info'
description={t(
'当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。',
)}
/>
) : (
<></>
)}
<Form layout='horizontal' style={{ marginTop: 10 }}>
<>
<Form.Input
field='channel_id'
label={t('渠道 ID')}
style={{ width: 176 }}
value={channel_id}
placeholder={t('可选值')}
name='channel_id'
onChange={(value) => handleInputChange(value, 'channel_id')}
/>
<Form.Input
field='mj_id'
label={t('任务 ID')}
style={{ width: 176 }}
value={mj_id}
placeholder={t('可选值')}
name='mj_id'
onChange={(value) => handleInputChange(value, 'mj_id')}
/>
<Form.DatePicker
field='start_timestamp'
label={t('起始时间')}
style={{ width: 272 }}
initValue={start_timestamp}
value={start_timestamp}
type='dateTime'
name='start_timestamp'
onChange={(value) => handleInputChange(value, 'start_timestamp')}
/>
<Form.DatePicker
field='end_timestamp'
fluid
label={t('结束时间')}
style={{ width: 272 }}
initValue={end_timestamp}
value={end_timestamp}
type='dateTime'
name='end_timestamp'
onChange={(value) => handleInputChange(value, 'end_timestamp')}
/>
<Form.Section>
<Button
label={t('查询')}
type='primary'
htmlType='submit'
className='btn-margin-right'
onClick={refresh}
>
{t('查询')}
</Button>
</Form.Section>
</>
</Form>
<Table
style={{ marginTop: 5 }}
columns={columns}
dataSource={pageData}
pagination={{
currentPage: activePage,
pageSize: ITEMS_PER_PAGE,
total: logCount,
pageSizeOpts: [10, 20, 50, 100],
onPageChange: handlePageChange,
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: logCount,
}),
}}
loading={loading}
/>
<Modal
visible={isModalOpen}
onOk={() => setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)}
closable={null}
bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
width={800} // 设置模态框宽度
>
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
</Modal>
<ImagePreview
src={modalImageUrl}
visible={isModalOpenurl}
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/>
</Layout>
</>
);
};
export default LogsTable;
-433
View File
@@ -1,433 +0,0 @@
import React, { useContext, useEffect, useRef, useMemo, useState } from 'react';
import { API, copy, showError, showInfo, showSuccess } from '../helpers';
import { useTranslation } from 'react-i18next';
import {
Banner,
Input,
Layout,
Modal,
Space,
Table,
Tag,
Tooltip,
Popover,
ImagePreview,
Button,
} from '@douyinfe/semi-ui';
import {
IconMore,
IconVerify,
IconUploadError,
IconHelpCircle,
} from '@douyinfe/semi-icons';
import { UserContext } from '../context/User/index.js';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
const ModelPricing = () => {
const { t } = useTranslation();
const [filteredValue, setFilteredValue] = useState([]);
const compositionRef = useRef({ isComposition: false });
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [modalImageUrl, setModalImageUrl] = useState('');
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [selectedGroup, setSelectedGroup] = useState('default');
const rowSelection = useMemo(
() => ({
onChange: (selectedRowKeys, selectedRows) => {
setSelectedRowKeys(selectedRowKeys);
},
}),
[],
);
const handleChange = (value) => {
if (compositionRef.current.isComposition) {
return;
}
const newFilteredValue = value ? [value] : [];
setFilteredValue(newFilteredValue);
};
const handleCompositionStart = () => {
compositionRef.current.isComposition = true;
};
const handleCompositionEnd = (event) => {
compositionRef.current.isComposition = false;
const value = event.target.value;
const newFilteredValue = value ? [value] : [];
setFilteredValue(newFilteredValue);
};
function renderQuotaType(type) {
// Ensure all cases are string literals by adding quotes.
switch (type) {
case 1:
return (
<Tag color='teal' size='large'>
{t('按次计费')}
</Tag>
);
case 0:
return (
<Tag color='violet' size='large'>
{t('按量计费')}
</Tag>
);
default:
return t('未知');
}
}
function renderAvailable(available) {
return available ? (
<Popover
content={
<div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
}
position='top'
key={available}
style={{
backgroundColor: 'rgba(var(--semi-blue-4),1)',
borderColor: 'rgba(var(--semi-blue-4),1)',
color: 'var(--semi-color-white)',
borderWidth: 1,
borderStyle: 'solid',
}}
>
<IconVerify style={{ color: 'green' }} size='large' />
</Popover>
) : null;
}
const columns = [
{
title: t('可用性'),
dataIndex: 'available',
render: (text, record, index) => {
// if record.enable_groups contains selectedGroup, then available is true
return renderAvailable(record.enable_groups.includes(selectedGroup));
},
sorter: (a, b) => {
const aAvailable = a.enable_groups.includes(selectedGroup);
const bAvailable = b.enable_groups.includes(selectedGroup);
return Number(aAvailable) - Number(bAvailable);
},
defaultSortOrder: 'descend',
},
{
title: t('模型名称'),
dataIndex: 'model_name',
render: (text, record, index) => {
return (
<>
<Tag
color='green'
size='large'
onClick={() => {
copyText(text);
}}
>
{text}
</Tag>
</>
);
},
onFilter: (value, record) =>
record.model_name.toLowerCase().includes(value.toLowerCase()),
filteredValue,
},
{
title: t('计费类型'),
dataIndex: 'quota_type',
render: (text, record, index) => {
return renderQuotaType(parseInt(text));
},
sorter: (a, b) => a.quota_type - b.quota_type,
},
{
title: t('可用分组'),
dataIndex: 'enable_groups',
render: (text, record, index) => {
// enable_groups is a string array
return (
<Space>
{text.map((group) => {
if (usableGroup[group]) {
if (group === selectedGroup) {
return (
<Tag color='blue' size='large' prefixIcon={<IconVerify />}>
{group}
</Tag>
);
} else {
return (
<Tag
color='blue'
size='large'
onClick={() => {
setSelectedGroup(group);
showInfo(
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
group: group,
ratio: groupRatio[group],
}),
);
}}
>
{group}
</Tag>
);
}
}
})}
</Space>
);
},
},
{
title: () => (
<span style={{ display: 'flex', alignItems: 'center' }}>
{t('倍率')}
<Popover
content={
<div style={{ padding: 8 }}>
{t('倍率是为了方便换算不同价格的模型')}
<br />
{t('点击查看倍率说明')}
</div>
}
position='top'
style={{
backgroundColor: 'rgba(var(--semi-blue-4),1)',
borderColor: 'rgba(var(--semi-blue-4),1)',
color: 'var(--semi-color-white)',
borderWidth: 1,
borderStyle: 'solid',
}}
>
<IconHelpCircle
onClick={() => {
setModalImageUrl('/ratio.png');
setIsModalOpenurl(true);
}}
/>
</Popover>
</span>
),
dataIndex: 'model_ratio',
render: (text, record, index) => {
let content = text;
let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
content = (
<>
<Text>
{t('模型倍率')}{record.quota_type === 0 ? text : t('无')}
</Text>
<br />
<Text>
{t('补全倍率')}
{record.quota_type === 0 ? completionRatio : t('无')}
</Text>
<br />
<Text>
{t('分组倍率')}{groupRatio[selectedGroup]}
</Text>
</>
);
return <div>{content}</div>;
},
},
{
title: t('模型价格'),
dataIndex: 'model_price',
render: (text, record, index) => {
let content = text;
if (record.quota_type === 0) {
// 这里的 *2 是因为 1倍率=0.002刀,请勿删除
let inputRatioPrice =
record.model_ratio * 2 * groupRatio[selectedGroup];
let completionRatioPrice =
record.model_ratio *
record.completion_ratio *
2 *
groupRatio[selectedGroup];
content = (
<>
<Text>
{t('提示')} ${inputRatioPrice} / 1M tokens
</Text>
<br />
<Text>
{t('补全')} ${completionRatioPrice} / 1M tokens
</Text>
</>
);
} else {
let price = parseFloat(text) * groupRatio[selectedGroup];
content = (
<>
${t('模型价格')}${price}
</>
);
}
return <div>{content}</div>;
},
},
];
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
const [userState, userDispatch] = useContext(UserContext);
const [groupRatio, setGroupRatio] = useState({});
const [usableGroup, setUsableGroup] = useState({});
const setModelsFormat = (models, groupRatio) => {
for (let i = 0; i < models.length; i++) {
models[i].key = models[i].model_name;
models[i].group_ratio = groupRatio[models[i].model_name];
}
// sort by quota_type
models.sort((a, b) => {
return a.quota_type - b.quota_type;
});
// sort by model_name, start with gpt is max, other use localeCompare
models.sort((a, b) => {
if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) {
return -1;
} else if (
!a.model_name.startsWith('gpt') &&
b.model_name.startsWith('gpt')
) {
return 1;
} else {
return a.model_name.localeCompare(b.model_name);
}
});
setModels(models);
};
const loadPricing = async () => {
setLoading(true);
let url = '';
url = `/api/pricing`;
const res = await API.get(url);
const { success, message, data, group_ratio, usable_group } = res.data;
if (success) {
setGroupRatio(group_ratio);
setUsableGroup(usable_group);
setSelectedGroup(userState.user ? userState.user.group : 'default');
setModelsFormat(data, group_ratio);
} else {
showError(message);
}
setLoading(false);
};
const refresh = async () => {
await loadPricing();
};
const copyText = async (text) => {
if (await copy(text)) {
showSuccess('已复制:' + text);
} else {
// setSearchKeyword(text);
Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
}
};
useEffect(() => {
refresh().then();
}, []);
return (
<>
<Layout>
{userState.user ? (
<Banner
type='success'
fullMode={false}
closeIcon='null'
description={t('您的默认分组为:{{group}},分组倍率为:{{ratio}}', {
group: userState.user.group,
ratio: groupRatio[userState.user.group],
})}
/>
) : (
<Banner
type='warning'
fullMode={false}
closeIcon='null'
description={t('您还未登陆,显示的价格为默认分组倍率: {{ratio}}', {
ratio: groupRatio['default'],
})}
/>
)}
<br />
<Banner
type='info'
fullMode={false}
description={
<div>
{t(
'按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)',
)}
</div>
}
closeIcon='null'
/>
<br />
<Space style={{ marginBottom: 16 }}>
<Input
placeholder={t('模糊搜索模型名称')}
style={{ width: 200 }}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onChange={handleChange}
showClear
/>
<Button
theme='light'
type='tertiary'
style={{ width: 150 }}
onClick={() => {
copyText(selectedRowKeys);
}}
disabled={selectedRowKeys == ''}
>
{t('复制选中模型')}
</Button>
</Space>
<Table
style={{ marginTop: 5 }}
columns={columns}
dataSource={models}
loading={loading}
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: models.length,
}),
pageSize: models.length,
showSizeChanger: false,
}}
rowSelection={rowSelection}
/>
<ImagePreview
src={modalImageUrl}
visible={isModalOpenurl}
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/>
</Layout>
</>
);
};
export default ModelPricing;
-113
View File
@@ -1,113 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
import { API, copy, showError, showNotice } from '../helpers';
import { useSearchParams } from 'react-router-dom';
const PasswordResetConfirm = () => {
const [inputs, setInputs] = useState({
email: '',
token: '',
});
const { email, token } = inputs;
const [loading, setLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const [newPassword, setNewPassword] = useState('');
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
let token = searchParams.get('token');
let email = searchParams.get('email');
setInputs({
token,
email,
});
}, []);
useEffect(() => {
let countdownInterval = null;
if (disableButton && countdown > 0) {
countdownInterval = setInterval(() => {
setCountdown(countdown - 1);
}, 1000);
} else if (countdown === 0) {
setDisableButton(false);
setCountdown(30);
}
return () => clearInterval(countdownInterval);
}, [disableButton, countdown]);
async function handleSubmit(e) {
setDisableButton(true);
if (!email) return;
setLoading(true);
const res = await API.post(`/api/user/reset`, {
email,
token,
});
const { success, message } = res.data;
if (success) {
let password = res.data.data;
setNewPassword(password);
await copy(password);
showNotice(`新密码已复制到剪贴板:${password}`);
} else {
showError(message);
}
setLoading(false);
}
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'>
<Image src='/logo.png' /> 密码重置确认
</Header>
<Form size='large'>
<Segment>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder='邮箱地址'
name='email'
value={email}
readOnly
/>
{newPassword && (
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='新密码'
name='newPassword'
value={newPassword}
readOnly
onClick={(e) => {
e.target.select();
navigator.clipboard.writeText(newPassword);
showNotice(`密码已复制到剪贴板:${newPassword}`);
}}
/>
)}
<Button
color='green'
fluid
size='large'
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
>
{disableButton ? `密码重置完成` : '提交'}
</Button>
</Segment>
</Form>
</Grid.Column>
</Grid>
);
};
export default PasswordResetConfirm;
-102
View File
@@ -1,102 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
import { API, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile';
const PasswordResetForm = () => {
const [inputs, setInputs] = useState({
email: '',
});
const { email } = inputs;
const [loading, setLoading] = useState(false);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
useEffect(() => {
let countdownInterval = null;
if (disableButton && countdown > 0) {
countdownInterval = setInterval(() => {
setCountdown(countdown - 1);
}, 1000);
} else if (countdown === 0) {
setDisableButton(false);
setCountdown(30);
}
return () => clearInterval(countdownInterval);
}, [disableButton, countdown]);
function handleChange(e) {
const { name, value } = e.target;
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
async function handleSubmit(e) {
setDisableButton(true);
if (!email) return;
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
return;
}
setLoading(true);
const res = await API.get(
`/api/reset_password?email=${email}&turnstile=${turnstileToken}`,
);
const { success, message } = res.data;
if (success) {
showSuccess('重置邮件发送成功,请检查邮箱!');
setInputs({ ...inputs, email: '' });
} else {
showError(message);
}
setLoading(false);
}
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'>
<Image src='/logo.png' /> 密码重置
</Header>
<Form size='large'>
<Segment>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder='邮箱地址'
name='email'
value={email}
onChange={handleChange}
/>
{turnstileEnabled ? (
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
) : (
<></>
)}
<Button
color='green'
fluid
size='large'
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
>
{disableButton ? `重试 (${countdown})` : '提交'}
</Button>
</Segment>
</Form>
</Grid.Column>
</Grid>
);
};
export default PasswordResetForm;
File diff suppressed because it is too large Load Diff
-12
View File
@@ -1,12 +0,0 @@
import { Navigate } from 'react-router-dom';
import { history } from '../helpers';
function PrivateRoute({ children }) {
if (!localStorage.getItem('user')) {
return <Navigate to='/login' state={{ from: history.location }} />;
}
return children;
}
export { PrivateRoute };
-434
View File
@@ -1,434 +0,0 @@
import React, { useContext, useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
API,
getLogo,
showError,
showInfo,
showSuccess,
updateAPI,
} from '../helpers';
import Turnstile from 'react-turnstile';
import {
Button,
Card,
Divider,
Form,
Icon,
Layout,
Modal,
} from '@douyinfe/semi-ui';
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import { IconGithubLogo } from '@douyinfe/semi-icons';
import {
onGitHubOAuthClicked,
onLinuxDOOAuthClicked,
onOIDCClicked,
} from './utils.js';
import OIDCIcon from './OIDCIcon.js';
import LinuxDoIcon from './LinuxDoIcon.js';
import WeChatIcon from './WeChatIcon.js';
import TelegramLoginButton from 'react-telegram-login/src';
import { setUserData } from '../helpers/data.js';
import { UserContext } from '../context/User/index.js';
import { useTranslation } from 'react-i18next';
const RegisterForm = () => {
const { t } = useTranslation();
const [inputs, setInputs] = useState({
username: '',
password: '',
password2: '',
email: '',
verification_code: '',
});
const { username, password, password2 } = inputs;
const [showEmailVerification, setShowEmailVerification] = useState(false);
const [userState, userDispatch] = useContext(UserContext);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [loading, setLoading] = useState(false);
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
const [status, setStatus] = useState({});
let navigate = useNavigate();
const logo = getLogo();
let affCode = new URLSearchParams(window.location.search).get('aff');
if (affCode) {
localStorage.setItem('aff', affCode);
}
useEffect(() => {
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
setStatus(status);
setShowEmailVerification(status.email_verification);
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
}
});
const onWeChatLoginClicked = () => {
setShowWeChatLoginModal(true);
};
const onSubmitWeChatVerificationCode = async () => {
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
return;
}
const res = await API.get(
`/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
setUserData(data);
updateAPI();
navigate('/');
showSuccess('登录成功!');
setShowWeChatLoginModal(false);
} else {
showError(message);
}
};
function handleChange(name, value) {
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
async function handleSubmit(e) {
if (password.length < 8) {
showInfo('密码长度不得小于 8 位!');
return;
}
if (password !== password2) {
showInfo('两次输入的密码不一致');
return;
}
if (username && password) {
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
return;
}
setLoading(true);
if (!affCode) {
affCode = localStorage.getItem('aff');
}
inputs.aff_code = affCode;
const res = await API.post(
`/api/user/register?turnstile=${turnstileToken}`,
inputs,
);
const { success, message } = res.data;
if (success) {
navigate('/login');
showSuccess('注册成功!');
} else {
showError(message);
}
setLoading(false);
}
}
const sendVerificationCode = async () => {
if (inputs.email === '') return;
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
return;
}
setLoading(true);
const res = await API.get(
`/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
);
const { success, message } = res.data;
if (success) {
showSuccess('验证码发送成功,请检查你的邮箱!');
} else {
showError(message);
}
setLoading(false);
};
const onTelegramLoginClicked = async (response) => {
const fields = [
'id',
'first_name',
'last_name',
'username',
'photo_url',
'auth_date',
'hash',
'lang',
];
const params = {};
fields.forEach((field) => {
if (response[field]) {
params[field] = response[field];
}
});
const res = await API.get(`/api/oauth/telegram/login`, { params });
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
showSuccess('登录成功!');
setUserData(data);
updateAPI();
navigate('/');
} else {
showError(message);
}
};
return (
<div>
<Layout>
<Layout.Header></Layout.Header>
<Layout.Content>
<div
style={{
justifyContent: 'center',
display: 'flex',
marginTop: 120,
}}
>
<div style={{ width: 500 }}>
<Card>
<Title heading={2} style={{ textAlign: 'center' }}>
{t('新用户注册')}
</Title>
<Form size='large'>
<Form.Input
field={'username'}
label={t('用户名')}
placeholder={t('用户名')}
name='username'
onChange={(value) => handleChange('username', value)}
/>
<Form.Input
field={'password'}
label={t('密码')}
placeholder={t('输入密码,最短 8 位,最长 20 位')}
name='password'
type='password'
onChange={(value) => handleChange('password', value)}
/>
<Form.Input
field={'password2'}
label={t('确认密码')}
placeholder={t('确认密码')}
name='password2'
type='password'
onChange={(value) => handleChange('password2', value)}
/>
{showEmailVerification ? (
<>
<Form.Input
field={'email'}
label={t('邮箱')}
placeholder={t('输入邮箱地址')}
onChange={(value) => handleChange('email', value)}
name='email'
type='email'
suffix={
<Button
onClick={sendVerificationCode}
disabled={loading}
>
{t('获取验证码')}
</Button>
}
/>
<Form.Input
field={'verification_code'}
label={t('验证码')}
placeholder={t('输入验证码')}
onChange={(value) =>
handleChange('verification_code', value)
}
name='verification_code'
/>
</>
) : (
<></>
)}
<Button
theme='solid'
style={{ width: '100%' }}
type={'primary'}
size='large'
htmlType={'submit'}
onClick={handleSubmit}
>
{t('注册')}
</Button>
</Form>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginTop: 20,
}}
>
<Text>
{t('已有账户?')}
<Link to='/login'>{t('点击登录')}</Link>
</Text>
</div>
{status.github_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.telegram_oauth ||
status.linuxdo_oauth ? (
<>
<Divider margin='12px' align='center'>
{t('第三方登录')}
</Divider>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: 20,
}}
>
{status.github_oauth ? (
<Button
type='primary'
icon={<IconGithubLogo />}
onClick={() =>
onGitHubOAuthClicked(status.github_client_id)
}
/>
) : (
<></>
)}
{status.oidc_enabled ? (
<Button
type='primary'
icon={<OIDCIcon />}
onClick={() =>
onOIDCClicked(
status.oidc_authorization_endpoint,
status.oidc_client_id,
)
}
/>
) : (
<></>
)}
{status.linuxdo_oauth ? (
<Button
icon={<LinuxDoIcon />}
onClick={() =>
onLinuxDOOAuthClicked(status.linuxdo_client_id)
}
/>
) : (
<></>
)}
{status.wechat_login ? (
<Button
type='primary'
style={{ color: 'rgba(var(--semi-green-5), 1)' }}
icon={<Icon svg={<WeChatIcon />} />}
onClick={onWeChatLoginClicked}
/>
) : (
<></>
)}
</div>
{status.telegram_oauth ? (
<>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: 5,
}}
>
<TelegramLoginButton
dataOnauth={onTelegramLoginClicked}
botName={status.telegram_bot_name}
/>
</div>
</>
) : (
<></>
)}
</>
) : (
<></>
)}
</Card>
<Modal
title={t('微信扫码登录')}
visible={showWeChatLoginModal}
maskClosable={true}
onOk={onSubmitWeChatVerificationCode}
onCancel={() => setShowWeChatLoginModal(false)}
okText={t('登录')}
size={'small'}
centered={true}
>
<div
style={{
display: 'flex',
alignItem: 'center',
flexDirection: 'column',
}}
>
<img src={status.wechat_qrcode} />
</div>
<div style={{ textAlign: 'center' }}>
<p>
{t(
'微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)',
)}
</p>
</div>
<Form size='large'>
<Form.Input
field={'wechat_verification_code'}
placeholder={t('验证码')}
label={t('验证码')}
value={inputs.wechat_verification_code}
onChange={(value) =>
handleChange('wechat_verification_code', value)
}
/>
</Form>
</Modal>
{turnstileEnabled ? (
<div
style={{
display: 'flex',
justifyContent: 'center',
marginTop: 20,
}}
>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
) : (
<></>
)}
</div>
</div>
</Layout.Content>
</Layout>
</div>
);
};
export default RegisterForm;
-535
View File
@@ -1,535 +0,0 @@
import React, { useContext, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { UserContext } from '../context/User';
import { StatusContext } from '../context/Status';
import { useTranslation } from 'react-i18next';
import {
API,
getLogo,
getSystemName,
isAdmin,
isMobile,
showError,
} from '../helpers';
import '../index.css';
import {
IconCalendarClock,
IconChecklistStroked,
IconComment,
IconCommentStroked,
IconCreditCard,
IconGift,
IconHelpCircle,
IconHistogram,
IconHome,
IconImage,
IconKey,
IconLayers,
IconPriceTag,
IconSetting,
IconUser,
} from '@douyinfe/semi-icons';
import {
Avatar,
Dropdown,
Layout,
Nav,
Switch,
Divider,
} from '@douyinfe/semi-ui';
import { setStatusData } from '../helpers/data.js';
import { stringToColor } from '../helpers/render.js';
import { useSetTheme, useTheme } from '../context/Theme/index.js';
import { StyleContext } from '../context/Style/index.js';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
// 自定义侧边栏按钮样式
const navItemStyle = {
borderRadius: '6px',
margin: '4px 8px',
};
// 自定义侧边栏按钮悬停样式
const navItemHoverStyle = {
backgroundColor: 'var(--semi-color-primary-light-default)',
color: 'var(--semi-color-primary)',
};
// 自定义侧边栏按钮选中样式
const navItemSelectedStyle = {
backgroundColor: 'var(--semi-color-primary-light-default)',
color: 'var(--semi-color-primary)',
fontWeight: '600',
};
// 自定义图标样式
const iconStyle = (itemKey, selectedKeys) => {
return {
fontSize: '18px',
color: selectedKeys.includes(itemKey)
? 'var(--semi-color-primary)'
: 'var(--semi-color-text-2)',
};
};
// Define routerMap as a constant outside the component
const routerMap = {
home: '/',
channel: '/channel',
token: '/token',
redemption: '/redemption',
topup: '/topup',
user: '/user',
log: '/log',
midjourney: '/midjourney',
setting: '/setting',
about: '/about',
detail: '/detail',
pricing: '/pricing',
task: '/task',
playground: '/playground',
personal: '/personal',
};
const SiderBar = () => {
const { t } = useTranslation();
const [styleState, styleDispatch] = useContext(StyleContext);
const [statusState, statusDispatch] = useContext(StatusContext);
const defaultIsCollapsed =
localStorage.getItem('default_collapse_sidebar') === 'true';
const [selectedKeys, setSelectedKeys] = useState(['home']);
const [isCollapsed, setIsCollapsed] = useState(defaultIsCollapsed);
const [chatItems, setChatItems] = useState([]);
const [openedKeys, setOpenedKeys] = useState([]);
const theme = useTheme();
const setTheme = useSetTheme();
const location = useLocation();
const [routerMapState, setRouterMapState] = useState(routerMap);
// 预先计算所有可能的图标样式
const allItemKeys = useMemo(() => {
const keys = [
'home',
'channel',
'token',
'redemption',
'topup',
'user',
'log',
'midjourney',
'setting',
'about',
'chat',
'detail',
'pricing',
'task',
'playground',
'personal',
];
// 添加聊天项的keys
for (let i = 0; i < chatItems.length; i++) {
keys.push('chat' + i);
}
return keys;
}, [chatItems]);
// 使用useMemo一次性计算所有图标样式
const iconStyles = useMemo(() => {
const styles = {};
allItemKeys.forEach((key) => {
styles[key] = iconStyle(key, selectedKeys);
});
return styles;
}, [allItemKeys, selectedKeys]);
const workspaceItems = useMemo(
() => [
{
text: t('数据看板'),
itemKey: 'detail',
to: '/detail',
icon: <IconCalendarClock />,
className:
localStorage.getItem('enable_data_export') === 'true'
? ''
: 'tableHiddle',
},
{
text: t('API令牌'),
itemKey: 'token',
to: '/token',
icon: <IconKey />,
},
{
text: t('使用日志'),
itemKey: 'log',
to: '/log',
icon: <IconHistogram />,
},
{
text: t('绘图日志'),
itemKey: 'midjourney',
to: '/midjourney',
icon: <IconImage />,
className:
localStorage.getItem('enable_drawing') === 'true'
? ''
: 'tableHiddle',
},
{
text: t('任务日志'),
itemKey: 'task',
to: '/task',
icon: <IconChecklistStroked />,
className:
localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
},
],
[
localStorage.getItem('enable_data_export'),
localStorage.getItem('enable_drawing'),
localStorage.getItem('enable_task'),
t,
],
);
const financeItems = useMemo(
() => [
{
text: t('钱包'),
itemKey: 'topup',
to: '/topup',
icon: <IconCreditCard />,
},
{
text: t('个人设置'),
itemKey: 'personal',
to: '/personal',
icon: <IconUser />,
},
],
[t],
);
const adminItems = useMemo(
() => [
{
text: t('渠道'),
itemKey: 'channel',
to: '/channel',
icon: <IconLayers />,
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('兑换码'),
itemKey: 'redemption',
to: '/redemption',
icon: <IconGift />,
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('用户管理'),
itemKey: 'user',
to: '/user',
icon: <IconUser />,
},
{
text: t('系统设置'),
itemKey: 'setting',
to: '/setting',
icon: <IconSetting />,
},
],
[isAdmin(), t],
);
const chatMenuItems = useMemo(
() => [
{
text: 'Playground',
itemKey: 'playground',
to: '/playground',
icon: <IconCommentStroked />,
},
{
text: t('聊天'),
itemKey: 'chat',
items: chatItems,
icon: <IconComment />,
},
],
[chatItems, t],
);
// Function to update router map with chat routes
const updateRouterMapWithChats = (chats) => {
const newRouterMap = { ...routerMap };
if (Array.isArray(chats) && chats.length > 0) {
for (let i = 0; i < chats.length; i++) {
newRouterMap['chat' + i] = '/chat/' + i;
}
}
setRouterMapState(newRouterMap);
return newRouterMap;
};
// Update the useEffect for chat items
useEffect(() => {
let chats = localStorage.getItem('chats');
if (chats) {
try {
chats = JSON.parse(chats);
if (Array.isArray(chats)) {
let chatItems = [];
for (let i = 0; i < chats.length; i++) {
let chat = {};
for (let key in chats[i]) {
chat.text = key;
chat.itemKey = 'chat' + i;
chat.to = '/chat/' + i;
}
chatItems.push(chat);
}
setChatItems(chatItems);
// Update router map with chat routes
updateRouterMapWithChats(chats);
}
} catch (e) {
console.error(e);
showError('聊天数据解析失败');
}
}
}, []);
// Update the useEffect for route selection
useEffect(() => {
const currentPath = location.pathname;
let matchingKey = Object.keys(routerMapState).find(
(key) => routerMapState[key] === currentPath,
);
// Handle chat routes
if (!matchingKey && currentPath.startsWith('/chat/')) {
const chatIndex = currentPath.split('/').pop();
if (!isNaN(chatIndex)) {
matchingKey = 'chat' + chatIndex;
} else {
matchingKey = 'chat';
}
}
// If we found a matching key, update the selected keys
if (matchingKey) {
setSelectedKeys([matchingKey]);
}
}, [location.pathname, routerMapState]);
useEffect(() => {
setIsCollapsed(styleState.siderCollapsed);
}, [styleState.siderCollapsed]);
// Custom divider style
const dividerStyle = {
margin: '8px 0',
opacity: 0.6,
};
// Custom group label style
const groupLabelStyle = {
padding: '8px 16px',
color: 'var(--semi-color-text-2)',
fontSize: '12px',
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: '0.5px',
};
return (
<>
<Nav
className='custom-sidebar-nav'
style={{
width: isCollapsed ? '60px' : '200px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
borderRight: '1px solid var(--semi-color-border)',
background: 'var(--semi-color-bg-1)',
borderRadius: styleState.isMobile ? '0' : '0 8px 8px 0',
position: 'relative',
zIndex: 95,
height: '100%',
overflowY: 'auto',
WebkitOverflowScrolling: 'touch', // Improve scrolling on iOS devices
}}
defaultIsCollapsed={
localStorage.getItem('default_collapse_sidebar') === 'true'
}
isCollapsed={isCollapsed}
onCollapseChange={(collapsed) => {
setIsCollapsed(collapsed);
// styleDispatch({ type: 'SET_SIDER', payload: true });
styleDispatch({ type: 'SET_SIDER_COLLAPSED', payload: collapsed });
localStorage.setItem('default_collapse_sidebar', collapsed);
// 确保在收起侧边栏时有选中的项目,避免不必要的计算
if (selectedKeys.length === 0) {
const currentPath = location.pathname;
const matchingKey = Object.keys(routerMapState).find(
(key) => routerMapState[key] === currentPath,
);
if (matchingKey) {
setSelectedKeys([matchingKey]);
} else if (currentPath.startsWith('/chat/')) {
setSelectedKeys(['chat']);
} else {
setSelectedKeys(['detail']); // 默认选中首页
}
}
}}
selectedKeys={selectedKeys}
itemStyle={navItemStyle}
hoverStyle={navItemHoverStyle}
selectedStyle={navItemSelectedStyle}
renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
return (
<Link
style={{ textDecoration: 'none' }}
to={routerMapState[props.itemKey] || routerMap[props.itemKey]}
>
{itemElement}
</Link>
);
}}
onSelect={(key) => {
if (key.itemKey.toString().startsWith('chat')) {
styleDispatch({ type: 'SET_INNER_PADDING', payload: false });
} else {
styleDispatch({ type: 'SET_INNER_PADDING', payload: true });
}
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
if (openedKeys.includes(key.itemKey)) {
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
}
setSelectedKeys([key.itemKey]);
}}
openKeys={openedKeys}
onOpenChange={(data) => {
setOpenedKeys(data.openKeys);
}}
>
{/* Chat Section - Only show if there are chat items */}
{chatMenuItems.map((item) => {
if (item.items && item.items.length > 0) {
return (
<Nav.Sub
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, {
style: iconStyles[item.itemKey],
})}
>
{item.items.map((subItem) => (
<Nav.Item
key={subItem.itemKey}
itemKey={subItem.itemKey}
text={subItem.text}
/>
))}
</Nav.Sub>
);
} else {
return (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, {
style: iconStyles[item.itemKey],
})}
/>
);
}
})}
{/* Divider */}
<Divider style={dividerStyle} />
{/* Workspace Section */}
{!isCollapsed && <Text style={groupLabelStyle}>{t('控制台')}</Text>}
{workspaceItems.map((item) => (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, {
style: iconStyles[item.itemKey],
})}
className={item.className}
/>
))}
{isAdmin() && (
<>
{/* Divider */}
<Divider style={dividerStyle} />
{/* Admin Section */}
{!isCollapsed && <Text style={groupLabelStyle}>{t('管理员')}</Text>}
{adminItems.map((item) => (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, {
style: iconStyles[item.itemKey],
})}
className={item.className}
/>
))}
</>
)}
{/* Divider */}
<Divider style={dividerStyle} />
{/* Finance Management Section */}
{!isCollapsed && <Text style={groupLabelStyle}>{t('个人中心')}</Text>}
{financeItems.map((item) => (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={item.text}
icon={React.cloneElement(item.icon, {
style: iconStyles[item.itemKey],
})}
className={item.className}
/>
))}
<Nav.Footer
style={{
paddingBottom: styleState?.isMobile ? '112px' : '',
}}
collapseButton={true}
collapseText={(collapsed) => {
if (collapsed) {
return t('展开侧边栏');
}
return t('收起侧边栏');
}}
/>
</Nav>
</>
);
};
export default SiderBar;
-512
View File
@@ -1,512 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Label } from 'semantic-ui-react';
import {
API,
copy,
isAdmin,
showError,
showSuccess,
timestamp2string,
} from '../helpers';
import {
Table,
Tag,
Form,
Button,
Layout,
Modal,
Typography,
Progress,
Card,
} from '@douyinfe/semi-ui';
import { ITEMS_PER_PAGE } from '../constants';
const colors = [
'amber',
'blue',
'cyan',
'green',
'grey',
'indigo',
'light-blue',
'lime',
'orange',
'pink',
'purple',
'red',
'teal',
'violet',
'yellow',
];
const renderTimestamp = (timestampInSeconds) => {
const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
const year = date.getFullYear(); // 获取年份
const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
};
function renderDuration(submit_time, finishTime) {
// 确保startTime和finishTime都是有效的时间戳
if (!submit_time || !finishTime) return 'N/A';
// 将时间戳转换为Date对象
const start = new Date(submit_time);
const finish = new Date(finishTime);
// 计算时间差(毫秒)
const durationMs = finish - start;
// 将时间差转换为秒,并保留一位小数
const durationSec = (durationMs / 1000).toFixed(1);
// 设置颜色:大于60秒则为红色,小于等于60秒则为绿色
const color = durationSec > 60 ? 'red' : 'green';
// 返回带有样式的颜色标签
return (
<Tag color={color} size='large'>
{durationSec}
</Tag>
);
}
const LogsTable = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalContent, setModalContent] = useState('');
const isAdminUser = isAdmin();
const columns = [
{
title: '提交时间',
dataIndex: 'submit_time',
render: (text, record, index) => {
return <div>{text ? renderTimestamp(text) : '-'}</div>;
},
},
{
title: '结束时间',
dataIndex: 'finish_time',
render: (text, record, index) => {
return <div>{text ? renderTimestamp(text) : '-'}</div>;
},
},
{
title: '进度',
dataIndex: 'progress',
width: 50,
render: (text, record, index) => {
return (
<div>
{
// 转换例如100%为数字100,如果text未定义,返回0
isNaN(text.replace('%', '')) ? (
text
) : (
<Progress
width={42}
type='circle'
showInfo={true}
percent={Number(text.replace('%', '') || 0)}
aria-label='drawing progress'
/>
)
}
</div>
);
},
},
{
title: '花费时间',
dataIndex: 'finish_time', // 以finish_time作为dataIndex
key: 'finish_time',
render: (finish, record) => {
// 假设record.start_time是存在的,并且finish是完成时间的时间戳
return <>{finish ? renderDuration(record.submit_time, finish) : '-'}</>;
},
},
{
title: '渠道',
dataIndex: 'channel_id',
className: isAdminUser ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return (
<div>
<Tag
color={colors[parseInt(text) % colors.length]}
size='large'
onClick={() => {
copyText(text); // 假设copyText是用于文本复制的函数
}}
>
{' '}
{text}{' '}
</Tag>
</div>
);
},
},
{
title: '平台',
dataIndex: 'platform',
render: (text, record, index) => {
return <div>{renderPlatform(text)}</div>;
},
},
{
title: '类型',
dataIndex: 'action',
render: (text, record, index) => {
return <div>{renderType(text)}</div>;
},
},
{
title: '任务ID(点击查看详情)',
dataIndex: 'task_id',
render: (text, record, index) => {
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
//style={{width: 100}}
onClick={() => {
setModalContent(JSON.stringify(record, null, 2));
setIsModalOpen(true);
}}
>
<div>{text}</div>
</Typography.Text>
);
},
},
{
title: '任务状态',
dataIndex: 'status',
render: (text, record, index) => {
return <div>{renderStatus(text)}</div>;
},
},
{
title: '失败原因',
dataIndex: 'fail_reason',
render: (text, record, index) => {
// 如果text未定义,返回替代文本,例如空字符串''或其他
if (!text) {
return '无';
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.Text>
);
},
},
];
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
const [logType] = useState(0);
let now = new Date();
// 初始化start_timestamp为前一天
let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const [inputs, setInputs] = useState({
channel_id: '',
task_id: '',
start_timestamp: timestamp2string(zeroNow.getTime() / 1000),
end_timestamp: '',
});
const { channel_id, task_id, start_timestamp, end_timestamp } = inputs;
const handleInputChange = (value, name) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const setLogsFormat = (logs) => {
for (let i = 0; i < logs.length; i++) {
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
logs[i].key = '' + logs[i].id;
}
// data.key = '' + data.id
setLogs(logs);
setLogCount(logs.length + ITEMS_PER_PAGE);
// console.log(logCount);
};
const loadLogs = async (startIdx) => {
setLoading(true);
let url = '';
let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000);
let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000);
if (isAdminUser) {
url = `/api/task/?p=${startIdx}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
} else {
url = `/api/task/self?p=${startIdx}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
}
const res = await API.get(url);
let { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setLogsFormat(data);
} else {
let newLogs = [...logs];
newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
setLogsFormat(newLogs);
}
} else {
showError(message);
}
setLoading(false);
};
const pageData = logs.slice(
(activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE,
);
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) {
// In this case we have to load more data and then append them.
loadLogs(page - 1).then((r) => {});
}
};
const refresh = async () => {
// setLoading(true);
setActivePage(1);
await loadLogs(0);
};
const copyText = async (text) => {
if (await copy(text)) {
showSuccess('已复制:' + text);
} else {
// setSearchKeyword(text);
Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
}
};
useEffect(() => {
refresh().then();
}, [logType]);
const renderType = (type) => {
switch (type) {
case 'MUSIC':
return (
<Label basic color='grey'>
{' '}
生成音乐{' '}
</Label>
);
case 'LYRICS':
return (
<Label basic color='pink'>
{' '}
生成歌词{' '}
</Label>
);
default:
return (
<Label basic color='black'>
{' '}
未知{' '}
</Label>
);
}
};
const renderPlatform = (type) => {
switch (type) {
case 'suno':
return (
<Label basic color='green'>
{' '}
Suno{' '}
</Label>
);
default:
return (
<Label basic color='black'>
{' '}
未知{' '}
</Label>
);
}
};
const renderStatus = (type) => {
switch (type) {
case 'SUCCESS':
return (
<Label basic color='green'>
{' '}
成功{' '}
</Label>
);
case 'NOT_START':
return (
<Label basic color='black'>
{' '}
未启动{' '}
</Label>
);
case 'SUBMITTED':
return (
<Label basic color='yellow'>
{' '}
队列中{' '}
</Label>
);
case 'IN_PROGRESS':
return (
<Label basic color='blue'>
{' '}
执行中{' '}
</Label>
);
case 'FAILURE':
return (
<Label basic color='red'>
{' '}
失败{' '}
</Label>
);
case 'QUEUED':
return (
<Label basic color='red'>
{' '}
排队中{' '}
</Label>
);
case 'UNKNOWN':
return (
<Label basic color='red'>
{' '}
未知{' '}
</Label>
);
case '':
return (
<Label basic color='black'>
{' '}
正在提交{' '}
</Label>
);
default:
return (
<Label basic color='black'>
{' '}
未知{' '}
</Label>
);
}
};
return (
<>
<Layout>
<Form layout='horizontal' labelPosition='inset'>
<>
{isAdminUser && (
<Form.Input
field='channel_id'
label='渠道 ID'
style={{ width: '236px', marginBottom: '10px' }}
value={channel_id}
placeholder={'可选值'}
name='channel_id'
onChange={(value) => handleInputChange(value, 'channel_id')}
/>
)}
<Form.Input
field='task_id'
label={'任务 ID'}
style={{ width: '236px', marginBottom: '10px' }}
value={task_id}
placeholder={'可选值'}
name='task_id'
onChange={(value) => handleInputChange(value, 'task_id')}
/>
<Form.DatePicker
field='start_timestamp'
label={'起始时间'}
style={{ width: '236px', marginBottom: '10px' }}
initValue={start_timestamp}
value={start_timestamp}
type='dateTime'
name='start_timestamp'
onChange={(value) => handleInputChange(value, 'start_timestamp')}
/>
<Form.DatePicker
field='end_timestamp'
fluid
label={'结束时间'}
style={{ width: '236px', marginBottom: '10px' }}
initValue={end_timestamp}
value={end_timestamp}
type='dateTime'
name='end_timestamp'
onChange={(value) => handleInputChange(value, 'end_timestamp')}
/>
<Button
label={'查询'}
type='primary'
htmlType='submit'
className='btn-margin-right'
onClick={refresh}
>
查询
</Button>
</>
</Form>
<Card>
<Table
columns={columns}
dataSource={pageData}
pagination={{
currentPage: activePage,
pageSize: ITEMS_PER_PAGE,
total: logCount,
pageSizeOpts: [10, 20, 50, 100],
onPageChange: handlePageChange,
}}
loading={loading}
/>
</Card>
<Modal
visible={isModalOpen}
onOk={() => setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)}
closable={null}
bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
width={800} // 设置模态框宽度
>
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
</Modal>
</Layout>
</>
);
};
export default LogsTable;
-515
View File
@@ -1,515 +0,0 @@
import React, { useEffect, useState } from 'react';
import { API, showError, showSuccess } from '../helpers';
import {
Button,
Form,
Popconfirm,
Space,
Table,
Tag,
Tooltip,
} from '@douyinfe/semi-ui';
import { ITEMS_PER_PAGE } from '../constants';
import { renderGroup, renderNumber, renderQuota } from '../helpers/render';
import AddUser from '../pages/User/AddUser';
import EditUser from '../pages/User/EditUser';
import { useTranslation } from 'react-i18next';
const UsersTable = () => {
const { t } = useTranslation();
function renderRole(role) {
switch (role) {
case 1:
return <Tag size='large'>{t('普通用户')}</Tag>;
case 10:
return (
<Tag color='yellow' size='large'>
{t('管理员')}
</Tag>
);
case 100:
return (
<Tag color='orange' size='large'>
{t('超级管理员')}
</Tag>
);
default:
return (
<Tag color='red' size='large'>
{t('未知身份')}
</Tag>
);
}
}
const columns = [
{
title: 'ID',
dataIndex: 'id',
},
{
title: t('用户名'),
dataIndex: 'username',
},
{
title: t('分组'),
dataIndex: 'group',
render: (text, record, index) => {
return <div>{renderGroup(text)}</div>;
},
},
{
title: t('统计信息'),
dataIndex: 'info',
render: (text, record, index) => {
return (
<div>
<Space spacing={1}>
<Tooltip content={t('剩余额度')}>
<Tag color='white' size='large'>
{renderQuota(record.quota)}
</Tag>
</Tooltip>
<Tooltip content={t('已用额度')}>
<Tag color='white' size='large'>
{renderQuota(record.used_quota)}
</Tag>
</Tooltip>
<Tooltip content={t('调用次数')}>
<Tag color='white' size='large'>
{renderNumber(record.request_count)}
</Tag>
</Tooltip>
</Space>
</div>
);
},
},
{
title: t('邀请信息'),
dataIndex: 'invite',
render: (text, record, index) => {
return (
<div>
<Space spacing={1}>
<Tooltip content={t('邀请人数')}>
<Tag color='white' size='large'>
{renderNumber(record.aff_count)}
</Tag>
</Tooltip>
<Tooltip content={t('邀请总收益')}>
<Tag color='white' size='large'>
{renderQuota(record.aff_history_quota)}
</Tag>
</Tooltip>
<Tooltip content={t('邀请人ID')}>
{record.inviter_id === 0 ? (
<Tag color='white' size='large'>
{t('无')}
</Tag>
) : (
<Tag color='white' size='large'>
{record.inviter_id}
</Tag>
)}
</Tooltip>
</Space>
</div>
);
},
},
{
title: t('角色'),
dataIndex: 'role',
render: (text, record, index) => {
return <div>{renderRole(text)}</div>;
},
},
{
title: t('状态'),
dataIndex: 'status',
render: (text, record, index) => {
return (
<div>
{record.DeletedAt !== null ? (
<Tag color='red'>{t('已注销')}</Tag>
) : (
renderStatus(text)
)}
</div>
);
},
},
{
title: '',
dataIndex: 'operate',
render: (text, record, index) => (
<div>
{record.DeletedAt !== null ? (
<></>
) : (
<>
<Popconfirm
title={t('确定?')}
okType={'warning'}
onConfirm={() => {
manageUser(record.id, 'promote', record);
}}
>
<Button theme='light' type='warning' style={{ marginRight: 1 }}>
{t('提升')}
</Button>
</Popconfirm>
<Popconfirm
title={t('确定?')}
okType={'warning'}
onConfirm={() => {
manageUser(record.id, 'demote', record);
}}
>
<Button
theme='light'
type='secondary'
style={{ marginRight: 1 }}
>
{t('降级')}
</Button>
</Popconfirm>
{record.status === 1 ? (
<Button
theme='light'
type='warning'
style={{ marginRight: 1 }}
onClick={async () => {
manageUser(record.id, 'disable', record);
}}
>
{t('禁用')}
</Button>
) : (
<Button
theme='light'
type='secondary'
style={{ marginRight: 1 }}
onClick={async () => {
manageUser(record.id, 'enable', record);
}}
disabled={record.status === 3}
>
{t('启用')}
</Button>
)}
<Button
theme='light'
type='tertiary'
style={{ marginRight: 1 }}
onClick={() => {
setEditingUser(record);
setShowEditUser(true);
}}
>
{t('编辑')}
</Button>
<Popconfirm
title={t('确定是否要注销此用户?')}
content={t('相当于删除用户,此修改将不可逆')}
okType={'danger'}
position={'left'}
onConfirm={() => {
manageUser(record.id, 'delete', record).then(() => {
removeRecord(record.id);
});
}}
>
<Button theme='light' type='danger' style={{ marginRight: 1 }}>
{t('注销')}
</Button>
</Popconfirm>
</>
)}
</div>
),
},
];
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [searchGroup, setSearchGroup] = useState('');
const [groupOptions, setGroupOptions] = useState([]);
const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);
const [showAddUser, setShowAddUser] = useState(false);
const [showEditUser, setShowEditUser] = useState(false);
const [editingUser, setEditingUser] = useState({
id: undefined,
});
const removeRecord = (key) => {
let newDataSource = [...users];
if (key != null) {
let idx = newDataSource.findIndex((data) => data.id === key);
if (idx > -1) {
// update deletedAt
newDataSource[idx].DeletedAt = new Date();
setUsers(newDataSource);
}
}
};
const setUserFormat = (users) => {
for (let i = 0; i < users.length; i++) {
users[i].key = users[i].id;
}
setUsers(users);
};
const loadUsers = async (startIdx, pageSize) => {
const res = await API.get(`/api/user/?p=${startIdx}&page_size=${pageSize}`);
const { success, message, data } = res.data;
if (success) {
const newPageData = data.items;
setActivePage(data.page);
setUserCount(data.total);
setUserFormat(newPageData);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadUsers(0, pageSize)
.then()
.catch((reason) => {
showError(reason);
});
fetchGroups().then();
}, []);
const manageUser = async (userId, action, record) => {
const res = await API.post('/api/user/manage', {
id: userId,
action,
});
const { success, message } = res.data;
if (success) {
showSuccess('操作成功完成!');
let user = res.data.data;
let newUsers = [...users];
if (action === 'delete') {
} else {
record.status = user.status;
record.role = user.role;
}
setUsers(newUsers);
} else {
showError(message);
}
};
const renderStatus = (status) => {
switch (status) {
case 1:
return <Tag size='large'>{t('已激活')}</Tag>;
case 2:
return (
<Tag size='large' color='red'>
{t('已封禁')}
</Tag>
);
default:
return (
<Tag size='large' color='grey'>
{t('未知状态')}
</Tag>
);
}
};
const searchUsers = async (
startIdx,
pageSize,
searchKeyword,
searchGroup,
) => {
if (searchKeyword === '' && searchGroup === '') {
// if keyword is blank, load files instead.
await loadUsers(startIdx, pageSize);
return;
}
setSearching(true);
const res = await API.get(
`/api/user/search?keyword=${searchKeyword}&group=${searchGroup}&p=${startIdx}&page_size=${pageSize}`,
);
const { success, message, data } = res.data;
if (success) {
const newPageData = data.items;
setActivePage(data.page);
setUserCount(data.total);
setUserFormat(newPageData);
} else {
showError(message);
}
setSearching(false);
};
const handleKeywordChange = async (value) => {
setSearchKeyword(value.trim());
};
const handlePageChange = (page) => {
setActivePage(page);
if (searchKeyword === '' && searchGroup === '') {
loadUsers(page, pageSize).then();
} else {
searchUsers(page, pageSize, searchKeyword, searchGroup).then();
}
};
const closeAddUser = () => {
setShowAddUser(false);
};
const closeEditUser = () => {
setShowEditUser(false);
setEditingUser({
id: undefined,
});
};
const refresh = async () => {
setActivePage(1);
if (searchKeyword === '') {
await loadUsers(activePage, pageSize);
} else {
await searchUsers(activePage, pageSize, searchKeyword, searchGroup);
}
};
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
// add 'all' option
// res.data.data.unshift('all');
if (res === undefined) {
return;
}
setGroupOptions(
res.data.data.map((group) => ({
label: group,
value: group,
})),
);
} catch (error) {
showError(error.message);
}
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('page-size', size + '');
setPageSize(size);
setActivePage(1);
loadUsers(activePage, size)
.then()
.catch((reason) => {
showError(reason);
});
};
return (
<>
<AddUser
refresh={refresh}
visible={showAddUser}
handleClose={closeAddUser}
></AddUser>
<EditUser
refresh={refresh}
visible={showEditUser}
handleClose={closeEditUser}
editingUser={editingUser}
></EditUser>
<Form
onSubmit={() => {
searchUsers(activePage, pageSize, searchKeyword, searchGroup);
}}
labelPosition='left'
>
<div style={{ display: 'flex' }}>
<Space>
<Tooltip
content={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
>
<Form.Input
label={t('搜索关键字')}
icon='search'
field='keyword'
iconPosition='left'
placeholder={t('搜索关键字')}
value={searchKeyword}
loading={searching}
onChange={(value) => handleKeywordChange(value)}
/>
</Tooltip>
<Form.Select
field='group'
label={t('分组')}
optionList={groupOptions}
onChange={(value) => {
setSearchGroup(value);
searchUsers(activePage, pageSize, searchKeyword, value);
}}
/>
<Button
label={t('查询')}
type='primary'
htmlType='submit'
className='btn-margin-right'
>
{t('查询')}
</Button>
<Button
theme='light'
type='primary'
onClick={() => {
setShowAddUser(true);
}}
>
{t('添加用户')}
</Button>
</Space>
</div>
</Form>
<Table
columns={columns}
dataSource={users}
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: users.length,
}),
currentPage: activePage,
pageSize: pageSize,
total: userCount,
pageSizeOpts: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: (size) => {
handlePageSizeChange(size);
},
onPageChange: handlePageChange,
}}
loading={loading}
/>
</>
);
};
export default UsersTable;
+536
View File
@@ -0,0 +1,536 @@
import React, { useContext, useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { UserContext } from '../../context/User/index.js';
import {
API,
getLogo,
showError,
showInfo,
showSuccess,
updateAPI,
getSystemName,
setUserData,
onGitHubOAuthClicked,
onOIDCClicked,
onLinuxDOOAuthClicked
} from '../../helpers/index.js';
import Turnstile from 'react-turnstile';
import {
Button,
Card,
Divider,
Form,
Icon,
Modal,
} from '@douyinfe/semi-ui';
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import TelegramLoginButton from 'react-telegram-login';
import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons';
import OIDCIcon from '../common/logo/OIDCIcon.js';
import WeChatIcon from '../common/logo/WeChatIcon.js';
import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const LoginForm = () => {
const [inputs, setInputs] = useState({
username: '',
password: '',
wechat_verification_code: '',
});
const [searchParams, setSearchParams] = useSearchParams();
const [submitted, setSubmitted] = useState(false);
const { username, password } = inputs;
const [userState, userDispatch] = useContext(UserContext);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
let navigate = useNavigate();
const [status, setStatus] = useState({});
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
const [showEmailLogin, setShowEmailLogin] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false);
const [githubLoading, setGithubLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
const [linuxdoLoading, setLinuxdoLoading] = useState(false);
const [emailLoginLoading, setEmailLoginLoading] = useState(false);
const [loginLoading, setLoginLoading] = useState(false);
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false);
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
const { t } = useTranslation();
const logo = getLogo();
const systemName = getSystemName();
let affCode = new URLSearchParams(window.location.search).get('aff');
if (affCode) {
localStorage.setItem('aff', affCode);
}
useEffect(() => {
if (searchParams.get('expired')) {
showError(t('未登录或登录已过期,请重新登录'));
}
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
setStatus(status);
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
}
}, []);
const onWeChatLoginClicked = () => {
setWechatLoading(true);
setShowWeChatLoginModal(true);
setWechatLoading(false);
};
const onSubmitWeChatVerificationCode = async () => {
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
return;
}
setWechatCodeSubmitLoading(true);
try {
const res = await API.get(
`/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
setUserData(data);
updateAPI();
navigate('/');
showSuccess('登录成功!');
setShowWeChatLoginModal(false);
} else {
showError(message);
}
} catch (error) {
showError('登录失败,请重试');
} finally {
setWechatCodeSubmitLoading(false);
}
};
function handleChange(name, value) {
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
async function handleSubmit(e) {
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
return;
}
setSubmitted(true);
setLoginLoading(true);
try {
if (username && password) {
const res = await API.post(
`/api/user/login?turnstile=${turnstileToken}`,
{
username,
password,
},
);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
setUserData(data);
updateAPI();
showSuccess('登录成功!');
if (username === 'root' && password === '123456') {
Modal.error({
title: '您正在使用默认密码!',
content: '请立刻修改默认密码!',
centered: true,
});
}
navigate('/console');
} else {
showError(message);
}
} else {
showError('请输入用户名和密码!');
}
} catch (error) {
showError('登录失败,请重试');
} finally {
setLoginLoading(false);
}
}
// 添加Telegram登录处理函数
const onTelegramLoginClicked = async (response) => {
const fields = [
'id',
'first_name',
'last_name',
'username',
'photo_url',
'auth_date',
'hash',
'lang',
];
const params = {};
fields.forEach((field) => {
if (response[field]) {
params[field] = response[field];
}
});
try {
const res = await API.get(`/api/oauth/telegram/login`, { params });
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
showSuccess('登录成功!');
setUserData(data);
updateAPI();
navigate('/');
} else {
showError(message);
}
} catch (error) {
showError('登录失败,请重试');
}
};
// 包装的GitHub登录点击处理
const handleGitHubClick = () => {
setGithubLoading(true);
try {
onGitHubOAuthClicked(status.github_client_id);
} finally {
// 由于重定向,这里不会执行到,但为了完整性添加
setTimeout(() => setGithubLoading(false), 3000);
}
};
// 包装的OIDC登录点击处理
const handleOIDCClick = () => {
setOidcLoading(true);
try {
onOIDCClicked(
status.oidc_authorization_endpoint,
status.oidc_client_id
);
} finally {
// 由于重定向,这里不会执行到,但为了完整性添加
setTimeout(() => setOidcLoading(false), 3000);
}
};
// 包装的LinuxDO登录点击处理
const handleLinuxDOClick = () => {
setLinuxdoLoading(true);
try {
onLinuxDOOAuthClicked(status.linuxdo_client_id);
} finally {
// 由于重定向,这里不会执行到,但为了完整性添加
setTimeout(() => setLinuxdoLoading(false), 3000);
}
};
// 包装的邮箱登录选项点击处理
const handleEmailLoginClick = () => {
setEmailLoginLoading(true);
setShowEmailLogin(true);
setEmailLoginLoading(false);
};
// 包装的重置密码点击处理
const handleResetPasswordClick = () => {
setResetPasswordLoading(true);
navigate('/reset');
setResetPasswordLoading(false);
};
// 包装的其他登录选项点击处理
const handleOtherLoginOptionsClick = () => {
setOtherLoginOptionsLoading(true);
setShowEmailLogin(false);
setOtherLoginOptionsLoading(false);
};
const renderOAuthOptions = () => {
return (
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-white'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
</div>
<div className="px-2 py-8">
<div className="space-y-3">
{status.wechat_login && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />}
size="large"
onClick={onWeChatLoginClicked}
loading={wechatLoading}
>
<span className="ml-3">{t('使用 微信 继续')}</span>
</Button>
)}
{status.github_oauth && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<IconGithubLogo size="large" />}
size="large"
onClick={handleGitHubClick}
loading={githubLoading}
>
<span className="ml-3">{t('使用 GitHub 继续')}</span>
</Button>
)}
{status.oidc_enabled && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
size="large"
onClick={handleOIDCClick}
loading={oidcLoading}
>
<span className="ml-3">{t('使用 OIDC 继续')}</span>
</Button>
)}
{status.linuxdo_oauth && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />}
size="large"
onClick={handleLinuxDOClick}
loading={linuxdoLoading}
>
<span className="ml-3">{t('使用 LinuxDO 继续')}</span>
</Button>
)}
{status.telegram_oauth && (
<div className="flex justify-center my-2">
<TelegramLoginButton
dataOnauth={onTelegramLoginClicked}
botName={status.telegram_bot_name}
/>
</div>
)}
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<Button
theme="solid"
type="primary"
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors"
icon={<IconMail size="large" />}
size="large"
onClick={handleEmailLoginClick}
loading={emailLoginLoading}
>
<span className="ml-3">{t('使用 邮箱或用户名 登录')}</span>
</Button>
</div>
<div className="mt-6 text-center text-sm">
<Text>{t('没有账户?')} <Link to="/register" className="text-blue-600 hover:text-blue-800 font-medium">{t('注册')}</Link></Text>
</div>
</div>
</Card>
</div>
</div>
);
};
const renderEmailLoginForm = () => {
return (
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3}>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
</div>
<div className="px-2 py-8">
<Form className="space-y-3">
<Form.Input
field="username"
label={t('用户名或邮箱')}
placeholder={t('请输入您的用户名或邮箱地址')}
name="username"
size="large"
className="!rounded-md"
onChange={(value) => handleChange('username', value)}
prefix={<IconMail />}
/>
<Form.Input
field="password"
label={t('密码')}
placeholder={t('请输入您的密码')}
name="password"
mode="password"
size="large"
className="!rounded-md"
onChange={(value) => handleChange('password', value)}
prefix={<IconLock />}
/>
<div className="space-y-2 pt-2">
<Button
theme="solid"
className="w-full !rounded-full"
type="primary"
htmlType="submit"
size="large"
onClick={handleSubmit}
loading={loginLoading}
>
{t('继续')}
</Button>
<Button
theme="borderless"
type='tertiary'
className="w-full !rounded-full"
size="large"
onClick={handleResetPasswordClick}
loading={resetPasswordLoading}
>
{t('忘记密码?')}
</Button>
</div>
</Form>
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
<>
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<div className="mt-4 text-center">
<Button
theme="outline"
type="tertiary"
className="w-full !rounded-full"
size="large"
onClick={handleOtherLoginOptionsClick}
loading={otherLoginOptionsLoading}
>
{t('其他登录选项')}
</Button>
</div>
</>
)}
<div className="mt-6 text-center text-sm">
<Text>{t('没有账户?')} <Link to="/register" className="text-blue-600 hover:text-blue-800 font-medium">{t('注册')}</Link></Text>
</div>
</div>
</Card>
</div>
</div>
);
};
// 微信登录模态框
const renderWeChatLoginModal = () => {
return (
<Modal
title={t('微信扫码登录')}
visible={showWeChatLoginModal}
maskClosable={true}
onOk={onSubmitWeChatVerificationCode}
onCancel={() => setShowWeChatLoginModal(false)}
okText={t('登录')}
size="small"
centered={true}
okButtonProps={{
loading: wechatCodeSubmitLoading,
}}
>
<div className="flex flex-col items-center">
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" />
</div>
<div className="text-center mb-4">
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
</div>
<Form size="large">
<Form.Input
field="wechat_verification_code"
placeholder={t('验证码')}
label={t('验证码')}
value={inputs.wechat_verification_code}
onChange={(value) => handleChange('wechat_verification_code', value)}
/>
</Form>
</Modal>
);
};
return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
{/* 背景图片容器 - 放大并保持居中 */}
<div
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
{/* 半透明遮罩层 */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
? renderEmailLoginForm()
: renderOAuthOptions()}
{renderWeChatLoginModal()}
{turnstileEnabled && (
<div className="flex justify-center mt-6">
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
</div>
</div>
);
};
export default LoginForm;
@@ -1,9 +1,8 @@
import React, { useContext, useEffect, useState } from 'react';
import { Dimmer, Loader, Segment } from 'semantic-ui-react';
import { Spin, Typography, Space } from '@douyinfe/semi-ui';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { API, showError, showSuccess, updateAPI } from '../helpers';
import { UserContext } from '../context/User';
import { setUserData } from '../helpers/data.js';
import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
import { UserContext } from '../../context/User';
const OAuth2Callback = (props) => {
const [searchParams, setSearchParams] = useSearchParams();
@@ -52,11 +51,15 @@ const OAuth2Callback = (props) => {
}, []);
return (
<Segment style={{ minHeight: '300px' }}>
<Dimmer active inverted>
<Loader size='large'>{prompt}</Loader>
</Dimmer>
</Segment>
<div className="flex items-center justify-center min-h-[300px] w-full bg-white rounded-lg shadow p-6">
<Space vertical align="center">
<Spin size="large" spinning={processing}>
<div className="min-h-[200px] min-w-[200px] flex items-center justify-center">
<Typography.Text type="secondary">{prompt}</Typography.Text>
</div>
</Spin>
</Space>
</div>
);
};
@@ -0,0 +1,155 @@
import React, { useEffect, useState } from 'react';
import { API, copy, showError, showNotice, getLogo, getSystemName } from '../../helpers';
import { useSearchParams, Link } from 'react-router-dom';
import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
import { IconMail, IconLock } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const { Text, Title } = Typography;
const PasswordResetConfirm = () => {
const { t } = useTranslation();
const [inputs, setInputs] = useState({
email: '',
token: '',
});
const { email, token } = inputs;
const [loading, setLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const [newPassword, setNewPassword] = useState('');
const [searchParams, setSearchParams] = useSearchParams();
const logo = getLogo();
const systemName = getSystemName();
useEffect(() => {
let token = searchParams.get('token');
let email = searchParams.get('email');
setInputs({
token,
email,
});
}, []);
useEffect(() => {
let countdownInterval = null;
if (disableButton && countdown > 0) {
countdownInterval = setInterval(() => {
setCountdown(countdown - 1);
}, 1000);
} else if (countdown === 0) {
setDisableButton(false);
setCountdown(30);
}
return () => clearInterval(countdownInterval);
}, [disableButton, countdown]);
async function handleSubmit(e) {
if (!email || !token) return;
setDisableButton(true);
setLoading(true);
const res = await API.post(`/api/user/reset`, {
email,
token,
});
const { success, message } = res.data;
if (success) {
let password = res.data.data;
setNewPassword(password);
await copy(password);
showNotice(`${t('密码已重置并已复制到剪贴板')}: ${password}`);
} else {
showError(message);
}
setLoading(false);
}
return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
{/* 背景图片容器 - 放大并保持居中 */}
<div
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
{/* 半透明遮罩层 */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-white'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置确认')}</Title>
</div>
<div className="px-2 py-8">
<Form className="space-y-3">
<Form.Input
field="email"
label={t('邮箱')}
name="email"
size="large"
className="!rounded-md"
value={email}
readOnly
prefix={<IconMail />}
/>
{newPassword && (
<Form.Input
field="newPassword"
label={t('新密码')}
name="newPassword"
size="large"
className="!rounded-md"
value={newPassword}
readOnly
prefix={<IconLock />}
onClick={(e) => {
e.target.select();
navigator.clipboard.writeText(newPassword);
showNotice(`${t('密码已复制到剪贴板')}: ${newPassword}`);
}}
/>
)}
<div className="space-y-2 pt-2">
<Button
theme="solid"
className="w-full !rounded-full"
type="primary"
htmlType="submit"
size="large"
onClick={handleSubmit}
loading={loading}
disabled={disableButton || newPassword}
>
{newPassword ? t('密码重置完成') : t('提交')}
</Button>
</div>
</Form>
<div className="mt-6 text-center text-sm">
<Text><Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('返回登录')}</Link></Text>
</div>
</div>
</Card>
</div>
</div>
</div>
</div>
);
};
export default PasswordResetConfirm;
@@ -0,0 +1,156 @@
import React, { useEffect, useState } from 'react';
import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../../helpers';
import Turnstile from 'react-turnstile';
import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
import { IconMail } from '@douyinfe/semi-icons';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const { Text, Title } = Typography;
const PasswordResetForm = () => {
const { t } = useTranslation();
const [inputs, setInputs] = useState({
email: '',
});
const { email } = inputs;
const [loading, setLoading] = useState(false);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const logo = getLogo();
const systemName = getSystemName();
useEffect(() => {
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
}
}, []);
useEffect(() => {
let countdownInterval = null;
if (disableButton && countdown > 0) {
countdownInterval = setInterval(() => {
setCountdown(countdown - 1);
}, 1000);
} else if (countdown === 0) {
setDisableButton(false);
setCountdown(30);
}
return () => clearInterval(countdownInterval);
}, [disableButton, countdown]);
function handleChange(value) {
setInputs((inputs) => ({ ...inputs, email: value }));
}
async function handleSubmit(e) {
if (!email) return;
if (turnstileEnabled && turnstileToken === '') {
showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!'));
return;
}
setDisableButton(true);
setLoading(true);
const res = await API.get(
`/api/reset_password?email=${email}&turnstile=${turnstileToken}`,
);
const { success, message } = res.data;
if (success) {
showSuccess(t('重置邮件发送成功,请检查邮箱!'));
setInputs({ ...inputs, email: '' });
} else {
showError(message);
}
setLoading(false);
}
return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
{/* 背景图片容器 - 放大并保持居中 */}
<div
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
{/* 半透明遮罩层 */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-white'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置')}</Title>
</div>
<div className="px-2 py-8">
<Form className="space-y-3">
<Form.Input
field="email"
label={t('邮箱')}
placeholder={t('请输入您的邮箱地址')}
name="email"
size="large"
className="!rounded-md"
value={email}
onChange={handleChange}
prefix={<IconMail />}
/>
<div className="space-y-2 pt-2">
<Button
theme="solid"
className="w-full !rounded-full"
type="primary"
htmlType="submit"
size="large"
onClick={handleSubmit}
loading={loading}
disabled={disableButton}
>
{disableButton ? `${t('重试')} (${countdown})` : t('提交')}
</Button>
</div>
</Form>
<div className="mt-6 text-center text-sm">
<Text>{t('想起来了?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
</div>
</div>
</Card>
{turnstileEnabled && (
<div className="flex justify-center mt-6">
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default PasswordResetForm;
+576
View File
@@ -0,0 +1,576 @@
import React, { useContext, useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
API,
getLogo,
showError,
showInfo,
showSuccess,
updateAPI,
getSystemName,
setUserData
} from '../../helpers/index.js';
import Turnstile from 'react-turnstile';
import {
Button,
Card,
Divider,
Form,
Icon,
Modal,
} from '@douyinfe/semi-ui';
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
import { IconGithubLogo, IconMail, IconUser, IconLock, IconKey } from '@douyinfe/semi-icons';
import {
onGitHubOAuthClicked,
onLinuxDOOAuthClicked,
onOIDCClicked,
} from '../../helpers/index.js';
import OIDCIcon from '../common/logo/OIDCIcon.js';
import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
import WeChatIcon from '../common/logo/WeChatIcon.js';
import TelegramLoginButton from 'react-telegram-login/src';
import { UserContext } from '../../context/User/index.js';
import { useTranslation } from 'react-i18next';
import Background from '/example.png';
const RegisterForm = () => {
const { t } = useTranslation();
const [inputs, setInputs] = useState({
username: '',
password: '',
password2: '',
email: '',
verification_code: '',
wechat_verification_code: '',
});
const { username, password, password2 } = inputs;
const [showEmailVerification, setShowEmailVerification] = useState(false);
const [userState, userDispatch] = useContext(UserContext);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [loading, setLoading] = useState(false);
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
const [showEmailRegister, setShowEmailRegister] = useState(false);
const [status, setStatus] = useState({});
const [wechatLoading, setWechatLoading] = useState(false);
const [githubLoading, setGithubLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
const [linuxdoLoading, setLinuxdoLoading] = useState(false);
const [emailRegisterLoading, setEmailRegisterLoading] = useState(false);
const [registerLoading, setRegisterLoading] = useState(false);
const [verificationCodeLoading, setVerificationCodeLoading] = useState(false);
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false);
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
let navigate = useNavigate();
const logo = getLogo();
const systemName = getSystemName();
let affCode = new URLSearchParams(window.location.search).get('aff');
if (affCode) {
localStorage.setItem('aff', affCode);
}
useEffect(() => {
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
setStatus(status);
setShowEmailVerification(status.email_verification);
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
}
}, []);
const onWeChatLoginClicked = () => {
setWechatLoading(true);
setShowWeChatLoginModal(true);
setWechatLoading(false);
};
const onSubmitWeChatVerificationCode = async () => {
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
return;
}
setWechatCodeSubmitLoading(true);
try {
const res = await API.get(
`/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
setUserData(data);
updateAPI();
navigate('/');
showSuccess('登录成功!');
setShowWeChatLoginModal(false);
} else {
showError(message);
}
} catch (error) {
showError('登录失败,请重试');
} finally {
setWechatCodeSubmitLoading(false);
}
};
function handleChange(name, value) {
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
async function handleSubmit(e) {
if (password.length < 8) {
showInfo('密码长度不得小于 8 位!');
return;
}
if (password !== password2) {
showInfo('两次输入的密码不一致');
return;
}
if (username && password) {
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
return;
}
setRegisterLoading(true);
try {
if (!affCode) {
affCode = localStorage.getItem('aff');
}
inputs.aff_code = affCode;
const res = await API.post(
`/api/user/register?turnstile=${turnstileToken}`,
inputs,
);
const { success, message } = res.data;
if (success) {
navigate('/login');
showSuccess('注册成功!');
} else {
showError(message);
}
} catch (error) {
showError('注册失败,请重试');
} finally {
setRegisterLoading(false);
}
}
}
const sendVerificationCode = async () => {
if (inputs.email === '') return;
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
return;
}
setVerificationCodeLoading(true);
try {
const res = await API.get(
`/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
);
const { success, message } = res.data;
if (success) {
showSuccess('验证码发送成功,请检查你的邮箱!');
} else {
showError(message);
}
} catch (error) {
showError('发送验证码失败,请重试');
} finally {
setVerificationCodeLoading(false);
}
};
const handleGitHubClick = () => {
setGithubLoading(true);
try {
onGitHubOAuthClicked(status.github_client_id);
} finally {
setTimeout(() => setGithubLoading(false), 3000);
}
};
const handleOIDCClick = () => {
setOidcLoading(true);
try {
onOIDCClicked(
status.oidc_authorization_endpoint,
status.oidc_client_id
);
} finally {
setTimeout(() => setOidcLoading(false), 3000);
}
};
const handleLinuxDOClick = () => {
setLinuxdoLoading(true);
try {
onLinuxDOOAuthClicked(status.linuxdo_client_id);
} finally {
setTimeout(() => setLinuxdoLoading(false), 3000);
}
};
const handleEmailRegisterClick = () => {
setEmailRegisterLoading(true);
setShowEmailRegister(true);
setEmailRegisterLoading(false);
};
const handleOtherRegisterOptionsClick = () => {
setOtherRegisterOptionsLoading(true);
setShowEmailRegister(false);
setOtherRegisterOptionsLoading(false);
};
const onTelegramLoginClicked = async (response) => {
const fields = [
'id',
'first_name',
'last_name',
'username',
'photo_url',
'auth_date',
'hash',
'lang',
];
const params = {};
fields.forEach((field) => {
if (response[field]) {
params[field] = response[field];
}
});
try {
const res = await API.get(`/api/oauth/telegram/login`, { params });
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
showSuccess('登录成功!');
setUserData(data);
updateAPI();
navigate('/');
} else {
showError(message);
}
} catch (error) {
showError('登录失败,请重试');
}
};
const renderOAuthOptions = () => {
return (
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-white'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
</div>
<div className="px-2 py-8">
<div className="space-y-3">
{status.wechat_login && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />}
size="large"
onClick={onWeChatLoginClicked}
loading={wechatLoading}
>
<span className="ml-3">{t('使用 微信 继续')}</span>
</Button>
)}
{status.github_oauth && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<IconGithubLogo size="large" />}
size="large"
onClick={handleGitHubClick}
loading={githubLoading}
>
<span className="ml-3">{t('使用 GitHub 继续')}</span>
</Button>
)}
{status.oidc_enabled && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
size="large"
onClick={handleOIDCClick}
loading={oidcLoading}
>
<span className="ml-3">{t('使用 OIDC 继续')}</span>
</Button>
)}
{status.linuxdo_oauth && (
<Button
theme='outline'
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
type="tertiary"
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />}
size="large"
onClick={handleLinuxDOClick}
loading={linuxdoLoading}
>
<span className="ml-3">{t('使用 LinuxDO 继续')}</span>
</Button>
)}
{status.telegram_oauth && (
<div className="flex justify-center my-2">
<TelegramLoginButton
dataOnauth={onTelegramLoginClicked}
botName={status.telegram_bot_name}
/>
</div>
)}
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<Button
theme="solid"
type="primary"
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors"
icon={<IconMail size="large" />}
size="large"
onClick={handleEmailRegisterClick}
loading={emailRegisterLoading}
>
<span className="ml-3">{t('使用 用户名 注册')}</span>
</Button>
</div>
<div className="mt-6 text-center text-sm">
<Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
</div>
</div>
</Card>
</div>
</div>
);
};
const renderEmailRegisterForm = () => {
return (
<div className="flex flex-col items-center">
<div className="w-full max-w-md">
<div className="flex items-center justify-center mb-6 gap-2">
<img src={logo} alt="Logo" className="h-10 rounded-full" />
<Title heading={3} className='!text-white'>{systemName}</Title>
</div>
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
<div className="flex justify-center pt-6 pb-2">
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
</div>
<div className="px-2 py-8">
<Form className="space-y-3">
<Form.Input
field="username"
label={t('用户名')}
placeholder={t('请输入用户名')}
name="username"
size="large"
className="!rounded-md"
onChange={(value) => handleChange('username', value)}
prefix={<IconUser />}
/>
<Form.Input
field="password"
label={t('密码')}
placeholder={t('输入密码,最短 8 位,最长 20 位')}
name="password"
mode="password"
size="large"
className="!rounded-md"
onChange={(value) => handleChange('password', value)}
prefix={<IconLock />}
/>
<Form.Input
field="password2"
label={t('确认密码')}
placeholder={t('确认密码')}
name="password2"
mode="password"
size="large"
className="!rounded-md"
onChange={(value) => handleChange('password2', value)}
prefix={<IconLock />}
/>
{showEmailVerification && (
<>
<Form.Input
field="email"
label={t('邮箱')}
placeholder={t('输入邮箱地址')}
name="email"
type="email"
size="large"
className="!rounded-md"
onChange={(value) => handleChange('email', value)}
prefix={<IconMail />}
suffix={
<Button
onClick={sendVerificationCode}
loading={verificationCodeLoading}
size="small"
className="!rounded-md mr-2"
>
{t('获取验证码')}
</Button>
}
/>
<Form.Input
field="verification_code"
label={t('验证码')}
placeholder={t('输入验证码')}
name="verification_code"
size="large"
className="!rounded-md"
onChange={(value) => handleChange('verification_code', value)}
prefix={<IconKey />}
/>
</>
)}
<div className="space-y-2 pt-2">
<Button
theme="solid"
className="w-full !rounded-full"
type="primary"
htmlType="submit"
size="large"
onClick={handleSubmit}
loading={registerLoading}
>
{t('注册')}
</Button>
</div>
</Form>
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
<>
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<div className="mt-4 text-center">
<Button
theme="outline"
type="tertiary"
className="w-full !rounded-full"
size="large"
onClick={handleOtherRegisterOptionsClick}
loading={otherRegisterOptionsLoading}
>
{t('其他注册选项')}
</Button>
</div>
</>
)}
<div className="mt-6 text-center text-sm">
<Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
</div>
</div>
</Card>
</div>
</div>
);
};
const renderWeChatLoginModal = () => {
return (
<Modal
title={t('微信扫码登录')}
visible={showWeChatLoginModal}
maskClosable={true}
onOk={onSubmitWeChatVerificationCode}
onCancel={() => setShowWeChatLoginModal(false)}
okText={t('登录')}
size="small"
centered={true}
okButtonProps={{
loading: wechatCodeSubmitLoading,
}}
>
<div className="flex flex-col items-center">
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" />
</div>
<div className="text-center mb-4">
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
</div>
<Form size="large">
<Form.Input
field="wechat_verification_code"
placeholder={t('验证码')}
label={t('验证码')}
value={inputs.wechat_verification_code}
onChange={(value) => handleChange('wechat_verification_code', value)}
/>
</Form>
</Modal>
);
};
return (
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
<div
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
style={{
backgroundImage: `url(${Background})`
}}
></div>
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
<div className="w-full max-w-sm relative z-10">
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
? renderEmailRegisterForm()
: renderOAuthOptions()}
{renderWeChatLoginModal()}
{turnstileEnabled && (
<div className="flex justify-center mt-6">
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
</div>
</div>
);
};
export default RegisterForm;
+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import { Spin } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
const Loading = ({ prompt: name = '', size = 'large' }) => {
const { t } = useTranslation();
return (
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000]">
<div className="flex flex-col items-center">
<Spin
size={size}
spinning={true}
tip={null}
/>
<span className="whitespace-nowrap mt-2 text-center" style={{ color: 'var(--semi-color-primary)' }}>
{name ? t('加载{{name}}中...', { name }) : t('加载中...')}
</span>
</div>
</div>
);
};
export default Loading;
@@ -11,8 +11,8 @@ const OIDCIcon = (props) => {
version='1.1'
xmlns='http://www.w3.org/2000/svg'
p-id='10969'
width='1em'
height='1em'
width='20'
height='20'
>
<path
d='M512 960C265 960 64 759 64 512S265 64 512 64s448 201 448 448-201 448-448 448z m0-882.6c-239.7 0-434.6 195-434.6 434.6s195 434.6 434.6 434.6 434.6-195 434.6-434.6S751.7 77.4 512 77.4z'
@@ -11,8 +11,8 @@ const WeChatIcon = () => {
version='1.1'
xmlns='http://www.w3.org/2000/svg'
p-id='5091'
width='16'
height='16'
width='20'
height='20'
>
<path
d='M690.1 377.4c5.9 0 11.8 0.2 17.6 0.5-24.4-128.7-158.3-227.1-319.9-227.1C209 150.8 64 271.4 64 420.2c0 81.1 43.6 154.2 111.9 203.6 5.5 3.9 9.1 10.3 9.1 17.6 0 2.4-0.5 4.6-1.1 6.9-5.5 20.3-14.2 52.8-14.6 54.3-0.7 2.6-1.7 5.2-1.7 7.9 0 5.9 4.8 10.8 10.8 10.8 2.3 0 4.2-0.9 6.2-2l70.9-40.9c5.3-3.1 11-5 17.2-5 3.2 0 6.4 0.5 9.5 1.4 33.1 9.5 68.8 14.8 105.7 14.8 6 0 11.9-0.1 17.8-0.4-7.1-21-10.9-43.1-10.9-66 0-135.8 132.2-245.8 295.3-245.8z m-194.3-86.5c23.8 0 43.2 19.3 43.2 43.1s-19.3 43.1-43.2 43.1c-23.8 0-43.2-19.3-43.2-43.1s19.4-43.1 43.2-43.1z m-215.9 86.2c-23.8 0-43.2-19.3-43.2-43.1s19.3-43.1 43.2-43.1 43.2 19.3 43.2 43.1-19.4 43.1-43.2 43.1z'
@@ -0,0 +1,513 @@
import ReactMarkdown from 'react-markdown';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github.css';
import './markdown.css';
import RemarkMath from 'remark-math';
import RemarkBreaks from 'remark-breaks';
import RehypeKatex from 'rehype-katex';
import RemarkGfm from 'remark-gfm';
import RehypeHighlight from 'rehype-highlight';
import { useRef, useState, useEffect, useMemo } from 'react';
import mermaid from 'mermaid';
import React from 'react';
import { useDebouncedCallback } from 'use-debounce';
import clsx from 'clsx';
import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
import { copy, rehypeSplitWordsIntoSpans } from '../../../helpers';
import { IconCopy } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
});
export function Mermaid(props) {
const ref = useRef(null);
const [hasError, setHasError] = useState(false);
useEffect(() => {
if (props.code && ref.current) {
mermaid
.run({
nodes: [ref.current],
suppressErrors: true,
})
.catch((e) => {
setHasError(true);
console.error('[Mermaid] ', e.message);
});
}
}, [props.code]);
function viewSvgInNewWindow() {
const svg = ref.current?.querySelector('svg');
if (!svg) return;
const text = new XMLSerializer().serializeToString(svg);
const blob = new Blob([text], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
}
if (hasError) {
return null;
}
return (
<div
className={clsx('mermaid-container')}
style={{
cursor: 'pointer',
overflow: 'auto',
padding: '12px',
border: '1px solid var(--semi-color-border)',
borderRadius: '8px',
backgroundColor: 'var(--semi-color-bg-1)',
margin: '12px 0',
}}
ref={ref}
onClick={() => viewSvgInNewWindow()}
>
{props.code}
</div>
);
}
export function PreCode(props) {
const ref = useRef(null);
const [mermaidCode, setMermaidCode] = useState('');
const [htmlCode, setHtmlCode] = useState('');
const { t } = useTranslation();
const renderArtifacts = useDebouncedCallback(() => {
if (!ref.current) return;
const mermaidDom = ref.current.querySelector('code.language-mermaid');
if (mermaidDom) {
setMermaidCode(mermaidDom.innerText);
}
const htmlDom = ref.current.querySelector('code.language-html');
const refText = ref.current.querySelector('code')?.innerText;
if (htmlDom) {
setHtmlCode(htmlDom.innerText);
} else if (
refText?.startsWith('<!DOCTYPE') ||
refText?.startsWith('<svg') ||
refText?.startsWith('<?xml')
) {
setHtmlCode(refText);
}
}, 600);
// 处理代码块的换行
useEffect(() => {
if (ref.current) {
const codeElements = ref.current.querySelectorAll('code');
const wrapLanguages = [
'',
'md',
'markdown',
'text',
'txt',
'plaintext',
'tex',
'latex',
];
codeElements.forEach((codeElement) => {
let languageClass = codeElement.className.match(/language-(\w+)/);
let name = languageClass ? languageClass[1] : '';
if (wrapLanguages.includes(name)) {
codeElement.style.whiteSpace = 'pre-wrap';
}
});
setTimeout(renderArtifacts, 1);
}
}, []);
return (
<>
<pre
ref={ref}
style={{
position: 'relative',
backgroundColor: 'var(--semi-color-fill-0)',
border: '1px solid var(--semi-color-border)',
borderRadius: '6px',
padding: '12px',
margin: '12px 0',
overflow: 'auto',
fontSize: '14px',
lineHeight: '1.4',
}}
>
<div
className="copy-code-button"
style={{
position: 'absolute',
top: '8px',
right: '8px',
display: 'flex',
gap: '4px',
zIndex: 10,
opacity: 0,
transition: 'opacity 0.2s ease',
}}
>
<Tooltip content={t('复制代码')}>
<Button
size="small"
theme="borderless"
icon={<IconCopy />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (ref.current) {
const code = ref.current.querySelector('code')?.innerText ?? '';
copy(code).then((success) => {
if (success) {
Toast.success(t('代码已复制到剪贴板'));
} else {
Toast.error(t('复制失败,请手动复制'));
}
});
}
}}
style={{
padding: '4px',
backgroundColor: 'var(--semi-color-bg-2)',
borderRadius: '4px',
cursor: 'pointer',
border: '1px solid var(--semi-color-border)',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)',
}}
/>
</Tooltip>
</div>
{props.children}
</pre>
{mermaidCode.length > 0 && (
<Mermaid code={mermaidCode} key={mermaidCode} />
)}
{htmlCode.length > 0 && (
<div
style={{
border: '1px solid var(--semi-color-border)',
borderRadius: '8px',
padding: '16px',
margin: '12px 0',
backgroundColor: 'var(--semi-color-bg-1)',
}}
>
<div style={{ marginBottom: '8px', fontSize: '12px', color: 'var(--semi-color-text-2)' }}>
HTML预览:
</div>
<div dangerouslySetInnerHTML={{ __html: htmlCode }} />
</div>
)}
</>
);
}
function CustomCode(props) {
const ref = useRef(null);
const [collapsed, setCollapsed] = useState(true);
const [showToggle, setShowToggle] = useState(false);
const { t } = useTranslation();
useEffect(() => {
if (ref.current) {
const codeHeight = ref.current.scrollHeight;
setShowToggle(codeHeight > 400);
ref.current.scrollTop = ref.current.scrollHeight;
}
}, [props.children]);
const toggleCollapsed = () => {
setCollapsed((collapsed) => !collapsed);
};
const renderShowMoreButton = () => {
if (showToggle && collapsed) {
return (
<div
style={{
position: 'absolute',
bottom: '8px',
right: '8px',
left: '8px',
display: 'flex',
justifyContent: 'center',
}}
>
<Button size="small" onClick={toggleCollapsed} theme="solid">
{t('显示更多')}
</Button>
</div>
);
}
return null;
};
return (
<div style={{ position: 'relative' }}>
<code
className={clsx(props?.className)}
ref={ref}
style={{
maxHeight: collapsed ? '400px' : 'none',
overflowY: 'hidden',
display: 'block',
padding: '8px 12px',
backgroundColor: 'var(--semi-color-fill-0)',
borderRadius: '4px',
fontSize: '13px',
lineHeight: '1.4',
}}
>
{props.children}
</code>
{renderShowMoreButton()}
</div>
);
}
function escapeBrackets(text) {
const pattern =
/(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g;
return text.replace(
pattern,
(match, codeBlock, squareBracket, roundBracket) => {
if (codeBlock) {
return codeBlock;
} else if (squareBracket) {
return `$$${squareBracket}$$`;
} else if (roundBracket) {
return `$${roundBracket}$`;
}
return match;
},
);
}
function tryWrapHtmlCode(text) {
// 尝试包装HTML代码
if (text.includes('```')) {
return text;
}
return text
.replace(
/([`]*?)(\w*?)([\n\r]*?)(<!DOCTYPE html>)/g,
(match, quoteStart, lang, newLine, doctype) => {
return !quoteStart ? '\n```html\n' + doctype : match;
},
)
.replace(
/(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g,
(match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => {
return !quoteEnd ? bodyEnd + space + htmlEnd + '\n```\n' : match;
},
);
}
function _MarkdownContent(props) {
const {
content,
className,
animated = false,
previousContentLength = 0,
} = props;
const escapedContent = useMemo(() => {
return tryWrapHtmlCode(escapeBrackets(content));
}, [content]);
// 判断是否为用户消息
const isUserMessage = className && className.includes('user-message');
const rehypePluginsBase = useMemo(() => {
const base = [
RehypeKatex,
[
RehypeHighlight,
{
detect: false,
ignoreMissing: true,
},
],
];
if (animated) {
base.push([rehypeSplitWordsIntoSpans, { previousContentLength }]);
}
return base;
}, [animated, previousContentLength]);
return (
<ReactMarkdown
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
rehypePlugins={rehypePluginsBase}
components={{
pre: PreCode,
code: CustomCode,
p: (pProps) => <p {...pProps} dir="auto" style={{ lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
a: (aProps) => {
const href = aProps.href || '';
if (/\.(aac|mp3|opus|wav)$/.test(href)) {
return (
<figure style={{ margin: '12px 0' }}>
<audio controls src={href} style={{ width: '100%' }}></audio>
</figure>
);
}
if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
return (
<video controls style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}>
<source src={href} />
</video>
);
}
const isInternal = /^\/#/i.test(href);
const target = isInternal ? '_self' : aProps.target ?? '_blank';
return (
<a
{...aProps}
target={target}
style={{
color: isUserMessage ? '#87CEEB' : 'var(--semi-color-primary)',
textDecoration: 'none',
}}
onMouseEnter={(e) => {
e.target.style.textDecoration = 'underline';
}}
onMouseLeave={(e) => {
e.target.style.textDecoration = 'none';
}}
/>
);
},
h1: (props) => <h1 {...props} style={{ fontSize: '24px', fontWeight: 'bold', margin: '20px 0 12px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h2: (props) => <h2 {...props} style={{ fontSize: '20px', fontWeight: 'bold', margin: '18px 0 10px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h3: (props) => <h3 {...props} style={{ fontSize: '18px', fontWeight: 'bold', margin: '16px 0 8px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h4: (props) => <h4 {...props} style={{ fontSize: '16px', fontWeight: 'bold', margin: '14px 0 6px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h5: (props) => <h5 {...props} style={{ fontSize: '14px', fontWeight: 'bold', margin: '12px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
h6: (props) => <h6 {...props} style={{ fontSize: '13px', fontWeight: 'bold', margin: '10px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
blockquote: (props) => (
<blockquote
{...props}
style={{
borderLeft: isUserMessage ? '4px solid rgba(255, 255, 255, 0.5)' : '4px solid var(--semi-color-primary)',
paddingLeft: '16px',
margin: '12px 0',
backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.1)' : 'var(--semi-color-fill-0)',
padding: '8px 16px',
borderRadius: '0 4px 4px 0',
fontStyle: 'italic',
color: isUserMessage ? 'white' : 'inherit',
}}
/>
),
ul: (props) => <ul {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
ol: (props) => <ol {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
li: (props) => <li {...props} style={{ margin: '4px 0', lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
table: (props) => (
<div style={{ overflow: 'auto', margin: '12px 0' }}>
<table
{...props}
style={{
width: '100%',
borderCollapse: 'collapse',
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
borderRadius: '6px',
overflow: 'hidden',
}}
/>
</div>
),
th: (props) => (
<th
{...props}
style={{
padding: '8px 12px',
backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.2)' : 'var(--semi-color-fill-1)',
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
fontWeight: 'bold',
textAlign: 'left',
color: isUserMessage ? 'white' : 'inherit',
}}
/>
),
td: (props) => (
<td
{...props}
style={{
padding: '8px 12px',
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
color: isUserMessage ? 'white' : 'inherit',
}}
/>
),
}}
>
{escapedContent}
</ReactMarkdown>
);
}
export const MarkdownContent = React.memo(_MarkdownContent);
export function MarkdownRenderer(props) {
const {
content,
loading,
fontSize = 14,
fontFamily = 'inherit',
className,
style,
animated = false,
previousContentLength = 0,
...otherProps
} = props;
return (
<div
className={clsx('markdown-body', className)}
style={{
fontSize: `${fontSize}px`,
fontFamily: fontFamily,
lineHeight: '1.6',
color: 'var(--semi-color-text-0)',
...style,
}}
dir="auto"
{...otherProps}
>
{loading ? (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '16px',
color: 'var(--semi-color-text-2)',
}}>
<div style={{
width: '16px',
height: '16px',
border: '2px solid var(--semi-color-border)',
borderTop: '2px solid var(--semi-color-primary)',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}} />
正在渲染...
</div>
) : (
<MarkdownContent
content={content}
className={className}
animated={animated}
previousContentLength={previousContentLength}
/>
)}
</div>
);
}
export default MarkdownRenderer;
@@ -0,0 +1,444 @@
/* 基础markdown样式 */
.markdown-body {
font-family: inherit;
line-height: 1.6;
color: var(--semi-color-text-0);
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
}
/* 用户消息样式 - 白色字体适配蓝色背景 */
.user-message {
color: white !important;
}
.user-message .markdown-body {
color: white !important;
}
.user-message h1,
.user-message h2,
.user-message h3,
.user-message h4,
.user-message h5,
.user-message h6 {
color: white !important;
}
.user-message p {
color: white !important;
}
.user-message span {
color: white !important;
}
.user-message div {
color: white !important;
}
.user-message li {
color: white !important;
}
.user-message td,
.user-message th {
color: white !important;
}
.user-message blockquote {
color: white !important;
border-left-color: rgba(255, 255, 255, 0.5) !important;
background-color: rgba(255, 255, 255, 0.1) !important;
}
.user-message code:not(pre code) {
color: #000 !important;
background-color: rgba(255, 255, 255, 0.9) !important;
}
.user-message a {
color: #87CEEB !important;
/* 浅蓝色链接 */
}
.user-message a:hover {
color: #B0E0E6 !important;
/* hover时更浅的蓝色 */
}
/* 表格在用户消息中的样式 */
.user-message table {
border-color: rgba(255, 255, 255, 0.3) !important;
}
.user-message th {
background-color: rgba(255, 255, 255, 0.2) !important;
border-color: rgba(255, 255, 255, 0.3) !important;
}
.user-message td {
border-color: rgba(255, 255, 255, 0.3) !important;
}
/* 加载动画 */
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 代码高亮主题 - 适配Semi Design */
.hljs {
display: block;
overflow-x: auto;
padding: 0;
background: transparent;
color: var(--semi-color-text-0);
}
.hljs-comment,
.hljs-quote {
color: var(--semi-color-text-2);
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-subst {
color: var(--semi-color-primary);
font-weight: bold;
}
.hljs-number,
.hljs-literal,
.hljs-variable,
.hljs-template-variable,
.hljs-tag .hljs-attr {
color: var(--semi-color-warning);
}
.hljs-string,
.hljs-doctag {
color: var(--semi-color-success);
}
.hljs-title,
.hljs-section,
.hljs-selector-id {
color: var(--semi-color-primary);
font-weight: bold;
}
.hljs-subst {
font-weight: normal;
}
.hljs-type,
.hljs-class .hljs-title {
color: var(--semi-color-info);
font-weight: bold;
}
.hljs-tag,
.hljs-name,
.hljs-attribute {
color: var(--semi-color-primary);
font-weight: normal;
}
.hljs-regexp,
.hljs-link {
color: var(--semi-color-tertiary);
}
.hljs-symbol,
.hljs-bullet {
color: var(--semi-color-warning);
}
.hljs-built_in,
.hljs-builtin-name {
color: var(--semi-color-info);
}
.hljs-meta {
color: var(--semi-color-text-2);
}
.hljs-deletion {
background: var(--semi-color-danger-light-default);
}
.hljs-addition {
background: var(--semi-color-success-light-default);
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
/* Mermaid容器样式 */
.mermaid-container {
transition: all 0.2s ease;
}
.mermaid-container:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
/* 代码块样式增强 */
pre {
position: relative;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
transition: all 0.2s ease;
}
pre:hover {
border-color: var(--semi-color-primary) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
pre:hover .copy-code-button {
opacity: 1 !important;
}
.copy-code-button {
opacity: 0;
transition: opacity 0.2s ease;
z-index: 10;
pointer-events: auto;
}
.copy-code-button:hover {
opacity: 1 !important;
}
.copy-code-button button {
pointer-events: auto !important;
cursor: pointer !important;
}
/* 确保按钮可点击 */
.copy-code-button .semi-button {
pointer-events: auto !important;
cursor: pointer !important;
transition: all 0.2s ease;
}
.copy-code-button .semi-button:hover {
background-color: var(--semi-color-fill-1) !important;
border-color: var(--semi-color-primary) !important;
transform: scale(1.05);
}
/* 表格响应式 */
@media (max-width: 768px) {
.markdown-body table {
font-size: 12px;
}
.markdown-body th,
.markdown-body td {
padding: 6px 8px;
}
}
/* 数学公式样式 */
.katex {
font-size: 1em;
}
.katex-display {
margin: 1em 0;
text-align: center;
}
/* 链接hover效果 */
.markdown-body a {
transition: all 0.2s ease;
}
/* 引用块样式增强 */
.markdown-body blockquote {
position: relative;
}
.markdown-body blockquote::before {
content: '"';
position: absolute;
left: -8px;
top: -8px;
font-size: 24px;
color: var(--semi-color-primary);
opacity: 0.3;
}
/* 列表样式增强 */
.markdown-body ul li::marker {
color: var(--semi-color-primary);
}
.markdown-body ol li::marker {
color: var(--semi-color-primary);
font-weight: bold;
}
/* 分隔线样式 */
.markdown-body hr {
border: none;
height: 1px;
background: linear-gradient(to right, transparent, var(--semi-color-border), transparent);
margin: 24px 0;
}
/* 图片样式 */
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin: 12px 0;
}
/* 内联代码样式 */
.markdown-body code:not(pre code) {
background-color: var(--semi-color-fill-1);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
color: var(--semi-color-primary);
border: 1px solid var(--semi-color-border);
}
/* 标题锚点样式 */
.markdown-body h1:hover,
.markdown-body h2:hover,
.markdown-body h3:hover,
.markdown-body h4:hover,
.markdown-body h5:hover,
.markdown-body h6:hover {
position: relative;
}
/* 任务列表样式 */
.markdown-body input[type="checkbox"] {
margin-right: 8px;
transform: scale(1.1);
}
.markdown-body li.task-list-item {
list-style: none;
margin-left: -20px;
}
/* 键盘按键样式 */
.markdown-body kbd {
background-color: var(--semi-color-fill-0);
border: 1px solid var(--semi-color-border);
border-radius: 3px;
box-shadow: 0 1px 0 var(--semi-color-border);
color: var(--semi-color-text-0);
display: inline-block;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.85em;
font-weight: 700;
line-height: 1;
padding: 2px 4px;
white-space: nowrap;
}
/* 详情折叠样式 */
.markdown-body details {
border: 1px solid var(--semi-color-border);
border-radius: 6px;
padding: 12px;
margin: 12px 0;
}
.markdown-body summary {
cursor: pointer;
font-weight: bold;
color: var(--semi-color-primary);
margin-bottom: 8px;
}
.markdown-body summary:hover {
color: var(--semi-color-primary-hover);
}
/* 脚注样式 */
.markdown-body .footnote-ref {
color: var(--semi-color-primary);
text-decoration: none;
font-weight: bold;
}
.markdown-body .footnote-ref:hover {
text-decoration: underline;
}
/* 警告块样式 */
.markdown-body .warning {
background-color: var(--semi-color-warning-light-default);
border-left: 4px solid var(--semi-color-warning);
padding: 12px 16px;
margin: 12px 0;
border-radius: 0 6px 6px 0;
}
.markdown-body .info {
background-color: var(--semi-color-info-light-default);
border-left: 4px solid var(--semi-color-info);
padding: 12px 16px;
margin: 12px 0;
border-radius: 0 6px 6px 0;
}
.markdown-body .success {
background-color: var(--semi-color-success-light-default);
border-left: 4px solid var(--semi-color-success);
padding: 12px 16px;
margin: 12px 0;
border-radius: 0 6px 6px 0;
}
.markdown-body .danger {
background-color: var(--semi-color-danger-light-default);
border-left: 4px solid var(--semi-color-danger);
padding: 12px 16px;
margin: 12px 0;
border-radius: 0 6px 6px 0;
}
@keyframes fade-in {
0% {
opacity: 0;
transform: translateY(6px) scale(0.98);
filter: blur(3px);
}
60% {
opacity: 0.85;
filter: blur(0.5px);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
.animate-fade-in {
animation: fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
will-change: opacity, transform;
}
-28
View File
@@ -1,28 +0,0 @@
import { Input, Typography } from '@douyinfe/semi-ui';
import React from 'react';
const TextInput = ({
label,
name,
value,
onChange,
placeholder,
type = 'text',
}) => {
return (
<>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>{label}</Typography.Text>
</div>
<Input
name={name}
placeholder={placeholder}
onChange={(value) => onChange(value)}
value={value}
autoComplete='new-password'
/>
</>
);
};
export default TextInput;
@@ -1,21 +0,0 @@
import { Input, InputNumber, Typography } from '@douyinfe/semi-ui';
import React from 'react';
const TextNumberInput = ({ label, name, value, onChange, placeholder }) => {
return (
<>
<div style={{ marginTop: 10 }}>
<Typography.Text strong>{label}</Typography.Text>
</div>
<InputNumber
name={name}
placeholder={placeholder}
onChange={(value) => onChange(value)}
value={value}
autoComplete='new-password'
/>
</>
);
};
export default TextNumberInput;
-68
View File
@@ -1,68 +0,0 @@
// src/hooks/useTokenKeys.js
import { useEffect, useState } from 'react';
import { API, showError } from '../helpers';
async function fetchTokenKeys() {
try {
const response = await API.get('/api/token/?p=0&size=100');
const { success, data } = response.data;
if (success) {
const activeTokens = data.filter((token) => token.status === 1);
return activeTokens.map((token) => token.key);
} else {
throw new Error('Failed to fetch token keys');
}
} catch (error) {
console.error('Error fetching token keys:', error);
return [];
}
}
function getServerAddress() {
let status = localStorage.getItem('status');
let serverAddress = '';
if (status) {
try {
status = JSON.parse(status);
serverAddress = status.server_address || '';
} catch (error) {
console.error('Failed to parse status from localStorage:', error);
}
}
if (!serverAddress) {
serverAddress = window.location.origin;
}
return serverAddress;
}
export function useTokenKeys(id) {
const [keys, setKeys] = useState([]);
// const [chatLink, setChatLink] = useState('');
const [serverAddress, setServerAddress] = useState('');
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadAllData = async () => {
const fetchedKeys = await fetchTokenKeys();
if (fetchedKeys.length === 0) {
showError('当前没有可用的启用令牌,请确认是否有令牌处于启用状态!');
setTimeout(() => {
window.location.href = '/token';
}, 1500); // 延迟 1.5 秒后跳转
}
setKeys(fetchedKeys);
setIsLoading(false);
// setChatLink(link);
const address = getServerAddress();
setServerAddress(address);
};
loadAllData();
}, []);
return { keys, serverAddress, isLoading };
}
+114
View File
@@ -0,0 +1,114 @@
import React, { useEffect, useState, useMemo, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { Typography } from '@douyinfe/semi-ui';
import { getFooterHTML, getLogo, getSystemName } from '../../helpers';
import { StatusContext } from '../../context/Status';
const FooterBar = () => {
const { t } = useTranslation();
const [footer, setFooter] = useState(getFooterHTML());
const systemName = getSystemName();
const logo = getLogo();
const [statusState] = useContext(StatusContext);
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
const loadFooter = () => {
let footer_html = localStorage.getItem('footer_html');
if (footer_html) {
setFooter(footer_html);
}
};
const currentYear = new Date().getFullYear();
const customFooter = useMemo(() => (
<footer className="relative bg-semi-color-bg-2 h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden">
<div className="absolute hidden md:block top-[204px] left-[-100px] w-[151px] h-[151px] rounded-full bg-[#FFD166]"></div>
<div className="absolute md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60"></div>
{isDemoSiteMode && (
<div className="flex flex-col md:flex-row justify-between w-full max-w-[1110px] mb-10 gap-8">
<div className="flex-shrink-0">
<img
src={logo}
alt={systemName}
className="w-16 h-16 rounded-full bg-gray-800 p-1.5 object-contain"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full">
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('关于我们')}</p>
<div className="flex flex-col gap-4">
<a href="https://docs.newapi.pro/wiki/project-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('关于项目')}</a>
<a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('联系我们')}</a>
<a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('功能特性')}</a>
</div>
</div>
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('文档')}</p>
<div className="flex flex-col gap-4">
<a href="https://docs.newapi.pro/getting-started/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('快速开始')}</a>
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('安装指南')}</a>
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('API 文档')}</a>
</div>
</div>
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('相关项目')}</p>
<div className="flex flex-col gap-4">
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">One API</a>
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">Midjourney-Proxy</a>
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">chatnio</a>
<a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">neko-api-key-tool</a>
</div>
</div>
<div className="text-left">
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('基于New API的项目')}</p>
<div className="flex flex-col gap-4">
<a href="https://github.com/Calcium-Ion/new-api-horizon" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">new-api-horizon</a>
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">VoAPI</a> */}
</div>
</div>
</div>
</div>
)}
<div className="flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-6">
<div className="flex flex-wrap items-center gap-2">
<Typography.Text className="text-sm !text-semi-color-text-1">© {currentYear} {systemName}. {t('版权所有')}</Typography.Text>
</div>
{isDemoSiteMode && (
<div className="text-sm">
<span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
<span className="!text-semi-color-primary">Douyin FE</span>
<span className="!text-semi-color-text-1"> & </span>
<a href="https://github.com/QuantumNous" target="_blank" rel="noreferrer" className="!text-semi-color-primary hover:!text-semi-color-primary-hover transition-colors">QuantumNous</a>
</div>
)}
</div>
</footer>
), [logo, systemName, t, currentYear, isDemoSiteMode]);
useEffect(() => {
loadFooter();
}, []);
return (
<div className="w-full">
{footer ? (
<div
className="custom-footer"
dangerouslySetInnerHTML={{ __html: footer }}
></div>
) : (
customFooter
)}
</div>
);
};
export default FooterBar;
+536
View File
@@ -0,0 +1,536 @@
import React, { useContext, useEffect, useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { UserContext } from '../../context/User/index.js';
import { useSetTheme, useTheme } from '../../context/Theme/index.js';
import { useTranslation } from 'react-i18next';
import { API, getLogo, getSystemName, showSuccess, stringToColor } from '../../helpers/index.js';
import fireworks from 'react-fireworks';
import { CN, GB } from 'country-flag-icons/react/3x2';
import NoticeModal from './NoticeModal.js';
import {
IconClose,
IconMenu,
IconLanguage,
IconChevronDown,
IconSun,
IconMoon,
IconExit,
IconUserSetting,
IconCreditCard,
IconKey,
IconBell,
} from '@douyinfe/semi-icons';
import {
Avatar,
Button,
Dropdown,
Tag,
Typography,
Skeleton,
} from '@douyinfe/semi-ui';
import { StatusContext } from '../../context/Status/index.js';
import { useStyle, styleActions } from '../../context/Style/index.js';
const HeaderBar = () => {
const { t, i18n } = useTranslation();
const [userState, userDispatch] = useContext(UserContext);
const [statusState, statusDispatch] = useContext(StatusContext);
const { state: styleState, dispatch: styleDispatch } = useStyle();
const [isLoading, setIsLoading] = useState(true);
let navigate = useNavigate();
const [currentLang, setCurrentLang] = useState(i18n.language);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const location = useLocation();
const [noticeVisible, setNoticeVisible] = useState(false);
const systemName = getSystemName();
const logo = getLogo();
const currentDate = new Date();
const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1;
const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false;
const docsLink = statusState?.status?.docs_link || '';
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
const theme = useTheme();
const setTheme = useSetTheme();
const mainNavLinks = [
{
text: t('首页'),
itemKey: 'home',
to: '/',
},
{
text: t('控制台'),
itemKey: 'console',
to: '/console',
},
{
text: t('定价'),
itemKey: 'pricing',
to: '/pricing',
},
...(docsLink
? [
{
text: t('文档'),
itemKey: 'docs',
isExternal: true,
externalLink: docsLink,
},
]
: []),
{
text: t('关于'),
itemKey: 'about',
to: '/about',
},
];
async function logout() {
await API.get('/api/user/logout');
showSuccess(t('注销成功!'));
userDispatch({ type: 'logout' });
localStorage.removeItem('user');
navigate('/login');
setMobileMenuOpen(false);
}
const handleNewYearClick = () => {
fireworks.init('root', {});
fireworks.start();
setTimeout(() => {
fireworks.stop();
}, 3000);
};
useEffect(() => {
if (theme === 'dark') {
document.body.setAttribute('theme-mode', 'dark');
document.documentElement.classList.add('dark');
} else {
document.body.removeAttribute('theme-mode');
document.documentElement.classList.remove('dark');
}
const iframe = document.querySelector('iframe');
if (iframe) {
iframe.contentWindow.postMessage({ themeMode: theme }, '*');
}
}, [theme, isNewYear]);
useEffect(() => {
const handleLanguageChanged = (lng) => {
setCurrentLang(lng);
const iframe = document.querySelector('iframe');
if (iframe) {
iframe.contentWindow.postMessage({ lang: lng }, '*');
}
};
i18n.on('languageChanged', handleLanguageChanged);
return () => {
i18n.off('languageChanged', handleLanguageChanged);
};
}, [i18n]);
useEffect(() => {
const timer = setTimeout(() => {
setIsLoading(false);
}, 500);
return () => clearTimeout(timer);
}, []);
const handleLanguageChange = (lang) => {
i18n.changeLanguage(lang);
setMobileMenuOpen(false);
};
const handleNavLinkClick = (itemKey) => {
if (itemKey === 'home') {
styleDispatch(styleActions.setSider(false));
}
setMobileMenuOpen(false);
};
const renderNavLinks = (isMobileView = false, isLoading = false) => {
if (isLoading) {
const skeletonLinkClasses = isMobileView
? 'flex items-center gap-1 p-3 w-full rounded-md'
: 'flex items-center gap-1 p-2 rounded-md';
return Array(4)
.fill(null)
.map((_, index) => (
<div key={index} className={skeletonLinkClasses}>
<Skeleton.Title style={{ width: isMobileView ? 100 : 60, height: 16 }} />
</div>
));
}
return mainNavLinks.map((link) => {
const commonLinkClasses = isMobileView
? 'flex items-center gap-1 p-3 w-full text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors font-semibold'
: 'flex items-center gap-1 p-2 text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors rounded-md font-semibold';
const linkContent = (
<span>{link.text}</span>
);
if (link.isExternal) {
return (
<a
key={link.itemKey}
href={link.externalLink}
target='_blank'
rel='noopener noreferrer'
className={commonLinkClasses}
onClick={() => handleNavLinkClick(link.itemKey)}
>
{linkContent}
</a>
);
}
let targetPath = link.to;
if (link.itemKey === 'console' && !userState.user) {
targetPath = '/login';
}
return (
<Link
key={link.itemKey}
to={targetPath}
className={commonLinkClasses}
onClick={() => handleNavLinkClick(link.itemKey)}
>
{linkContent}
</Link>
);
});
};
const renderUserArea = () => {
if (isLoading) {
return (
<div className="flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1">
<Skeleton.Avatar size="extra-small" className="shadow-sm" />
<div className="ml-1.5 mr-1">
<Skeleton.Title style={{ width: styleState.isMobile ? 15 : 50, height: 12 }} />
</div>
</div>
);
}
if (userState.user) {
return (
<Dropdown
position="bottomRight"
render={
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
<Dropdown.Item
onClick={() => {
navigate('/console/personal');
setMobileMenuOpen(false);
}}
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
>
<div className="flex items-center gap-2">
<IconUserSetting size="small" className="text-gray-500 dark:text-gray-400" />
<span>{t('个人设置')}</span>
</div>
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
navigate('/console/token');
setMobileMenuOpen(false);
}}
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
>
<div className="flex items-center gap-2">
<IconKey size="small" className="text-gray-500 dark:text-gray-400" />
<span>{t('API令牌')}</span>
</div>
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
navigate('/console/topup');
setMobileMenuOpen(false);
}}
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
>
<div className="flex items-center gap-2">
<IconCreditCard size="small" className="text-gray-500 dark:text-gray-400" />
<span>{t('钱包')}</span>
</div>
</Dropdown.Item>
<Dropdown.Item onClick={logout} className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white">
<div className="flex items-center gap-2">
<IconExit size="small" className="text-gray-500 dark:text-gray-400" />
<span>{t('退出')}</span>
</div>
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button
theme="borderless"
type="tertiary"
className="flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
>
<Avatar
size="extra-small"
color={stringToColor(userState.user.username)}
className="mr-1"
>
{userState.user.username[0].toUpperCase()}
</Avatar>
<span className="hidden md:inline">
<Typography.Text className="!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1">
{userState.user.username}
</Typography.Text>
</span>
<IconChevronDown className="text-xs text-semi-color-text-2 dark:text-gray-400" />
</Button>
</Dropdown>
);
} else {
const showRegisterButton = !isSelfUseMode;
const commonSizingAndLayoutClass = "flex items-center justify-center !py-[10px] !px-1.5";
const loginButtonSpecificStyling = "!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors";
let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`;
let registerButtonClasses = `${commonSizingAndLayoutClass}`;
const loginButtonTextSpanClass = "!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5";
const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5";
if (showRegisterButton) {
if (styleState.isMobile) {
loginButtonClasses += " !rounded-full";
} else {
loginButtonClasses += " !rounded-l-full !rounded-r-none";
}
registerButtonClasses += " !rounded-r-full !rounded-l-none";
} else {
loginButtonClasses += " !rounded-full";
}
return (
<div className="flex items-center">
<Link to="/login" onClick={() => handleNavLinkClick('login')} className="flex">
<Button
theme="borderless"
type="tertiary"
className={loginButtonClasses}
>
<span className={loginButtonTextSpanClass}>
{t('登录')}
</span>
</Button>
</Link>
{showRegisterButton && (
<div className="hidden md:block">
<Link to="/register" onClick={() => handleNavLinkClick('register')} className="flex -ml-px">
<Button
theme="solid"
type="primary"
className={registerButtonClasses}
>
<span className={registerButtonTextSpanClass}>
{t('注册')}
</span>
</Button>
</Link>
</div>
)}
</div>
);
}
};
// 检查当前路由是否以/console开头
const isConsoleRoute = location.pathname.startsWith('/console');
return (
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
<NoticeModal
visible={noticeVisible}
onClose={() => setNoticeVisible(false)}
isMobile={styleState.isMobile}
/>
<div className="w-full px-4">
<div className="flex items-center justify-between h-16">
<div className="flex items-center">
<div className="md:hidden">
<Button
icon={
isConsoleRoute
? (styleState.showSider ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
: (mobileMenuOpen ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
}
aria-label={
isConsoleRoute
? (styleState.showSider ? t('关闭侧边栏') : t('打开侧边栏'))
: (mobileMenuOpen ? t('关闭菜单') : t('打开菜单'))
}
onClick={() => {
if (isConsoleRoute) {
// 控制侧边栏的显示/隐藏,无论是否移动设备
styleDispatch(styleActions.toggleSider());
} else {
// 控制HeaderBar自己的移动菜单
setMobileMenuOpen(!mobileMenuOpen);
}
}}
theme="borderless"
type="tertiary"
className="!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700"
/>
</div>
<Link to="/" onClick={() => handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2">
{isLoading ? (
<Skeleton.Image className="h-7 md:h-8 !rounded-full" style={{ width: 32, height: 32 }} />
) : (
<img src={logo} alt="logo" className="h-7 md:h-8 transition-transform duration-300 ease-in-out group-hover:scale-105 rounded-full" />
)}
<div className="hidden md:flex items-center gap-2">
<div className="flex items-center gap-2">
{isLoading ? (
<Skeleton.Title style={{ width: 120, height: 24 }} />
) : (
<Typography.Title heading={4} className="!text-lg !font-semibold !mb-0
bg-gradient-to-r from-blue-500 to-purple-500 dark:from-blue-400 dark:to-purple-400
bg-clip-text text-transparent">
{systemName}
</Typography.Title>
)}
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
<Tag
color={isSelfUseMode ? 'purple' : 'blue'}
className="text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm"
size="small"
shape='circle'
>
{isSelfUseMode ? t('自用模式') : t('演示站点')}
</Tag>
)}
</div>
</div>
</Link>
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
<div className="md:hidden">
<Tag
color={isSelfUseMode ? 'purple' : 'blue'}
className="ml-2 text-xs px-1 py-0.5 rounded whitespace-nowrap shadow-sm"
size="small"
shape='circle'
>
{isSelfUseMode ? t('自用模式') : t('演示站点')}
</Tag>
</div>
)}
<nav className="hidden md:flex items-center gap-1 lg:gap-2 ml-6">
{renderNavLinks(false, isLoading)}
</nav>
</div>
<div className="flex items-center gap-2 md:gap-3">
{isNewYear && (
<Dropdown
position="bottomRight"
render={
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
<Dropdown.Item onClick={handleNewYearClick} className="!text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-gray-600">
Happy New Year!!! 🎉
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button
theme="borderless"
type="tertiary"
icon={<span className="text-xl">🎉</span>}
aria-label="New Year"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full"
/>
</Dropdown>
)}
<Button
icon={<IconBell className="text-lg" />}
aria-label={t('系统公告')}
onClick={() => setNoticeVisible(true)}
theme="borderless"
type="tertiary"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
/>
<Button
icon={theme === 'dark' ? <IconSun size="large" className="text-yellow-500" /> : <IconMoon size="large" className="text-gray-300" />}
aria-label={t('切换主题')}
onClick={() => setTheme(theme === 'dark' ? false : true)}
theme="borderless"
type="tertiary"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
/>
<Dropdown
position="bottomRight"
render={
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
<Dropdown.Item
onClick={() => handleLanguageChange('zh')}
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
>
<CN title="中文" className="!w-5 !h-auto" />
<span>中文</span>
</Dropdown.Item>
<Dropdown.Item
onClick={() => handleLanguageChange('en')}
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'en' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
>
<GB title="English" className="!w-5 !h-auto" />
<span>English</span>
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button
icon={<IconLanguage className="text-lg" />}
aria-label={t('切换语言')}
theme="borderless"
type="tertiary"
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
/>
</Dropdown>
{renderUserArea()}
</div>
</div>
</div>
<div className="md:hidden">
<div
className={`
absolute top-16 left-0 right-0 bg-semi-color-bg-0
shadow-lg p-3
transform transition-all duration-300 ease-in-out
${(!isConsoleRoute && mobileMenuOpen) ? 'translate-y-0 opacity-100 visible' : '-translate-y-4 opacity-0 invisible'}
`}
>
<nav className="flex flex-col gap-1">
{renderNavLinks(true, isLoading)}
</nav>
</div>
</div>
</header>
);
};
export default HeaderBar;
+94
View File
@@ -0,0 +1,94 @@
import React, { useEffect, useState } from 'react';
import { Button, Modal, Empty } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { API, showError } from '../../helpers';
import { marked } from 'marked';
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
const NoticeModal = ({ visible, onClose, isMobile }) => {
const { t } = useTranslation();
const [noticeContent, setNoticeContent] = useState('');
const [loading, setLoading] = useState(false);
const handleCloseTodayNotice = () => {
const today = new Date().toDateString();
localStorage.setItem('notice_close_date', today);
onClose();
};
const displayNotice = async () => {
setLoading(true);
try {
const res = await API.get('/api/notice');
const { success, message, data } = res.data;
if (success) {
if (data !== '') {
const htmlNotice = marked.parse(data);
setNoticeContent(htmlNotice);
} else {
setNoticeContent('');
}
} else {
showError(message);
}
} catch (error) {
showError(error.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (visible) {
displayNotice();
}
}, [visible]);
const renderContent = () => {
if (loading) {
return <div className="py-12"><Empty description={t('加载中...')} /></div>;
}
if (!noticeContent) {
return (
<div className="py-12">
<Empty
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
description={t('暂无公告')}
/>
</div>
);
}
return (
<div
dangerouslySetInnerHTML={{ __html: noticeContent }}
className="max-h-[60vh] overflow-y-auto pr-2"
style={{
scrollbarWidth: 'thin',
scrollbarColor: 'var(--semi-color-tertiary) transparent'
}}
/>
);
};
return (
<Modal
title={t('系统公告')}
visible={visible}
onCancel={onClose}
footer={(
<div className="flex justify-end">
<Button type='secondary' className='!rounded-full' onClick={handleCloseTodayNotice}>{t('今日关闭')}</Button>
<Button type="primary" className='!rounded-full' onClick={onClose}>{t('关闭公告')}</Button>
</div>
)}
size={isMobile ? 'full-width' : 'large'}
>
{renderContent()}
</Modal>
);
};
export default NoticeModal;
@@ -1,23 +1,30 @@
import HeaderBar from './HeaderBar.js';
import { Layout } from '@douyinfe/semi-ui';
import SiderBar from './SiderBar.js';
import App from '../App.js';
import App from '../../App.js';
import FooterBar from './Footer.js';
import { ToastContainer } from 'react-toastify';
import React, { useContext, useEffect } from 'react';
import { StyleContext } from '../context/Style/index.js';
import { useStyle } from '../../context/Style/index.js';
import { useTranslation } from 'react-i18next';
import { API, getLogo, getSystemName, showError } from '../helpers/index.js';
import { setStatusData } from '../helpers/data.js';
import { UserContext } from '../context/User/index.js';
import { StatusContext } from '../context/Status/index.js';
import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js';
import { UserContext } from '../../context/User/index.js';
import { StatusContext } from '../../context/Status/index.js';
import { useLocation } from 'react-router-dom';
const { Sider, Content, Header, Footer } = Layout;
const PageLayout = () => {
const [userState, userDispatch] = useContext(UserContext);
const [statusState, statusDispatch] = useContext(StatusContext);
const [styleState, styleDispatch] = useContext(StyleContext);
const { state: styleState } = useStyle();
const { i18n } = useTranslation();
const location = useLocation();
const shouldHideFooter = location.pathname === '/console/playground' || location.pathname.startsWith('/console/chat');
const shouldInnerPadding = location.pathname.includes('/console') &&
!location.pathname.startsWith('/console/chat') &&
location.pathname !== '/console/playground';
const loadUser = () => {
let user = localStorage.getItem('user');
@@ -61,15 +68,8 @@ const PageLayout = () => {
if (savedLang) {
i18n.changeLanguage(savedLang);
}
// 默认显示侧边栏
styleDispatch({ type: 'SET_SIDER', payload: true });
}, [i18n]);
// 获取侧边栏折叠状态
const isSidebarCollapsed =
localStorage.getItem('default_collapse_sidebar') === 'true';
return (
<Layout
style={{
@@ -84,19 +84,18 @@ const PageLayout = () => {
padding: 0,
height: 'auto',
lineHeight: 'normal',
position: styleState.isMobile ? 'sticky' : 'fixed',
position: 'fixed',
width: '100%',
top: 0,
zIndex: 100,
boxShadow: '0 1px 6px rgba(0, 0, 0, 0.08)',
}}
>
<HeaderBar />
</Header>
<Layout
style={{
marginTop: styleState.isMobile ? '0' : '56px',
height: styleState.isMobile ? 'auto' : 'calc(100vh - 56px)',
marginTop: '64px',
height: 'calc(100vh - 64px)',
overflow: styleState.isMobile ? 'visible' : 'auto',
display: 'flex',
flexDirection: 'column',
@@ -107,13 +106,11 @@ const PageLayout = () => {
style={{
position: 'fixed',
left: 0,
top: '56px',
top: '64px',
zIndex: 99,
background: 'var(--semi-color-bg-1)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
border: 'none',
paddingRight: '0',
height: 'calc(100vh - 56px)',
height: 'calc(100vh - 64px)',
}}
>
<SiderBar />
@@ -126,7 +123,7 @@ const PageLayout = () => {
: styleState.showSider
? styleState.siderCollapsed
? '60px'
: '200px'
: '180px'
: '0',
transition: 'margin-left 0.3s ease',
flex: '1 1 auto',
@@ -139,21 +136,23 @@ const PageLayout = () => {
flex: '1 0 auto',
overflowY: styleState.isMobile ? 'visible' : 'auto',
WebkitOverflowScrolling: 'touch',
padding: styleState.shouldInnerPadding ? '24px' : '0',
padding: shouldInnerPadding ? '24px' : '0',
position: 'relative',
marginTop: styleState.isMobile ? '2px' : '0',
}}
>
<App />
</Content>
<Layout.Footer
style={{
flex: '0 0 auto',
width: '100%',
}}
>
<FooterBar />
</Layout.Footer>
{!shouldHideFooter && (
<Layout.Footer
style={{
flex: '0 0 auto',
width: '100%',
}}
>
<FooterBar />
</Layout.Footer>
)}
</Layout>
</Layout>
<ToastContainer />
@@ -1,6 +1,6 @@
import React, { useContext, useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { StatusContext } from '../context/Status';
import { StatusContext } from '../../context/Status';
const SetupCheck = ({ children }) => {
const [statusState] = useContext(StatusContext);
+448
View File
@@ -0,0 +1,448 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js';
import { ChevronLeft } from 'lucide-react';
import { useStyle, styleActions } from '../../context/Style/index.js';
import {
isAdmin,
isRoot,
showError
} from '../../helpers/index.js';
import {
Nav,
Divider,
Tooltip,
} from '@douyinfe/semi-ui';
const routerMap = {
home: '/',
channel: '/console/channel',
token: '/console/token',
redemption: '/console/redemption',
topup: '/console/topup',
user: '/console/user',
log: '/console/log',
midjourney: '/console/midjourney',
setting: '/console/setting',
about: '/about',
detail: '/console',
pricing: '/pricing',
task: '/console/task',
playground: '/console/playground',
personal: '/console/personal',
};
const SiderBar = () => {
const { t } = useTranslation();
const { state: styleState, dispatch: styleDispatch } = useStyle();
const [selectedKeys, setSelectedKeys] = useState(['home']);
const [isCollapsed, setIsCollapsed] = useState(styleState.siderCollapsed);
const [chatItems, setChatItems] = useState([]);
const [openedKeys, setOpenedKeys] = useState([]);
const location = useLocation();
const [routerMapState, setRouterMapState] = useState(routerMap);
const workspaceItems = useMemo(
() => [
{
text: t('数据看板'),
itemKey: 'detail',
to: '/detail',
className:
localStorage.getItem('enable_data_export') === 'true'
? ''
: 'tableHiddle',
},
{
text: t('API令牌'),
itemKey: 'token',
to: '/token',
},
{
text: t('使用日志'),
itemKey: 'log',
to: '/log',
},
{
text: t('绘图日志'),
itemKey: 'midjourney',
to: '/midjourney',
className:
localStorage.getItem('enable_drawing') === 'true'
? ''
: 'tableHiddle',
},
{
text: t('任务日志'),
itemKey: 'task',
to: '/task',
className:
localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
},
],
[
localStorage.getItem('enable_data_export'),
localStorage.getItem('enable_drawing'),
localStorage.getItem('enable_task'),
t,
],
);
const financeItems = useMemo(
() => [
{
text: t('钱包'),
itemKey: 'topup',
to: '/topup',
},
{
text: t('个人设置'),
itemKey: 'personal',
to: '/personal',
},
],
[t],
);
const adminItems = useMemo(
() => [
{
text: t('渠道'),
itemKey: 'channel',
to: '/channel',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('兑换码'),
itemKey: 'redemption',
to: '/redemption',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('用户管理'),
itemKey: 'user',
to: '/user',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('系统设置'),
itemKey: 'setting',
to: '/setting',
className: isRoot() ? '' : 'tableHiddle',
},
],
[isAdmin(), isRoot(), t],
);
const chatMenuItems = useMemo(
() => [
{
text: t('操练场'),
itemKey: 'playground',
to: '/playground',
},
{
text: t('聊天'),
itemKey: 'chat',
items: chatItems,
},
],
[chatItems, t],
);
// 更新路由映射,添加聊天路由
const updateRouterMapWithChats = (chats) => {
const newRouterMap = { ...routerMap };
if (Array.isArray(chats) && chats.length > 0) {
for (let i = 0; i < chats.length; i++) {
newRouterMap['chat' + i] = '/console/chat/' + i;
}
}
setRouterMapState(newRouterMap);
return newRouterMap;
};
// 加载聊天项
useEffect(() => {
let chats = localStorage.getItem('chats');
if (chats) {
try {
chats = JSON.parse(chats);
if (Array.isArray(chats)) {
let chatItems = [];
for (let i = 0; i < chats.length; i++) {
let chat = {};
for (let key in chats[i]) {
chat.text = key;
chat.itemKey = 'chat' + i;
chat.to = '/console/chat/' + i;
}
chatItems.push(chat);
}
setChatItems(chatItems);
updateRouterMapWithChats(chats);
}
} catch (e) {
console.error(e);
showError('聊天数据解析失败');
}
}
}, []);
// 根据当前路径设置选中的菜单项
useEffect(() => {
const currentPath = location.pathname;
let matchingKey = Object.keys(routerMapState).find(
(key) => routerMapState[key] === currentPath,
);
// 处理聊天路由
if (!matchingKey && currentPath.startsWith('/console/chat/')) {
const chatIndex = currentPath.split('/').pop();
if (!isNaN(chatIndex)) {
matchingKey = 'chat' + chatIndex;
} else {
matchingKey = 'chat';
}
}
// 如果找到匹配的键,更新选中的键
if (matchingKey) {
setSelectedKeys([matchingKey]);
}
}, [location.pathname, routerMapState]);
// 同步折叠状态
useEffect(() => {
setIsCollapsed(styleState.siderCollapsed);
}, [styleState.siderCollapsed]);
// 获取菜单项对应的颜色
const getItemColor = (itemKey) => {
switch (itemKey) {
case 'detail': return sidebarIconColors.dashboard;
case 'playground': return sidebarIconColors.terminal;
case 'chat': return sidebarIconColors.message;
case 'token': return sidebarIconColors.key;
case 'log': return sidebarIconColors.chart;
case 'midjourney': return sidebarIconColors.image;
case 'task': return sidebarIconColors.check;
case 'topup': return sidebarIconColors.credit;
case 'channel': return sidebarIconColors.layers;
case 'redemption': return sidebarIconColors.gift;
case 'user':
case 'personal': return sidebarIconColors.user;
case 'setting': return sidebarIconColors.settings;
default:
// 处理聊天项
if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message;
return 'currentColor';
}
};
// 渲染自定义菜单项
const renderNavItem = (item) => {
// 跳过隐藏的项目
if (item.className === 'tableHiddle') return null;
const isSelected = selectedKeys.includes(item.itemKey);
const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
return (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={
<div className="flex items-center">
<span className="truncate font-medium text-sm" style={{ color: textColor }}>
{item.text}
</span>
</div>
}
icon={
<div className="sidebar-icon-container flex-shrink-0">
{getLucideIcon(item.itemKey, isSelected)}
</div>
}
className={item.className}
/>
);
};
// 渲染子菜单项
const renderSubItem = (item) => {
if (item.items && item.items.length > 0) {
const isSelected = selectedKeys.includes(item.itemKey);
const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
return (
<Nav.Sub
key={item.itemKey}
itemKey={item.itemKey}
text={
<div className="flex items-center">
<span className="truncate font-medium text-sm" style={{ color: textColor }}>
{item.text}
</span>
</div>
}
icon={
<div className="sidebar-icon-container flex-shrink-0">
{getLucideIcon(item.itemKey, isSelected)}
</div>
}
>
{item.items.map((subItem) => {
const isSubSelected = selectedKeys.includes(subItem.itemKey);
const subTextColor = isSubSelected ? getItemColor(subItem.itemKey) : 'inherit';
return (
<Nav.Item
key={subItem.itemKey}
itemKey={subItem.itemKey}
text={
<span className="truncate font-medium text-sm" style={{ color: subTextColor }}>
{subItem.text}
</span>
}
/>
);
})}
</Nav.Sub>
);
} else {
return renderNavItem(item);
}
};
return (
<div
className="sidebar-container"
style={{ width: isCollapsed ? '60px' : '180px' }}
>
<Nav
className="sidebar-nav custom-sidebar-nav"
defaultIsCollapsed={styleState.siderCollapsed}
isCollapsed={isCollapsed}
onCollapseChange={(collapsed) => {
setIsCollapsed(collapsed);
styleDispatch(styleActions.setSiderCollapsed(collapsed));
// 确保在收起侧边栏时有选中的项目
if (selectedKeys.length === 0) {
const currentPath = location.pathname;
const matchingKey = Object.keys(routerMapState).find(
(key) => routerMapState[key] === currentPath,
);
if (matchingKey) {
setSelectedKeys([matchingKey]);
} else if (currentPath.startsWith('/console/chat/')) {
setSelectedKeys(['chat']);
} else {
setSelectedKeys(['detail']); // 默认选中首页
}
}
}}
selectedKeys={selectedKeys}
itemStyle="sidebar-nav-item"
hoverStyle="sidebar-nav-item:hover"
selectedStyle="sidebar-nav-item-selected"
renderWrapper={({ itemElement, props }) => {
const to = routerMapState[props.itemKey] || routerMap[props.itemKey];
// 如果没有路由,直接返回元素
if (!to) return itemElement;
return (
<Link
style={{ textDecoration: 'none' }}
to={to}
>
{itemElement}
</Link>
);
}}
onSelect={(key) => {
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
if (openedKeys.includes(key.itemKey)) {
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
}
setSelectedKeys([key.itemKey]);
}}
openKeys={openedKeys}
onOpenChange={(data) => {
setOpenedKeys(data.openKeys);
}}
>
{/* 聊天区域 */}
<div className="sidebar-section">
{!isCollapsed && (
<div className="sidebar-group-label">{t('聊天')}</div>
)}
{chatMenuItems.map((item) => renderSubItem(item))}
</div>
{/* 控制台区域 */}
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
<div className="sidebar-group-label">{t('控制台')}</div>
)}
{workspaceItems.map((item) => renderNavItem(item))}
</div>
{/* 管理员区域 - 只在管理员时显示 */}
{isAdmin() && (
<>
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
<div className="sidebar-group-label">{t('管理员')}</div>
)}
{adminItems.map((item) => renderNavItem(item))}
</div>
</>
)}
{/* 个人中心区域 */}
<Divider className="sidebar-divider" />
<div>
{!isCollapsed && (
<div className="sidebar-group-label">{t('个人中心')}</div>
)}
{financeItems.map((item) => renderNavItem(item))}
</div>
</Nav>
{/* 底部折叠按钮 */}
<div
className="sidebar-collapse-button"
onClick={() => {
const newCollapsed = !isCollapsed;
setIsCollapsed(newCollapsed);
styleDispatch(styleActions.setSiderCollapsed(newCollapsed));
}}
>
<Tooltip content={isCollapsed ? t('展开侧边栏') : t('收起侧边栏')} position="right">
<div className="sidebar-collapse-button-inner">
<span
className="sidebar-collapse-icon-container"
style={{ transform: isCollapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
<ChevronLeft size={16} strokeWidth={2.5} color="var(--semi-color-text-2)" />
</span>
</div>
</Tooltip>
</div>
</div>
);
};
export default SiderBar;
+113
View File
@@ -0,0 +1,113 @@
import React from 'react';
import {
Card,
Chat,
Typography,
Button,
} from '@douyinfe/semi-ui';
import {
MessageSquare,
Eye,
EyeOff,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import CustomInputRender from './CustomInputRender';
const ChatArea = ({
chatRef,
message,
inputs,
styleState,
showDebugPanel,
roleInfo,
onMessageSend,
onMessageCopy,
onMessageReset,
onMessageDelete,
onStopGenerator,
onClearMessages,
onToggleDebugPanel,
renderCustomChatContent,
renderChatBoxAction,
}) => {
const { t } = useTranslation();
const renderInputArea = React.useCallback((props) => {
return <CustomInputRender {...props} />;
}, []);
return (
<Card
className="h-full"
bordered={false}
bodyStyle={{ padding: 0, height: 'calc(100vh - 66px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
{/* 聊天头部 */}
{styleState.isMobile ? (
<div className="pt-4"></div>
) : (
<div className="px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center">
<MessageSquare size={20} className="text-white" />
</div>
<div>
<Typography.Title heading={5} className="!text-white mb-0">
{t('AI 对话')}
</Typography.Title>
<Typography.Text className="!text-white/80 text-sm hidden sm:inline">
{inputs.model || t('选择模型开始对话')}
</Typography.Text>
</div>
</div>
<div className="flex items-center gap-2">
<Button
icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />}
onClick={onToggleDebugPanel}
theme="borderless"
type="primary"
size="small"
className="!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10"
>
{showDebugPanel ? t('隐藏调试') : t('显示调试')}
</Button>
</div>
</div>
</div>
)}
{/* 聊天内容区域 */}
<div className="flex-1 overflow-hidden">
<Chat
ref={chatRef}
chatBoxRenderConfig={{
renderChatBoxContent: renderCustomChatContent,
renderChatBoxAction: renderChatBoxAction,
renderChatBoxTitle: () => null,
}}
renderInputArea={renderInputArea}
roleConfig={roleInfo}
style={{
height: '100%',
maxWidth: '100%',
overflow: 'hidden'
}}
chats={message}
onMessageSend={onMessageSend}
onMessageCopy={onMessageCopy}
onMessageReset={onMessageReset}
onMessageDelete={onMessageDelete}
showClearContext
showStopGenerate
onStopGenerator={onStopGenerator}
onClear={onClearMessages}
className="h-full"
placeholder={t('请输入您的问题...')}
/>
</div>
</Card>
);
};
export default ChatArea;
+313
View File
@@ -0,0 +1,313 @@
import React, { useState, useMemo, useCallback } from 'react';
import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
import { Copy, ChevronDown, ChevronUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { copy } from '../../helpers';
const PERFORMANCE_CONFIG = {
MAX_DISPLAY_LENGTH: 50000, // 最大显示字符数
PREVIEW_LENGTH: 5000, // 预览长度
VERY_LARGE_MULTIPLIER: 2, // 超大内容倍数
};
const codeThemeStyles = {
container: {
backgroundColor: '#1e1e1e',
color: '#d4d4d4',
fontFamily: 'Consolas, "Courier New", Monaco, "SF Mono", monospace',
fontSize: '13px',
lineHeight: '1.4',
borderRadius: '8px',
border: '1px solid #3c3c3c',
position: 'relative',
overflow: 'hidden',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
},
content: {
height: '100%',
overflowY: 'auto',
overflowX: 'auto',
padding: '16px',
margin: 0,
whiteSpace: 'pre',
wordBreak: 'normal',
background: '#1e1e1e',
},
actionButton: {
position: 'absolute',
zIndex: 10,
backgroundColor: 'rgba(45, 45, 45, 0.9)',
border: '1px solid rgba(255, 255, 255, 0.1)',
color: '#d4d4d4',
borderRadius: '6px',
transition: 'all 0.2s ease',
},
actionButtonHover: {
backgroundColor: 'rgba(60, 60, 60, 0.95)',
borderColor: 'rgba(255, 255, 255, 0.2)',
transform: 'scale(1.05)',
},
noContent: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#666',
fontSize: '14px',
fontStyle: 'italic',
backgroundColor: 'var(--semi-color-fill-0)',
borderRadius: '8px',
},
performanceWarning: {
padding: '8px 12px',
backgroundColor: 'rgba(255, 193, 7, 0.1)',
border: '1px solid rgba(255, 193, 7, 0.3)',
borderRadius: '6px',
color: '#ffc107',
fontSize: '12px',
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '8px',
},
};
const highlightJson = (str) => {
return str.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
(match) => {
let color = '#b5cea8';
if (/^"/.test(match)) {
color = /:$/.test(match) ? '#9cdcfe' : '#ce9178';
} else if (/true|false|null/.test(match)) {
color = '#569cd6';
}
return `<span style="color: ${color}">${match}</span>`;
}
);
};
const isJsonLike = (content, language) => {
if (language === 'json') return true;
const trimmed = content.trim();
return (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'));
};
const formatContent = (content) => {
if (!content) return '';
if (typeof content === 'object') {
try {
return JSON.stringify(content, null, 2);
} catch (e) {
return String(content);
}
}
if (typeof content === 'string') {
try {
const parsed = JSON.parse(content);
return JSON.stringify(parsed, null, 2);
} catch (e) {
return content;
}
}
return String(content);
};
const CodeViewer = ({ content, title, language = 'json' }) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const [isHoveringCopy, setIsHoveringCopy] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const formattedContent = useMemo(() => formatContent(content), [content]);
const contentMetrics = useMemo(() => {
const length = formattedContent.length;
const isLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH;
const isVeryLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH * PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER;
return { length, isLarge, isVeryLarge };
}, [formattedContent.length]);
const displayContent = useMemo(() => {
if (!contentMetrics.isLarge || isExpanded) {
return formattedContent;
}
return formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) +
'\n\n// ... 内容被截断以提升性能 ...';
}, [formattedContent, contentMetrics.isLarge, isExpanded]);
const highlightedContent = useMemo(() => {
if (contentMetrics.isVeryLarge && !isExpanded) {
return displayContent;
}
if (isJsonLike(displayContent, language)) {
return highlightJson(displayContent);
}
return displayContent;
}, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]);
const handleCopy = useCallback(async () => {
try {
const textToCopy = typeof content === 'object' && content !== null
? JSON.stringify(content, null, 2)
: content;
const success = await copy(textToCopy);
setCopied(true);
Toast.success(t('已复制到剪贴板'));
setTimeout(() => setCopied(false), 2000);
if (!success) {
throw new Error('Copy operation failed');
}
} catch (err) {
Toast.error(t('复制失败'));
console.error('Copy failed:', err);
}
}, [content, t]);
const handleToggleExpand = useCallback(() => {
if (contentMetrics.isVeryLarge && !isExpanded) {
setIsProcessing(true);
setTimeout(() => {
setIsExpanded(true);
setIsProcessing(false);
}, 100);
} else {
setIsExpanded(!isExpanded);
}
}, [isExpanded, contentMetrics.isVeryLarge]);
if (!content) {
const placeholderText = {
preview: t('正在构造请求体预览...'),
request: t('暂无请求数据'),
response: t('暂无响应数据')
}[title] || t('暂无数据');
return (
<div style={codeThemeStyles.noContent}>
<span>{placeholderText}</span>
</div>
);
}
const warningTop = contentMetrics.isLarge ? '52px' : '12px';
const contentPadding = contentMetrics.isLarge ? '52px' : '16px';
return (
<div style={codeThemeStyles.container} className="h-full">
{/* 性能警告 */}
{contentMetrics.isLarge && (
<div style={codeThemeStyles.performanceWarning}>
<span></span>
<span>
{contentMetrics.isVeryLarge
? t('内容较大,已启用性能优化模式')
: t('内容较大,部分功能可能受限')}
</span>
</div>
)}
{/* 复制按钮 */}
<div
style={{
...codeThemeStyles.actionButton,
...(isHoveringCopy ? codeThemeStyles.actionButtonHover : {}),
top: warningTop,
right: '12px',
}}
onMouseEnter={() => setIsHoveringCopy(true)}
onMouseLeave={() => setIsHoveringCopy(false)}
>
<Tooltip content={copied ? t('已复制') : t('复制代码')}>
<Button
icon={<Copy size={14} />}
onClick={handleCopy}
size="small"
theme="borderless"
style={{
backgroundColor: 'transparent',
border: 'none',
color: copied ? '#4ade80' : '#d4d4d4',
padding: '6px',
}}
/>
</Tooltip>
</div>
{/* 代码内容 */}
<div
style={{
...codeThemeStyles.content,
paddingTop: contentPadding,
}}
className="model-settings-scroll"
>
{isProcessing ? (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '200px',
color: '#888'
}}>
<div style={{
width: '20px',
height: '20px',
border: '2px solid #444',
borderTop: '2px solid #888',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginRight: '8px'
}} />
{t('正在处理大内容...')}
</div>
) : (
<div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
)}
</div>
{/* 展开/收起按钮 */}
{contentMetrics.isLarge && !isProcessing && (
<div style={{
...codeThemeStyles.actionButton,
bottom: '12px',
left: '50%',
transform: 'translateX(-50%)',
}}>
<Tooltip content={isExpanded ? t('收起内容') : t('显示完整内容')}>
<Button
icon={isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
onClick={handleToggleExpand}
size="small"
theme="borderless"
style={{
backgroundColor: 'transparent',
border: 'none',
color: '#d4d4d4',
padding: '6px 12px',
}}
>
{isExpanded ? t('收起') : t('展开')}
{!isExpanded && (
<span style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}>
(+{Math.round((contentMetrics.length - PERFORMANCE_CONFIG.PREVIEW_LENGTH) / 1000)}K)
</span>
)}
</Button>
</Tooltip>
</div>
)}
</div>
);
};
export default CodeViewer;
@@ -0,0 +1,260 @@
import React, { useRef } from 'react';
import {
Button,
Typography,
Toast,
Modal,
Dropdown,
} from '@douyinfe/semi-ui';
import {
Download,
Upload,
RotateCcw,
Settings2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { exportConfig, importConfig, clearConfig, hasStoredConfig, getConfigTimestamp } from './configStorage';
const ConfigManager = ({
currentConfig,
onConfigImport,
onConfigReset,
styleState,
messages,
}) => {
const { t } = useTranslation();
const fileInputRef = useRef(null);
const handleExport = () => {
try {
// 在导出前先保存当前配置,确保导出的是最新内容
const configWithTimestamp = {
...currentConfig,
timestamp: new Date().toISOString(),
};
localStorage.setItem('playground_config', JSON.stringify(configWithTimestamp));
exportConfig(currentConfig, messages);
Toast.success({
content: t('配置已导出到下载文件夹'),
duration: 3,
});
} catch (error) {
Toast.error({
content: t('导出配置失败: ') + error.message,
duration: 3,
});
}
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (event) => {
const file = event.target.files[0];
if (!file) return;
try {
const importedConfig = await importConfig(file);
Modal.confirm({
title: t('确认导入配置'),
content: t('导入的配置将覆盖当前设置,是否继续?'),
okText: t('确定导入'),
cancelText: t('取消'),
onOk: () => {
onConfigImport(importedConfig);
Toast.success({
content: t('配置导入成功'),
duration: 3,
});
},
});
} catch (error) {
Toast.error({
content: t('导入配置失败: ') + error.message,
duration: 3,
});
} finally {
// 重置文件输入,允许重复选择同一文件
event.target.value = '';
}
};
const handleReset = () => {
Modal.confirm({
title: t('重置配置'),
content: t('将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?'),
okText: t('确定重置'),
cancelText: t('取消'),
okButtonProps: {
type: 'danger',
},
onOk: () => {
// 询问是否同时重置消息
Modal.confirm({
title: t('重置选项'),
content: t('是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。'),
okText: t('同时重置消息'),
cancelText: t('仅重置配置'),
okButtonProps: {
type: 'danger',
},
onOk: () => {
clearConfig();
onConfigReset({ resetMessages: true });
Toast.success({
content: t('配置和消息已全部重置'),
duration: 3,
});
},
onCancel: () => {
clearConfig();
onConfigReset({ resetMessages: false });
Toast.success({
content: t('配置已重置,对话消息已保留'),
duration: 3,
});
},
});
},
});
};
const getConfigStatus = () => {
if (hasStoredConfig()) {
const timestamp = getConfigTimestamp();
if (timestamp) {
const date = new Date(timestamp);
return t('上次保存: ') + date.toLocaleString();
}
return t('已有保存的配置');
}
return t('暂无保存的配置');
};
const dropdownItems = [
{
node: 'item',
name: 'export',
onClick: handleExport,
children: (
<div className="flex items-center gap-2">
<Download size={14} />
{t('导出配置')}
</div>
),
},
{
node: 'item',
name: 'import',
onClick: handleImportClick,
children: (
<div className="flex items-center gap-2">
<Upload size={14} />
{t('导入配置')}
</div>
),
},
{
node: 'divider',
},
{
node: 'item',
name: 'reset',
onClick: handleReset,
children: (
<div className="flex items-center gap-2 text-red-600">
<RotateCcw size={14} />
{t('重置配置')}
</div>
),
},
];
if (styleState.isMobile) {
// 移动端显示简化的下拉菜单
return (
<>
<Dropdown
trigger="click"
position="bottomLeft"
showTick
menu={dropdownItems}
>
<Button
icon={<Settings2 size={14} />}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50"
/>
</Dropdown>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</>
);
}
// 桌面端显示紧凑的按钮组
return (
<div className="space-y-3">
{/* 配置状态信息和重置按钮 */}
<div className="flex items-center justify-between">
<Typography.Text className="text-xs text-gray-500">
{getConfigStatus()}
</Typography.Text>
<Button
icon={<RotateCcw size={12} />}
size="small"
theme="borderless"
type="danger"
onClick={handleReset}
className="!rounded-full !text-xs !px-2"
/>
</div>
{/* 导出和导入按钮 */}
<div className="flex gap-2">
<Button
icon={<Download size={12} />}
size="small"
theme="solid"
type="primary"
onClick={handleExport}
className="!rounded-lg flex-1 !text-xs !h-7"
>
{t('导出')}
</Button>
<Button
icon={<Upload size={12} />}
size="small"
theme="outline"
type="primary"
onClick={handleImportClick}
className="!rounded-lg flex-1 !text-xs !h-7"
>
{t('导入')}
</Button>
</div>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
);
};
export default ConfigManager;
@@ -0,0 +1,58 @@
import React from 'react';
const CustomInputRender = (props) => {
const { detailProps } = props;
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps;
// 清空按钮
const styledClearNode = clearContextNode
? React.cloneElement(clearContextNode, {
className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`,
style: {
...clearContextNode.props.style,
width: '32px',
height: '32px',
minWidth: '32px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}
})
: null;
// 发送按钮
const styledSendNode = React.cloneElement(sendNode, {
className: `!rounded-full !bg-purple-500 hover:!bg-purple-600 flex-shrink-0 transition-all ${sendNode.props.className || ''}`,
style: {
...sendNode.props.style,
width: '32px',
height: '32px',
minWidth: '32px',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}
});
return (
<div className="p-2 sm:p-4">
<div
className="flex items-center gap-2 sm:gap-3 p-2 bg-gray-50 rounded-xl sm:rounded-2xl shadow-sm hover:shadow-md transition-shadow"
style={{ border: '1px solid var(--semi-color-border)' }}
onClick={onClick}
>
{/* 清空对话按钮 - 左边 */}
{styledClearNode}
<div className="flex-1">
{inputNode}
</div>
{/* 发送按钮 - 右边 */}
{styledSendNode}
</div>
</div>
);
};
export default CustomInputRender;
@@ -0,0 +1,190 @@
import React, { useState, useEffect } from 'react';
import {
TextArea,
Typography,
Button,
Switch,
Banner,
} from '@douyinfe/semi-ui';
import {
Code,
Edit,
Check,
X,
AlertTriangle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
const CustomRequestEditor = ({
customRequestMode,
customRequestBody,
onCustomRequestModeChange,
onCustomRequestBodyChange,
defaultPayload,
}) => {
const { t } = useTranslation();
const [isValid, setIsValid] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [localValue, setLocalValue] = useState(customRequestBody || '');
// 当切换到自定义模式时,用默认payload初始化
useEffect(() => {
if (customRequestMode && (!customRequestBody || customRequestBody.trim() === '')) {
const defaultJson = defaultPayload ? JSON.stringify(defaultPayload, null, 2) : '';
setLocalValue(defaultJson);
onCustomRequestBodyChange(defaultJson);
}
}, [customRequestMode, defaultPayload, customRequestBody, onCustomRequestBodyChange]);
// 同步外部传入的customRequestBody到本地状态
useEffect(() => {
if (customRequestBody !== localValue) {
setLocalValue(customRequestBody || '');
validateJson(customRequestBody || '');
}
}, [customRequestBody]);
// 验证JSON格式
const validateJson = (value) => {
if (!value.trim()) {
setIsValid(true);
setErrorMessage('');
return true;
}
try {
JSON.parse(value);
setIsValid(true);
setErrorMessage('');
return true;
} catch (error) {
setIsValid(false);
setErrorMessage(`JSON格式错误: ${error.message}`);
return false;
}
};
const handleValueChange = (value) => {
setLocalValue(value);
validateJson(value);
// 始终保存用户输入,让预览逻辑处理JSON解析错误
onCustomRequestBodyChange(value);
};
const handleModeToggle = (enabled) => {
onCustomRequestModeChange(enabled);
if (enabled && defaultPayload) {
const defaultJson = JSON.stringify(defaultPayload, null, 2);
setLocalValue(defaultJson);
onCustomRequestBodyChange(defaultJson);
}
};
const formatJson = () => {
try {
const parsed = JSON.parse(localValue);
const formatted = JSON.stringify(parsed, null, 2);
setLocalValue(formatted);
onCustomRequestBodyChange(formatted);
setIsValid(true);
setErrorMessage('');
} catch (error) {
// 如果格式化失败,保持原样
}
};
return (
<div className="space-y-4">
{/* 自定义模式开关 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Code size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
自定义请求体模式
</Typography.Text>
</div>
<Switch
checked={customRequestMode}
onChange={handleModeToggle}
checkedText="开"
uncheckedText="关"
size="small"
/>
</div>
{customRequestMode && (
<>
{/* 提示信息 */}
<Banner
type="warning"
description="启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。"
icon={<AlertTriangle size={16} />}
className="!rounded-lg"
closable={false}
/>
{/* JSON编辑器 */}
<div>
<div className="flex items-center justify-between mb-2">
<Typography.Text strong className="text-sm">
请求体 JSON
</Typography.Text>
<div className="flex items-center gap-2">
{isValid ? (
<div className="flex items-center gap-1 text-green-600">
<Check size={14} />
<Typography.Text className="text-xs">
格式正确
</Typography.Text>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<X size={14} />
<Typography.Text className="text-xs">
格式错误
</Typography.Text>
</div>
)}
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Edit size={14} />}
onClick={formatJson}
disabled={!isValid}
className="!rounded-lg"
>
格式化
</Button>
</div>
</div>
<TextArea
value={localValue}
onChange={handleValueChange}
placeholder='{"model": "gpt-4o", "messages": [...], ...}'
autosize={{ minRows: 8, maxRows: 20 }}
className={`custom-request-textarea !rounded-lg font-mono text-sm ${!isValid ? '!border-red-500' : ''}`}
style={{
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
lineHeight: '1.5',
}}
/>
{!isValid && errorMessage && (
<Typography.Text type="danger" className="text-xs mt-1 block">
{errorMessage}
</Typography.Text>
)}
<Typography.Text className="text-xs text-gray-500 mt-2 block">
请输入有效的JSON格式的请求体您可以参考预览面板中的默认请求体格式
</Typography.Text>
</div>
</>
)}
</div>
);
};
export default CustomRequestEditor;
+193
View File
@@ -0,0 +1,193 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Typography,
Tabs,
TabPane,
Button,
Dropdown,
} from '@douyinfe/semi-ui';
import {
Code,
Zap,
Clock,
X,
Eye,
Send,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import CodeViewer from './CodeViewer';
const DebugPanel = ({
debugData,
activeDebugTab,
onActiveDebugTabChange,
styleState,
onCloseDebugPanel,
customRequestMode,
}) => {
const { t } = useTranslation();
const [activeKey, setActiveKey] = useState(activeDebugTab);
useEffect(() => {
setActiveKey(activeDebugTab);
}, [activeDebugTab]);
const handleTabChange = (key) => {
setActiveKey(key);
onActiveDebugTabChange(key);
};
const renderArrow = (items, pos, handleArrowClick, defaultNode) => {
const style = {
width: 32,
height: 32,
margin: '0 12px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: '100%',
background: 'rgba(var(--semi-grey-1), 1)',
color: 'var(--semi-color-text)',
cursor: 'pointer',
};
return (
<Dropdown
render={
<Dropdown.Menu>
{items.map(item => {
return (
<Dropdown.Item
key={item.itemKey}
onClick={() => handleTabChange(item.itemKey)}
>
{item.tab}
</Dropdown.Item>
);
})}
</Dropdown.Menu>
}
>
{pos === 'start' ? (
<div style={style} onClick={handleArrowClick}>
</div>
) : (
<div style={style} onClick={handleArrowClick}>
</div>
)}
</Dropdown>
);
};
return (
<Card
className="h-full flex flex-col"
bordered={false}
bodyStyle={{
padding: styleState.isMobile ? '16px' : '24px',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
<div className="flex items-center justify-between mb-6 flex-shrink-0">
<div className="flex items-center">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3">
<Code size={20} className="text-white" />
</div>
<Typography.Title heading={5} className="mb-0">
{t('调试信息')}
</Typography.Title>
</div>
{styleState.isMobile && onCloseDebugPanel && (
<Button
icon={<X size={16} />}
onClick={onCloseDebugPanel}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg"
/>
)}
</div>
<div className="flex-1 overflow-hidden debug-panel">
<Tabs
renderArrow={renderArrow}
type="card"
collapsible
className="h-full"
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
activeKey={activeKey}
onChange={handleTabChange}
>
<TabPane tab={
<div className="flex items-center gap-2">
<Eye size={16} />
{t('预览请求体')}
{customRequestMode && (
<span className="px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full">
自定义
</span>
)}
</div>
} itemKey="preview">
<CodeViewer
content={debugData.previewRequest}
title="preview"
language="json"
/>
</TabPane>
<TabPane tab={
<div className="flex items-center gap-2">
<Send size={16} />
{t('实际请求体')}
</div>
} itemKey="request">
<CodeViewer
content={debugData.request}
title="request"
language="json"
/>
</TabPane>
<TabPane tab={
<div className="flex items-center gap-2">
<Zap size={16} />
{t('响应')}
</div>
} itemKey="response">
<CodeViewer
content={debugData.response}
title="response"
language="json"
/>
</TabPane>
</Tabs>
</div>
<div className="flex items-center justify-between mt-4 pt-4 flex-shrink-0">
{(debugData.timestamp || debugData.previewTimestamp) && (
<div className="flex items-center gap-2">
<Clock size={14} className="text-gray-500" />
<Typography.Text className="text-xs text-gray-500">
{activeKey === 'preview' && debugData.previewTimestamp
? `${t('预览更新')}: ${new Date(debugData.previewTimestamp).toLocaleString()}`
: debugData.timestamp
? `${t('最后请求')}: ${new Date(debugData.timestamp).toLocaleString()}`
: ''}
</Typography.Text>
</div>
)}
</div>
</Card>
);
};
export default DebugPanel;
@@ -0,0 +1,71 @@
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import {
Settings,
Eye,
EyeOff,
} from 'lucide-react';
const FloatingButtons = ({
styleState,
showSettings,
showDebugPanel,
onToggleSettings,
onToggleDebugPanel,
}) => {
if (!styleState.isMobile) return null;
return (
<>
{/* 设置按钮 */}
{!showSettings && (
<Button
icon={<Settings size={18} />}
style={{
position: 'fixed',
right: 16,
bottom: 90,
zIndex: 1000,
width: 36,
height: 36,
borderRadius: '50%',
padding: 0,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
background: 'linear-gradient(to right, #8b5cf6, #6366f1)',
}}
onClick={onToggleSettings}
theme='solid'
type='primary'
className="lg:hidden"
/>
)}
{/* 调试按钮 */}
{!showSettings && (
<Button
icon={showDebugPanel ? <EyeOff size={18} /> : <Eye size={18} />}
onClick={onToggleDebugPanel}
theme="solid"
type={showDebugPanel ? "danger" : "primary"}
style={{
position: 'fixed',
right: 16,
bottom: 140,
zIndex: 1000,
width: 36,
height: 36,
borderRadius: '50%',
padding: 0,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
background: showDebugPanel
? 'linear-gradient(to right, #e11d48, #be123c)'
: 'linear-gradient(to right, #4f46e5, #6366f1)',
}}
className="lg:hidden !rounded-full !p-0"
/>
)}
</>
);
};
export default FloatingButtons;
@@ -0,0 +1,113 @@
import React from 'react';
import {
Input,
Typography,
Button,
Switch,
} from '@douyinfe/semi-ui';
import { IconFile } from '@douyinfe/semi-icons';
import {
FileText,
Plus,
X,
Image,
} from 'lucide-react';
const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnabledChange, disabled = false }) => {
const handleAddImageUrl = () => {
const newUrls = [...imageUrls, ''];
onImageUrlsChange(newUrls);
};
const handleUpdateImageUrl = (index, value) => {
const newUrls = [...imageUrls];
newUrls[index] = value;
onImageUrlsChange(newUrls);
};
const handleRemoveImageUrl = (index) => {
const newUrls = imageUrls.filter((_, i) => i !== index);
onImageUrlsChange(newUrls);
};
return (
<div className={disabled ? 'opacity-50' : ''}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Image size={16} className={imageEnabled && !disabled ? "text-blue-500" : "text-gray-400"} />
<Typography.Text strong className="text-sm">
图片地址
</Typography.Text>
{disabled && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<div className="flex items-center gap-2">
<Switch
checked={imageEnabled}
onChange={onImageEnabledChange}
checkedText="启用"
uncheckedText="停用"
size="small"
className="flex-shrink-0"
disabled={disabled}
/>
<Button
icon={<Plus size={14} />}
size="small"
theme="solid"
type="primary"
onClick={handleAddImageUrl}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={!imageEnabled || disabled}
/>
</div>
</div>
{!imageEnabled ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
{disabled ? '图片功能在自定义请求体模式下不可用' : '启用后可添加图片URL进行多模态对话'}
</Typography.Text>
) : imageUrls.length === 0 ? (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
{disabled ? '图片功能在自定义请求体模式下不可用' : '点击 + 按钮添加图片URL进行多模态对话'}
</Typography.Text>
) : (
<Typography.Text className="text-xs text-gray-500 mb-2 block">
已添加 {imageUrls.length} 张图片{disabled ? ' (自定义模式下不可用)' : ''}
</Typography.Text>
)}
<div className={`space-y-2 max-h-32 overflow-y-auto image-list-scroll ${!imageEnabled || disabled ? 'opacity-50' : ''}`}>
{imageUrls.map((url, index) => (
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
<Input
placeholder={`https://example.com/image${index + 1}.jpg`}
value={url}
onChange={(value) => handleUpdateImageUrl(index, value)}
className="!rounded-lg"
size="small"
prefix={<IconFile size='small' />}
disabled={!imageEnabled || disabled}
/>
</div>
<Button
icon={<X size={12} />}
size="small"
theme="borderless"
type="danger"
onClick={() => handleRemoveImageUrl(index)}
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0 !text-red-500 hover:!bg-red-50 flex-shrink-0"
disabled={!imageEnabled || disabled}
/>
</div>
))}
</div>
</div>
);
};
export default ImageUrlInput;
@@ -0,0 +1,121 @@
import React from 'react';
import {
Button,
Tooltip,
} from '@douyinfe/semi-ui';
import {
RefreshCw,
Copy,
Trash2,
UserCheck,
Edit,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
const MessageActions = ({
message,
styleState,
onMessageReset,
onMessageCopy,
onMessageDelete,
onRoleToggle,
onMessageEdit,
isAnyMessageGenerating = false,
isEditing = false
}) => {
const { t } = useTranslation();
const isLoading = message.status === 'loading' || message.status === 'incomplete';
const shouldDisableActions = isAnyMessageGenerating || isEditing;
const canToggleRole = message.role === 'assistant' || message.role === 'system';
const canEdit = !isLoading && message.content && typeof onMessageEdit === 'function' && !isEditing;
return (
<div className="flex items-center gap-0.5">
{!isLoading && (
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('重试')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<RefreshCw size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageReset(message)}
disabled={shouldDisableActions}
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-blue-600 hover:!bg-blue-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('重试')}
/>
</Tooltip>
)}
{message.content && (
<Tooltip content={t('复制')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Copy size={styleState.isMobile ? 12 : 14} />}
onClick={() => onMessageCopy(message)}
className={`!rounded-full !text-gray-400 hover:!text-green-600 hover:!bg-green-50 ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('复制')}
/>
</Tooltip>
)}
{canEdit && (
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('编辑')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Edit size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageEdit(message)}
disabled={shouldDisableActions}
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-yellow-600 hover:!bg-yellow-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('编辑')}
/>
</Tooltip>
)}
{canToggleRole && !isLoading && (
<Tooltip
content={
shouldDisableActions
? t('操作暂时被禁用')
: message.role === 'assistant'
? t('切换为System角色')
: t('切换为Assistant角色')
}
position="top"
>
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<UserCheck size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onRoleToggle && onRoleToggle(message)}
disabled={shouldDisableActions}
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : message.role === 'system' ? '!text-purple-500 hover:!text-purple-700 hover:!bg-purple-50' : '!text-gray-400 hover:!text-purple-600 hover:!bg-purple-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={message.role === 'assistant' ? t('切换为System角色') : t('切换为Assistant角色')}
/>
</Tooltip>
)}
{!isLoading && (
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('删除')} position="top">
<Button
theme="borderless"
type="tertiary"
size="small"
icon={<Trash2 size={styleState.isMobile ? 12 : 14} />}
onClick={() => !shouldDisableActions && onMessageDelete(message)}
disabled={shouldDisableActions}
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : '!text-gray-400 hover:!text-red-600 hover:!bg-red-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
aria-label={t('删除')}
/>
</Tooltip>
)}
</div>
);
};
export default MessageActions;
@@ -0,0 +1,313 @@
import React, { useRef, useEffect } from 'react';
import {
Typography,
TextArea,
Button,
} from '@douyinfe/semi-ui';
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
import ThinkingContent from './ThinkingContent';
import {
Loader2,
Check,
X,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
const MessageContent = ({
message,
className,
styleState,
onToggleReasoningExpansion,
isEditing = false,
onEditSave,
onEditCancel,
editValue,
onEditValueChange
}) => {
const { t } = useTranslation();
const previousContentLengthRef = useRef(0);
const lastContentRef = useRef('');
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
useEffect(() => {
if (!isThinkingStatus) {
previousContentLengthRef.current = 0;
lastContentRef.current = '';
}
}, [isThinkingStatus]);
if (message.status === 'error') {
let errorText;
if (Array.isArray(message.content)) {
const textContent = message.content.find(item => item.type === 'text');
errorText = textContent && textContent.text && typeof textContent.text === 'string'
? textContent.text
: t('请求发生错误');
} else if (typeof message.content === 'string') {
errorText = message.content;
} else {
errorText = t('请求发生错误');
}
return (
<div className={`${className} flex items-center p-4 bg-red-50 rounded-xl`}>
<Typography.Text type="danger" className="text-sm">
{errorText}
</Typography.Text>
</div>
);
}
let currentExtractedThinkingContent = null;
let currentDisplayableFinalContent = "";
let thinkingSource = null;
const getTextContent = (content) => {
if (Array.isArray(content)) {
const textItem = content.find(item => item.type === 'text');
return textItem && textItem.text && typeof textItem.text === 'string' ? textItem.text : '';
} else if (typeof content === 'string') {
return content;
}
return '';
};
currentDisplayableFinalContent = getTextContent(message.content);
if (message.role === 'assistant') {
let baseContentForDisplay = getTextContent(message.content);
let combinedThinkingContent = "";
if (message.reasoningContent) {
combinedThinkingContent = message.reasoningContent;
thinkingSource = 'reasoningContent';
}
if (baseContentForDisplay.includes('<think>')) {
const thinkTagRegex = /<think>([\s\S]*?)<\/think>/g;
let match;
let thoughtsFromPairedTags = [];
let replyParts = [];
let lastIndex = 0;
while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) {
replyParts.push(baseContentForDisplay.substring(lastIndex, match.index));
thoughtsFromPairedTags.push(match[1]);
lastIndex = match.index + match[0].length;
}
replyParts.push(baseContentForDisplay.substring(lastIndex));
if (thoughtsFromPairedTags.length > 0) {
const pairedThoughtsStr = thoughtsFromPairedTags.join('\n\n---\n\n');
if (combinedThinkingContent) {
combinedThinkingContent += '\n\n---\n\n' + pairedThoughtsStr;
} else {
combinedThinkingContent = pairedThoughtsStr;
}
thinkingSource = thinkingSource ? thinkingSource + ' & <think> tags' : '<think> tags';
}
baseContentForDisplay = replyParts.join('');
}
if (isThinkingStatus) {
const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>');
if (lastOpenThinkIndex !== -1) {
const fragmentAfterLastOpen = baseContentForDisplay.substring(lastOpenThinkIndex);
if (!fragmentAfterLastOpen.includes('</think>')) {
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
if (unclosedThought) {
if (combinedThinkingContent) {
combinedThinkingContent += '\n\n---\n\n' + unclosedThought;
} else {
combinedThinkingContent = unclosedThought;
}
thinkingSource = thinkingSource ? thinkingSource + ' + streaming <think>' : 'streaming <think>';
}
baseContentForDisplay = baseContentForDisplay.substring(0, lastOpenThinkIndex);
}
}
}
currentExtractedThinkingContent = combinedThinkingContent || null;
currentDisplayableFinalContent = baseContentForDisplay.replace(/<\/?think>/g, '').trim();
}
const finalExtractedThinkingContent = currentExtractedThinkingContent;
const finalDisplayableFinalContent = currentDisplayableFinalContent;
if (message.role === 'assistant' &&
isThinkingStatus &&
!finalExtractedThinkingContent &&
(!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) {
return (
<div className={`${className} flex items-center gap-2 sm:gap-4 bg-gradient-to-r from-purple-50 to-indigo-50`}>
<div className="w-5 h-5 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
<Loader2 className="animate-spin text-white" size={styleState.isMobile ? 16 : 20} />
</div>
</div>
);
}
return (
<div className={className}>
{message.role === 'system' && (
<div className="mb-2 sm:mb-4">
<div className="flex items-center gap-2 p-2 sm:p-3 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg" style={{ border: '1px solid var(--semi-color-border)' }}>
<div className="w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-sm">
<Typography.Text className="text-white text-xs font-bold">S</Typography.Text>
</div>
<Typography.Text className="text-amber-700 text-xs sm:text-sm font-medium">
{t('系统消息')}
</Typography.Text>
</div>
</div>
)}
{message.role === 'assistant' && (
<ThinkingContent
message={message}
finalExtractedThinkingContent={finalExtractedThinkingContent}
thinkingSource={thinkingSource}
styleState={styleState}
onToggleReasoningExpansion={onToggleReasoningExpansion}
/>
)}
{isEditing ? (
<div className="space-y-3">
<TextArea
value={editValue}
onChange={(value) => onEditValueChange(value)}
placeholder={t('请输入消息内容...')}
autosize={{ minRows: 3, maxRows: 12 }}
style={{
resize: 'vertical',
fontSize: styleState.isMobile ? '14px' : '15px',
lineHeight: '1.6',
}}
className="!border-blue-200 focus:!border-blue-400 !bg-blue-50/50"
/>
<div className="flex items-center gap-2 w-full">
<Button
size="small"
type="danger"
theme="light"
icon={<X size={14} />}
onClick={onEditCancel}
className="flex-1"
>
{t('取消')}
</Button>
<Button
size="small"
type="warning"
theme="solid"
icon={<Check size={14} />}
onClick={onEditSave}
disabled={!editValue || editValue.trim() === ''}
className="flex-1"
>
{t('保存')}
</Button>
</div>
</div>
) : (
(() => {
if (Array.isArray(message.content)) {
const textContent = message.content.find(item => item.type === 'text');
const imageContents = message.content.filter(item => item.type === 'image_url');
return (
<div>
{imageContents.length > 0 && (
<div className="mb-3 space-y-2">
{imageContents.map((imgItem, index) => (
<div key={index} className="max-w-sm">
<img
src={imgItem.image_url.url}
alt={`用户上传的图片 ${index + 1}`}
className="rounded-lg max-w-full h-auto shadow-sm border"
style={{ maxHeight: '300px' }}
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'block';
}}
/>
<div
className="text-red-500 text-sm p-2 bg-red-50 rounded-lg border border-red-200"
style={{ display: 'none' }}
>
图片加载失败: {imgItem.image_url.url}
</div>
</div>
))}
</div>
)}
{textContent && textContent.text && typeof textContent.text === 'string' && textContent.text.trim() !== '' && (
<div className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}>
<MarkdownRenderer
content={textContent.text}
className={message.role === 'user' ? 'user-message' : ''}
animated={false}
previousContentLength={0}
/>
</div>
)}
</div>
);
}
if (typeof message.content === 'string') {
if (message.role === 'assistant') {
if (finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') {
// 获取上一次的内容长度
let prevLength = 0;
if (isThinkingStatus && lastContentRef.current) {
// 只有当前内容包含上一次内容时,才使用上一次的长度
if (finalDisplayableFinalContent.startsWith(lastContentRef.current)) {
prevLength = lastContentRef.current.length;
}
}
// 更新最后内容的引用
if (isThinkingStatus) {
lastContentRef.current = finalDisplayableFinalContent;
}
return (
<div className="prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm">
<MarkdownRenderer
content={finalDisplayableFinalContent}
className=""
animated={isThinkingStatus}
previousContentLength={prevLength}
/>
</div>
);
}
} else {
return (
<div className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}>
<MarkdownRenderer
content={message.content}
className={message.role === 'user' ? 'user-message' : ''}
animated={false}
previousContentLength={0}
/>
</div>
);
}
}
return null;
})()
)}
</div>
);
};
export default MessageContent;
@@ -0,0 +1,60 @@
import React from 'react';
import MessageContent from './MessageContent';
import MessageActions from './MessageActions';
import SettingsPanel from './SettingsPanel';
import DebugPanel from './DebugPanel';
// 优化的消息内容组件
export const OptimizedMessageContent = React.memo(MessageContent, (prevProps, nextProps) => {
// 只有这些属性变化时才重新渲染
return (
prevProps.message.id === nextProps.message.id &&
prevProps.message.content === nextProps.message.content &&
prevProps.message.status === nextProps.message.status &&
prevProps.message.role === nextProps.message.role &&
prevProps.message.reasoningContent === nextProps.message.reasoningContent &&
prevProps.message.isReasoningExpanded === nextProps.message.isReasoningExpanded &&
prevProps.isEditing === nextProps.isEditing &&
prevProps.editValue === nextProps.editValue &&
prevProps.styleState.isMobile === nextProps.styleState.isMobile
);
});
// 优化的消息操作组件
export const OptimizedMessageActions = React.memo(MessageActions, (prevProps, nextProps) => {
return (
prevProps.message.id === nextProps.message.id &&
prevProps.message.role === nextProps.message.role &&
prevProps.isAnyMessageGenerating === nextProps.isAnyMessageGenerating &&
prevProps.isEditing === nextProps.isEditing &&
prevProps.onMessageReset === nextProps.onMessageReset
);
});
// 优化的设置面板组件
export const OptimizedSettingsPanel = React.memo(SettingsPanel, (prevProps, nextProps) => {
return (
JSON.stringify(prevProps.inputs) === JSON.stringify(nextProps.inputs) &&
JSON.stringify(prevProps.parameterEnabled) === JSON.stringify(nextProps.parameterEnabled) &&
JSON.stringify(prevProps.models) === JSON.stringify(nextProps.models) &&
JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) &&
prevProps.customRequestMode === nextProps.customRequestMode &&
prevProps.customRequestBody === nextProps.customRequestBody &&
prevProps.showDebugPanel === nextProps.showDebugPanel &&
prevProps.showSettings === nextProps.showSettings &&
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages)
);
});
// 优化的调试面板组件
export const OptimizedDebugPanel = React.memo(DebugPanel, (prevProps, nextProps) => {
return (
prevProps.show === nextProps.show &&
prevProps.activeTab === nextProps.activeTab &&
JSON.stringify(prevProps.debugData) === JSON.stringify(nextProps.debugData) &&
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
prevProps.customRequestMode === nextProps.customRequestMode &&
prevProps.showDebugPanel === nextProps.showDebugPanel
);
});
@@ -0,0 +1,241 @@
import React from 'react';
import {
Input,
Slider,
Typography,
Button,
Tag,
} from '@douyinfe/semi-ui';
import {
Hash,
Thermometer,
Target,
Repeat,
Ban,
Shuffle,
Check,
X,
} from 'lucide-react';
const ParameterControl = ({
inputs,
parameterEnabled,
onInputChange,
onParameterToggle,
disabled = false,
}) => {
return (
<>
{/* Temperature */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Thermometer size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Temperature
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.temperature}
</Tag>
</div>
<Button
theme={parameterEnabled.temperature ? 'solid' : 'borderless'}
type={parameterEnabled.temperature ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.temperature ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('temperature')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
控制输出的随机性和创造性
</Typography.Text>
<Slider
step={0.1}
min={0.1}
max={1}
value={inputs.temperature}
onChange={(value) => onInputChange('temperature', value)}
className="mt-2"
disabled={!parameterEnabled.temperature || disabled}
/>
</div>
{/* Top P */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.top_p || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Target size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Top P
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.top_p}
</Tag>
</div>
<Button
theme={parameterEnabled.top_p ? 'solid' : 'borderless'}
type={parameterEnabled.top_p ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('top_p')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
核采样控制词汇选择的多样性
</Typography.Text>
<Slider
step={0.1}
min={0.1}
max={1}
value={inputs.top_p}
onChange={(value) => onInputChange('top_p', value)}
className="mt-2"
disabled={!parameterEnabled.top_p || disabled}
/>
</div>
{/* Frequency Penalty */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.frequency_penalty || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Repeat size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Frequency Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.frequency_penalty}
</Tag>
</div>
<Button
theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.frequency_penalty ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('frequency_penalty')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
频率惩罚减少重复词汇的出现
</Typography.Text>
<Slider
step={0.1}
min={-2}
max={2}
value={inputs.frequency_penalty}
onChange={(value) => onInputChange('frequency_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.frequency_penalty || disabled}
/>
</div>
{/* Presence Penalty */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.presence_penalty || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Ban size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Presence Penalty
</Typography.Text>
<Tag size="small" className="!rounded-full">
{inputs.presence_penalty}
</Tag>
</div>
<Button
theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'}
type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.presence_penalty ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('presence_penalty')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Typography.Text className="text-xs text-gray-500 mb-2">
存在惩罚鼓励讨论新话题
</Typography.Text>
<Slider
step={0.1}
min={-2}
max={2}
value={inputs.presence_penalty}
onChange={(value) => onInputChange('presence_penalty', value)}
className="mt-2"
disabled={!parameterEnabled.presence_penalty || disabled}
/>
</div>
{/* MaxTokens */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.max_tokens || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Hash size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Max Tokens
</Typography.Text>
</div>
<Button
theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'}
type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.max_tokens ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('max_tokens')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Input
placeholder='MaxTokens'
name='max_tokens'
required
autoComplete='new-password'
defaultValue={0}
value={inputs.max_tokens}
onChange={(value) => onInputChange('max_tokens', value)}
className="!rounded-lg"
disabled={!parameterEnabled.max_tokens || disabled}
/>
</div>
{/* Seed */}
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.seed || disabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shuffle size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
Seed
</Typography.Text>
<Typography.Text className="text-xs text-gray-400">
(可选用于复现结果)
</Typography.Text>
</div>
<Button
theme={parameterEnabled.seed ? 'solid' : 'borderless'}
type={parameterEnabled.seed ? 'primary' : 'tertiary'}
size="small"
icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
onClick={() => onParameterToggle('seed')}
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
disabled={disabled}
/>
</div>
<Input
placeholder='随机种子 (留空为随机)'
name='seed'
autoComplete='new-password'
value={inputs.seed || ''}
onChange={(value) => onInputChange('seed', value === '' ? null : value)}
className="!rounded-lg"
disabled={!parameterEnabled.seed || disabled}
/>
</div>
</>
);
};
export default ParameterControl;
@@ -0,0 +1,234 @@
import React from 'react';
import {
Card,
Select,
Typography,
Button,
Switch,
} from '@douyinfe/semi-ui';
import {
Sparkles,
Users,
ToggleLeft,
X,
Settings,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { renderGroupOption } from '../../helpers';
import ParameterControl from './ParameterControl';
import ImageUrlInput from './ImageUrlInput';
import ConfigManager from './ConfigManager';
import CustomRequestEditor from './CustomRequestEditor';
const SettingsPanel = ({
inputs,
parameterEnabled,
models,
groups,
styleState,
showDebugPanel,
customRequestMode,
customRequestBody,
onInputChange,
onParameterToggle,
onCloseSettings,
onConfigImport,
onConfigReset,
onCustomRequestModeChange,
onCustomRequestBodyChange,
previewPayload,
messages,
}) => {
const { t } = useTranslation();
const currentConfig = {
inputs,
parameterEnabled,
showDebugPanel,
customRequestMode,
customRequestBody,
};
return (
<Card
className="h-full flex flex-col"
bordered={false}
bodyStyle={{
padding: styleState.isMobile ? '16px' : '24px',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
{/* 标题区域 - 与调试面板保持一致 */}
<div className="flex items-center justify-between mb-6 flex-shrink-0">
<div className="flex items-center">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center mr-3">
<Settings size={20} className="text-white" />
</div>
<Typography.Title heading={5} className="mb-0">
{t('模型配置')}
</Typography.Title>
</div>
{styleState.isMobile && onCloseSettings && (
<Button
icon={<X size={16} />}
onClick={onCloseSettings}
theme="borderless"
type="tertiary"
size="small"
className="!rounded-lg"
/>
)}
</div>
{/* 移动端配置管理 */}
{styleState.isMobile && (
<div className="mb-4 flex-shrink-0">
<ConfigManager
currentConfig={currentConfig}
onConfigImport={onConfigImport}
onConfigReset={onConfigReset}
styleState={{ ...styleState, isMobile: false }}
messages={messages}
/>
</div>
)}
<div className="space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll">
{/* 自定义请求体编辑器 */}
<CustomRequestEditor
customRequestMode={customRequestMode}
customRequestBody={customRequestBody}
onCustomRequestModeChange={onCustomRequestModeChange}
onCustomRequestBodyChange={onCustomRequestBodyChange}
defaultPayload={previewPayload}
/>
{/* 分组选择 */}
<div className={customRequestMode ? 'opacity-50' : ''}>
<div className="flex items-center gap-2 mb-2">
<Users size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
{t('分组')}
</Typography.Text>
{customRequestMode && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<Select
placeholder={t('请选择分组')}
name='group'
required
selection
onChange={(value) => onInputChange('group', value)}
value={inputs.group}
autoComplete='new-password'
optionList={groups}
renderOptionItem={renderGroupOption}
style={{ width: '100%' }}
dropdownStyle={{ width: '100%', maxWidth: '100%' }}
className="!rounded-lg"
disabled={customRequestMode}
/>
</div>
{/* 模型选择 */}
<div className={customRequestMode ? 'opacity-50' : ''}>
<div className="flex items-center gap-2 mb-2">
<Sparkles size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
{t('模型')}
</Typography.Text>
{customRequestMode && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<Select
placeholder={t('请选择模型')}
name='model'
required
selection
searchPosition='dropdown'
filter
onChange={(value) => onInputChange('model', value)}
value={inputs.model}
autoComplete='new-password'
optionList={models}
style={{ width: '100%' }}
dropdownStyle={{ width: '100%', maxWidth: '100%' }}
className="!rounded-lg"
disabled={customRequestMode}
/>
</div>
{/* 图片URL输入 */}
<div className={customRequestMode ? 'opacity-50' : ''}>
<ImageUrlInput
imageUrls={inputs.imageUrls}
imageEnabled={inputs.imageEnabled}
onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)}
onImageEnabledChange={(enabled) => onInputChange('imageEnabled', enabled)}
disabled={customRequestMode}
/>
</div>
{/* 参数控制组件 */}
<div className={customRequestMode ? 'opacity-50' : ''}>
<ParameterControl
inputs={inputs}
parameterEnabled={parameterEnabled}
onInputChange={onInputChange}
onParameterToggle={onParameterToggle}
disabled={customRequestMode}
/>
</div>
{/* 流式输出开关 */}
<div className={customRequestMode ? 'opacity-50' : ''}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ToggleLeft size={16} className="text-gray-500" />
<Typography.Text strong className="text-sm">
流式输出
</Typography.Text>
{customRequestMode && (
<Typography.Text className="text-xs text-orange-600">
(已在自定义模式中忽略)
</Typography.Text>
)}
</div>
<Switch
checked={inputs.stream}
onChange={(checked) => onInputChange('stream', checked)}
checkedText="开"
uncheckedText="关"
size="small"
disabled={customRequestMode}
/>
</div>
</div>
</div>
{/* 桌面端的配置管理放在底部 */}
{!styleState.isMobile && (
<div className="flex-shrink-0 pt-3">
<ConfigManager
currentConfig={currentConfig}
onConfigImport={onConfigImport}
onConfigReset={onConfigReset}
styleState={styleState}
messages={messages}
/>
</div>
)}
</Card>
);
};
export default SettingsPanel;
@@ -0,0 +1,125 @@
import React, { useEffect, useRef } from 'react';
import { Typography } from '@douyinfe/semi-ui';
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
import { ChevronRight, ChevronUp, Brain, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const ThinkingContent = ({
message,
finalExtractedThinkingContent,
thinkingSource,
styleState,
onToggleReasoningExpansion
}) => {
const { t } = useTranslation();
const scrollRef = useRef(null);
const lastContentRef = useRef('');
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
const headerText = (isThinkingStatus && !message.isThinkingComplete) ? t('思考中...') : t('思考过程');
useEffect(() => {
if (scrollRef.current && finalExtractedThinkingContent && message.isReasoningExpanded) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [finalExtractedThinkingContent, message.isReasoningExpanded]);
useEffect(() => {
if (!isThinkingStatus) {
lastContentRef.current = '';
}
}, [isThinkingStatus]);
if (!finalExtractedThinkingContent) return null;
let prevLength = 0;
if (isThinkingStatus && lastContentRef.current) {
if (finalExtractedThinkingContent.startsWith(lastContentRef.current)) {
prevLength = lastContentRef.current.length;
}
}
if (isThinkingStatus) {
lastContentRef.current = finalExtractedThinkingContent;
}
return (
<div className="rounded-xl sm:rounded-2xl mb-2 sm:mb-4 overflow-hidden shadow-sm backdrop-blur-sm">
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-gradient-to-r hover:from-white/20 hover:to-purple-50/30 transition-all"
style={{
background: 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
position: 'relative'
}}
onClick={() => onToggleReasoningExpansion(message.id)}
>
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
</div>
<div className="flex items-center gap-2 sm:gap-4 relative">
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-white/20 flex items-center justify-center shadow-lg">
<Brain style={{ color: 'white' }} size={styleState.isMobile ? 12 : 16} />
</div>
<div className="flex flex-col">
<Typography.Text strong style={{ color: 'white' }} className="text-sm sm:text-base">
{headerText}
</Typography.Text>
{thinkingSource && (
<Typography.Text style={{ color: 'white' }} className="text-xs mt-0.5 opacity-80 hidden sm:block">
来源: {thinkingSource}
</Typography.Text>
)}
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3 relative">
{isThinkingStatus && !message.isThinkingComplete && (
<div className="flex items-center gap-1 sm:gap-2">
<Loader2 style={{ color: 'white' }} className="animate-spin" size={styleState.isMobile ? 14 : 18} />
<Typography.Text style={{ color: 'white' }} className="text-xs sm:text-sm font-medium opacity-90">
思考中
</Typography.Text>
</div>
)}
{(!isThinkingStatus || message.isThinkingComplete) && (
<div className="w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-white/20 flex items-center justify-center">
{message.isReasoningExpanded ?
<ChevronUp size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} /> :
<ChevronRight size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} />
}
</div>
)}
</div>
</div>
<div
className={`transition-all duration-500 ease-out ${message.isReasoningExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
} overflow-hidden bg-gradient-to-br from-purple-50 via-indigo-50 to-violet-50`}
>
{message.isReasoningExpanded && (
<div className="p-3 sm:p-5 pt-2 sm:pt-4">
<div
ref={scrollRef}
className="bg-white/70 backdrop-blur-sm rounded-lg sm:rounded-xl p-2 shadow-inner overflow-x-auto overflow-y-auto thinking-content-scroll"
style={{
maxHeight: '200px',
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(0, 0, 0, 0.3) transparent'
}}
>
<div className="prose prose-xs sm:prose-sm prose-purple max-w-none text-xs sm:text-sm">
<MarkdownRenderer
content={finalExtractedThinkingContent}
className=""
animated={isThinkingStatus}
previousContentLength={prevLength}
/>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default ThinkingContent;
@@ -0,0 +1,203 @@
import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../constants/playground.constants';
const MESSAGES_STORAGE_KEY = 'playground_messages';
/**
* 保存配置到 localStorage
* @param {Object} config - 要保存的配置对象
*/
export const saveConfig = (config) => {
try {
const configToSave = {
...config,
timestamp: new Date().toISOString(),
};
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(configToSave));
} catch (error) {
console.error('保存配置失败:', error);
}
};
/**
* 保存消息到 localStorage
* @param {Array} messages - 要保存的消息数组
*/
export const saveMessages = (messages) => {
try {
const messagesToSave = {
messages,
timestamp: new Date().toISOString(),
};
localStorage.setItem(STORAGE_KEYS.MESSAGES, JSON.stringify(messagesToSave));
} catch (error) {
console.error('保存消息失败:', error);
}
};
/**
* 从 localStorage 加载配置
* @returns {Object} 配置对象,如果不存在则返回默认配置
*/
export const loadConfig = () => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
const mergedConfig = {
inputs: {
...DEFAULT_CONFIG.inputs,
...parsedConfig.inputs,
},
parameterEnabled: {
...DEFAULT_CONFIG.parameterEnabled,
...parsedConfig.parameterEnabled,
},
showDebugPanel: parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
customRequestMode: parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
customRequestBody: parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
};
return mergedConfig;
}
} catch (error) {
console.error('加载配置失败:', error);
}
return DEFAULT_CONFIG;
};
/**
* 从 localStorage 加载消息
* @returns {Array} 消息数组,如果不存在则返回 null
*/
export const loadMessages = () => {
try {
const savedMessages = localStorage.getItem(STORAGE_KEYS.MESSAGES);
if (savedMessages) {
const parsedMessages = JSON.parse(savedMessages);
return parsedMessages.messages || null;
}
} catch (error) {
console.error('加载消息失败:', error);
}
return null;
};
/**
* 清除保存的配置
*/
export const clearConfig = () => {
try {
localStorage.removeItem(STORAGE_KEYS.CONFIG);
localStorage.removeItem(STORAGE_KEYS.MESSAGES); // 同时清除消息
} catch (error) {
console.error('清除配置失败:', error);
}
};
/**
* 清除保存的消息
*/
export const clearMessages = () => {
try {
localStorage.removeItem(STORAGE_KEYS.MESSAGES);
} catch (error) {
console.error('清除消息失败:', error);
}
};
/**
* 检查是否有保存的配置
* @returns {boolean} 是否存在保存的配置
*/
export const hasStoredConfig = () => {
try {
return localStorage.getItem(STORAGE_KEYS.CONFIG) !== null;
} catch (error) {
console.error('检查配置失败:', error);
return false;
}
};
/**
* 获取配置的最后保存时间
* @returns {string|null} 最后保存时间的 ISO 字符串
*/
export const getConfigTimestamp = () => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
return parsedConfig.timestamp || null;
}
} catch (error) {
console.error('获取配置时间戳失败:', error);
}
return null;
};
/**
* 导出配置为 JSON 文件(包含消息)
* @param {Object} config - 要导出的配置
* @param {Array} messages - 要导出的消息
*/
export const exportConfig = (config, messages = null) => {
try {
const configToExport = {
...config,
messages: messages || loadMessages(), // 包含消息数据
exportTime: new Date().toISOString(),
version: '1.0',
};
const dataStr = JSON.stringify(configToExport, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(dataBlob);
link.download = `playground-config-${new Date().toISOString().split('T')[0]}.json`;
link.click();
URL.revokeObjectURL(link.href);
} catch (error) {
console.error('导出配置失败:', error);
}
};
/**
* 从文件导入配置(包含消息)
* @param {File} file - 包含配置的 JSON 文件
* @returns {Promise<Object>} 导入的配置对象
*/
export const importConfig = (file) => {
return new Promise((resolve, reject) => {
try {
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedConfig = JSON.parse(e.target.result);
if (importedConfig.inputs && importedConfig.parameterEnabled) {
// 如果导入的配置包含消息,也一起导入
if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
saveMessages(importedConfig.messages);
}
resolve(importedConfig);
} else {
reject(new Error('配置文件格式无效'));
}
} catch (parseError) {
reject(new Error('解析配置文件失败: ' + parseError.message));
}
};
reader.onerror = () => reject(new Error('读取文件失败'));
reader.readAsText(file);
} catch (error) {
reject(new Error('导入配置失败: ' + error.message));
}
});
};
+20
View File
@@ -0,0 +1,20 @@
export { default as SettingsPanel } from './SettingsPanel';
export { default as ChatArea } from './ChatArea';
export { default as DebugPanel } from './DebugPanel';
export { default as MessageContent } from './MessageContent';
export { default as MessageActions } from './MessageActions';
export { default as CustomInputRender } from './CustomInputRender';
export { default as ParameterControl } from './ParameterControl';
export { default as ImageUrlInput } from './ImageUrlInput';
export { default as FloatingButtons } from './FloatingButtons';
export { default as ConfigManager } from './ConfigManager';
export {
saveConfig,
loadConfig,
clearConfig,
hasStoredConfig,
getConfigTimestamp,
exportConfig,
importConfig,
} from './configStorage';
@@ -1,11 +1,11 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../helpers';
import { API, showError, showSuccess } from '../../helpers';
import { useTranslation } from 'react-i18next';
import SettingGeminiModel from '../pages/Setting/Model/SettingGeminiModel.js';
import SettingClaudeModel from '../pages/Setting/Model/SettingClaudeModel.js';
import SettingGlobalModel from '../pages/Setting/Model/SettingGlobalModel.js';
import SettingGeminiModel from '../../pages/Setting/Model/SettingGeminiModel.js';
import SettingClaudeModel from '../../pages/Setting/Model/SettingClaudeModel.js';
import SettingGlobalModel from '../../pages/Setting/Model/SettingGlobalModel.js';
const ModelSetting = () => {
const { t } = useTranslation();
@@ -1,20 +1,20 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import SettingsGeneral from '../pages/Setting/Operation/SettingsGeneral.js';
import SettingsDrawing from '../pages/Setting/Operation/SettingsDrawing.js';
import SettingsSensitiveWords from '../pages/Setting/Operation/SettingsSensitiveWords.js';
import SettingsLog from '../pages/Setting/Operation/SettingsLog.js';
import SettingsDataDashboard from '../pages/Setting/Operation/SettingsDataDashboard.js';
import SettingsMonitoring from '../pages/Setting/Operation/SettingsMonitoring.js';
import SettingsCreditLimit from '../pages/Setting/Operation/SettingsCreditLimit.js';
import ModelSettingsVisualEditor from '../pages/Setting/Operation/ModelSettingsVisualEditor.js';
import GroupRatioSettings from '../pages/Setting/Operation/GroupRatioSettings.js';
import ModelRatioSettings from '../pages/Setting/Operation/ModelRatioSettings.js';
import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral.js';
import SettingsDrawing from '../../pages/Setting/Operation/SettingsDrawing.js';
import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords.js';
import SettingsLog from '../../pages/Setting/Operation/SettingsLog.js';
import SettingsDataDashboard from '../../pages/Setting/Operation/SettingsDataDashboard.js';
import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring.js';
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit.js';
import ModelSettingsVisualEditor from '../../pages/Setting/Operation/ModelSettingsVisualEditor.js';
import GroupRatioSettings from '../../pages/Setting/Operation/GroupRatioSettings.js';
import ModelRatioSettings from '../../pages/Setting/Operation/ModelRatioSettings.js';
import { API, showError, showSuccess } from '../helpers';
import SettingsChats from '../pages/Setting/Operation/SettingsChats.js';
import { API, showError, showSuccess } from '../../helpers';
import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
import { useTranslation } from 'react-i18next';
import ModelRatioNotSetEditor from '../pages/Setting/Operation/ModelRationNotSetEditor.js';
import ModelRatioNotSetEditor from '../../pages/Setting/Operation/ModelRationNotSetEditor.js';
const OperationSetting = () => {
const { t } = useTranslation();
@@ -9,10 +9,10 @@ import {
Space,
Card,
} from '@douyinfe/semi-ui';
import { API, showError, showSuccess, timestamp2string } from '../helpers';
import { API, showError, showSuccess, timestamp2string } from '../../helpers';
import { marked } from 'marked';
import { useTranslation } from 'react-i18next';
import { StatusContext } from '../context/Status/index.js';
import { StatusContext } from '../../context/Status/index.js';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
const OtherSetting = () => {
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { API, showError, showSuccess } from '../helpers';
import SettingsChats from '../pages/Setting/Operation/SettingsChats.js';
import { API, showError, showSuccess } from '../../helpers/index.js';
import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
import { useTranslation } from 'react-i18next';
import RequestRateLimit from '../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
import RequestRateLimit from '../../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
const RateLimitSetting = () => {
const { t } = useTranslation();
@@ -13,12 +13,12 @@ import {
} from '@douyinfe/semi-ui';
const { Text } = Typography;
import {
API,
removeTrailingSlash,
showError,
showSuccess,
verifyJSON,
} from '../helpers/utils';
import { API } from '../helpers/api';
verifyJSON
} from '../../helpers';
import axios from 'axios';
const SystemSetting = () => {
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
API,
@@ -8,26 +8,6 @@ import {
showError,
showSuccess,
timestamp2string,
} from '../helpers';
import {
Avatar,
Button,
Descriptions,
Form,
Layout,
Modal,
Popover,
Select,
Space,
Spin,
Table,
Tag,
Tooltip,
Checkbox,
} from '@douyinfe/semi-ui';
import { ITEMS_PER_PAGE } from '../constants';
import {
renderAudioModelPrice,
renderClaudeLogContent,
renderClaudeModelPrice,
@@ -39,13 +19,34 @@ import {
renderNumber,
renderQuota,
stringToColor,
} from '../helpers/render';
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
import { getLogOther } from '../helpers/other.js';
import { StyleContext } from '../context/Style/index.js';
import { IconInherit, IconRefresh, IconSetting } from '@douyinfe/semi-icons';
getLogOther,
renderModelTag,
} from '../../helpers';
const { Header } = Layout;
import {
Avatar,
Button,
Descriptions,
Modal,
Popover,
Select,
Space,
Spin,
Table,
Tag,
Tooltip,
Checkbox,
Card,
Typography,
Divider,
Input,
DatePicker,
} from '@douyinfe/semi-ui';
import { ITEMS_PER_PAGE } from '../../constants';
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
import { IconSetting, IconSearch, IconForward } from '@douyinfe/semi-icons';
const { Text } = Typography;
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
@@ -81,37 +82,37 @@ const LogsTable = () => {
switch (type) {
case 1:
return (
<Tag color='cyan' size='large'>
<Tag color='cyan' size='large' shape='circle'>
{t('充值')}
</Tag>
);
case 2:
return (
<Tag color='lime' size='large'>
<Tag color='lime' size='large' shape='circle'>
{t('消费')}
</Tag>
);
case 3:
return (
<Tag color='orange' size='large'>
<Tag color='orange' size='large' shape='circle'>
{t('管理')}
</Tag>
);
case 4:
return (
<Tag color='purple' size='large'>
<Tag color='purple' size='large' shape='circle'>
{t('系统')}
</Tag>
);
case 5:
return (
<Tag color='red' size='large'>
<Tag color='red' size='large' shape='circle'>
{t('错误')}
</Tag>
);
default:
return (
<Tag color='grey' size='large'>
<Tag color='grey' size='large' shape='circle'>
{t('未知')}
</Tag>
);
@@ -121,13 +122,13 @@ const LogsTable = () => {
function renderIsStream(bool) {
if (bool) {
return (
<Tag color='blue' size='large'>
<Tag color='blue' size='large' shape='circle'>
{t('流')}
</Tag>
);
} else {
return (
<Tag color='purple' size='large'>
<Tag color='purple' size='large' shape='circle'>
{t('非流')}
</Tag>
);
@@ -138,21 +139,21 @@ const LogsTable = () => {
const time = parseInt(type);
if (time < 101) {
return (
<Tag color='green' size='large'>
<Tag color='green' size='large' shape='circle'>
{' '}
{time} s{' '}
</Tag>
);
} else if (time < 300) {
return (
<Tag color='orange' size='large'>
<Tag color='orange' size='large' shape='circle'>
{' '}
{time} s{' '}
</Tag>
);
} else {
return (
<Tag color='red' size='large'>
<Tag color='red' size='large' shape='circle'>
{' '}
{time} s{' '}
</Tag>
@@ -165,21 +166,21 @@ const LogsTable = () => {
time = time.toFixed(1);
if (time < 3) {
return (
<Tag color='green' size='large'>
<Tag color='green' size='large' shape='circle'>
{' '}
{time} s{' '}
</Tag>
);
} else if (time < 10) {
return (
<Tag color='orange' size='large'>
<Tag color='orange' size='large' shape='circle'>
{' '}
{time} s{' '}
</Tag>
);
} else {
return (
<Tag color='red' size='large'>
<Tag color='red' size='large' shape='circle'>
{' '}
{time} s{' '}
</Tag>
@@ -194,18 +195,11 @@ const LogsTable = () => {
other?.upstream_model_name &&
other?.upstream_model_name !== '';
if (!modelMapped) {
return (
<Tag
color={stringToColor(record.model_name)}
size='large'
onClick={(event) => {
copyText(event, record.model_name).then((r) => {});
}}
>
{' '}
{record.model_name}{' '}
</Tag>
);
return renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => { });
},
});
} else {
return (
<>
@@ -214,57 +208,43 @@ const LogsTable = () => {
content={
<div style={{ padding: 10 }}>
<Space vertical align={'start'}>
<Tag
color={stringToColor(record.model_name)}
size='large'
onClick={(event) => {
copyText(event, record.model_name).then((r) => {});
}}
>
{t('请求并计费模型')} {record.model_name}{' '}
</Tag>
<Tag
color={stringToColor(other.upstream_model_name)}
size='large'
onClick={(event) => {
copyText(event, other.upstream_model_name).then(
(r) => {},
);
}}
>
{t('实际模型')} {other.upstream_model_name}{' '}
</Tag>
<div className='flex items-center'>
<Text strong style={{ marginRight: 8 }}>
{t('请求并计费模型')}:
</Text>
{renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => { });
},
})}
</div>
<div className='flex items-center'>
<Text strong style={{ marginRight: 8 }}>
{t('实际模型')}:
</Text>
{renderModelTag(other.upstream_model_name, {
onClick: (event) => {
copyText(event, other.upstream_model_name).then(
(r) => { },
);
},
})}
</div>
</Space>
</div>
}
>
<Tag
color={stringToColor(record.model_name)}
size='large'
onClick={(event) => {
copyText(event, record.model_name).then((r) => {});
}}
suffixIcon={
<IconRefresh
style={{ width: '0.8em', height: '0.8em', opacity: 0.6 }}
{renderModelTag(record.model_name, {
onClick: (event) => {
copyText(event, record.model_name).then((r) => { });
},
suffixIcon: (
<IconForward
style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }}
/>
}
>
{' '}
{record.model_name}{' '}
</Tag>
),
})}
</Popover>
{/*<Tooltip content={t('实际模型')}>*/}
{/* <Tag*/}
{/* color={stringToColor(other.upstream_model_name)}*/}
{/* size='large'*/}
{/* onClick={(event) => {*/}
{/* copyText(event, other.upstream_model_name).then(r => {});*/}
{/* }}*/}
{/* >*/}
{/* {' '}{other.upstream_model_name}{' '}*/}
{/* </Tag>*/}
{/*</Tooltip>*/}
</Space>
</>
);
@@ -386,6 +366,7 @@ const LogsTable = () => {
<Tag
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
>
{' '}
{text}{' '}
@@ -437,6 +418,7 @@ const LogsTable = () => {
<Tag
color='grey'
size='large'
shape='circle'
onClick={(event) => {
//cancel the row click event
copyText(event, text);
@@ -600,6 +582,7 @@ const LogsTable = () => {
key: COLUMN_KEYS.DETAILS,
title: t('详情'),
dataIndex: 'content',
fixed: 'right',
render: (text, record, index) => {
let other = getLogOther(record.other);
if (other == null || record.type !== 2) {
@@ -620,21 +603,21 @@ const LogsTable = () => {
}
let content = other?.claude
? renderClaudeModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
)
other.model_ratio,
other.model_price,
other.group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
other.cache_creation_tokens || 0,
other.cache_creation_ratio || 1.0,
)
: renderModelPriceSimple(
other.model_ratio,
other.model_price,
other.group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
);
other.model_ratio,
other.model_price,
other.group_ratio,
other.cache_tokens || 0,
other.cache_ratio || 1.0,
);
return (
<Paragraph
ellipsis={{
@@ -673,15 +656,29 @@ const LogsTable = () => {
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
footer={
<>
<Button onClick={() => initDefaultColumns()}>{t('重置')}</Button>
<Button onClick={() => setShowColumnSelector(false)}>
<div className='flex justify-end'>
<Button
theme='light'
onClick={() => initDefaultColumns()}
className='!rounded-full'
>
{t('重置')}
</Button>
<Button
theme='light'
onClick={() => setShowColumnSelector(false)}
className='!rounded-full'
>
{t('取消')}
</Button>
<Button type='primary' onClick={() => setShowColumnSelector(false)}>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
className='!rounded-full'
>
{t('确定')}
</Button>
</>
</div>
}
>
<div style={{ marginBottom: 20 }}>
@@ -697,15 +694,8 @@ const LogsTable = () => {
</Checkbox>
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
maxHeight: '400px',
overflowY: 'auto',
border: '1px solid var(--semi-color-border)',
borderRadius: '6px',
padding: '16px',
}}
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
style={{ border: '1px solid var(--semi-color-border)' }}
>
{allColumns.map((column) => {
// Skip admin-only columns for non-admin users
@@ -719,10 +709,7 @@ const LogsTable = () => {
}
return (
<div
key={column.key}
style={{ width: '50%', marginBottom: 16, paddingRight: 8 }}
>
<div key={column.key} className='w-1/2 mb-4 pr-2'>
<Checkbox
checked={!!visibleColumns[column.key]}
onChange={(e) =>
@@ -739,7 +726,6 @@ const LogsTable = () => {
);
};
const [styleState, styleDispatch] = useContext(StyleContext);
const [logs, setLogs] = useState([]);
const [expandData, setExpandData] = useState({});
const [showStat, setShowStat] = useState(false);
@@ -921,27 +907,27 @@ const LogsTable = () => {
key: t('日志详情'),
value: other?.claude
? renderClaudeLogContent(
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other.cache_ratio || 1.0,
other.cache_creation_ratio || 1.0,
)
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other.cache_ratio || 1.0,
other.cache_creation_ratio || 1.0,
)
: renderLogContent(
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
false,
1.0,
undefined,
other.web_search || false,
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
),
other?.model_ratio,
other.completion_ratio,
other.model_price,
other.group_ratio,
other?.user_group_ratio,
false,
1.0,
undefined,
other.web_search || false,
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
),
});
}
if (logs[i].type === 2) {
@@ -1007,6 +993,9 @@ const LogsTable = () => {
other?.file_search || false,
other?.file_search_call_count || 0,
other?.file_search_price || 0,
other?.audio_input_seperate_price || false,
other?.audio_input_token_count || 0,
other?.audio_input_price || 0,
);
}
expandDataLocal.push({
@@ -1056,7 +1045,7 @@ const LogsTable = () => {
const handlePageChange = (page) => {
setActivePage(page);
loadLogs(page, pageSize, logType).then((r) => {});
loadLogs(page, pageSize, logType).then((r) => { });
};
const handlePageSizeChange = async (size) => {
@@ -1101,89 +1090,73 @@ const LogsTable = () => {
return <Descriptions data={expandData[record.key]} />;
};
// 检查是否有任何记录有展开内容
const hasExpandableRows = () => {
return logs.some(
(log) => expandData[log.key] && expandData[log.key].length > 0,
);
};
return (
<>
{renderColumnSelector()}
<Layout>
<Header>
<Spin spinning={loadingStat}>
<Space>
<Tag
color='blue'
size='large'
style={{
padding: 15,
borderRadius: '8px',
fontWeight: 500,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}
>
{t('消耗额度')}: {renderQuota(stat.quota)}
</Tag>
<Tag
color='pink'
size='large'
style={{
padding: 15,
borderRadius: '8px',
fontWeight: 500,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}
>
RPM: {stat.rpm}
</Tag>
<Tag
color='white'
size='large'
style={{
padding: 15,
border: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
borderRadius: '8px',
fontWeight: 500,
}}
>
TPM: {stat.tpm}
</Tag>
</Space>
</Spin>
</Header>
<Form layout='horizontal' style={{ marginTop: 10 }}>
<>
<Form.Section>
<div style={{ marginBottom: 10 }}>
{styleState.isMobile ? (
<div>
<Form.DatePicker
field='start_timestamp'
label={t('起始时间')}
style={{ width: 272 }}
initValue={start_timestamp}
type='dateTime'
onChange={(value) => {
console.log(value);
handleInputChange(value, 'start_timestamp');
}}
/>
<Form.DatePicker
field='end_timestamp'
fluid
label={t('结束时间')}
style={{ width: 272 }}
initValue={end_timestamp}
type='dateTime'
onChange={(value) =>
handleInputChange(value, 'end_timestamp')
}
/>
</div>
) : (
<Form.DatePicker
field='range_timestamp'
label={t('时间范围')}
initValue={[start_timestamp, end_timestamp]}
<Card
className='!rounded-2xl mb-4'
title={
<div className='flex flex-col w-full'>
<Spin spinning={loadingStat}>
<Space>
<Tag
color='blue'
size='large'
style={{
padding: 15,
borderRadius: '9999px',
fontWeight: 500,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}
>
{t('消耗额度')}: {renderQuota(stat.quota)}
</Tag>
<Tag
color='pink'
size='large'
style={{
padding: 15,
borderRadius: '9999px',
fontWeight: 500,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}
>
RPM: {stat.rpm}
</Tag>
<Tag
color='white'
size='large'
style={{
padding: 15,
border: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
borderRadius: '9999px',
fontWeight: 500,
}}
>
TPM: {stat.tpm}
</Tag>
</Space>
</Spin>
<Divider margin='12px' />
{/* 搜索表单区域 */}
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
{/* 时间选择器 */}
<div className='col-span-1 lg:col-span-2'>
<DatePicker
className='w-full'
value={[start_timestamp, end_timestamp]}
type='dateTimeRange'
name='range_timestamp'
onChange={(value) => {
if (Array.isArray(value) && value.length === 2) {
handleInputChange(value[0], 'start_timestamp');
@@ -1191,100 +1164,118 @@ const LogsTable = () => {
}
}}
/>
</div>
{/* 日志类型选择器 */}
<Select
value={logType.toString()}
placeholder={t('日志类型')}
className='!rounded-full'
onChange={(value) => {
setLogType(parseInt(value));
loadLogs(0, pageSize, parseInt(value));
}}
>
<Select.Option value='0'>{t('全部')}</Select.Option>
<Select.Option value='1'>{t('充值')}</Select.Option>
<Select.Option value='2'>{t('消费')}</Select.Option>
<Select.Option value='3'>{t('管理')}</Select.Option>
<Select.Option value='4'>{t('系统')}</Select.Option>
<Select.Option value='5'>{t('错误')}</Select.Option>
</Select>
{/* 其他搜索字段 */}
<Input
prefix={<IconSearch />}
placeholder={t('令牌名称')}
value={token_name}
onChange={(value) => handleInputChange(value, 'token_name')}
className='!rounded-full'
showClear
/>
<Input
prefix={<IconSearch />}
placeholder={t('模型名称')}
value={model_name}
onChange={(value) => handleInputChange(value, 'model_name')}
className='!rounded-full'
showClear
/>
<Input
prefix={<IconSearch />}
placeholder={t('分组')}
value={group}
onChange={(value) => handleInputChange(value, 'group')}
className='!rounded-full'
showClear
/>
{isAdminUser && (
<>
<Input
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
value={channel}
onChange={(value) => handleInputChange(value, 'channel')}
className='!rounded-full'
showClear
/>
<Input
prefix={<IconSearch />}
placeholder={t('用户名称')}
value={username}
onChange={(value) => handleInputChange(value, 'username')}
className='!rounded-full'
showClear
/>
</>
)}
</div>
</Form.Section>
<Form.Input
field='token_name'
label={t('令牌名称')}
value={token_name}
placeholder={t('可选值')}
name='token_name'
onChange={(value) => handleInputChange(value, 'token_name')}
/>
<Form.Input
field='model_name'
label={t('模型名称')}
value={model_name}
placeholder={t('可选值')}
name='model_name'
onChange={(value) => handleInputChange(value, 'model_name')}
/>
<Form.Input
field='group'
label={t('分组')}
value={group}
placeholder={t('可选值')}
name='group'
onChange={(value) => handleInputChange(value, 'group')}
/>
{isAdminUser && (
<>
<Form.Input
field='channel'
label={t('渠道 ID')}
value={channel}
placeholder={t('可选值')}
name='channel'
onChange={(value) => handleInputChange(value, 'channel')}
/>
<Form.Input
field='username'
label={t('用户名称')}
value={username}
placeholder={t('可选值')}
name='username'
onChange={(value) => handleInputChange(value, 'username')}
/>
</>
)}
<Button
label={t('查询')}
type='primary'
htmlType='submit'
className='btn-margin-right'
onClick={refresh}
loading={loading}
style={{ marginTop: 24 }}
>
{t('查询')}
</Button>
<Form.Section></Form.Section>
</>
</Form>
<div style={{ marginTop: 10 }}>
<Select
defaultValue='0'
style={{ width: 120 }}
onChange={(value) => {
setLogType(parseInt(value));
loadLogs(0, pageSize, parseInt(value));
}}
>
<Select.Option value='0'>{t('全部')}</Select.Option>
<Select.Option value='1'>{t('充值')}</Select.Option>
<Select.Option value='2'>{t('消费')}</Select.Option>
<Select.Option value='3'>{t('管理')}</Select.Option>
<Select.Option value='4'>{t('系统')}</Select.Option>
<Select.Option value='5'>{t('错误')}</Select.Option>
</Select>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
style={{ marginLeft: 8 }}
>
{t('列设置')}
</Button>
</div>
{/* 操作按钮区域 */}
<div className='flex justify-between items-center pt-2'>
<div></div>
<div className='flex gap-2'>
<Button
type='primary'
onClick={refresh}
loading={loading}
className='!rounded-full'
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className='!rounded-full'
>
{t('列设置')}
</Button>
</div>
</div>
</div>
</div>
}
shadows='always'
bordered={false}
>
<Table
style={{ marginTop: 5 }}
columns={getVisibleColumns()}
expandedRowRender={expandRowRender}
expandRowByClick={true}
{...(hasExpandableRows() && {
expandedRowRender: expandRowRender,
expandRowByClick: true,
rowExpandable: (record) => expandData[record.key] && expandData[record.key].length > 0
})}
dataSource={logs}
rowKey='key'
loading={loading}
scroll={{ x: 'max-content' }}
className='rounded-xl overflow-hidden'
size='middle'
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
@@ -1295,7 +1286,7 @@ const LogsTable = () => {
currentPage: activePage,
pageSize: pageSize,
total: logCount,
pageSizeOpts: [10, 20, 50, 100],
pageSizeOptions: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: (size) => {
handlePageSizeChange(size);
@@ -1303,7 +1294,7 @@ const LogsTable = () => {
onPageChange: handlePageChange,
}}
/>
</Layout>
</Card>
</>
);
};
+908
View File
@@ -0,0 +1,908 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
API,
copy,
isAdmin,
showError,
showSuccess,
timestamp2string,
} from '../../helpers';
import {
Button,
Card,
Checkbox,
DatePicker,
Divider,
ImagePreview,
Input,
Layout,
Modal,
Progress,
Skeleton,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import { ITEMS_PER_PAGE } from '../../constants';
import {
IconEyeOpened,
IconSearch,
IconSetting,
} from '@douyinfe/semi-icons';
const { Text } = Typography;
const colors = [
'amber',
'blue',
'cyan',
'green',
'grey',
'indigo',
'light-blue',
'lime',
'orange',
'pink',
'purple',
'red',
'teal',
'violet',
'yellow',
];
// 定义列键值常量
const COLUMN_KEYS = {
SUBMIT_TIME: 'submit_time',
DURATION: 'duration',
CHANNEL: 'channel',
TYPE: 'type',
TASK_ID: 'task_id',
SUBMIT_RESULT: 'submit_result',
TASK_STATUS: 'task_status',
PROGRESS: 'progress',
IMAGE: 'image',
PROMPT: 'prompt',
PROMPT_EN: 'prompt_en',
FAIL_REASON: 'fail_reason',
};
const LogsTable = () => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalContent, setModalContent] = useState('');
// 列可见性状态
const [visibleColumns, setVisibleColumns] = useState({});
const [showColumnSelector, setShowColumnSelector] = useState(false);
const isAdminUser = isAdmin();
// 加载保存的列偏好设置
useEffect(() => {
const savedColumns = localStorage.getItem('mj-logs-table-columns');
if (savedColumns) {
try {
const parsed = JSON.parse(savedColumns);
const defaults = getDefaultColumnVisibility();
const merged = { ...defaults, ...parsed };
setVisibleColumns(merged);
} catch (e) {
console.error('Failed to parse saved column preferences', e);
initDefaultColumns();
}
} else {
initDefaultColumns();
}
}, []);
// 获取默认列可见性
const getDefaultColumnVisibility = () => {
return {
[COLUMN_KEYS.SUBMIT_TIME]: true,
[COLUMN_KEYS.DURATION]: true,
[COLUMN_KEYS.CHANNEL]: isAdminUser,
[COLUMN_KEYS.TYPE]: true,
[COLUMN_KEYS.TASK_ID]: true,
[COLUMN_KEYS.SUBMIT_RESULT]: isAdminUser,
[COLUMN_KEYS.TASK_STATUS]: true,
[COLUMN_KEYS.PROGRESS]: true,
[COLUMN_KEYS.IMAGE]: true,
[COLUMN_KEYS.PROMPT]: true,
[COLUMN_KEYS.PROMPT_EN]: true,
[COLUMN_KEYS.FAIL_REASON]: true,
};
};
// 初始化默认列可见性
const initDefaultColumns = () => {
const defaults = getDefaultColumnVisibility();
setVisibleColumns(defaults);
localStorage.setItem('mj-logs-table-columns', JSON.stringify(defaults));
};
// 处理列可见性变化
const handleColumnVisibilityChange = (columnKey, checked) => {
const updatedColumns = { ...visibleColumns, [columnKey]: checked };
setVisibleColumns(updatedColumns);
};
// 处理全选
const handleSelectAll = (checked) => {
const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
const updatedColumns = {};
allKeys.forEach((key) => {
if ((key === COLUMN_KEYS.CHANNEL || key === COLUMN_KEYS.SUBMIT_RESULT) && !isAdminUser) {
updatedColumns[key] = false;
} else {
updatedColumns[key] = checked;
}
});
setVisibleColumns(updatedColumns);
};
// 更新表格时保存列可见性
useEffect(() => {
if (Object.keys(visibleColumns).length > 0) {
localStorage.setItem('mj-logs-table-columns', JSON.stringify(visibleColumns));
}
}, [visibleColumns]);
function renderType(type) {
switch (type) {
case 'IMAGINE':
return (
<Tag color='blue' size='large' shape='circle'>
{t('绘图')}
</Tag>
);
case 'UPSCALE':
return (
<Tag color='orange' size='large' shape='circle'>
{t('放大')}
</Tag>
);
case 'VARIATION':
return (
<Tag color='purple' size='large' shape='circle'>
{t('变换')}
</Tag>
);
case 'HIGH_VARIATION':
return (
<Tag color='purple' size='large' shape='circle'>
{t('强变换')}
</Tag>
);
case 'LOW_VARIATION':
return (
<Tag color='purple' size='large' shape='circle'>
{t('弱变换')}
</Tag>
);
case 'PAN':
return (
<Tag color='cyan' size='large' shape='circle'>
{t('平移')}
</Tag>
);
case 'DESCRIBE':
return (
<Tag color='yellow' size='large' shape='circle'>
{t('图生文')}
</Tag>
);
case 'BLEND':
return (
<Tag color='lime' size='large' shape='circle'>
{t('图混合')}
</Tag>
);
case 'UPLOAD':
return (
<Tag color='blue' size='large' shape='circle'>
上传文件
</Tag>
);
case 'SHORTEN':
return (
<Tag color='pink' size='large' shape='circle'>
{t('缩词')}
</Tag>
);
case 'REROLL':
return (
<Tag color='indigo' size='large' shape='circle'>
{t('重绘')}
</Tag>
);
case 'INPAINT':
return (
<Tag color='violet' size='large' shape='circle'>
{t('局部重绘-提交')}
</Tag>
);
case 'ZOOM':
return (
<Tag color='teal' size='large' shape='circle'>
{t('变焦')}
</Tag>
);
case 'CUSTOM_ZOOM':
return (
<Tag color='teal' size='large' shape='circle'>
{t('自定义变焦-提交')}
</Tag>
);
case 'MODAL':
return (
<Tag color='green' size='large' shape='circle'>
{t('窗口处理')}
</Tag>
);
case 'SWAP_FACE':
return (
<Tag color='light-green' size='large' shape='circle'>
{t('换脸')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
{t('未知')}
</Tag>
);
}
}
function renderCode(code) {
switch (code) {
case 1:
return (
<Tag color='green' size='large' shape='circle'>
{t('已提交')}
</Tag>
);
case 21:
return (
<Tag color='lime' size='large' shape='circle'>
{t('等待中')}
</Tag>
);
case 22:
return (
<Tag color='orange' size='large' shape='circle'>
{t('重复提交')}
</Tag>
);
case 0:
return (
<Tag color='yellow' size='large' shape='circle'>
{t('未提交')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
{t('未知')}
</Tag>
);
}
}
function renderStatus(type) {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large' shape='circle'>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large' shape='circle'>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large' shape='circle'>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large' shape='circle'>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large' shape='circle'>
{t('失败')}
</Tag>
);
case 'MODAL':
return (
<Tag color='yellow' size='large' shape='circle'>
{t('窗口等待')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
{t('未知')}
</Tag>
);
}
}
const renderTimestamp = (timestampInSeconds) => {
const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
const year = date.getFullYear(); // 获取年份
const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
};
// 修改renderDuration函数以包含颜色逻辑
function renderDuration(submit_time, finishTime) {
if (!submit_time || !finishTime) return 'N/A';
const start = new Date(submit_time);
const finish = new Date(finishTime);
const durationMs = finish - start;
const durationSec = (durationMs / 1000).toFixed(1);
const color = durationSec > 60 ? 'red' : 'green';
return (
<Tag color={color} size='large' shape='circle'>
{durationSec} {t('秒')}
</Tag>
);
}
// 定义所有列
const allColumns = [
{
key: COLUMN_KEYS.SUBMIT_TIME,
title: t('提交时间'),
dataIndex: 'submit_time',
render: (text, record, index) => {
return <div>{renderTimestamp(text / 1000)}</div>;
},
},
{
key: COLUMN_KEYS.DURATION,
title: t('花费时间'),
dataIndex: 'finish_time',
render: (finish, record) => {
return renderDuration(record.submit_time, finish);
},
},
{
key: COLUMN_KEYS.CHANNEL,
title: t('渠道'),
dataIndex: 'channel_id',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return isAdminUser ? (
<div>
<Tag
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
onClick={() => {
copyText(text);
}}
>
{' '}
{text}{' '}
</Tag>
</div>
) : (
<></>
);
},
},
{
key: COLUMN_KEYS.TYPE,
title: t('类型'),
dataIndex: 'action',
render: (text, record, index) => {
return <div>{renderType(text)}</div>;
},
},
{
key: COLUMN_KEYS.TASK_ID,
title: t('任务ID'),
dataIndex: 'mj_id',
render: (text, record, index) => {
return <div>{text}</div>;
},
},
{
key: COLUMN_KEYS.SUBMIT_RESULT,
title: t('提交结果'),
dataIndex: 'code',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return isAdminUser ? <div>{renderCode(text)}</div> : <></>;
},
},
{
key: COLUMN_KEYS.TASK_STATUS,
title: t('任务状态'),
dataIndex: 'status',
className: isAdmin() ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return <div>{renderStatus(text)}</div>;
},
},
{
key: COLUMN_KEYS.PROGRESS,
title: t('进度'),
dataIndex: 'progress',
render: (text, record, index) => {
return (
<div>
{
<Progress
stroke={
record.status === 'FAILURE'
? 'var(--semi-color-warning)'
: null
}
percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true}
aria-label='drawing progress'
style={{ minWidth: '200px' }}
/>
}
</div>
);
},
},
{
key: COLUMN_KEYS.IMAGE,
title: t('结果图片'),
dataIndex: 'image_url',
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Button
onClick={() => {
setModalImageUrl(text);
setIsModalOpenurl(true);
}}
>
{t('查看图片')}
</Button>
);
},
},
{
key: COLUMN_KEYS.PROMPT,
title: 'Prompt',
dataIndex: 'prompt',
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.Text>
);
},
},
{
key: COLUMN_KEYS.PROMPT_EN,
title: 'PromptEn',
dataIndex: 'prompt_en',
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.Text>
);
},
},
{
key: COLUMN_KEYS.FAIL_REASON,
title: t('失败原因'),
dataIndex: 'fail_reason',
fixed: 'right',
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.Text>
);
},
},
];
// 根据可见性设置过滤列
const getVisibleColumns = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
};
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
const [logType, setLogType] = useState(0);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [showBanner, setShowBanner] = useState(false);
// 定义模态框图片URL的状态和更新函数
const [modalImageUrl, setModalImageUrl] = useState('');
let now = new Date();
// 初始化start_timestamp为前一天
const [inputs, setInputs] = useState({
channel_id: '',
mj_id: '',
start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000),
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
});
const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs;
const [stat, setStat] = useState({
quota: 0,
token: 0,
});
const handleInputChange = (value, name) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const setLogsFormat = (logs) => {
for (let i = 0; i < logs.length; i++) {
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
logs[i].key = '' + logs[i].id;
}
// data.key = '' + data.id
setLogs(logs);
setLogCount(logs.length + pageSize);
// console.log(logCount);
};
const loadLogs = async (startIdx, pageSize = ITEMS_PER_PAGE) => {
setLoading(true);
let url = '';
let localStartTimestamp = Date.parse(start_timestamp);
let localEndTimestamp = Date.parse(end_timestamp);
if (isAdminUser) {
url = `/api/mj/?p=${startIdx}&page_size=${pageSize}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
} else {
url = `/api/mj/self/?p=${startIdx}&page_size=${pageSize}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
}
const res = await API.get(url);
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setLogsFormat(data);
} else {
let newLogs = [...logs];
newLogs.splice(startIdx * pageSize, data.length, ...data);
setLogsFormat(newLogs);
}
} else {
showError(message);
}
setLoading(false);
};
const pageData = logs.slice(
(activePage - 1) * pageSize,
activePage * pageSize,
);
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(logs.length / pageSize) + 1) {
// In this case we have to load more data and then append them.
loadLogs(page - 1, pageSize).then((r) => { });
}
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('mj-page-size', size + '');
setPageSize(size);
setActivePage(1);
await loadLogs(0, size);
};
const refresh = async () => {
// setLoading(true);
setActivePage(1);
await loadLogs(0, pageSize);
};
const copyText = async (text) => {
if (await copy(text)) {
showSuccess(t('已复制:') + text);
} else {
// setSearchKeyword(text);
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
}
};
useEffect(() => {
const localPageSize = parseInt(localStorage.getItem('mj-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
loadLogs(0, localPageSize).then();
}, [logType]);
useEffect(() => {
const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');
if (mjNotifyEnabled !== 'true') {
setShowBanner(true);
}
}, []);
// 列选择器模态框
const renderColumnSelector = () => {
return (
<Modal
title={t('列设置')}
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button
theme="light"
onClick={() => initDefaultColumns()}
className="!rounded-full"
>
{t('重置')}
</Button>
<Button
theme="light"
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
>
{t('取消')}
</Button>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
>
{t('确定')}
</Button>
</div>
}
>
<div style={{ marginBottom: 20 }}>
<Checkbox
checked={Object.values(visibleColumns).every((v) => v === true)}
indeterminate={
Object.values(visibleColumns).some((v) => v === true) &&
!Object.values(visibleColumns).every((v) => v === true)
}
onChange={(e) => handleSelectAll(e.target.checked)}
>
{t('全选')}
</Checkbox>
</div>
<div className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4" style={{ border: '1px solid var(--semi-color-border)' }}>
{allColumns.map((column) => {
// 为非管理员用户跳过管理员专用列
if (
!isAdminUser &&
(column.key === COLUMN_KEYS.CHANNEL ||
column.key === COLUMN_KEYS.SUBMIT_RESULT)
) {
return null;
}
return (
<div key={column.key} className="w-1/2 mb-4 pr-2">
<Checkbox
checked={!!visibleColumns[column.key]}
onChange={(e) =>
handleColumnVisibilityChange(column.key, e.target.checked)
}
>
{column.title}
</Checkbox>
</div>
);
})}
</div>
</Modal>
);
};
return (
<>
{renderColumnSelector()}
<Layout>
<Card
className="!rounded-2xl mb-4"
title={
<div className="flex flex-col w-full">
<div className="flex flex-col md:flex-row justify-between items-center">
<div className="flex items-center text-orange-500 mb-2 md:mb-0">
<IconEyeOpened className="mr-2" />
{loading ? (
<Skeleton.Title
style={{
width: 300,
marginBottom: 0,
marginTop: 0
}}
/>
) : (
<Text>
{isAdminUser && showBanner
? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。')
: t('Midjourney 任务记录')}
</Text>
)}
</div>
</div>
<Divider margin="12px" />
{/* 搜索表单区域 */}
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 时间选择器 */}
<div className="col-span-1 lg:col-span-2">
<DatePicker
className="w-full"
value={[start_timestamp, end_timestamp]}
type='dateTimeRange'
onChange={(value) => {
if (Array.isArray(value) && value.length === 2) {
handleInputChange(value[0], 'start_timestamp');
handleInputChange(value[1], 'end_timestamp');
}
}}
/>
</div>
{/* 任务 ID */}
<Input
prefix={<IconSearch />}
placeholder={t('任务 ID')}
value={mj_id}
onChange={(value) => handleInputChange(value, 'mj_id')}
className="!rounded-full"
showClear
/>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Input
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
value={channel_id}
onChange={(value) => handleInputChange(value, 'channel_id')}
className="!rounded-full"
showClear
/>
)}
</div>
{/* 操作按钮区域 */}
<div className="flex justify-between items-center pt-2">
<div></div>
<div className="flex gap-2">
<Button
type='primary'
onClick={refresh}
loading={loading}
className="!rounded-full"
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full"
>
{t('列设置')}
</Button>
</div>
</div>
</div>
</div>
}
shadows='always'
bordered={false}
>
<Table
columns={getVisibleColumns()}
dataSource={pageData}
rowKey='key'
loading={loading}
scroll={{ x: 'max-content' }}
className="rounded-xl overflow-hidden"
size="middle"
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: logCount,
}),
currentPage: activePage,
pageSize: pageSize,
total: logCount,
pageSizeOptions: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: (size) => {
handlePageSizeChange(size);
},
onPageChange: handlePageChange,
}}
/>
</Card>
<Modal
visible={isModalOpen}
onOk={() => setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)}
closable={null}
bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
width={800} // 设置模态框宽度
>
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
</Modal>
<ImagePreview
src={modalImageUrl}
visible={isModalOpenurl}
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/>
</Layout>
</>
);
};
export default LogsTable;
+637
View File
@@ -0,0 +1,637 @@
import React, { useContext, useEffect, useRef, useMemo, useState } from 'react';
import { API, copy, showError, showInfo, showSuccess, getModelCategories, renderModelTag } from '../../helpers';
import { useTranslation } from 'react-i18next';
import {
Input,
Layout,
Modal,
Space,
Table,
Tag,
Tooltip,
Popover,
ImagePreview,
Button,
Card,
Tabs,
TabPane,
Dropdown,
} from '@douyinfe/semi-ui';
import {
IconVerify,
IconHelpCircle,
IconSearch,
IconCopy,
IconInfoCircle,
IconLayers,
} from '@douyinfe/semi-icons';
import { UserContext } from '../../context/User/index.js';
import { AlertCircle } from 'lucide-react';
const ModelPricing = () => {
const { t } = useTranslation();
const [filteredValue, setFilteredValue] = useState([]);
const compositionRef = useRef({ isComposition: false });
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [modalImageUrl, setModalImageUrl] = useState('');
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [selectedGroup, setSelectedGroup] = useState('default');
const [activeKey, setActiveKey] = useState('all');
const [pageSize, setPageSize] = useState(10);
const rowSelection = useMemo(
() => ({
onChange: (selectedRowKeys, selectedRows) => {
setSelectedRowKeys(selectedRowKeys);
},
}),
[],
);
const handleChange = (value) => {
if (compositionRef.current.isComposition) {
return;
}
const newFilteredValue = value ? [value] : [];
setFilteredValue(newFilteredValue);
};
const handleCompositionStart = () => {
compositionRef.current.isComposition = true;
};
const handleCompositionEnd = (event) => {
compositionRef.current.isComposition = false;
const value = event.target.value;
const newFilteredValue = value ? [value] : [];
setFilteredValue(newFilteredValue);
};
function renderQuotaType(type) {
switch (type) {
case 1:
return (
<Tag color='teal' size='large' shape='circle'>
{t('按次计费')}
</Tag>
);
case 0:
return (
<Tag color='violet' size='large' shape='circle'>
{t('按量计费')}
</Tag>
);
default:
return t('未知');
}
}
function renderAvailable(available) {
return available ? (
<Popover
content={
<div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
}
position='top'
key={available}
className="bg-green-50"
>
<IconVerify style={{ color: 'rgb(22 163 74)' }} size='large' />
</Popover>
) : null;
}
const columns = [
{
title: t('可用性'),
dataIndex: 'available',
render: (text, record, index) => {
return renderAvailable(record.enable_groups.includes(selectedGroup));
},
sorter: (a, b) => {
const aAvailable = a.enable_groups.includes(selectedGroup);
const bAvailable = b.enable_groups.includes(selectedGroup);
return Number(aAvailable) - Number(bAvailable);
},
defaultSortOrder: 'descend',
},
{
title: t('模型名称'),
dataIndex: 'model_name',
render: (text, record, index) => {
return renderModelTag(text, {
onClick: () => {
copyText(text);
}
});
},
onFilter: (value, record) =>
record.model_name.toLowerCase().includes(value.toLowerCase()),
filteredValue,
},
{
title: t('计费类型'),
dataIndex: 'quota_type',
render: (text, record, index) => {
return renderQuotaType(parseInt(text));
},
sorter: (a, b) => a.quota_type - b.quota_type,
},
{
title: t('可用分组'),
dataIndex: 'enable_groups',
render: (text, record, index) => {
return (
<Space wrap>
{text.map((group) => {
if (usableGroup[group]) {
if (group === selectedGroup) {
return (
<Tag color='blue' size='large' shape='circle' prefixIcon={<IconVerify />}>
{group}
</Tag>
);
} else {
return (
<Tag
color='blue'
size='large'
onClick={() => {
setSelectedGroup(group);
showInfo(
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
group: group,
ratio: groupRatio[group],
}),
);
}}
className="cursor-pointer hover:opacity-80 transition-opacity !rounded-full"
>
{group}
</Tag>
);
}
}
})}
</Space>
);
},
},
{
title: () => (
<div className="flex items-center space-x-1">
<span>{t('倍率')}</span>
<Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
<IconHelpCircle
className="text-blue-500 cursor-pointer"
onClick={() => {
setModalImageUrl('/ratio.png');
setIsModalOpenurl(true);
}}
/>
</Tooltip>
</div>
),
dataIndex: 'model_ratio',
render: (text, record, index) => {
let content = text;
let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
content = (
<div className="space-y-1">
<div className="text-gray-700">
{t('模型倍率')}{record.quota_type === 0 ? text : t('无')}
</div>
<div className="text-gray-700">
{t('补全倍率')}
{record.quota_type === 0 ? completionRatio : t('无')}
</div>
<div className="text-gray-700">
{t('分组倍率')}{groupRatio[selectedGroup]}
</div>
</div>
);
return content;
},
},
{
title: t('模型价格'),
dataIndex: 'model_price',
render: (text, record, index) => {
let content = text;
if (record.quota_type === 0) {
let inputRatioPrice =
record.model_ratio * 2 * groupRatio[selectedGroup];
let completionRatioPrice =
record.model_ratio *
record.completion_ratio *
2 *
groupRatio[selectedGroup];
content = (
<div className="space-y-1">
<div className="text-gray-700">
{t('提示')} ${inputRatioPrice.toFixed(3)} / 1M tokens
</div>
<div className="text-gray-700">
{t('补全')} ${completionRatioPrice.toFixed(3)} / 1M tokens
</div>
</div>
);
} else {
let price = parseFloat(text) * groupRatio[selectedGroup];
content = (
<div className="text-gray-700">
{t('模型价格')}${price.toFixed(3)}
</div>
);
}
return content;
},
},
];
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
const [userState, userDispatch] = useContext(UserContext);
const [groupRatio, setGroupRatio] = useState({});
const [usableGroup, setUsableGroup] = useState({});
const setModelsFormat = (models, groupRatio) => {
for (let i = 0; i < models.length; i++) {
models[i].key = models[i].model_name;
models[i].group_ratio = groupRatio[models[i].model_name];
}
models.sort((a, b) => {
return a.quota_type - b.quota_type;
});
models.sort((a, b) => {
if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) {
return -1;
} else if (
!a.model_name.startsWith('gpt') &&
b.model_name.startsWith('gpt')
) {
return 1;
} else {
return a.model_name.localeCompare(b.model_name);
}
});
setModels(models);
};
const loadPricing = async () => {
setLoading(true);
let url = '/api/pricing';
const res = await API.get(url);
const { success, message, data, group_ratio, usable_group } = res.data;
if (success) {
setGroupRatio(group_ratio);
setUsableGroup(usable_group);
setSelectedGroup(userState.user ? userState.user.group : 'default');
setModelsFormat(data, group_ratio);
} else {
showError(message);
}
setLoading(false);
};
const refresh = async () => {
await loadPricing();
};
const copyText = async (text) => {
if (await copy(text)) {
showSuccess(t('已复制:') + text);
} else {
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
}
};
useEffect(() => {
refresh().then();
}, []);
const modelCategories = getModelCategories(t);
const categoryCounts = useMemo(() => {
const counts = {};
if (models.length > 0) {
counts['all'] = models.length;
Object.entries(modelCategories).forEach(([key, category]) => {
if (key !== 'all') {
counts[key] = models.filter(model => category.filter(model)).length;
}
});
}
return counts;
}, [models, modelCategories]);
const renderArrow = (items, pos, handleArrowClick) => {
const style = {
width: 32,
height: 32,
margin: '0 12px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: '100%',
background: 'rgba(var(--semi-grey-1), 1)',
color: 'var(--semi-color-text)',
cursor: 'pointer',
};
return (
<Dropdown
render={
<Dropdown.Menu>
{items.map(item => {
const key = item.itemKey;
const modelCount = categoryCounts[key] || 0;
return (
<Dropdown.Item
key={item.itemKey}
onClick={() => setActiveKey(item.itemKey)}
icon={modelCategories[item.itemKey]?.icon}
>
<div className="flex items-center gap-2">
{modelCategories[item.itemKey]?.label || item.itemKey}
<Tag
color={activeKey === item.itemKey ? 'red' : 'grey'}
size='small'
shape='circle'
>
{modelCount}
</Tag>
</div>
</Dropdown.Item>
);
})}
</Dropdown.Menu>
}
>
<div style={style} onClick={handleArrowClick}>
{pos === 'start' ? '←' : '→'}
</div>
</Dropdown>
);
};
// 检查分类是否有对应的模型
const availableCategories = useMemo(() => {
if (!models.length) return ['all'];
return Object.entries(modelCategories).filter(([key, category]) => {
if (key === 'all') return true;
return models.some(model => category.filter(model));
}).map(([key]) => key);
}, [models]);
// 渲染标签页
const renderTabs = () => {
return (
<Tabs
renderArrow={renderArrow}
activeKey={activeKey}
type="card"
collapsible
onChange={key => setActiveKey(key)}
className="mt-2"
>
{Object.entries(modelCategories)
.filter(([key]) => availableCategories.includes(key))
.map(([key, category]) => {
const modelCount = categoryCounts[key] || 0;
return (
<TabPane
tab={
<span className="flex items-center gap-2">
{category.icon && <span className="w-4 h-4">{category.icon}</span>}
{category.label}
<Tag
color={activeKey === key ? 'red' : 'grey'}
size='small'
shape='circle'
>
{modelCount}
</Tag>
</span>
}
itemKey={key}
key={key}
/>
);
})}
</Tabs>
);
};
// 优化过滤逻辑
const filteredModels = useMemo(() => {
let result = models;
// 先按分类过滤
if (activeKey !== 'all') {
result = result.filter(model => modelCategories[activeKey].filter(model));
}
// 再按搜索词过滤
if (filteredValue.length > 0) {
const searchTerm = filteredValue[0].toLowerCase();
result = result.filter(model =>
model.model_name.toLowerCase().includes(searchTerm)
);
}
return result;
}, [activeKey, models, filteredValue]);
// 搜索和操作区组件
const SearchAndActions = useMemo(() => (
<Card className="!rounded-xl mb-6" bordered={false}>
<div className="flex flex-wrap items-center gap-4">
<div className="flex-1 min-w-[200px]">
<Input
prefix={<IconSearch />}
placeholder={t('模糊搜索模型名称')}
className="!rounded-lg"
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onChange={handleChange}
showClear
size="large"
/>
</div>
<Button
theme='light'
type='primary'
icon={<IconCopy />}
onClick={() => copyText(selectedRowKeys)}
disabled={selectedRowKeys.length === 0}
className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 text-white"
size="large"
>
{t('复制选中模型')}
</Button>
</div>
</Card>
), [selectedRowKeys, t]);
// 表格组件
const ModelTable = useMemo(() => (
<Card className="!rounded-xl overflow-hidden" bordered={false}>
<Table
columns={columns}
dataSource={filteredModels}
loading={loading}
rowSelection={rowSelection}
className="custom-table"
pagination={{
defaultPageSize: 10,
pageSize: pageSize,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: filteredModels.length,
}),
onPageSizeChange: (size) => setPageSize(size),
}}
/>
</Card>
), [filteredModels, loading, columns, rowSelection, pageSize, t]);
return (
<div className="bg-gray-50">
<Layout>
<Layout.Content>
<div className="flex justify-center p-4 sm:p-6 md:p-8">
<div className="w-full">
{/* 主卡片容器 */}
<Card className="!rounded-2xl shadow-lg border-0">
{/* 顶部状态卡片 */}
<Card
className="!rounded-2xl !border-0 !shadow-md overflow-hidden mb-6"
style={{
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
position: 'relative'
}}
bodyStyle={{ padding: 0 }}
>
{/* 装饰性背景元素 */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-white opacity-3 rounded-full"></div>
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div>
</div>
<div className="relative p-6 sm:p-8" style={{ color: 'white' }}>
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 lg:gap-6">
<div className="flex items-start">
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-white/10 flex items-center justify-center mr-3 sm:mr-4">
<IconLayers size="extra-large" className="text-white" />
</div>
<div className="flex-1 min-w-0">
<div className="text-base sm:text-lg font-semibold mb-1 sm:mb-2">
{t('模型定价')}
</div>
<div className="text-sm text-white/80">
{userState.user ? (
<div className="flex items-center">
<IconVerify className="mr-1.5 flex-shrink-0" size="small" />
<span className="truncate">
{t('当前分组')}: {userState.user.group}{t('倍率')}: {groupRatio[userState.user.group]}
</span>
</div>
) : (
<div className="flex items-center">
<AlertCircle size={14} className="mr-1.5 flex-shrink-0" />
<span className="truncate">
{t('未登录,使用默认分组倍率')}: {groupRatio['default']}
</span>
</div>
)}
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 sm:gap-3 mt-2 lg:mt-0">
<div
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
style={{ backdropFilter: 'blur(10px)' }}
>
<div className="text-xs text-white/70 mb-0.5">{t('分组倍率')}</div>
<div className="text-sm sm:text-base font-semibold">{groupRatio[selectedGroup] || '1.0'}x</div>
</div>
<div
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
style={{ backdropFilter: 'blur(10px)' }}
>
<div className="text-xs text-white/70 mb-0.5">{t('可用模型')}</div>
<div className="text-sm sm:text-base font-semibold">
{models.filter(m => m.enable_groups.includes(selectedGroup)).length}
</div>
</div>
<div
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
style={{ backdropFilter: 'blur(10px)' }}
>
<div className="text-xs text-white/70 mb-0.5">{t('计费类型')}</div>
<div className="text-sm sm:text-base font-semibold">2</div>
</div>
</div>
</div>
{/* 计费说明 */}
<div className="mt-4 sm:mt-5">
<div className="flex items-start">
<div
className="w-full flex items-start space-x-2 px-3 py-2 sm:px-4 sm:py-2.5 rounded-lg text-xs sm:text-sm"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
backdropFilter: 'blur(10px)'
}}
>
<IconInfoCircle className="flex-shrink-0 mt-0.5" size="small" />
<span>
{t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')}
</span>
</div>
</div>
</div>
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
</div>
</Card>
{/* 模型分类 Tabs */}
<div className="mb-6">
{renderTabs()}
{/* 搜索和表格区域 */}
{SearchAndActions}
{ModelTable}
</div>
{/* 倍率说明图预览 */}
<ImagePreview
src={modalImageUrl}
visible={isModalOpenurl}
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/>
</Card>
</div>
</div>
</Layout.Content>
</Layout>
</div>
);
};
export default ModelPricing;
@@ -5,23 +5,39 @@ import {
showError,
showSuccess,
timestamp2string,
} from '../helpers';
renderQuota
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../constants';
import { renderQuota } from '../helpers/render';
import { ITEMS_PER_PAGE } from '../../constants';
import {
Button,
Card,
Divider,
Form,
Dropdown,
Input,
Modal,
Popconfirm,
Popover,
Space,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import EditRedemption from '../pages/Redemption/EditRedemption';
import {
IconPlus,
IconCopy,
IconSearch,
IconEyeOpened,
IconEdit,
IconDelete,
IconStop,
IconPlay,
IconMore,
} from '@douyinfe/semi-icons';
import EditRedemption from '../../pages/Redemption/EditRedemption';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
function renderTimestamp(timestamp) {
return <>{timestamp2string(timestamp)}</>;
}
@@ -33,25 +49,25 @@ const RedemptionsTable = () => {
switch (status) {
case 1:
return (
<Tag color='green' size='large'>
<Tag color='green' size='large' shape='circle'>
{t('未使用')}
</Tag>
);
case 2:
return (
<Tag color='red' size='large'>
<Tag color='red' size='large' shape='circle'>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='grey' size='large'>
<Tag color='grey' size='large' shape='circle'>
{t('已使用')}
</Tag>
);
default:
return (
<Tag color='black' size='large'>
<Tag color='black' size='large' shape='circle'>
{t('未知状态')}
</Tag>
);
@@ -99,76 +115,108 @@ const RedemptionsTable = () => {
{
title: '',
dataIndex: 'operate',
render: (text, record, index) => (
<div>
<Popover content={record.key} style={{ padding: 20 }} position='top'>
<Button theme='light' type='tertiary' style={{ marginRight: 1 }}>
{t('查看')}
</Button>
</Popover>
<Button
theme='light'
type='secondary'
style={{ marginRight: 1 }}
onClick={async (text) => {
await copyText(record.key);
}}
>
{t('复制')}
</Button>
<Popconfirm
title={t('确定是否要删除此兑换码?')}
content={t('此修改将不可逆')}
okType={'danger'}
position={'left'}
onConfirm={() => {
manageRedemption(record.id, 'delete', record).then(() => {
removeRecord(record.key);
fixed: 'right',
render: (text, record, index) => {
// 创建更多操作的下拉菜单项
const moreMenuItems = [
{
node: 'item',
name: t('删除'),
icon: <IconDelete />,
type: 'danger',
onClick: () => {
Modal.confirm({
title: t('确定是否要删除此兑换码?'),
content: t('此修改将不可逆'),
onOk: () => {
manageRedemption(record.id, 'delete', record).then(() => {
removeRecord(record.key);
});
},
});
}}
>
<Button theme='light' type='danger' style={{ marginRight: 1 }}>
{t('删除')}
</Button>
</Popconfirm>
{record.status === 1 ? (
<Button
theme='light'
type='warning'
style={{ marginRight: 1 }}
onClick={async () => {
manageRedemption(record.id, 'disable', record);
}}
>
{t('禁用')}
</Button>
) : (
},
}
];
// 动态添加启用/禁用按钮
if (record.status === 1) {
moreMenuItems.push({
node: 'item',
name: t('禁用'),
icon: <IconStop />,
type: 'warning',
onClick: () => {
manageRedemption(record.id, 'disable', record);
},
});
} else {
moreMenuItems.push({
node: 'item',
name: t('启用'),
icon: <IconPlay />,
type: 'secondary',
onClick: () => {
manageRedemption(record.id, 'enable', record);
},
disabled: record.status === 3,
});
}
return (
<Space>
<Popover content={record.key} style={{ padding: 20 }} position='top'>
<Button
icon={<IconEyeOpened />}
theme='light'
type='tertiary'
size="small"
className="!rounded-full"
>
{t('查看')}
</Button>
</Popover>
<Button
icon={<IconCopy />}
theme='light'
type='secondary'
style={{ marginRight: 1 }}
size="small"
className="!rounded-full"
onClick={async () => {
manageRedemption(record.id, 'enable', record);
await copyText(record.key);
}}
disabled={record.status === 3}
>
{t('启用')}
{t('复制')}
</Button>
)}
<Button
theme='light'
type='tertiary'
style={{ marginRight: 1 }}
onClick={() => {
setEditingRedemption(record);
setShowEdit(true);
}}
disabled={record.status !== 1}
>
{t('编辑')}
</Button>
</div>
),
<Button
icon={<IconEdit />}
theme='light'
type='tertiary'
size="small"
className="!rounded-full"
onClick={() => {
setEditingRedemption(record);
setShowEdit(true);
}}
disabled={record.status !== 1}
>
{t('编辑')}
</Button>
<Dropdown
trigger='click'
position='bottomRight'
menu={moreMenuItems}
>
<Button
icon={<IconMore />}
theme='light'
type='tertiary'
size="small"
className="!rounded-full"
/>
</Dropdown>
</Space>
);
},
},
];
@@ -187,6 +235,11 @@ const RedemptionsTable = () => {
const closeEdit = () => {
setShowEdit(false);
setTimeout(() => {
setEditingRedemption({
id: undefined,
});
}, 500);
};
const setRedemptionFormat = (redeptions) => {
@@ -225,8 +278,11 @@ const RedemptionsTable = () => {
if (await copy(text)) {
showSuccess(t('已复制到剪贴板!'));
} else {
// setSearchKeyword(text);
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
Modal.error({
title: t('无法复制到剪贴板,请手动复制'),
content: text,
size: 'large'
});
}
};
@@ -245,13 +301,14 @@ const RedemptionsTable = () => {
.catch((reason) => {
showError(reason);
});
}, []);
}, [pageSize]);
const refresh = async () => {
await loadRedemptions(activePage - 1, pageSize);
};
const manageRedemption = async (id, action, record) => {
setLoading(true);
let data = { id };
let res;
switch (action) {
@@ -272,7 +329,6 @@ const RedemptionsTable = () => {
showSuccess(t('操作成功完成!'));
let redemption = res.data.data;
let newRedemptions = [...redemptions];
// let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
if (action === 'delete') {
} else {
record.status = redemption.status;
@@ -281,6 +337,7 @@ const RedemptionsTable = () => {
} else {
showError(message);
}
setLoading(false);
};
const searchRedemptions = async (keyword, page, pageSize) => {
@@ -333,8 +390,8 @@ const RedemptionsTable = () => {
let pageData = redemptions;
const rowSelection = {
onSelect: (record, selected) => {},
onSelectAll: (selected, selectedRows) => {},
onSelect: (record, selected) => { },
onSelectAll: (selected, selectedRows) => { },
onChange: (selectedRowKeys, selectedRows) => {
setSelectedKeys(selectedRows);
},
@@ -352,6 +409,80 @@ const RedemptionsTable = () => {
}
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-orange-500">
<IconEyeOpened className="mr-2" />
<Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
</div>
</div>
<Divider margin="12px" />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<Button
theme='light'
type='primary'
icon={<IconPlus />}
className="!rounded-full w-full md:w-auto"
onClick={() => {
setEditingRedemption({
id: undefined,
});
setShowEdit(true);
}}
>
{t('添加兑换码')}
</Button>
<Button
type='warning'
icon={<IconCopy />}
className="!rounded-full w-full md:w-auto"
onClick={async () => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个兑换码!'));
return;
}
let keys = '';
for (let i = 0; i < selectedKeys.length; i++) {
keys +=
selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
}
await copyText(keys);
}}
>
{t('复制所选兑换码到剪贴板')}
</Button>
</div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
<div className="relative w-full md:w-64">
<Input
prefix={<IconSearch />}
placeholder={t('关键字(id或者名称)')}
value={searchKeyword}
onChange={handleKeywordChange}
className="!rounded-full"
showClear
/>
</div>
<Button
type="primary"
onClick={() => {
searchRedemptions(searchKeyword, 1, pageSize).then();
}}
loading={searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
</div>
</div>
</div>
);
return (
<>
<EditRedemption
@@ -360,88 +491,47 @@ const RedemptionsTable = () => {
visiable={showEdit}
handleClose={closeEdit}
></EditRedemption>
<Form
onSubmit={() => {
searchRedemptions(searchKeyword, activePage, pageSize).then();
}}
>
<Form.Input
label={t('搜索关键字')}
field='keyword'
icon='search'
iconPosition='left'
placeholder={t('关键字(id或者名称)')}
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
/>
</Form>
<Divider style={{ margin: '5px 0 15px 0' }} />
<div>
<Button
theme='light'
type='primary'
style={{ marginRight: 8 }}
onClick={() => {
setEditingRedemption({
id: undefined,
});
setShowEdit(true);
}}
>
{t('添加兑换码')}
</Button>
<Button
label={t('复制所选兑换码')}
type='warning'
onClick={async () => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个兑换码!'));
return;
}
let keys = '';
for (let i = 0; i < selectedKeys.length; i++) {
keys +=
selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
}
await copyText(keys);
}}
>
{t('复制所选兑换码到剪贴板')}
</Button>
</div>
<Table
style={{ marginTop: 20 }}
columns={columns}
dataSource={pageData}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: tokenCount,
showSizeChanger: true,
pageSizeOpts: [10, 20, 50, 100],
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: tokenCount,
}),
onPageSizeChange: (size) => {
setPageSize(size);
setActivePage(1);
if (searchKeyword === '') {
loadRedemptions(1, size).then();
} else {
searchRedemptions(searchKeyword, 1, size).then();
}
},
onPageChange: handlePageChange,
}}
loading={loading}
rowSelection={rowSelection}
onRow={handleRow}
></Table>
<Card
className="!rounded-2xl"
title={renderHeader()}
shadows='always'
bordered={false}
>
<Table
columns={columns}
dataSource={pageData}
scroll={{ x: 'max-content' }}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: tokenCount,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: tokenCount,
}),
onPageSizeChange: (size) => {
setPageSize(size);
setActivePage(1);
if (searchKeyword === '') {
loadRedemptions(1, size).then();
} else {
searchRedemptions(searchKeyword, 1, size).then();
}
},
onPageChange: handlePageChange,
}}
loading={loading}
rowSelection={rowSelection}
onRow={handleRow}
className="rounded-xl overflow-hidden"
size="middle"
></Table>
</Card>
</>
);
};
+743
View File
@@ -0,0 +1,743 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
API,
copy,
isAdmin,
showError,
showSuccess,
timestamp2string,
} from '../../helpers';
import {
Button,
Card,
Checkbox,
DatePicker,
Divider,
Input,
Layout,
Modal,
Progress,
Skeleton,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import { ITEMS_PER_PAGE } from '../../constants';
import {
IconEyeOpened,
IconSearch,
IconSetting,
} from '@douyinfe/semi-icons';
const { Text } = Typography;
const colors = [
'amber',
'blue',
'cyan',
'green',
'grey',
'indigo',
'light-blue',
'lime',
'orange',
'pink',
'purple',
'red',
'teal',
'violet',
'yellow',
];
// 定义列键值常量
const COLUMN_KEYS = {
SUBMIT_TIME: 'submit_time',
FINISH_TIME: 'finish_time',
DURATION: 'duration',
CHANNEL: 'channel',
PLATFORM: 'platform',
TYPE: 'type',
TASK_ID: 'task_id',
TASK_STATUS: 'task_status',
PROGRESS: 'progress',
FAIL_REASON: 'fail_reason',
};
const renderTimestamp = (timestampInSeconds) => {
const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
const year = date.getFullYear(); // 获取年份
const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
};
function renderDuration(submit_time, finishTime) {
// 确保startTime和finishTime都是有效的时间戳
if (!submit_time || !finishTime) return 'N/A';
// 将时间戳转换为Date对象
const start = new Date(submit_time);
const finish = new Date(finishTime);
// 计算时间差(毫秒)
const durationMs = finish - start;
// 将时间差转换为秒,并保留一位小数
const durationSec = (durationMs / 1000).toFixed(1);
// 设置颜色:大于60秒则为红色,小于等于60秒则为绿色
const color = durationSec > 60 ? 'red' : 'green';
// 返回带有样式的颜色标签
return (
<Tag color={color} size='large'>
{durationSec}
</Tag>
);
}
const LogsTable = () => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalContent, setModalContent] = useState('');
// 列可见性状态
const [visibleColumns, setVisibleColumns] = useState({});
const [showColumnSelector, setShowColumnSelector] = useState(false);
const isAdminUser = isAdmin();
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
// 加载保存的列偏好设置
useEffect(() => {
const savedColumns = localStorage.getItem('task-logs-table-columns');
if (savedColumns) {
try {
const parsed = JSON.parse(savedColumns);
const defaults = getDefaultColumnVisibility();
const merged = { ...defaults, ...parsed };
setVisibleColumns(merged);
} catch (e) {
console.error('Failed to parse saved column preferences', e);
initDefaultColumns();
}
} else {
initDefaultColumns();
}
}, []);
// 获取默认列可见性
const getDefaultColumnVisibility = () => {
return {
[COLUMN_KEYS.SUBMIT_TIME]: true,
[COLUMN_KEYS.FINISH_TIME]: true,
[COLUMN_KEYS.DURATION]: true,
[COLUMN_KEYS.CHANNEL]: isAdminUser,
[COLUMN_KEYS.PLATFORM]: true,
[COLUMN_KEYS.TYPE]: true,
[COLUMN_KEYS.TASK_ID]: true,
[COLUMN_KEYS.TASK_STATUS]: true,
[COLUMN_KEYS.PROGRESS]: true,
[COLUMN_KEYS.FAIL_REASON]: true,
};
};
// 初始化默认列可见性
const initDefaultColumns = () => {
const defaults = getDefaultColumnVisibility();
setVisibleColumns(defaults);
localStorage.setItem('task-logs-table-columns', JSON.stringify(defaults));
};
// 处理列可见性变化
const handleColumnVisibilityChange = (columnKey, checked) => {
const updatedColumns = { ...visibleColumns, [columnKey]: checked };
setVisibleColumns(updatedColumns);
};
// 处理全选
const handleSelectAll = (checked) => {
const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
const updatedColumns = {};
allKeys.forEach((key) => {
if (key === COLUMN_KEYS.CHANNEL && !isAdminUser) {
updatedColumns[key] = false;
} else {
updatedColumns[key] = checked;
}
});
setVisibleColumns(updatedColumns);
};
// 更新表格时保存列可见性
useEffect(() => {
if (Object.keys(visibleColumns).length > 0) {
localStorage.setItem('task-logs-table-columns', JSON.stringify(visibleColumns));
}
}, [visibleColumns]);
const renderType = (type) => {
switch (type) {
case 'MUSIC':
return (
<Tag color='grey' size='large' shape='circle'>
{t('生成音乐')}
</Tag>
);
case 'LYRICS':
return (
<Tag color='pink' size='large' shape='circle'>
{t('生成歌词')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
{t('未知')}
</Tag>
);
}
};
const renderPlatform = (type) => {
switch (type) {
case 'suno':
return (
<Tag color='green' size='large' shape='circle'>
Suno
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
{t('未知')}
</Tag>
);
}
};
const renderStatus = (type) => {
switch (type) {
case 'SUCCESS':
return (
<Tag color='green' size='large' shape='circle'>
{t('成功')}
</Tag>
);
case 'NOT_START':
return (
<Tag color='grey' size='large' shape='circle'>
{t('未启动')}
</Tag>
);
case 'SUBMITTED':
return (
<Tag color='yellow' size='large' shape='circle'>
{t('队列中')}
</Tag>
);
case 'IN_PROGRESS':
return (
<Tag color='blue' size='large' shape='circle'>
{t('执行中')}
</Tag>
);
case 'FAILURE':
return (
<Tag color='red' size='large' shape='circle'>
{t('失败')}
</Tag>
);
case 'QUEUED':
return (
<Tag color='orange' size='large' shape='circle'>
{t('排队中')}
</Tag>
);
case 'UNKNOWN':
return (
<Tag color='white' size='large' shape='circle'>
{t('未知')}
</Tag>
);
case '':
return (
<Tag color='grey' size='large' shape='circle'>
{t('正在提交')}
</Tag>
);
default:
return (
<Tag color='white' size='large' shape='circle'>
{t('未知')}
</Tag>
);
}
};
// 定义所有列
const allColumns = [
{
key: COLUMN_KEYS.SUBMIT_TIME,
title: t('提交时间'),
dataIndex: 'submit_time',
render: (text, record, index) => {
return <div>{text ? renderTimestamp(text) : '-'}</div>;
},
},
{
key: COLUMN_KEYS.FINISH_TIME,
title: t('结束时间'),
dataIndex: 'finish_time',
render: (text, record, index) => {
return <div>{text ? renderTimestamp(text) : '-'}</div>;
},
},
{
key: COLUMN_KEYS.DURATION,
title: t('花费时间'),
dataIndex: 'finish_time',
render: (finish, record) => {
return <>{finish ? renderDuration(record.submit_time, finish) : '-'}</>;
},
},
{
key: COLUMN_KEYS.CHANNEL,
title: t('渠道'),
dataIndex: 'channel_id',
className: isAdminUser ? 'tableShow' : 'tableHiddle',
render: (text, record, index) => {
return isAdminUser ? (
<div>
<Tag
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
onClick={() => {
copyText(text);
}}
>
{text}
</Tag>
</div>
) : (
<></>
);
},
},
{
key: COLUMN_KEYS.PLATFORM,
title: t('平台'),
dataIndex: 'platform',
render: (text, record, index) => {
return <div>{renderPlatform(text)}</div>;
},
},
{
key: COLUMN_KEYS.TYPE,
title: t('类型'),
dataIndex: 'action',
render: (text, record, index) => {
return <div>{renderType(text)}</div>;
},
},
{
key: COLUMN_KEYS.TASK_ID,
title: t('任务ID'),
dataIndex: 'task_id',
render: (text, record, index) => {
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
onClick={() => {
setModalContent(JSON.stringify(record, null, 2));
setIsModalOpen(true);
}}
>
<div>{text}</div>
</Typography.Text>
);
},
},
{
key: COLUMN_KEYS.TASK_STATUS,
title: t('任务状态'),
dataIndex: 'status',
render: (text, record, index) => {
return <div>{renderStatus(text)}</div>;
},
},
{
key: COLUMN_KEYS.PROGRESS,
title: t('进度'),
dataIndex: 'progress',
render: (text, record, index) => {
return (
<div>
{
isNaN(text?.replace('%', '')) ? (
text || '-'
) : (
<Progress
stroke={
record.status === 'FAILURE'
? 'var(--semi-color-warning)'
: null
}
percent={text ? parseInt(text.replace('%', '')) : 0}
showInfo={true}
aria-label='task progress'
style={{ minWidth: '200px' }}
/>
)
}
</div>
);
},
},
{
key: COLUMN_KEYS.FAIL_REASON,
title: t('失败原因'),
dataIndex: 'fail_reason',
fixed: 'right',
render: (text, record, index) => {
if (!text) {
return t('无');
}
return (
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ width: 100 }}
onClick={() => {
setModalContent(text);
setIsModalOpen(true);
}}
>
{text}
</Typography.Text>
);
},
},
];
// 根据可见性设置过滤列
const getVisibleColumns = () => {
return allColumns.filter((column) => visibleColumns[column.key]);
};
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
const [logType] = useState(0);
let now = new Date();
// 初始化start_timestamp为前一天
let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const [inputs, setInputs] = useState({
channel_id: '',
task_id: '',
start_timestamp: timestamp2string(zeroNow.getTime() / 1000),
end_timestamp: '',
});
const { channel_id, task_id, start_timestamp, end_timestamp } = inputs;
const handleInputChange = (value, name) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const setLogsFormat = (logs) => {
for (let i = 0; i < logs.length; i++) {
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
logs[i].key = '' + logs[i].id;
}
// data.key = '' + data.id
setLogs(logs);
setLogCount(logs.length + ITEMS_PER_PAGE);
// console.log(logCount);
};
const loadLogs = async (startIdx, pageSize = ITEMS_PER_PAGE) => {
setLoading(true);
let url = '';
let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000);
let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000);
if (isAdminUser) {
url = `/api/task/?p=${startIdx}&page_size=${pageSize}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
} else {
url = `/api/task/self?p=${startIdx}&page_size=${pageSize}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
}
const res = await API.get(url);
let { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setLogsFormat(data);
} else {
let newLogs = [...logs];
newLogs.splice(startIdx * pageSize, data.length, ...data);
setLogsFormat(newLogs);
}
} else {
showError(message);
}
setLoading(false);
};
const pageData = logs.slice(
(activePage - 1) * pageSize,
activePage * pageSize,
);
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(logs.length / pageSize) + 1) {
loadLogs(page - 1, pageSize).then((r) => { });
}
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('task-page-size', size + '');
setPageSize(size);
setActivePage(1);
await loadLogs(0, size);
};
const refresh = async () => {
setActivePage(1);
await loadLogs(0, pageSize);
};
const copyText = async (text) => {
if (await copy(text)) {
showSuccess(t('已复制:') + text);
} else {
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
}
};
useEffect(() => {
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
setPageSize(localPageSize);
loadLogs(0, localPageSize).then();
}, [logType]);
// 列选择器模态框
const renderColumnSelector = () => {
return (
<Modal
title={t('列设置')}
visible={showColumnSelector}
onCancel={() => setShowColumnSelector(false)}
footer={
<div className="flex justify-end">
<Button
theme="light"
onClick={() => initDefaultColumns()}
className="!rounded-full"
>
{t('重置')}
</Button>
<Button
theme="light"
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
>
{t('取消')}
</Button>
<Button
type='primary'
onClick={() => setShowColumnSelector(false)}
className="!rounded-full"
>
{t('确定')}
</Button>
</div>
}
>
<div style={{ marginBottom: 20 }}>
<Checkbox
checked={Object.values(visibleColumns).every((v) => v === true)}
indeterminate={
Object.values(visibleColumns).some((v) => v === true) &&
!Object.values(visibleColumns).every((v) => v === true)
}
onChange={(e) => handleSelectAll(e.target.checked)}
>
{t('全选')}
</Checkbox>
</div>
<div className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4" style={{ border: '1px solid var(--semi-color-border)' }}>
{allColumns.map((column) => {
// 为非管理员用户跳过管理员专用列
if (!isAdminUser && column.key === COLUMN_KEYS.CHANNEL) {
return null;
}
return (
<div key={column.key} className="w-1/2 mb-4 pr-2">
<Checkbox
checked={!!visibleColumns[column.key]}
onChange={(e) =>
handleColumnVisibilityChange(column.key, e.target.checked)
}
>
{column.title}
</Checkbox>
</div>
);
})}
</div>
</Modal>
);
};
return (
<>
{renderColumnSelector()}
<Layout>
<Card
className="!rounded-2xl mb-4"
title={
<div className="flex flex-col w-full">
<div className="flex flex-col md:flex-row justify-between items-center">
<div className="flex items-center text-orange-500 mb-2 md:mb-0">
<IconEyeOpened className="mr-2" />
{loading ? (
<Skeleton.Title
style={{
width: 300,
marginBottom: 0,
marginTop: 0
}}
/>
) : (
<Text>{t('任务记录')}</Text>
)}
</div>
</div>
<Divider margin="12px" />
{/* 搜索表单区域 */}
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 时间选择器 */}
<div className="col-span-1 lg:col-span-2">
<DatePicker
className="w-full"
value={[start_timestamp, end_timestamp]}
type='dateTimeRange'
onChange={(value) => {
if (Array.isArray(value) && value.length === 2) {
handleInputChange(value[0], 'start_timestamp');
handleInputChange(value[1], 'end_timestamp');
}
}}
/>
</div>
{/* 任务 ID */}
<Input
prefix={<IconSearch />}
placeholder={t('任务 ID')}
value={task_id}
onChange={(value) => handleInputChange(value, 'task_id')}
className="!rounded-full"
showClear
/>
{/* 渠道 ID - 仅管理员可见 */}
{isAdminUser && (
<Input
prefix={<IconSearch />}
placeholder={t('渠道 ID')}
value={channel_id}
onChange={(value) => handleInputChange(value, 'channel_id')}
className="!rounded-full"
showClear
/>
)}
</div>
{/* 操作按钮区域 */}
<div className="flex justify-between items-center pt-2">
<div></div>
<div className="flex gap-2">
<Button
type='primary'
onClick={refresh}
loading={loading}
className="!rounded-full"
>
{t('查询')}
</Button>
<Button
theme='light'
type='tertiary'
icon={<IconSetting />}
onClick={() => setShowColumnSelector(true)}
className="!rounded-full"
>
{t('列设置')}
</Button>
</div>
</div>
</div>
</div>
}
shadows='always'
bordered={false}
>
<Table
columns={getVisibleColumns()}
dataSource={pageData}
rowKey='key'
loading={loading}
scroll={{ x: 'max-content' }}
className="rounded-xl overflow-hidden"
size="middle"
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: logCount,
}),
currentPage: activePage,
pageSize: pageSize,
total: logCount,
pageSizeOptions: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: (size) => {
handlePageSizeChange(size);
},
onPageChange: handlePageChange,
}}
/>
</Card>
<Modal
visible={isModalOpen}
onOk={() => setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)}
closable={null}
bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
width={800} // 设置模态框宽度
>
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
</Modal>
</Layout>
</>
);
};
export default LogsTable;
@@ -5,26 +5,36 @@ import {
showError,
showSuccess,
timestamp2string,
} from '../helpers';
renderGroup,
renderQuota
} from '../../helpers';
import { ITEMS_PER_PAGE } from '../constants';
import { renderGroup, renderQuota } from '../helpers/render';
import { ITEMS_PER_PAGE } from '../../constants';
import {
Button,
Divider,
Card,
Dropdown,
Form,
Modal,
Popconfirm,
Popover,
Space,
SplitButtonGroup,
Table,
Tag,
Input,
} from '@douyinfe/semi-ui';
import { IconTreeTriangleDown } from '@douyinfe/semi-icons';
import EditToken from '../pages/Token/EditToken';
import {
IconPlus,
IconCopy,
IconSearch,
IconTreeTriangleDown,
IconEyeOpened,
IconEdit,
IconDelete,
IconStop,
IconPlay,
IconMore,
} from '@douyinfe/semi-icons';
import EditToken from '../../pages/Token/EditToken';
import { useTranslation } from 'react-i18next';
function renderTimestamp(timestamp) {
@@ -39,38 +49,38 @@ const TokensTable = () => {
case 1:
if (model_limits_enabled) {
return (
<Tag color='green' size='large'>
<Tag color='green' size='large' shape='circle'>
{t('已启用:限制模型')}
</Tag>
);
} else {
return (
<Tag color='green' size='large'>
<Tag color='green' size='large' shape='circle'>
{t('已启用')}
</Tag>
);
}
case 2:
return (
<Tag color='red' size='large'>
<Tag color='red' size='large' shape='circle'>
{t('已禁用')}
</Tag>
);
case 3:
return (
<Tag color='yellow' size='large'>
<Tag color='yellow' size='large' shape='circle'>
{t('已过期')}
</Tag>
);
case 4:
return (
<Tag color='grey' size='large'>
<Tag color='grey' size='large' shape='circle'>
{t('已耗尽')}
</Tag>
);
default:
return (
<Tag color='black' size='large'>
<Tag color='black' size='large' shape='circle'>
{t('未知状态')}
</Tag>
);
@@ -111,11 +121,11 @@ const TokensTable = () => {
return (
<div>
{record.unlimited_quota ? (
<Tag size={'large'} color={'white'}>
<Tag size={'large'} color={'white'} shape='circle'>
{t('无限制')}
</Tag>
) : (
<Tag size={'large'} color={'light-blue'}>
<Tag size={'large'} color={'light-blue'} shape='circle'>
{renderQuota(parseInt(text))}
</Tag>
)}
@@ -144,6 +154,7 @@ const TokensTable = () => {
{
title: '',
dataIndex: 'operate',
fixed: 'right',
render: (text, record, index) => {
let chats = localStorage.getItem('chats');
let chatsArray = [];
@@ -151,16 +162,11 @@ const TokensTable = () => {
if (shouldUseCustom) {
try {
// console.log(chats);
chats = JSON.parse(chats);
// check chats is array
if (Array.isArray(chats)) {
for (let i = 0; i < chats.length; i++) {
let chat = {};
chat.node = 'item';
// c is a map
// chat.key = chats[i].name;
// console.log(chats[i])
for (let key in chats[i]) {
if (chats[i].hasOwnProperty(key)) {
chat.key = i;
@@ -178,33 +184,72 @@ const TokensTable = () => {
showError(t('聊天链接配置错误,请联系管理员'));
}
}
// 创建更多操作的下拉菜单项
const moreMenuItems = [
{
node: 'item',
name: t('查看'),
icon: <IconEyeOpened />,
onClick: () => {
Modal.info({
title: t('令牌详情'),
content: 'sk-' + record.key,
size: 'large',
});
},
},
{
node: 'item',
name: t('删除'),
icon: <IconDelete />,
type: 'danger',
onClick: () => {
Modal.confirm({
title: t('确定是否要删除此令牌?'),
content: t('此修改将不可逆'),
onOk: () => {
manageToken(record.id, 'delete', record).then(() => {
removeRecord(record.key);
});
},
});
},
}
];
// 动态添加启用/禁用按钮
if (record.status === 1) {
moreMenuItems.push({
node: 'item',
name: t('禁用'),
icon: <IconStop />,
type: 'warning',
onClick: () => {
manageToken(record.id, 'disable', record);
},
});
} else {
moreMenuItems.push({
node: 'item',
name: t('启用'),
icon: <IconPlay />,
type: 'secondary',
onClick: () => {
manageToken(record.id, 'enable', record);
},
});
}
return (
<div>
<Popover
content={'sk-' + record.key}
style={{ padding: 20 }}
position='top'
>
<Button theme='light' type='tertiary' style={{ marginRight: 1 }}>
{t('查看')}
</Button>
</Popover>
<Button
theme='light'
type='secondary'
style={{ marginRight: 1 }}
onClick={async (text) => {
await copyText('sk-' + record.key);
}}
>
{t('复制')}
</Button>
<Space wrap>
<SplitButtonGroup
style={{ marginRight: 1 }}
className="!rounded-full overflow-hidden"
aria-label={t('项目操作按钮组')}
>
<Button
theme='light'
size="small"
style={{ color: 'rgba(var(--semi-teal-7), 1)' }}
onClick={() => {
if (chatsArray.length === 0) {
@@ -227,56 +272,35 @@ const TokensTable = () => {
>
<Button
style={{
padding: '8px 4px',
padding: '4px 4px',
color: 'rgba(var(--semi-teal-7), 1)',
}}
type='primary'
icon={<IconTreeTriangleDown />}
size="small"
></Button>
</Dropdown>
</SplitButtonGroup>
<Popconfirm
title={t('确定是否要删除此令牌?')}
content={t('此修改将不可逆')}
okType={'danger'}
position={'left'}
onConfirm={() => {
manageToken(record.id, 'delete', record).then(() => {
removeRecord(record.key);
});
<Button
icon={<IconCopy />}
theme='light'
type='secondary'
size="small"
className="!rounded-full"
onClick={async (text) => {
await copyText('sk-' + record.key);
}}
>
<Button theme='light' type='danger' style={{ marginRight: 1 }}>
{t('删除')}
</Button>
</Popconfirm>
{record.status === 1 ? (
<Button
theme='light'
type='warning'
style={{ marginRight: 1 }}
onClick={async () => {
manageToken(record.id, 'disable', record);
}}
>
{t('禁用')}
</Button>
) : (
<Button
theme='light'
type='secondary'
style={{ marginRight: 1 }}
onClick={async () => {
manageToken(record.id, 'enable', record);
}}
>
{t('启用')}
</Button>
)}
{t('复制')}
</Button>
<Button
icon={<IconEdit />}
theme='light'
type='tertiary'
style={{ marginRight: 1 }}
size="small"
className="!rounded-full"
onClick={() => {
setEditingToken(record);
setShowEdit(true);
@@ -284,7 +308,21 @@ const TokensTable = () => {
>
{t('编辑')}
</Button>
</div>
<Dropdown
trigger='click'
position='bottomRight'
menu={moreMenuItems}
>
<Button
icon={<IconMore />}
theme='light'
type='tertiary'
size="small"
className="!rounded-full"
/>
</Dropdown>
</Space>
);
},
},
@@ -362,7 +400,6 @@ const TokensTable = () => {
};
const onOpenLink = async (type, url, record) => {
// console.log(type, url, key);
let status = localStorage.getItem('status');
let serverAddress = '';
if (status) {
@@ -379,6 +416,8 @@ const TokensTable = () => {
window.open(url, '_blank');
};
useEffect(() => {
loadTokens(0)
.then()
@@ -421,11 +460,9 @@ const TokensTable = () => {
showSuccess('操作成功完成!');
let token = res.data.data;
let newTokens = [...tokens];
// let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
if (action === 'delete') {
} else {
record.status = token.status;
// newTokens[realIdx].status = token.status;
}
setTokensFormat(newTokens);
} else {
@@ -436,7 +473,6 @@ const TokensTable = () => {
const searchTokens = async () => {
if (searchKeyword === '' && searchToken === '') {
// if keyword is blank, load files instead.
await loadTokens(0);
setActivePage(1);
return;
@@ -480,14 +516,13 @@ const TokensTable = () => {
const handlePageChange = (page) => {
setActivePage(page);
if (page === Math.ceil(tokens.length / pageSize) + 1) {
// In this case we have to load more data and then append them.
loadTokens(page - 1).then((r) => {});
loadTokens(page - 1).then((r) => { });
}
};
const rowSelection = {
onSelect: (record, selected) => {},
onSelectAll: (selected, selectedRows) => {},
onSelect: (record, selected) => { },
onSelectAll: (selected, selectedRows) => { },
onChange: (selectedRowKeys, selectedRows) => {
setSelectedKeys(selectedRows);
},
@@ -505,6 +540,80 @@ const TokensTable = () => {
}
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<Button
theme="light"
type="primary"
icon={<IconPlus />}
className="!rounded-full w-full md:w-auto"
onClick={() => {
setEditingToken({
id: undefined,
});
setShowEdit(true);
}}
>
{t('添加令牌')}
</Button>
<Button
theme="light"
type="warning"
icon={<IconCopy />}
className="!rounded-full w-full md:w-auto"
onClick={async () => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个令牌!'));
return;
}
let keys = '';
for (let i = 0; i < selectedKeys.length; i++) {
keys +=
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
}
await copyText(keys);
}}
>
{t('复制所选令牌到剪贴板')}
</Button>
</div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
<div className="relative w-full md:w-56">
<Input
prefix={<IconSearch />}
placeholder={t('搜索关键字')}
value={searchKeyword}
onChange={handleKeywordChange}
className="!rounded-full"
showClear
/>
</div>
<div className="relative w-full md:w-56">
<Input
prefix={<IconSearch />}
placeholder={t('密钥')}
value={searchToken}
onChange={handleSearchTokenChange}
className="!rounded-full"
showClear
/>
</div>
<Button
type="primary"
onClick={searchTokens}
loading={searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
</div>
</div>
</div>
);
return (
<>
<EditToken
@@ -513,99 +622,42 @@ const TokensTable = () => {
visiable={showEdit}
handleClose={closeEdit}
></EditToken>
<Form
layout='horizontal'
style={{ marginTop: 10 }}
labelPosition={'left'}
>
<Form.Input
field='keyword'
label={t('搜索关键字')}
placeholder={t('令牌名称')}
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
/>
<Form.Input
field='token'
label={t('密钥')}
placeholder={t('密钥')}
value={searchToken}
loading={searching}
onChange={handleSearchTokenChange}
/>
<Button
label={t('查询')}
type='primary'
htmlType='submit'
className='btn-margin-right'
onClick={searchTokens}
style={{ marginRight: 8 }}
>
{t('查询')}
</Button>
</Form>
<Divider style={{ margin: '15px 0' }} />
<div>
<Button
theme='light'
type='primary'
style={{ marginRight: 8 }}
onClick={() => {
setEditingToken({
id: undefined,
});
setShowEdit(true);
}}
>
{t('添加令牌')}
</Button>
<Button
label={t('复制所选令牌')}
type='warning'
onClick={async () => {
if (selectedKeys.length === 0) {
showError(t('请至少选择一个令牌!'));
return;
}
let keys = '';
for (let i = 0; i < selectedKeys.length; i++) {
keys +=
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
}
await copyText(keys);
}}
>
{t('复制所选令牌到剪贴板')}
</Button>
</div>
<Table
style={{ marginTop: 20 }}
columns={columns}
dataSource={pageData}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: tokenCount,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: tokens.length,
}),
onPageSizeChange: (size) => {
setPageSize(size);
setActivePage(1);
},
onPageChange: handlePageChange,
}}
loading={loading}
rowSelection={rowSelection}
onRow={handleRow}
></Table>
<Card
className="!rounded-2xl"
title={renderHeader()}
shadows='always'
bordered={false}
>
<Table
columns={columns}
dataSource={pageData}
scroll={{ x: 'max-content' }}
pagination={{
currentPage: activePage,
pageSize: pageSize,
total: tokenCount,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50, 100],
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: tokens.length,
}),
onPageSizeChange: (size) => {
setPageSize(size);
setActivePage(1);
},
onPageChange: handlePageChange,
}}
loading={loading}
rowSelection={rowSelection}
onRow={handleRow}
className="rounded-xl overflow-hidden"
size="middle"
></Table>
</Card>
</>
);
};
+581
View File
@@ -0,0 +1,581 @@
import React, { useEffect, useState } from 'react';
import { API, showError, showSuccess, renderGroup, renderNumber, renderQuota } from '../../helpers';
import {
Button,
Card,
Divider,
Dropdown,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import {
IconPlus,
IconSearch,
IconEdit,
IconDelete,
IconStop,
IconPlay,
IconMore,
IconUserAdd,
IconArrowUp,
IconArrowDown,
} from '@douyinfe/semi-icons';
import { ITEMS_PER_PAGE } from '../../constants';
import AddUser from '../../pages/User/AddUser';
import EditUser from '../../pages/User/EditUser';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
const UsersTable = () => {
const { t } = useTranslation();
function renderRole(role) {
switch (role) {
case 1:
return (
<Tag size='large' color='blue' shape='circle'>
{t('普通用户')}
</Tag>
);
case 10:
return (
<Tag color='yellow' size='large' shape='circle'>
{t('管理员')}
</Tag>
);
case 100:
return (
<Tag color='orange' size='large' shape='circle'>
{t('超级管理员')}
</Tag>
);
default:
return (
<Tag color='red' size='large' shape='circle'>
{t('未知身份')}
</Tag>
);
}
}
const renderStatus = (status) => {
switch (status) {
case 1:
return <Tag size='large' color='green' shape='circle'>{t('已激活')}</Tag>;
case 2:
return (
<Tag size='large' color='red' shape='circle'>
{t('已封禁')}
</Tag>
);
default:
return (
<Tag size='large' color='grey' shape='circle'>
{t('未知状态')}
</Tag>
);
}
};
const columns = [
{
title: 'ID',
dataIndex: 'id',
},
{
title: t('用户名'),
dataIndex: 'username',
},
{
title: t('分组'),
dataIndex: 'group',
render: (text, record, index) => {
return <div>{renderGroup(text)}</div>;
},
},
{
title: t('统计信息'),
dataIndex: 'info',
render: (text, record, index) => {
return (
<div>
<Space spacing={1}>
<Tag color='white' size='large' shape='circle' className="!text-xs">
{t('剩余')}: {renderQuota(record.quota)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
{t('已用')}: {renderQuota(record.used_quota)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
{t('调用')}: {renderNumber(record.request_count)}
</Tag>
</Space>
</div>
);
},
},
{
title: t('邀请信息'),
dataIndex: 'invite',
render: (text, record, index) => {
return (
<div>
<Space spacing={1}>
<Tag color='white' size='large' shape='circle' className="!text-xs">
{t('邀请')}: {renderNumber(record.aff_count)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
{t('收益')}: {renderQuota(record.aff_history_quota)}
</Tag>
<Tag color='white' size='large' shape='circle' className="!text-xs">
{record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`}
</Tag>
</Space>
</div>
);
},
},
{
title: t('角色'),
dataIndex: 'role',
render: (text, record, index) => {
return <div>{renderRole(text)}</div>;
},
},
{
title: t('状态'),
dataIndex: 'status',
render: (text, record, index) => {
return (
<div>
{record.DeletedAt !== null ? (
<Tag color='red' shape='circle'>{t('已注销')}</Tag>
) : (
renderStatus(text)
)}
</div>
);
},
},
{
title: '',
dataIndex: 'operate',
fixed: 'right',
render: (text, record, index) => {
if (record.DeletedAt !== null) {
return <></>;
}
// 创建更多操作的下拉菜单项
const moreMenuItems = [
{
node: 'item',
name: t('提升'),
icon: <IconArrowUp />,
type: 'warning',
onClick: () => {
Modal.confirm({
title: t('确定要提升此用户吗?'),
content: t('此操作将提升用户的权限级别'),
onOk: () => {
manageUser(record.id, 'promote', record);
},
});
},
},
{
node: 'item',
name: t('降级'),
icon: <IconArrowDown />,
type: 'secondary',
onClick: () => {
Modal.confirm({
title: t('确定要降级此用户吗?'),
content: t('此操作将降低用户的权限级别'),
onOk: () => {
manageUser(record.id, 'demote', record);
},
});
},
},
{
node: 'item',
name: t('注销'),
icon: <IconDelete />,
type: 'danger',
onClick: () => {
Modal.confirm({
title: t('确定是否要注销此用户?'),
content: t('相当于删除用户,此修改将不可逆'),
onOk: () => {
manageUser(record.id, 'delete', record).then(() => {
removeRecord(record.id);
});
},
});
},
}
];
// 动态添加启用/禁用按钮
if (record.status === 1) {
moreMenuItems.splice(-1, 0, {
node: 'item',
name: t('禁用'),
icon: <IconStop />,
type: 'warning',
onClick: () => {
manageUser(record.id, 'disable', record);
},
});
} else {
moreMenuItems.splice(-1, 0, {
node: 'item',
name: t('启用'),
icon: <IconPlay />,
type: 'secondary',
onClick: () => {
manageUser(record.id, 'enable', record);
},
disabled: record.status === 3,
});
}
return (
<Space>
<Button
icon={<IconEdit />}
theme='light'
type='tertiary'
size="small"
className="!rounded-full"
onClick={() => {
setEditingUser(record);
setShowEditUser(true);
}}
>
{t('编辑')}
</Button>
<Dropdown
trigger='click'
position='bottomRight'
menu={moreMenuItems}
>
<Button
icon={<IconMore />}
theme='light'
type='tertiary'
size="small"
className="!rounded-full"
/>
</Dropdown>
</Space>
);
},
},
];
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [searchGroup, setSearchGroup] = useState('');
const [groupOptions, setGroupOptions] = useState([]);
const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);
const [showAddUser, setShowAddUser] = useState(false);
const [showEditUser, setShowEditUser] = useState(false);
const [editingUser, setEditingUser] = useState({
id: undefined,
});
const removeRecord = (key) => {
let newDataSource = [...users];
if (key != null) {
let idx = newDataSource.findIndex((data) => data.id === key);
if (idx > -1) {
// update deletedAt
newDataSource[idx].DeletedAt = new Date();
setUsers(newDataSource);
}
}
};
const setUserFormat = (users) => {
for (let i = 0; i < users.length; i++) {
users[i].key = users[i].id;
}
setUsers(users);
};
const loadUsers = async (startIdx, pageSize) => {
const res = await API.get(`/api/user/?p=${startIdx}&page_size=${pageSize}`);
const { success, message, data } = res.data;
if (success) {
const newPageData = data.items;
setActivePage(data.page);
setUserCount(data.total);
setUserFormat(newPageData);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadUsers(0, pageSize)
.then()
.catch((reason) => {
showError(reason);
});
fetchGroups().then();
}, []);
const manageUser = async (userId, action, record) => {
const res = await API.post('/api/user/manage', {
id: userId,
action,
});
const { success, message } = res.data;
if (success) {
showSuccess('操作成功完成!');
let user = res.data.data;
let newUsers = [...users];
if (action === 'delete') {
} else {
record.status = user.status;
record.role = user.role;
}
setUsers(newUsers);
} else {
showError(message);
}
};
const searchUsers = async (
startIdx,
pageSize,
searchKeyword,
searchGroup,
) => {
if (searchKeyword === '' && searchGroup === '') {
// if keyword is blank, load files instead.
await loadUsers(startIdx, pageSize);
return;
}
setSearching(true);
const res = await API.get(
`/api/user/search?keyword=${searchKeyword}&group=${searchGroup}&p=${startIdx}&page_size=${pageSize}`,
);
const { success, message, data } = res.data;
if (success) {
const newPageData = data.items;
setActivePage(data.page);
setUserCount(data.total);
setUserFormat(newPageData);
} else {
showError(message);
}
setSearching(false);
};
const handleKeywordChange = async (value) => {
setSearchKeyword(value.trim());
};
const handlePageChange = (page) => {
setActivePage(page);
if (searchKeyword === '' && searchGroup === '') {
loadUsers(page, pageSize).then();
} else {
searchUsers(page, pageSize, searchKeyword, searchGroup).then();
}
};
const closeAddUser = () => {
setShowAddUser(false);
};
const closeEditUser = () => {
setShowEditUser(false);
setEditingUser({
id: undefined,
});
};
const refresh = async () => {
setActivePage(1);
if (searchKeyword === '') {
await loadUsers(activePage, pageSize);
} else {
await searchUsers(activePage, pageSize, searchKeyword, searchGroup);
}
};
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
// add 'all' option
// res.data.data.unshift('all');
if (res === undefined) {
return;
}
setGroupOptions(
res.data.data.map((group) => ({
label: group,
value: group,
})),
);
} catch (error) {
showError(error.message);
}
};
const handlePageSizeChange = async (size) => {
localStorage.setItem('page-size', size + '');
setPageSize(size);
setActivePage(1);
loadUsers(activePage, size)
.then()
.catch((reason) => {
showError(reason);
});
};
const handleRow = (record, index) => {
if (record.DeletedAt !== null || record.status !== 1) {
return {
style: {
background: 'var(--semi-color-disabled-border)',
},
};
} else {
return {};
}
};
const renderHeader = () => (
<div className="flex flex-col w-full">
<div className="mb-2">
<div className="flex items-center text-blue-500">
<IconUserAdd className="mr-2" />
<Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
</div>
</div>
<Divider margin="12px" />
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
<Button
theme='light'
type='primary'
icon={<IconPlus />}
className="!rounded-full w-full md:w-auto"
onClick={() => {
setShowAddUser(true);
}}
>
{t('添加用户')}
</Button>
</div>
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
<div className="relative w-full md:w-64">
<Input
prefix={<IconSearch />}
placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
value={searchKeyword}
onChange={handleKeywordChange}
className="!rounded-full"
showClear
/>
</div>
<div className="w-full md:w-48">
<Select
placeholder={t('选择分组')}
optionList={groupOptions}
value={searchGroup}
onChange={(value) => {
setSearchGroup(value);
searchUsers(activePage, pageSize, searchKeyword, value);
}}
className="!rounded-full w-full"
showClear
/>
</div>
<Button
type="primary"
onClick={() => {
searchUsers(activePage, pageSize, searchKeyword, searchGroup);
}}
loading={searching}
className="!rounded-full w-full md:w-auto"
>
{t('查询')}
</Button>
</div>
</div>
</div>
);
return (
<>
<AddUser
refresh={refresh}
visible={showAddUser}
handleClose={closeAddUser}
></AddUser>
<EditUser
refresh={refresh}
visible={showEditUser}
handleClose={closeEditUser}
editingUser={editingUser}
></EditUser>
<Card
className="!rounded-2xl"
title={renderHeader()}
shadows='always'
bordered={false}
>
<Table
columns={columns}
dataSource={users}
scroll={{ x: 'max-content' }}
pagination={{
formatPageText: (page) =>
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
start: page.currentStart,
end: page.currentEnd,
total: userCount,
}),
currentPage: activePage,
pageSize: pageSize,
total: userCount,
pageSizeOpts: [10, 20, 50, 100],
showSizeChanger: true,
onPageSizeChange: (size) => {
handlePageSizeChange(size);
},
onPageChange: handlePageChange,
}}
loading={loading}
onRow={handleRow}
className="rounded-xl overflow-hidden"
size="middle"
/>
</Card>
</>
);
};
export default UsersTable;
-76
View File
@@ -1,76 +0,0 @@
import { API, showError } from '../helpers';
export async function getOAuthState() {
let path = '/api/oauth/state';
let affCode = localStorage.getItem('aff');
if (affCode && affCode.length > 0) {
path += `?aff=${affCode}`;
}
const res = await API.get(path);
const { success, message, data } = res.data;
if (success) {
return data;
} else {
showError(message);
return '';
}
}
export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) {
const state = await getOAuthState();
if (!state) return;
const redirect_uri = `${window.location.origin}/oauth/oidc`;
const response_type = 'code';
const scope = 'openid profile email';
const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`;
if (openInNewTab) {
window.open(url);
} else {
window.location.href = url;
}
}
export async function onGitHubOAuthClicked(github_client_id) {
const state = await getOAuthState();
if (!state) return;
window.open(
`https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`,
);
}
export async function onLinuxDOOAuthClicked(linuxdo_client_id) {
const state = await getOAuthState();
if (!state) return;
window.open(
`https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${linuxdo_client_id}&state=${state}`,
);
}
let channelModels = undefined;
export async function loadChannelModels() {
const res = await API.get('/api/models');
const { success, data } = res.data;
if (!success) {
return;
}
channelModels = data;
localStorage.setItem('channel_models', JSON.stringify(data));
}
export function getChannelModels(type) {
if (channelModels !== undefined && type in channelModels) {
if (!channelModels[type]) {
return [];
}
return channelModels[type];
}
let models = localStorage.getItem('channel_models');
if (!models) {
return [];
}
channelModels = JSON.parse(models);
if (type in channelModels) {
return channelModels[type];
}
return [];
}
+1 -1
View File
@@ -113,7 +113,7 @@ export const CHANNEL_OPTIONS = [
{
value: 45,
color: 'blue',
label: '字节火山方舟、豆包、DeepSeek通用',
label: '字节火山方舟、豆包通用',
},
{
value: 48,
+4 -3
View File
@@ -1,4 +1,5 @@
export * from './toast.constants';
export * from './user.constants';
export * from './common.constant';
export * from './channel.constants';
export * from './user.constants';
export * from './toast.constants';
export * from './common.constant';
export * from './playground.constants';
+95
View File
@@ -0,0 +1,95 @@
// ========== 消息相关常量 ==========
export const MESSAGE_STATUS = {
LOADING: 'loading',
INCOMPLETE: 'incomplete',
COMPLETE: 'complete',
ERROR: 'error',
};
export const MESSAGE_ROLES = {
USER: 'user',
ASSISTANT: 'assistant',
SYSTEM: 'system',
};
// 默认消息示例
export const DEFAULT_MESSAGES = [
{
role: MESSAGE_ROLES.USER,
id: '2',
createAt: 1715676751919,
content: '你好',
},
{
role: MESSAGE_ROLES.ASSISTANT,
id: '3',
createAt: 1715676751919,
content: '你好,请问有什么可以帮助您的吗?',
reasoningContent: '',
isReasoningExpanded: false,
},
];
// ========== UI 相关常量 ==========
export const DEBUG_TABS = {
PREVIEW: 'preview',
REQUEST: 'request',
RESPONSE: 'response',
};
// ========== API 相关常量 ==========
export const API_ENDPOINTS = {
CHAT_COMPLETIONS: '/pg/chat/completions',
USER_MODELS: '/api/user/models',
USER_GROUPS: '/api/user/self/groups',
};
// ========== 配置默认值 ==========
export const DEFAULT_CONFIG = {
inputs: {
model: 'gpt-4o',
group: '',
temperature: 0.7,
top_p: 1,
max_tokens: 4096,
frequency_penalty: 0,
presence_penalty: 0,
seed: null,
stream: true,
imageEnabled: false,
imageUrls: [''],
},
parameterEnabled: {
temperature: true,
top_p: true,
max_tokens: false,
frequency_penalty: true,
presence_penalty: true,
seed: false,
},
systemPrompt: '',
showDebugPanel: false,
customRequestMode: false,
customRequestBody: '',
};
// ========== 正则表达式 ==========
export const THINK_TAG_REGEX = /<think>([\s\S]*?)<\/think>/g;
// ========== 错误消息 ==========
export const ERROR_MESSAGES = {
NO_TEXT_CONTENT: '此消息没有可复制的文本内容',
INVALID_MESSAGE_TYPE: '无法复制此类型的消息内容',
COPY_FAILED: '复制失败,请手动选择文本复制',
COPY_HTTPS_REQUIRED: '复制功能需要 HTTPS 环境,请手动复制',
BROWSER_NOT_SUPPORTED: '浏览器不支持复制功能,请手动复制',
JSON_PARSE_ERROR: '自定义请求体格式错误,请检查JSON格式',
API_REQUEST_ERROR: '请求发生错误',
NETWORK_ERROR: '网络连接失败或服务器无响应',
};
// ========== 存储键名 ==========
export const STORAGE_KEYS = {
CONFIG: 'playground_config',
MESSAGES: 'playground_messages',
};
+208 -87
View File
@@ -1,106 +1,227 @@
// contexts/User/index.jsx
// contexts/Style/index.js
import React, { useState, useEffect } from 'react';
import { isMobile } from '../../helpers/index.js';
import React, { useReducer, useEffect, useMemo, createContext } from 'react';
import { useLocation } from 'react-router-dom';
import { isMobile as getIsMobile } from '../../helpers';
export const StyleContext = React.createContext({
dispatch: () => null,
});
// Action Types
const ACTION_TYPES = {
TOGGLE_SIDER: 'TOGGLE_SIDER',
SET_SIDER: 'SET_SIDER',
SET_MOBILE: 'SET_MOBILE',
SET_SIDER_COLLAPSED: 'SET_SIDER_COLLAPSED',
BATCH_UPDATE: 'BATCH_UPDATE',
};
export const StyleProvider = ({ children }) => {
const [state, setState] = useState({
isMobile: isMobile(),
showSider: false,
siderCollapsed: false,
shouldInnerPadding: false,
});
// Constants
const STORAGE_KEYS = {
SIDEBAR_COLLAPSED: 'default_collapse_sidebar',
};
const dispatch = (action) => {
if ('type' in action) {
switch (action.type) {
case 'TOGGLE_SIDER':
setState((prev) => ({ ...prev, showSider: !prev.showSider }));
break;
case 'SET_SIDER':
setState((prev) => ({ ...prev, showSider: action.payload }));
break;
case 'SET_MOBILE':
setState((prev) => ({ ...prev, isMobile: action.payload }));
break;
case 'SET_SIDER_COLLAPSED':
setState((prev) => ({ ...prev, siderCollapsed: action.payload }));
break;
case 'SET_INNER_PADDING':
setState((prev) => ({ ...prev, shouldInnerPadding: action.payload }));
break;
default:
setState((prev) => ({ ...prev, ...action }));
}
} else {
setState((prev) => ({ ...prev, ...action }));
}
const ROUTE_PATTERNS = {
CONSOLE: '/console',
};
/**
* 判断路径是否为控制台路由
* @param {string} pathname - 路由路径
* @returns {boolean} 是否为控制台路由
*/
const isConsoleRoute = (pathname) => {
return pathname === ROUTE_PATTERNS.CONSOLE ||
pathname.startsWith(ROUTE_PATTERNS.CONSOLE + '/');
};
/**
* 获取初始状态
* @param {string} pathname - 当前路由路径
* @returns {Object} 初始状态对象
*/
const getInitialState = (pathname) => {
const isMobile = getIsMobile();
const isConsole = isConsoleRoute(pathname);
const isCollapsed = localStorage.getItem(STORAGE_KEYS.SIDEBAR_COLLAPSED) === 'true';
return {
isMobile,
showSider: isConsole && !isMobile,
siderCollapsed: isCollapsed,
isManualSiderControl: false,
};
};
/**
* Style reducer
* @param {Object} state - 当前状态
* @param {Object} action - action 对象
* @returns {Object} 新状态
*/
const styleReducer = (state, action) => {
switch (action.type) {
case ACTION_TYPES.TOGGLE_SIDER:
return {
...state,
showSider: !state.showSider,
isManualSiderControl: true,
};
case ACTION_TYPES.SET_SIDER:
return {
...state,
showSider: action.payload,
isManualSiderControl: action.isManualControl ?? false,
};
case ACTION_TYPES.SET_MOBILE:
return {
...state,
isMobile: action.payload,
};
case ACTION_TYPES.SET_SIDER_COLLAPSED:
// 自动保存到 localStorage
localStorage.setItem(STORAGE_KEYS.SIDEBAR_COLLAPSED, action.payload.toString());
return {
...state,
siderCollapsed: action.payload,
};
case ACTION_TYPES.BATCH_UPDATE:
return {
...state,
...action.payload,
};
default:
return state;
}
};
// Context (内部使用,不导出)
const StyleContext = createContext(null);
/**
* 自定义 Hook - 处理窗口大小变化
* @param {Function} dispatch - dispatch 函数
* @param {Object} state - 当前状态
* @param {string} pathname - 当前路径
*/
const useWindowResize = (dispatch, state, pathname) => {
useEffect(() => {
const updateIsMobile = () => {
const mobileDetected = isMobile();
dispatch({ type: 'SET_MOBILE', payload: mobileDetected });
// If on mobile, we might want to auto-hide the sidebar
if (mobileDetected && state.showSider) {
dispatch({ type: 'SET_SIDER', payload: false });
}
};
updateIsMobile();
const updateShowSider = () => {
// check pathname
const pathname = window.location.pathname;
if (
pathname === '' ||
pathname === '/' ||
pathname.includes('/home') ||
pathname.includes('/chat')
) {
dispatch({ type: 'SET_SIDER', payload: false });
dispatch({ type: 'SET_INNER_PADDING', payload: false });
} else if (pathname === '/setup') {
dispatch({ type: 'SET_SIDER', payload: false });
dispatch({ type: 'SET_INNER_PADDING', payload: false });
} else {
// Only show sidebar on non-mobile devices by default
dispatch({ type: 'SET_SIDER', payload: !isMobile() });
dispatch({ type: 'SET_INNER_PADDING', payload: true });
}
};
updateShowSider();
const updateSiderCollapsed = () => {
const isCollapsed =
localStorage.getItem('default_collapse_sidebar') === 'true';
dispatch({ type: 'SET_SIDER_COLLAPSED', payload: isCollapsed });
};
updateSiderCollapsed();
// Add event listeners to handle window resize
const handleResize = () => {
updateIsMobile();
const isMobile = getIsMobile();
dispatch({ type: ACTION_TYPES.SET_MOBILE, payload: isMobile });
// 只有在非手动控制的情况下,才根据屏幕大小自动调整侧边栏
if (!state.isManualSiderControl && isConsoleRoute(pathname)) {
dispatch({
type: ACTION_TYPES.SET_SIDER,
payload: !isMobile,
isManualControl: false
});
}
};
window.addEventListener('resize', handleResize);
let timeoutId;
const debouncedResize = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(handleResize, 150);
};
// Cleanup event listener on component unmount
window.addEventListener('resize', debouncedResize);
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('resize', debouncedResize);
clearTimeout(timeoutId);
};
}, []);
}, [dispatch, state.isManualSiderControl, pathname]);
};
/**
* 自定义 Hook - 处理路由变化
* @param {Function} dispatch - dispatch 函数
* @param {string} pathname - 当前路径
*/
const useRouteChange = (dispatch, pathname) => {
useEffect(() => {
const isMobile = getIsMobile();
const isConsole = isConsoleRoute(pathname);
dispatch({
type: ACTION_TYPES.BATCH_UPDATE,
payload: {
showSider: isConsole && !isMobile,
isManualSiderControl: false,
},
});
}, [pathname, dispatch]);
};
/**
* 自定义 Hook - 处理移动设备侧边栏自动收起
* @param {Object} state - 当前状态
* @param {Function} dispatch - dispatch 函数
*/
const useMobileSiderAutoHide = (state, dispatch) => {
useEffect(() => {
// 移动设备上,如果不是手动控制且侧边栏是打开的,则自动关闭
if (state.isMobile && state.showSider && !state.isManualSiderControl) {
dispatch({ type: ACTION_TYPES.SET_SIDER, payload: false });
}
}, [state.isMobile, state.showSider, state.isManualSiderControl, dispatch]);
};
/**
* Style Provider 组件
*/
export const StyleProvider = ({ children }) => {
const location = useLocation();
const pathname = location.pathname;
const [state, dispatch] = useReducer(
styleReducer,
pathname,
getInitialState
);
useWindowResize(dispatch, state, pathname);
useRouteChange(dispatch, pathname);
useMobileSiderAutoHide(state, dispatch);
const contextValue = useMemo(
() => ({ state, dispatch }),
[state]
);
return (
<StyleContext.Provider value={[state, dispatch]}>
<StyleContext.Provider value={contextValue}>
{children}
</StyleContext.Provider>
);
};
/**
* 自定义 Hook - 使用 StyleContext
* @returns {{state: Object, dispatch: Function}} context value
*/
export const useStyle = () => {
const context = React.useContext(StyleContext);
if (!context) {
throw new Error('useStyle must be used within StyleProvider');
}
return context;
};
// 导出 action creators 以便外部使用
export const styleActions = {
toggleSider: () => ({ type: ACTION_TYPES.TOGGLE_SIDER }),
setSider: (show, isManualControl = false) => ({
type: ACTION_TYPES.SET_SIDER,
payload: show,
isManualControl
}),
setMobile: (isMobile) => ({ type: ACTION_TYPES.SET_MOBILE, payload: isMobile }),
setSiderCollapsed: (collapsed) => ({
type: ACTION_TYPES.SET_SIDER_COLLAPSED,
payload: collapsed
}),
};
+184 -1
View File
@@ -1,5 +1,6 @@
import { getUserIdFromLocalStorage, showError } from './utils';
import { getUserIdFromLocalStorage, showError, formatMessageForAPI, isValidMessage } from './utils';
import axios from 'axios';
import { MESSAGE_ROLES } from '../constants/playground.constants';
export let API = axios.create({
baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL
@@ -29,3 +30,185 @@ API.interceptors.response.use(
showError(error);
},
);
// playground
// 构建API请求负载
export const buildApiPayload = (messages, systemPrompt, inputs, parameterEnabled) => {
const processedMessages = messages
.filter(isValidMessage)
.map(formatMessageForAPI)
.filter(Boolean);
// 如果有系统提示,插入到消息开头
if (systemPrompt && systemPrompt.trim()) {
processedMessages.unshift({
role: MESSAGE_ROLES.SYSTEM,
content: systemPrompt.trim()
});
}
const payload = {
model: inputs.model,
messages: processedMessages,
stream: inputs.stream,
};
// 添加启用的参数
const parameterMappings = {
temperature: 'temperature',
top_p: 'top_p',
max_tokens: 'max_tokens',
frequency_penalty: 'frequency_penalty',
presence_penalty: 'presence_penalty',
seed: 'seed'
};
Object.entries(parameterMappings).forEach(([key, param]) => {
if (parameterEnabled[key] && inputs[param] !== undefined && inputs[param] !== null) {
payload[param] = inputs[param];
}
});
return payload;
};
// 处理API错误响应
export const handleApiError = (error, response = null) => {
const errorInfo = {
error: error.message || '未知错误',
timestamp: new Date().toISOString(),
stack: error.stack
};
if (response) {
errorInfo.status = response.status;
errorInfo.statusText = response.statusText;
}
if (error.message.includes('HTTP error')) {
errorInfo.details = '服务器返回了错误状态码';
} else if (error.message.includes('Failed to fetch')) {
errorInfo.details = '网络连接失败或服务器无响应';
}
return errorInfo;
};
// 处理模型数据
export const processModelsData = (data, currentModel) => {
const modelOptions = data.map(model => ({
label: model,
value: model,
}));
const hasCurrentModel = modelOptions.some(option => option.value === currentModel);
const selectedModel = hasCurrentModel && modelOptions.length > 0
? currentModel
: modelOptions[0]?.value;
return { modelOptions, selectedModel };
};
// 处理分组数据
export const processGroupsData = (data, userGroup) => {
let groupOptions = Object.entries(data).map(([group, info]) => ({
label: info.desc.length > 20 ? info.desc.substring(0, 20) + '...' : info.desc,
value: group,
ratio: info.ratio,
fullLabel: info.desc,
}));
if (groupOptions.length === 0) {
groupOptions = [{
label: '用户分组',
value: '',
ratio: 1,
}];
} else if (userGroup) {
const userGroupIndex = groupOptions.findIndex(g => g.value === userGroup);
if (userGroupIndex > -1) {
const userGroupOption = groupOptions.splice(userGroupIndex, 1)[0];
groupOptions.unshift(userGroupOption);
}
}
return groupOptions;
};
// 原来components中的utils.js
export async function getOAuthState() {
let path = '/api/oauth/state';
let affCode = localStorage.getItem('aff');
if (affCode && affCode.length > 0) {
path += `?aff=${affCode}`;
}
const res = await API.get(path);
const { success, message, data } = res.data;
if (success) {
return data;
} else {
showError(message);
return '';
}
}
export async function onOIDCClicked(auth_url, client_id, openInNewTab = false) {
const state = await getOAuthState();
if (!state) return;
const redirect_uri = `${window.location.origin}/oauth/oidc`;
const response_type = 'code';
const scope = 'openid profile email';
const url = `${auth_url}?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}`;
if (openInNewTab) {
window.open(url);
} else {
window.location.href = url;
}
}
export async function onGitHubOAuthClicked(github_client_id) {
const state = await getOAuthState();
if (!state) return;
window.open(
`https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`,
);
}
export async function onLinuxDOOAuthClicked(linuxdo_client_id) {
const state = await getOAuthState();
if (!state) return;
window.open(
`https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${linuxdo_client_id}&state=${state}`,
);
}
let channelModels = undefined;
export async function loadChannelModels() {
const res = await API.get('/api/models');
const { success, data } = res.data;
if (!success) {
return;
}
channelModels = data;
localStorage.setItem('channel_models', JSON.stringify(data));
}
export function getChannelModels(type) {
if (channelModels !== undefined && type in channelModels) {
if (!channelModels[type]) {
return [];
}
return channelModels[type];
}
let models = localStorage.getItem('channel_models');
if (!models) {
return [];
}
channelModels = JSON.parse(models);
if (type in channelModels) {
return channelModels[type];
}
return [];
}
-10
View File
@@ -1,10 +0,0 @@
export function authHeader() {
// return authorization header with jwt token
let user = JSON.parse(localStorage.getItem('user'));
if (user && user.token) {
return { Authorization: 'Bearer ' + user.token };
} else {
return {};
}
}
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { history } from './history';
export function authHeader() {
// return authorization header with jwt token
let user = JSON.parse(localStorage.getItem('user'));
if (user && user.token) {
return { Authorization: 'Bearer ' + user.token };
} else {
return {};
}
}
export const AuthRedirect = ({ children }) => {
const user = localStorage.getItem('user');
if (user) {
return <Navigate to="/console" replace />;
}
return children;
};
function PrivateRoute({ children }) {
if (!localStorage.getItem('user')) {
return <Navigate to='/login' state={{ from: history.location }} />;
}
return children;
}
export { PrivateRoute };
+5 -1
View File
@@ -1,4 +1,8 @@
export * from './history';
export * from './auth-header';
export * from './auth';
export * from './utils';
export * from './api';
export * from './render';
export * from './log';
export * from './data';
export * from './token';
@@ -4,4 +4,4 @@ export function getLogOther(otherStr) {
}
let other = JSON.parse(otherStr);
return other;
}
}
+644 -215
View File
@@ -1,6 +1,479 @@
import i18next from 'i18next';
import { Modal, Tag, Typography } from '@douyinfe/semi-ui';
import { copy, isMobile, showSuccess } from './utils.js';
import { copy, isMobile, showSuccess } from './utils';
import { visit } from 'unist-util-visit';
import {
OpenAI,
Claude,
Gemini,
Moonshot,
Zhipu,
Qwen,
DeepSeek,
Minimax,
Wenxin,
Spark,
Midjourney,
Hunyuan,
Cohere,
Cloudflare,
Ai360,
Yi,
Jina,
Mistral,
XAI,
Ollama,
Doubao,
} from '@lobehub/icons';
import {
LayoutDashboard,
TerminalSquare,
MessageSquare,
Key,
BarChart3,
Image as ImageIcon,
CheckSquare,
CreditCard,
Layers,
Gift,
User,
Settings,
CircleUser,
} from 'lucide-react';
// 侧边栏图标颜色映射
export const sidebarIconColors = {
dashboard: '#4F46E5', // 紫蓝色
terminal: '#10B981', // 绿色
message: '#06B6D4', // 青色
key: '#3B82F6', // 蓝色
chart: '#8B5CF6', // 紫色
image: '#EC4899', // 粉色
check: '#F59E0B', // 琥珀色
credit: '#F97316', // 橙色
layers: '#EF4444', // 红色
gift: '#F43F5E', // 玫红色
user: '#6366F1', // 靛蓝色
settings: '#6B7280', // 灰色
};
// 获取侧边栏Lucide图标组件
export function getLucideIcon(key, selected = false) {
const size = 16;
const strokeWidth = 2;
const commonProps = {
size,
strokeWidth,
className: `transition-colors duration-200 ${selected ? 'transition-transform duration-200 scale-105' : ''}`,
};
// 根据不同的key返回不同的图标
switch (key) {
case 'detail':
return (
<LayoutDashboard
{...commonProps}
color={selected ? sidebarIconColors.dashboard : 'currentColor'}
/>
);
case 'playground':
return (
<TerminalSquare
{...commonProps}
color={selected ? sidebarIconColors.terminal : 'currentColor'}
/>
);
case 'chat':
return (
<MessageSquare
{...commonProps}
color={selected ? sidebarIconColors.message : 'currentColor'}
/>
);
case 'token':
return (
<Key
{...commonProps}
color={selected ? sidebarIconColors.key : 'currentColor'}
/>
);
case 'log':
return (
<BarChart3
{...commonProps}
color={selected ? sidebarIconColors.chart : 'currentColor'}
/>
);
case 'midjourney':
return (
<ImageIcon
{...commonProps}
color={selected ? sidebarIconColors.image : 'currentColor'}
/>
);
case 'task':
return (
<CheckSquare
{...commonProps}
color={selected ? sidebarIconColors.check : 'currentColor'}
/>
);
case 'topup':
return (
<CreditCard
{...commonProps}
color={selected ? sidebarIconColors.credit : 'currentColor'}
/>
);
case 'channel':
return (
<Layers
{...commonProps}
color={selected ? sidebarIconColors.layers : 'currentColor'}
/>
);
case 'redemption':
return (
<Gift
{...commonProps}
color={selected ? sidebarIconColors.gift : 'currentColor'}
/>
);
case 'user':
case 'personal':
return (
<User
{...commonProps}
color={selected ? sidebarIconColors.user : 'currentColor'}
/>
);
case 'setting':
return (
<Settings
{...commonProps}
color={selected ? sidebarIconColors.settings : 'currentColor'}
/>
);
default:
return (
<CircleUser
{...commonProps}
color={selected ? sidebarIconColors.user : 'currentColor'}
/>
);
}
}
// 获取模型分类
export const getModelCategories = (() => {
let categoriesCache = null;
let lastLocale = null;
return (t) => {
const currentLocale = i18next.language;
if (categoriesCache && lastLocale === currentLocale) {
return categoriesCache;
}
categoriesCache = {
all: {
label: t('全部模型'),
icon: null,
filter: () => true,
},
openai: {
label: 'OpenAI',
icon: <OpenAI />,
filter: (model) =>
model.model_name.toLowerCase().includes('gpt') ||
model.model_name.toLowerCase().includes('dall-e') ||
model.model_name.toLowerCase().includes('whisper') ||
model.model_name.toLowerCase().includes('tts') ||
model.model_name.toLowerCase().includes('text-') ||
model.model_name.toLowerCase().includes('babbage') ||
model.model_name.toLowerCase().includes('davinci') ||
model.model_name.toLowerCase().includes('curie') ||
model.model_name.toLowerCase().includes('ada') ||
model.model_name.toLowerCase().includes('o1') ||
model.model_name.toLowerCase().includes('o3') ||
model.model_name.toLowerCase().includes('o4'),
},
anthropic: {
label: 'Anthropic',
icon: <Claude.Color />,
filter: (model) => model.model_name.toLowerCase().includes('claude'),
},
gemini: {
label: 'Gemini',
icon: <Gemini.Color />,
filter: (model) => model.model_name.toLowerCase().includes('gemini'),
},
moonshot: {
label: 'Moonshot',
icon: <Moonshot />,
filter: (model) => model.model_name.toLowerCase().includes('moonshot'),
},
zhipu: {
label: t('智谱'),
icon: <Zhipu.Color />,
filter: (model) =>
model.model_name.toLowerCase().includes('chatglm') ||
model.model_name.toLowerCase().includes('glm-'),
},
qwen: {
label: t('通义千问'),
icon: <Qwen.Color />,
filter: (model) => model.model_name.toLowerCase().includes('qwen'),
},
deepseek: {
label: 'DeepSeek',
icon: <DeepSeek.Color />,
filter: (model) => model.model_name.toLowerCase().includes('deepseek'),
},
minimax: {
label: 'MiniMax',
icon: <Minimax.Color />,
filter: (model) => model.model_name.toLowerCase().includes('abab'),
},
baidu: {
label: t('文心一言'),
icon: <Wenxin.Color />,
filter: (model) => model.model_name.toLowerCase().includes('ernie'),
},
xunfei: {
label: t('讯飞星火'),
icon: <Spark.Color />,
filter: (model) => model.model_name.toLowerCase().includes('spark'),
},
midjourney: {
label: 'Midjourney',
icon: <Midjourney />,
filter: (model) => model.model_name.toLowerCase().includes('mj_'),
},
tencent: {
label: t('腾讯混元'),
icon: <Hunyuan.Color />,
filter: (model) => model.model_name.toLowerCase().includes('hunyuan'),
},
cohere: {
label: 'Cohere',
icon: <Cohere.Color />,
filter: (model) => model.model_name.toLowerCase().includes('command'),
},
cloudflare: {
label: 'Cloudflare',
icon: <Cloudflare.Color />,
filter: (model) => model.model_name.toLowerCase().includes('@cf/'),
},
ai360: {
label: t('360智脑'),
icon: <Ai360.Color />,
filter: (model) => model.model_name.toLowerCase().includes('360'),
},
yi: {
label: t('零一万物'),
icon: <Yi.Color />,
filter: (model) => model.model_name.toLowerCase().includes('yi'),
},
jina: {
label: 'Jina',
icon: <Jina />,
filter: (model) => model.model_name.toLowerCase().includes('jina'),
},
mistral: {
label: 'Mistral AI',
icon: <Mistral.Color />,
filter: (model) => model.model_name.toLowerCase().includes('mistral'),
},
xai: {
label: 'xAI',
icon: <XAI />,
filter: (model) => model.model_name.toLowerCase().includes('grok'),
},
llama: {
label: 'Llama',
icon: <Ollama />,
filter: (model) => model.model_name.toLowerCase().includes('llama'),
},
doubao: {
label: t('豆包'),
icon: <Doubao.Color />,
filter: (model) => model.model_name.toLowerCase().includes('doubao'),
},
};
lastLocale = currentLocale;
return categoriesCache;
};
})();
// 颜色列表
const colors = [
'amber',
'blue',
'cyan',
'green',
'grey',
'indigo',
'light-blue',
'lime',
'orange',
'pink',
'purple',
'red',
'teal',
'violet',
'yellow',
];
// 基础10色色板 (N ≤ 10)
const baseColors = [
'#1664FF', // 主色
'#1AC6FF',
'#FF8A00',
'#3CC780',
'#7442D4',
'#FFC400',
'#304D77',
'#B48DEB',
'#009488',
'#FF7DDA',
];
// 扩展20色色板 (10 < N ≤ 20)
const extendedColors = [
'#1664FF',
'#B2CFFF',
'#1AC6FF',
'#94EFFF',
'#FF8A00',
'#FFCE7A',
'#3CC780',
'#B9EDCD',
'#7442D4',
'#DDC5FA',
'#FFC400',
'#FAE878',
'#304D77',
'#8B959E',
'#B48DEB',
'#EFE3FF',
'#009488',
'#59BAA8',
'#FF7DDA',
'#FFCFEE',
];
// 模型颜色映射
export const modelColorMap = {
'dall-e': 'rgb(147,112,219)', // 深紫色
// 'dall-e-2': 'rgb(147,112,219)', // 介于紫色和蓝色之间的色调
'dall-e-3': 'rgb(153,50,204)', // 介于紫罗兰和洋红之间的色调
'gpt-3.5-turbo': 'rgb(184,227,167)', // 浅绿色
// 'gpt-3.5-turbo-0301': 'rgb(131,220,131)', // 亮绿色
'gpt-3.5-turbo-0613': 'rgb(60,179,113)', // 海洋绿
'gpt-3.5-turbo-1106': 'rgb(32,178,170)', // 浅海洋绿
'gpt-3.5-turbo-16k': 'rgb(149,252,206)', // 淡橙色
'gpt-3.5-turbo-16k-0613': 'rgb(119,255,214)', // 淡桃
'gpt-3.5-turbo-instruct': 'rgb(175,238,238)', // 粉蓝色
'gpt-4': 'rgb(135,206,235)', // 天蓝色
// 'gpt-4-0314': 'rgb(70,130,180)', // 钢蓝色
'gpt-4-0613': 'rgb(100,149,237)', // 矢车菊蓝
'gpt-4-1106-preview': 'rgb(30,144,255)', // 道奇蓝
'gpt-4-0125-preview': 'rgb(2,177,236)', // 深天蓝
'gpt-4-turbo-preview': 'rgb(2,177,255)', // 深天蓝
'gpt-4-32k': 'rgb(104,111,238)', // 中紫色
// 'gpt-4-32k-0314': 'rgb(90,105,205)', // 暗灰蓝色
'gpt-4-32k-0613': 'rgb(61,71,139)', // 暗蓝灰色
'gpt-4-all': 'rgb(65,105,225)', // 皇家蓝
'gpt-4-gizmo-*': 'rgb(0,0,255)', // 纯蓝色
'gpt-4-vision-preview': 'rgb(25,25,112)', // 午夜蓝
'text-ada-001': 'rgb(255,192,203)', // 粉红色
'text-babbage-001': 'rgb(255,160,122)', // 浅珊瑚色
'text-curie-001': 'rgb(219,112,147)', // 苍紫罗兰色
// 'text-davinci-002': 'rgb(199,21,133)', // 中紫罗兰红色
'text-davinci-003': 'rgb(219,112,147)', // 苍紫罗兰色(与Curie相同,表示同一个系列)
'text-davinci-edit-001': 'rgb(255,105,180)', // 热粉色
'text-embedding-ada-002': 'rgb(255,182,193)', // 浅粉红
'text-embedding-v1': 'rgb(255,174,185)', // 浅粉红色(略有区别)
'text-moderation-latest': 'rgb(255,130,171)', // 强粉色
'text-moderation-stable': 'rgb(255,160,122)', // 浅珊瑚色(与Babbage相同,表示同一类功能)
'tts-1': 'rgb(255,140,0)', // 深橙色
'tts-1-1106': 'rgb(255,165,0)', // 橙色
'tts-1-hd': 'rgb(255,215,0)', // 金色
'tts-1-hd-1106': 'rgb(255,223,0)', // 金黄色(略有区别)
'whisper-1': 'rgb(245,245,220)', // 米色
'claude-3-opus-20240229': 'rgb(255,132,31)', // 橙红色
'claude-3-sonnet-20240229': 'rgb(253,135,93)', // 橙色
'claude-3-haiku-20240307': 'rgb(255,175,146)', // 浅橙色
'claude-2.1': 'rgb(255,209,190)', // 浅橙色(略有区别)
};
export function modelToColor(modelName) {
// 1. 如果模型在预定义的 modelColorMap 中,使用预定义颜色
if (modelColorMap[modelName]) {
return modelColorMap[modelName];
}
// 2. 生成一个稳定的数字作为索引
let hash = 0;
for (let i = 0; i < modelName.length; i++) {
hash = (hash << 5) - hash + modelName.charCodeAt(i);
hash = hash & hash; // Convert to 32-bit integer
}
hash = Math.abs(hash);
// 3. 根据模型名称长度选择不同的色板
const colorPalette = modelName.length > 10 ? extendedColors : baseColors;
// 4. 使用hash值选择颜色
const index = hash % colorPalette.length;
return colorPalette[index];
}
export function stringToColor(str) {
let sum = 0;
for (let i = 0; i < str.length; i++) {
sum += str.charCodeAt(i);
}
let i = sum % colors.length;
return colors[i];
}
// 渲染带有模型图标的标签
export function renderModelTag(modelName, options = {}) {
const {
color,
size = 'large',
shape = 'circle',
onClick,
suffixIcon,
} = options;
const categories = getModelCategories(i18next.t);
let icon = null;
for (const [key, category] of Object.entries(categories)) {
if (key !== 'all' && category.filter({ model_name: modelName })) {
icon = category.icon;
break;
}
}
return (
<Tag
color={color || stringToColor(modelName)}
prefixIcon={icon}
suffixIcon={suffixIcon}
size={size}
shape={shape}
onClick={onClick}
>
{modelName}
</Tag>
);
}
export function renderText(text, limit) {
if (text.length > limit) {
@@ -17,7 +490,7 @@ export function renderText(text, limit) {
export function renderGroup(group) {
if (group === '') {
return (
<Tag size='large' key='default' color='orange'>
<Tag size='large' key='default' color='orange' shape='circle'>
{i18next.t('用户分组')}
</Tag>
);
@@ -39,6 +512,7 @@ export function renderGroup(group) {
size='large'
color={tagColors[group] || stringToColor(group)}
key={group}
shape='circle'
onClick={async (event) => {
event.stopPropagation();
if (await copy(group)) {
@@ -323,6 +797,9 @@ export function renderModelPrice(
fileSearch = false,
fileSearchCallCount = 0,
fileSearchPrice = 0,
audioInputSeperatePrice = false,
audioInputTokens = 0,
audioInputPrice = 0,
) {
if (modelPrice !== -1) {
return i18next.t(
@@ -350,9 +827,12 @@ export function renderModelPrice(
effectiveInputTokens =
inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
}
if (audioInputTokens > 0) {
effectiveInputTokens -= audioInputTokens;
}
let price =
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
@@ -361,8 +841,11 @@ export function renderModelPrice(
<>
<article>
<p>
{i18next.t('输入价格:${{price}} / 1M tokens', {
{i18next.t('输入价格:${{price}} / 1M tokens{{audioPrice}}', {
price: inputRatioPrice,
audioPrice: audioInputSeperatePrice
? `,音频 $${audioInputPrice} / 1M tokens`
: '',
})}
</p>
<p>
@@ -416,96 +899,93 @@ export function renderModelPrice(
)}
<p></p>
<p>
{cacheTokens > 0 && !image && !webSearch && !fileSearch
? i18next.t(
'输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
{(() => {
// 构建输入部分描述
let inputDesc = '';
if (image && imageOutputTokens > 0) {
inputDesc = i18next.t(
'(输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}}',
{
nonImageInput: inputTokens - imageOutputTokens,
imageInput: imageOutputTokens,
imageRatio: imageRatio,
price: inputRatioPrice,
},
);
} else if (cacheTokens > 0) {
inputDesc = i18next.t(
'(输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}}',
{
nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens,
cachePrice: inputRatioPrice * cacheRatio,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
total: price.toFixed(6),
cachePrice: cacheRatioPrice,
},
)
: image && imageOutputTokens > 0 && !webSearch && !fileSearch
? i18next.t(
'输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
{
nonImageInput: inputTokens - imageOutputTokens,
imageInput: imageOutputTokens,
imageRatio: imageRatio,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
total: price.toFixed(6),
},
)
: webSearch && webSearchCallCount > 0 && !image && !fileSearch
);
} else if (audioInputSeperatePrice && audioInputTokens > 0) {
inputDesc = i18next.t(
'(输入 {{nonAudioInput}} tokens / 1M tokens * ${{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * ${{audioPrice}}',
{
nonAudioInput: inputTokens - audioInputTokens,
audioInput: audioInputTokens,
price: inputRatioPrice,
audioPrice: audioInputPrice,
},
);
} else {
inputDesc = i18next.t(
'(输入 {{input}} tokens / 1M tokens * ${{price}}',
{
input: inputTokens,
price: inputRatioPrice,
},
);
}
// 构建输出部分描述
const outputDesc = i18next.t(
'输出 {{completion}} tokens / 1M tokens * ${{compPrice}}) * 分组倍率 {{ratio}}',
{
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
},
);
// 构建额外服务描述
const extraServices = [
webSearch && webSearchCallCount > 0
? i18next.t(
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + Web搜索 {{webSearchCallCount}}次 / 1K 次 * ${{webSearchPrice}} * {{ratio}} = ${{total}}',
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
count: webSearchCallCount,
price: webSearchPrice,
ratio: groupRatio,
webSearchCallCount,
webSearchPrice,
total: price.toFixed(6),
},
)
: fileSearch &&
fileSearchCallCount > 0 &&
!image &&
!webSearch
? i18next.t(
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} * {{ratio}}= ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
fileSearchCallCount,
fileSearchPrice,
total: price.toFixed(6),
},
)
: webSearch &&
webSearchCallCount > 0 &&
fileSearch &&
fileSearchCallCount > 0 &&
!image
? i18next.t(
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + Web搜索 {{webSearchCallCount}}次 / 1K 次 * ${{webSearchPrice}} * {{ratio}}+ 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} * {{ratio}}= ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
webSearchCallCount,
webSearchPrice,
fileSearchCallCount,
fileSearchPrice,
total: price.toFixed(6),
},
)
: i18next.t(
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
{
input: inputTokens,
price: inputRatioPrice,
completion: completionTokens,
compPrice: completionRatioPrice,
ratio: groupRatio,
total: price.toFixed(6),
},
)}
: '',
fileSearch && fileSearchCallCount > 0
? i18next.t(
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
{
count: fileSearchCallCount,
price: fileSearchPrice,
ratio: groupRatio,
},
)
: '',
].join('');
return i18next.t(
'{{inputDesc}} + {{outputDesc}}{{extraServices}} = ${{total}}',
{
inputDesc,
outputDesc,
extraServices,
total: price.toFixed(6),
},
);
})()}
</p>
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
</article>
@@ -798,137 +1278,6 @@ export function renderQuotaWithPrompt(quota, digits) {
return '';
}
const colors = [
'amber',
'blue',
'cyan',
'green',
'grey',
'indigo',
'light-blue',
'lime',
'orange',
'pink',
'purple',
'red',
'teal',
'violet',
'yellow',
];
// 基础10色色板 (N ≤ 10)
const baseColors = [
'#1664FF', // 主色
'#1AC6FF',
'#FF8A00',
'#3CC780',
'#7442D4',
'#FFC400',
'#304D77',
'#B48DEB',
'#009488',
'#FF7DDA',
];
// 扩展20色色板 (10 < N ≤ 20)
const extendedColors = [
'#1664FF',
'#B2CFFF',
'#1AC6FF',
'#94EFFF',
'#FF8A00',
'#FFCE7A',
'#3CC780',
'#B9EDCD',
'#7442D4',
'#DDC5FA',
'#FFC400',
'#FAE878',
'#304D77',
'#8B959E',
'#B48DEB',
'#EFE3FF',
'#009488',
'#59BAA8',
'#FF7DDA',
'#FFCFEE',
];
export const modelColorMap = {
'dall-e': 'rgb(147,112,219)', // 深紫色
// 'dall-e-2': 'rgb(147,112,219)', // 介于紫色和蓝色之间的色调
'dall-e-3': 'rgb(153,50,204)', // 介于紫罗兰和洋红之间的色调
'gpt-3.5-turbo': 'rgb(184,227,167)', // 浅绿色
// 'gpt-3.5-turbo-0301': 'rgb(131,220,131)', // 亮绿色
'gpt-3.5-turbo-0613': 'rgb(60,179,113)', // 海洋绿
'gpt-3.5-turbo-1106': 'rgb(32,178,170)', // 浅海洋绿
'gpt-3.5-turbo-16k': 'rgb(149,252,206)', // 淡橙色
'gpt-3.5-turbo-16k-0613': 'rgb(119,255,214)', // 淡桃
'gpt-3.5-turbo-instruct': 'rgb(175,238,238)', // 粉蓝色
'gpt-4': 'rgb(135,206,235)', // 天蓝色
// 'gpt-4-0314': 'rgb(70,130,180)', // 钢蓝色
'gpt-4-0613': 'rgb(100,149,237)', // 矢车菊蓝
'gpt-4-1106-preview': 'rgb(30,144,255)', // 道奇蓝
'gpt-4-0125-preview': 'rgb(2,177,236)', // 深天蓝
'gpt-4-turbo-preview': 'rgb(2,177,255)', // 深天蓝
'gpt-4-32k': 'rgb(104,111,238)', // 中紫色
// 'gpt-4-32k-0314': 'rgb(90,105,205)', // 暗灰蓝色
'gpt-4-32k-0613': 'rgb(61,71,139)', // 暗蓝灰色
'gpt-4-all': 'rgb(65,105,225)', // 皇家蓝
'gpt-4-gizmo-*': 'rgb(0,0,255)', // 纯蓝色
'gpt-4-vision-preview': 'rgb(25,25,112)', // 午夜蓝
'text-ada-001': 'rgb(255,192,203)', // 粉红色
'text-babbage-001': 'rgb(255,160,122)', // 浅珊瑚色
'text-curie-001': 'rgb(219,112,147)', // 苍紫罗兰色
// 'text-davinci-002': 'rgb(199,21,133)', // 中紫罗兰红色
'text-davinci-003': 'rgb(219,112,147)', // 苍紫罗兰色(与Curie相同,表示同一个系列)
'text-davinci-edit-001': 'rgb(255,105,180)', // 热粉色
'text-embedding-ada-002': 'rgb(255,182,193)', // 浅粉红
'text-embedding-v1': 'rgb(255,174,185)', // 浅粉红色(略有区别)
'text-moderation-latest': 'rgb(255,130,171)', // 强粉色
'text-moderation-stable': 'rgb(255,160,122)', // 浅珊瑚色(与Babbage相同,表示同一类功能)
'tts-1': 'rgb(255,140,0)', // 深橙色
'tts-1-1106': 'rgb(255,165,0)', // 橙色
'tts-1-hd': 'rgb(255,215,0)', // 金色
'tts-1-hd-1106': 'rgb(255,223,0)', // 金黄色(略有区别)
'whisper-1': 'rgb(245,245,220)', // 米色
'claude-3-opus-20240229': 'rgb(255,132,31)', // 橙红色
'claude-3-sonnet-20240229': 'rgb(253,135,93)', // 橙色
'claude-3-haiku-20240307': 'rgb(255,175,146)', // 浅橙色
'claude-2.1': 'rgb(255,209,190)', // 浅橙色(略有区别)
};
export function modelToColor(modelName) {
// 1. 如果模型在预定义的 modelColorMap 中,使用预定义颜色
if (modelColorMap[modelName]) {
return modelColorMap[modelName];
}
// 2. 生成一个稳定的数字作为索引
let hash = 0;
for (let i = 0; i < modelName.length; i++) {
hash = (hash << 5) - hash + modelName.charCodeAt(i);
hash = hash & hash; // Convert to 32-bit integer
}
hash = Math.abs(hash);
// 3. 根据模型名称长度选择不同的色板
const colorPalette = modelName.length > 10 ? extendedColors : baseColors;
// 4. 使用hash值选择颜色
const index = hash % colorPalette.length;
return colorPalette[index];
}
export function stringToColor(str) {
let sum = 0;
for (let i = 0; i < str.length; i++) {
sum += str.charCodeAt(i);
}
let i = sum % colors.length;
return colors[i];
}
export function renderClaudeModelPrice(
inputTokens,
completionTokens,
@@ -1127,3 +1476,83 @@ export function renderClaudeModelPriceSimple(
}
}
}
/**
* rehype 插件:将段落等文本节点拆分为逐词 <span>,并添加淡入动画 class。
* 仅在流式渲染阶段使用,避免已渲染文字重复动画。
*/
export function rehypeSplitWordsIntoSpans(options = {}) {
const { previousContentLength = 0 } = options;
return (tree) => {
let currentCharCount = 0; // 当前已处理的字符数
visit(tree, 'element', (node) => {
if (
['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong'].includes(
node.tagName,
) &&
node.children
) {
const newChildren = [];
node.children.forEach((child) => {
if (child.type === 'text') {
try {
// 使用 Intl.Segmenter 精准拆分中英文及标点
const segmenter = new Intl.Segmenter('zh', {
granularity: 'word',
});
const segments = segmenter.segment(child.value);
Array.from(segments)
.map((seg) => seg.segment)
.filter(Boolean)
.forEach((word) => {
const wordStartPos = currentCharCount;
const wordEndPos = currentCharCount + word.length;
// 判断这个词是否是新增的(在 previousContentLength 之后)
const isNewContent = wordStartPos >= previousContentLength;
newChildren.push({
type: 'element',
tagName: 'span',
properties: {
className: isNewContent ? ['animate-fade-in'] : [],
},
children: [{ type: 'text', value: word }],
});
currentCharCount = wordEndPos;
});
} catch (_) {
// Fallback:如果浏览器不支持 Segmenter
const textStartPos = currentCharCount;
const isNewContent = textStartPos >= previousContentLength;
if (isNewContent) {
// 新内容,添加动画
newChildren.push({
type: 'element',
tagName: 'span',
properties: {
className: ['animate-fade-in'],
},
children: [{ type: 'text', value: child.value }],
});
} else {
// 旧内容,不添加动画
newChildren.push(child);
}
currentCharCount += child.value.length;
}
} else {
newChildren.push(child);
}
});
node.children = newChildren;
}
});
};
}
+45
View File
@@ -0,0 +1,45 @@
import { API } from './api';
/**
* 获取可用的token keys
* @returns {Promise<string[]>} 返回active状态的token key数组
*/
export async function fetchTokenKeys() {
try {
const response = await API.get('/api/token/?p=0&size=100');
const { success, data } = response.data;
if (success) {
const activeTokens = data.filter((token) => token.status === 1);
return activeTokens.map((token) => token.key);
} else {
throw new Error('Failed to fetch token keys');
}
} catch (error) {
console.error('Error fetching token keys:', error);
return [];
}
}
/**
* 获取服务器地址
* @returns {string} 服务器地址
*/
export function getServerAddress() {
let status = localStorage.getItem('status');
let serverAddress = '';
if (status) {
try {
status = JSON.parse(status);
serverAddress = status.server_address || '';
} catch (error) {
console.error('Failed to parse status from localStorage:', error);
}
}
if (!serverAddress) {
serverAddress = window.location.origin;
}
return serverAddress;
}
+165
View File
@@ -2,6 +2,7 @@ import { Toast } from '@douyinfe/semi-ui';
import { toastConstants } from '../constants';
import React from 'react';
import { toast } from 'react-toastify';
import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants';
const HTMLToastContent = ({ htmlContent }) => {
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
@@ -95,6 +96,8 @@ export function showError(error) {
if (error.name === 'AxiosError') {
switch (error.response.status) {
case 401:
// 清除用户状态
localStorage.removeItem('user');
// toast.error('错误:未登录或登录已过期,请重新登录!', showErrorOptions);
window.location.href = '/login?expired=true';
break;
@@ -281,3 +284,165 @@ export function compareObjects(oldObject, newObject) {
return changedProperties;
}
// playground message
// 生成唯一ID
let messageId = 4;
export const generateMessageId = () => `${messageId++}`;
// 提取消息中的文本内容
export const getTextContent = (message) => {
if (!message || !message.content) return '';
if (Array.isArray(message.content)) {
const textContent = message.content.find(item => item.type === 'text');
return textContent?.text || '';
}
return typeof message.content === 'string' ? message.content : '';
};
// 处理 think 标签
export const processThinkTags = (content, reasoningContent = '') => {
if (!content || !content.includes('<think>')) {
return { content, reasoningContent };
}
const thoughts = [];
const replyParts = [];
let lastIndex = 0;
let match;
THINK_TAG_REGEX.lastIndex = 0;
while ((match = THINK_TAG_REGEX.exec(content)) !== null) {
replyParts.push(content.substring(lastIndex, match.index));
thoughts.push(match[1]);
lastIndex = match.index + match[0].length;
}
replyParts.push(content.substring(lastIndex));
const processedContent = replyParts.join('').replace(/<\/?think>/g, '').trim();
const thoughtsStr = thoughts.join('\n\n---\n\n');
const processedReasoningContent = reasoningContent && thoughtsStr
? `${reasoningContent}\n\n---\n\n${thoughtsStr}`
: reasoningContent || thoughtsStr;
return {
content: processedContent,
reasoningContent: processedReasoningContent
};
};
// 处理未完成的 think 标签
export const processIncompleteThinkTags = (content, reasoningContent = '') => {
if (!content) return { content: '', reasoningContent };
const lastOpenThinkIndex = content.lastIndexOf('<think>');
if (lastOpenThinkIndex === -1) {
return processThinkTags(content, reasoningContent);
}
const fragmentAfterLastOpen = content.substring(lastOpenThinkIndex);
if (!fragmentAfterLastOpen.includes('</think>')) {
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
const cleanContent = content.substring(0, lastOpenThinkIndex);
const processedReasoningContent = unclosedThought
? reasoningContent ? `${reasoningContent}\n\n---\n\n${unclosedThought}` : unclosedThought
: reasoningContent;
return processThinkTags(cleanContent, processedReasoningContent);
}
return processThinkTags(content, reasoningContent);
};
// 构建消息内容(包含图片)
export const buildMessageContent = (textContent, imageUrls = [], imageEnabled = false) => {
if (!textContent && (!imageUrls || imageUrls.length === 0)) {
return '';
}
const validImageUrls = imageUrls.filter(url => url && url.trim() !== '');
if (imageEnabled && validImageUrls.length > 0) {
return [
{ type: 'text', text: textContent || '' },
...validImageUrls.map(url => ({
type: 'image_url',
image_url: { url: url.trim() }
}))
];
}
return textContent || '';
};
// 创建新消息
export const createMessage = (role, content, options = {}) => ({
role,
content,
createAt: Date.now(),
id: generateMessageId(),
...options
});
// 创建加载中的助手消息
export const createLoadingAssistantMessage = () => createMessage(
MESSAGE_ROLES.ASSISTANT,
'',
{
reasoningContent: '',
isReasoningExpanded: true,
isThinkingComplete: false,
hasAutoCollapsed: false,
status: 'loading'
}
);
// 检查消息是否包含图片
export const hasImageContent = (message) => {
return message &&
Array.isArray(message.content) &&
message.content.some(item => item.type === 'image_url');
};
// 格式化消息用于API请求
export const formatMessageForAPI = (message) => {
if (!message) return null;
return {
role: message.role,
content: message.content
};
};
// 验证消息是否有效
export const isValidMessage = (message) => {
return message &&
message.role &&
(message.content || message.content === '');
};
// 获取最后一条用户消息
export const getLastUserMessage = (messages) => {
if (!Array.isArray(messages)) return null;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === MESSAGE_ROLES.USER) {
return messages[i];
}
}
return null;
};
// 获取最后一条助手消息
export const getLastAssistantMessage = (messages) => {
if (!Array.isArray(messages)) return null;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === MESSAGE_ROLES.ASSISTANT) {
return messages[i];
}
}
return null;
};
+404
View File
@@ -0,0 +1,404 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { SSE } from 'sse';
import {
API_ENDPOINTS,
MESSAGE_STATUS,
DEBUG_TABS
} from '../constants/playground.constants';
import {
getUserIdFromLocalStorage,
handleApiError,
processThinkTags,
processIncompleteThinkTags
} from '../helpers';
export const useApiRequest = (
setMessage,
setDebugData,
setActiveDebugTab,
sseSourceRef,
saveMessages
) => {
const { t } = useTranslation();
// 处理消息自动关闭逻辑的公共函数
const applyAutoCollapseLogic = useCallback((message, isThinkingComplete = true) => {
const shouldAutoCollapse = isThinkingComplete && !message.hasAutoCollapsed;
return {
isThinkingComplete,
hasAutoCollapsed: shouldAutoCollapse || message.hasAutoCollapsed,
isReasoningExpanded: shouldAutoCollapse ? false : message.isReasoningExpanded,
};
}, []);
// 流式消息更新
const streamMessageUpdate = useCallback((textChunk, type) => {
setMessage(prevMessage => {
const lastMessage = prevMessage[prevMessage.length - 1];
if (!lastMessage) return prevMessage;
if (lastMessage.role !== 'assistant') return prevMessage;
if (lastMessage.status === MESSAGE_STATUS.ERROR) {
return prevMessage;
}
if (lastMessage.status === MESSAGE_STATUS.LOADING ||
lastMessage.status === MESSAGE_STATUS.INCOMPLETE) {
let newMessage = { ...lastMessage };
if (type === 'reasoning') {
newMessage = {
...newMessage,
reasoningContent: (lastMessage.reasoningContent || '') + textChunk,
status: MESSAGE_STATUS.INCOMPLETE,
isThinkingComplete: false,
};
} else if (type === 'content') {
const shouldCollapseReasoning = !lastMessage.content && lastMessage.reasoningContent;
const newContent = (lastMessage.content || '') + textChunk;
let shouldCollapseFromThinkTag = false;
let thinkingCompleteFromTags = lastMessage.isThinkingComplete;
if (lastMessage.isReasoningExpanded && newContent.includes('</think>')) {
const thinkMatches = newContent.match(/<think>/g);
const thinkCloseMatches = newContent.match(/<\/think>/g);
if (thinkMatches && thinkCloseMatches &&
thinkCloseMatches.length >= thinkMatches.length) {
shouldCollapseFromThinkTag = true;
thinkingCompleteFromTags = true; // think标签闭合也标记思考完成
}
}
// 如果开始接收content内容,且之前有reasoning内容,或者think标签已闭合,则标记思考完成
const isThinkingComplete = (lastMessage.reasoningContent && !lastMessage.isThinkingComplete) ||
thinkingCompleteFromTags;
const autoCollapseState = applyAutoCollapseLogic(lastMessage, isThinkingComplete);
newMessage = {
...newMessage,
content: newContent,
status: MESSAGE_STATUS.INCOMPLETE,
...autoCollapseState,
};
}
return [...prevMessage.slice(0, -1), newMessage];
}
return prevMessage;
});
}, [setMessage, applyAutoCollapseLogic]);
// 完成消息
const completeMessage = useCallback((status = MESSAGE_STATUS.COMPLETE) => {
setMessage(prevMessage => {
const lastMessage = prevMessage[prevMessage.length - 1];
if (lastMessage.status === MESSAGE_STATUS.COMPLETE ||
lastMessage.status === MESSAGE_STATUS.ERROR) {
return prevMessage;
}
const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
const updatedMessages = [
...prevMessage.slice(0, -1),
{
...lastMessage,
status: status,
...autoCollapseState,
}
];
// 在消息完成时保存,传入更新后的消息列表
if (status === MESSAGE_STATUS.COMPLETE || status === MESSAGE_STATUS.ERROR) {
setTimeout(() => saveMessages(updatedMessages), 0);
}
return updatedMessages;
});
}, [setMessage, applyAutoCollapseLogic, saveMessages]);
// 非流式请求
const handleNonStreamRequest = useCallback(async (payload) => {
setDebugData(prev => ({
...prev,
request: payload,
timestamp: new Date().toISOString(),
response: null
}));
setActiveDebugTab(DEBUG_TABS.REQUEST);
try {
const response = await fetch(API_ENDPOINTS.CHAT_COMPLETIONS, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'New-Api-User': getUserIdFromLocalStorage(),
},
body: JSON.stringify(payload),
});
if (!response.ok) {
let errorBody = '';
try {
errorBody = await response.text();
} catch (e) {
errorBody = '无法读取错误响应体';
}
const errorInfo = handleApiError(
new Error(`HTTP error! status: ${response.status}, body: ${errorBody}`),
response
);
setDebugData(prev => ({
...prev,
response: JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
throw new Error(`HTTP error! status: ${response.status}, body: ${errorBody}`);
}
const data = await response.json();
setDebugData(prev => ({
...prev,
response: JSON.stringify(data, null, 2)
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
if (data.choices?.[0]) {
const choice = data.choices[0];
let content = choice.message?.content || '';
let reasoningContent = choice.message?.reasoning_content || '';
const processed = processThinkTags(content, reasoningContent);
setMessage(prevMessage => {
const newMessages = [...prevMessage];
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage?.status === MESSAGE_STATUS.LOADING) {
const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
newMessages[newMessages.length - 1] = {
...lastMessage,
content: processed.content,
reasoningContent: processed.reasoningContent,
status: MESSAGE_STATUS.COMPLETE,
...autoCollapseState,
};
}
return newMessages;
});
}
} catch (error) {
console.error('Non-stream request error:', error);
const errorInfo = handleApiError(error);
setDebugData(prev => ({
...prev,
response: JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
setMessage(prevMessage => {
const newMessages = [...prevMessage];
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage?.status === MESSAGE_STATUS.LOADING) {
const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
newMessages[newMessages.length - 1] = {
...lastMessage,
content: t('请求发生错误: ') + error.message,
status: MESSAGE_STATUS.ERROR,
...autoCollapseState,
};
}
return newMessages;
});
}
}, [setDebugData, setActiveDebugTab, setMessage, t, applyAutoCollapseLogic]);
// SSE请求
const handleSSE = useCallback((payload) => {
setDebugData(prev => ({
...prev,
request: payload,
timestamp: new Date().toISOString(),
response: null
}));
setActiveDebugTab(DEBUG_TABS.REQUEST);
const source = new SSE(API_ENDPOINTS.CHAT_COMPLETIONS, {
headers: {
'Content-Type': 'application/json',
'New-Api-User': getUserIdFromLocalStorage(),
},
method: 'POST',
payload: JSON.stringify(payload),
});
sseSourceRef.current = source;
let responseData = '';
let hasReceivedFirstResponse = false;
source.addEventListener('message', (e) => {
if (e.data === '[DONE]') {
source.close();
sseSourceRef.current = null;
setDebugData(prev => ({ ...prev, response: responseData }));
completeMessage();
return;
}
try {
const payload = JSON.parse(e.data);
responseData += e.data + '\n';
if (!hasReceivedFirstResponse) {
setActiveDebugTab(DEBUG_TABS.RESPONSE);
hasReceivedFirstResponse = true;
}
const delta = payload.choices?.[0]?.delta;
if (delta) {
if (delta.reasoning_content) {
streamMessageUpdate(delta.reasoning_content, 'reasoning');
}
if (delta.content) {
streamMessageUpdate(delta.content, 'content');
}
}
} catch (error) {
console.error('Failed to parse SSE message:', error);
const errorInfo = `解析错误: ${error.message}`;
setDebugData(prev => ({
...prev,
response: responseData + `\n\nError: ${errorInfo}`
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
streamMessageUpdate(t('解析响应数据时发生错误'), 'content');
completeMessage(MESSAGE_STATUS.ERROR);
}
});
source.addEventListener('error', (e) => {
console.error('SSE Error:', e);
const errorMessage = e.data || t('请求发生错误');
const errorInfo = handleApiError(new Error(errorMessage));
errorInfo.readyState = source.readyState;
setDebugData(prev => ({
...prev,
response: responseData + '\n\nSSE Error:\n' + JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
streamMessageUpdate(errorMessage, 'content');
completeMessage(MESSAGE_STATUS.ERROR);
sseSourceRef.current = null;
source.close();
});
source.addEventListener('readystatechange', (e) => {
if (e.readyState >= 2 && source.status !== undefined && source.status !== 200) {
const errorInfo = handleApiError(new Error('HTTP状态错误'));
errorInfo.status = source.status;
errorInfo.readyState = source.readyState;
setDebugData(prev => ({
...prev,
response: responseData + '\n\nHTTP Error:\n' + JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
source.close();
streamMessageUpdate(t('连接已断开'), 'content');
completeMessage(MESSAGE_STATUS.ERROR);
}
});
try {
source.stream();
} catch (error) {
console.error('Failed to start SSE stream:', error);
const errorInfo = handleApiError(error);
setDebugData(prev => ({
...prev,
response: 'Stream启动失败:\n' + JSON.stringify(errorInfo, null, 2)
}));
setActiveDebugTab(DEBUG_TABS.RESPONSE);
streamMessageUpdate(t('建立连接时发生错误'), 'content');
completeMessage(MESSAGE_STATUS.ERROR);
}
}, [setDebugData, setActiveDebugTab, streamMessageUpdate, completeMessage, t, applyAutoCollapseLogic]);
// 停止生成
const onStopGenerator = useCallback(() => {
// 如果仍有活动的 SSE 连接,首先关闭
if (sseSourceRef.current) {
sseSourceRef.current.close();
sseSourceRef.current = null;
}
// 无论是否存在 SSE 连接,都尝试处理最后一条正在生成的消息
setMessage(prevMessage => {
if (prevMessage.length === 0) return prevMessage;
const lastMessage = prevMessage[prevMessage.length - 1];
if (lastMessage.status === MESSAGE_STATUS.LOADING ||
lastMessage.status === MESSAGE_STATUS.INCOMPLETE) {
const processed = processIncompleteThinkTags(
lastMessage.content || '',
lastMessage.reasoningContent || ''
);
const autoCollapseState = applyAutoCollapseLogic(lastMessage, true);
const updatedMessages = [
...prevMessage.slice(0, -1),
{
...lastMessage,
status: MESSAGE_STATUS.COMPLETE,
reasoningContent: processed.reasoningContent || null,
content: processed.content,
...autoCollapseState,
}
];
// 停止生成时也保存,传入更新后的消息列表
setTimeout(() => saveMessages(updatedMessages), 0);
return updatedMessages;
}
return prevMessage;
});
}, [setMessage, applyAutoCollapseLogic, saveMessages]);
// 发送请求
const sendRequest = useCallback((payload, isStream) => {
if (isStream) {
handleSSE(payload);
} else {
handleNonStreamRequest(payload);
}
}, [handleSSE, handleNonStreamRequest]);
return {
sendRequest,
onStopGenerator,
streamMessageUpdate,
completeMessage,
};
};
+69
View File
@@ -0,0 +1,69 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { API, processModelsData, processGroupsData } from '../helpers';
import { API_ENDPOINTS } from '../constants/playground.constants';
export const useDataLoader = (
userState,
inputs,
handleInputChange,
setModels,
setGroups
) => {
const { t } = useTranslation();
const loadModels = useCallback(async () => {
try {
const res = await API.get(API_ENDPOINTS.USER_MODELS);
const { success, message, data } = res.data;
if (success) {
const { modelOptions, selectedModel } = processModelsData(data, inputs.model);
setModels(modelOptions);
if (selectedModel !== inputs.model) {
handleInputChange('model', selectedModel);
}
} else {
showError(t(message));
}
} catch (error) {
showError(t('加载模型失败'));
}
}, [inputs.model, handleInputChange, setModels, t]);
const loadGroups = useCallback(async () => {
try {
const res = await API.get(API_ENDPOINTS.USER_GROUPS);
const { success, message, data } = res.data;
if (success) {
const userGroup = userState?.user?.group || JSON.parse(localStorage.getItem('user'))?.group;
const groupOptions = processGroupsData(data, userGroup);
setGroups(groupOptions);
const hasCurrentGroup = groupOptions.some(option => option.value === inputs.group);
if (!hasCurrentGroup) {
handleInputChange('group', groupOptions[0]?.value || '');
}
} else {
showError(t(message));
}
} catch (error) {
showError(t('加载分组失败'));
}
}, [userState, inputs.group, handleInputChange, setGroups, t]);
// 自动加载数据
useEffect(() => {
if (userState?.user) {
loadModels();
loadGroups();
}
}, [userState?.user, loadModels, loadGroups]);
return {
loadModels,
loadGroups
};
};
+223
View File
@@ -0,0 +1,223 @@
import { useCallback } from 'react';
import { Toast, Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { getTextContent } from '../helpers';
import { ERROR_MESSAGES } from '../constants/playground.constants';
export const useMessageActions = (message, setMessage, onMessageSend, saveMessages) => {
const { t } = useTranslation();
// 复制消息
const handleMessageCopy = useCallback((targetMessage) => {
const textToCopy = getTextContent(targetMessage);
if (!textToCopy) {
Toast.warning({
content: t(ERROR_MESSAGES.NO_TEXT_CONTENT),
duration: 2,
});
return;
}
const copyToClipboard = async (text) => {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
Toast.success({
content: t('消息已复制到剪贴板'),
duration: 2,
});
} catch (err) {
console.error('Clipboard API 复制失败:', err);
fallbackCopy(text);
}
} else {
fallbackCopy(text);
}
};
const fallbackCopy = (text) => {
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.cssText = `
position: fixed;
top: -9999px;
left: -9999px;
opacity: 0;
pointer-events: none;
z-index: -1;
`;
textArea.setAttribute('readonly', '');
document.body.appendChild(textArea);
textArea.select();
textArea.setSelectionRange(0, text.length);
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
Toast.success({
content: t('消息已复制到剪贴板'),
duration: 2,
});
} else {
throw new Error('execCommand copy failed');
}
} catch (err) {
console.error('回退复制方案也失败:', err);
let errorMessage = t(ERROR_MESSAGES.COPY_FAILED);
if (window.location.protocol === 'http:' && window.location.hostname !== 'localhost') {
errorMessage = t(ERROR_MESSAGES.COPY_HTTPS_REQUIRED);
} else if (!navigator.clipboard && !document.execCommand) {
errorMessage = t(ERROR_MESSAGES.BROWSER_NOT_SUPPORTED);
}
Toast.error({
content: errorMessage,
duration: 4,
});
}
};
copyToClipboard(textToCopy);
}, [t]);
// 重新生成消息
const handleMessageReset = useCallback((targetMessage) => {
setMessage(prevMessages => {
// 使用引用查找索引,防止重复 id 造成误匹配
let messageIndex = prevMessages.findIndex(msg => msg === targetMessage);
// 回退到 id 匹配(兼容不同引用场景)
if (messageIndex === -1) {
messageIndex = prevMessages.findIndex(msg => msg.id === targetMessage.id);
}
if (messageIndex === -1) return prevMessages;
if (targetMessage.role === 'user') {
const newMessages = prevMessages.slice(0, messageIndex);
const contentToSend = getTextContent(targetMessage);
setTimeout(() => {
onMessageSend(contentToSend);
}, 100);
return newMessages;
} else if (targetMessage.role === 'assistant' || targetMessage.role === 'system') {
let userMessageIndex = messageIndex - 1;
while (userMessageIndex >= 0 && prevMessages[userMessageIndex].role !== 'user') {
userMessageIndex--;
}
if (userMessageIndex >= 0) {
const userMessage = prevMessages[userMessageIndex];
const newMessages = prevMessages.slice(0, userMessageIndex);
const contentToSend = getTextContent(userMessage);
setTimeout(() => {
onMessageSend(contentToSend);
}, 100);
return newMessages;
}
}
return prevMessages;
});
}, [setMessage, onMessageSend]);
// 删除消息
const handleMessageDelete = useCallback((targetMessage) => {
Modal.confirm({
title: t('确认删除'),
content: t('确定要删除这条消息吗?'),
okText: t('确定'),
cancelText: t('取消'),
okButtonProps: {
type: 'danger',
},
onOk: () => {
setMessage(prevMessages => {
// 使用引用查找索引,防止重复 id 造成误匹配
let messageIndex = prevMessages.findIndex(msg => msg === targetMessage);
// 回退到 id 匹配(兼容不同引用场景)
if (messageIndex === -1) {
messageIndex = prevMessages.findIndex(msg => msg.id === targetMessage.id);
}
if (messageIndex === -1) return prevMessages;
let updatedMessages;
if (targetMessage.role === 'user' && messageIndex < prevMessages.length - 1) {
const nextMessage = prevMessages[messageIndex + 1];
if (nextMessage.role === 'assistant') {
Toast.success({
content: t('已删除消息及其回复'),
duration: 2,
});
updatedMessages = prevMessages.filter((_, index) =>
index !== messageIndex && index !== messageIndex + 1
);
} else {
Toast.success({
content: t('消息已删除'),
duration: 2,
});
updatedMessages = prevMessages.filter(msg => msg.id !== targetMessage.id);
}
} else {
Toast.success({
content: t('消息已删除'),
duration: 2,
});
updatedMessages = prevMessages.filter(msg => msg.id !== targetMessage.id);
}
// 删除消息后保存,传入更新后的消息列表
setTimeout(() => saveMessages(updatedMessages), 0);
return updatedMessages;
});
},
});
}, [setMessage, t, saveMessages]);
// 切换角色
const handleRoleToggle = useCallback((targetMessage) => {
if (!(targetMessage.role === 'assistant' || targetMessage.role === 'system')) {
return;
}
const newRole = targetMessage.role === 'assistant' ? 'system' : 'assistant';
setMessage(prevMessages => {
const updatedMessages = prevMessages.map(msg => {
if (msg.id === targetMessage.id &&
(msg.role === 'assistant' || msg.role === 'system')) {
return { ...msg, role: newRole };
}
return msg;
});
// 切换角色后保存,传入更新后的消息列表
setTimeout(() => saveMessages(updatedMessages), 0);
return updatedMessages;
});
Toast.success({
content: t(`已切换为${newRole === 'system' ? 'System' : 'Assistant'}角色`),
duration: 2,
});
}, [setMessage, t, saveMessages]);
return {
handleMessageCopy,
handleMessageReset,
handleMessageDelete,
handleRoleToggle,
};
};
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useState, useRef } from 'react';
import { Toast, Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { getTextContent, buildApiPayload, createLoadingAssistantMessage } from '../helpers';
import { MESSAGE_ROLES } from '../constants/playground.constants';
export const useMessageEdit = (
setMessage,
inputs,
parameterEnabled,
sendRequest,
saveMessages
) => {
const { t } = useTranslation();
const [editingMessageId, setEditingMessageId] = useState(null);
const [editValue, setEditValue] = useState('');
const editingMessageRef = useRef(null);
const handleMessageEdit = useCallback((targetMessage) => {
const editableContent = getTextContent(targetMessage);
setEditingMessageId(targetMessage.id);
editingMessageRef.current = targetMessage;
setEditValue(editableContent);
}, []);
const handleEditSave = useCallback(() => {
if (!editingMessageId || !editValue.trim()) return;
setMessage(prevMessages => {
let messageIndex = prevMessages.findIndex(msg => msg === editingMessageRef.current);
if (messageIndex === -1) {
messageIndex = prevMessages.findIndex(msg => msg.id === editingMessageId);
}
const targetMessage = prevMessages[messageIndex];
let newContent;
if (Array.isArray(targetMessage.content)) {
newContent = targetMessage.content.map(item =>
item.type === 'text' ? { ...item, text: editValue.trim() } : item
);
} else {
newContent = editValue.trim();
}
const updatedMessages = prevMessages.map(msg =>
msg.id === editingMessageId ? { ...msg, content: newContent } : msg
);
// 处理用户消息编辑后的重新生成
if (targetMessage.role === MESSAGE_ROLES.USER) {
const hasSubsequentAssistantReply = messageIndex < prevMessages.length - 1 &&
prevMessages[messageIndex + 1].role === MESSAGE_ROLES.ASSISTANT;
if (hasSubsequentAssistantReply) {
Modal.confirm({
title: t('消息已编辑'),
content: t('检测到该消息后有AI回复,是否删除后续回复并重新生成?'),
okText: t('重新生成'),
cancelText: t('仅保存'),
onOk: () => {
const messagesUntilUser = updatedMessages.slice(0, messageIndex + 1);
setMessage(messagesUntilUser);
// 编辑后保存(重新生成的情况),传入更新后的消息列表
setTimeout(() => saveMessages(messagesUntilUser), 0);
setTimeout(() => {
const payload = buildApiPayload(messagesUntilUser, null, inputs, parameterEnabled);
setMessage(prevMsg => [...prevMsg, createLoadingAssistantMessage()]);
sendRequest(payload, inputs.stream);
}, 100);
},
onCancel: () => {
setMessage(updatedMessages);
// 编辑后保存(仅保存的情况),传入更新后的消息列表
setTimeout(() => saveMessages(updatedMessages), 0);
}
});
return prevMessages;
}
}
// 编辑后保存(普通情况),传入更新后的消息列表
setTimeout(() => saveMessages(updatedMessages), 0);
return updatedMessages;
});
setEditingMessageId(null);
editingMessageRef.current = null;
setEditValue('');
Toast.success({ content: t('消息已更新'), duration: 2 });
}, [editingMessageId, editValue, t, inputs, parameterEnabled, sendRequest, setMessage, saveMessages]);
const handleEditCancel = useCallback(() => {
setEditingMessageId(null);
editingMessageRef.current = null;
setEditValue('');
}, []);
return {
editingMessageId,
editValue,
setEditValue,
handleMessageEdit,
handleEditSave,
handleEditCancel
};
};
+225
View File
@@ -0,0 +1,225 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { DEFAULT_MESSAGES, DEFAULT_CONFIG, DEBUG_TABS, MESSAGE_STATUS } from '../constants/playground.constants';
import { loadConfig, saveConfig, loadMessages, saveMessages } from '../components/playground/configStorage';
import { processIncompleteThinkTags } from '../helpers';
export const usePlaygroundState = () => {
// 使用惰性初始化,确保只在组件首次挂载时加载配置和消息
const [savedConfig] = useState(() => loadConfig());
const [initialMessages] = useState(() => loadMessages() || DEFAULT_MESSAGES);
// 基础配置状态
const [inputs, setInputs] = useState(savedConfig.inputs || DEFAULT_CONFIG.inputs);
const [parameterEnabled, setParameterEnabled] = useState(
savedConfig.parameterEnabled || DEFAULT_CONFIG.parameterEnabled
);
const [showDebugPanel, setShowDebugPanel] = useState(
savedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel
);
const [customRequestMode, setCustomRequestMode] = useState(
savedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode
);
const [customRequestBody, setCustomRequestBody] = useState(
savedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody
);
// UI状态
const [showSettings, setShowSettings] = useState(false);
const [models, setModels] = useState([]);
const [groups, setGroups] = useState([]);
const [status, setStatus] = useState({});
// 消息相关状态 - 使用加载的消息初始化
const [message, setMessage] = useState(initialMessages);
// 调试状态
const [debugData, setDebugData] = useState({
request: null,
response: null,
timestamp: null,
previewRequest: null,
previewTimestamp: null
});
const [activeDebugTab, setActiveDebugTab] = useState(DEBUG_TABS.PREVIEW);
const [previewPayload, setPreviewPayload] = useState(null);
// 编辑状态
const [editingMessageId, setEditingMessageId] = useState(null);
const [editValue, setEditValue] = useState('');
// Refs
const sseSourceRef = useRef(null);
const chatRef = useRef(null);
const saveConfigTimeoutRef = useRef(null);
const saveMessagesTimeoutRef = useRef(null);
// 配置更新函数
const handleInputChange = useCallback((name, value) => {
setInputs(prev => ({ ...prev, [name]: value }));
}, []);
const handleParameterToggle = useCallback((paramName) => {
setParameterEnabled(prev => ({
...prev,
[paramName]: !prev[paramName]
}));
}, []);
// 消息保存函数 - 改为立即保存,可以接受参数
const saveMessagesImmediately = useCallback((messagesToSave) => {
// 如果提供了参数,使用参数;否则使用当前状态
saveMessages(messagesToSave || message);
}, [message]);
// 配置保存
const debouncedSaveConfig = useCallback(() => {
if (saveConfigTimeoutRef.current) {
clearTimeout(saveConfigTimeoutRef.current);
}
saveConfigTimeoutRef.current = setTimeout(() => {
const configToSave = {
inputs,
parameterEnabled,
showDebugPanel,
customRequestMode,
customRequestBody,
};
saveConfig(configToSave);
}, 1000);
}, [inputs, parameterEnabled, showDebugPanel, customRequestMode, customRequestBody]);
// 配置导入/重置
const handleConfigImport = useCallback((importedConfig) => {
if (importedConfig.inputs) {
setInputs(prev => ({ ...prev, ...importedConfig.inputs }));
}
if (importedConfig.parameterEnabled) {
setParameterEnabled(prev => ({ ...prev, ...importedConfig.parameterEnabled }));
}
if (typeof importedConfig.showDebugPanel === 'boolean') {
setShowDebugPanel(importedConfig.showDebugPanel);
}
if (importedConfig.customRequestMode) {
setCustomRequestMode(importedConfig.customRequestMode);
}
if (importedConfig.customRequestBody) {
setCustomRequestBody(importedConfig.customRequestBody);
}
// 如果导入的配置包含消息,也恢复消息
if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
setMessage(importedConfig.messages);
}
}, []);
const handleConfigReset = useCallback((options = {}) => {
const { resetMessages = false } = options;
setInputs(DEFAULT_CONFIG.inputs);
setParameterEnabled(DEFAULT_CONFIG.parameterEnabled);
setShowDebugPanel(DEFAULT_CONFIG.showDebugPanel);
setCustomRequestMode(DEFAULT_CONFIG.customRequestMode);
setCustomRequestBody(DEFAULT_CONFIG.customRequestBody);
// 只有在明确指定时才重置消息
if (resetMessages) {
setMessage([]);
setTimeout(() => {
setMessage(DEFAULT_MESSAGES);
}, 0);
}
}, []);
// 清理定时器
useEffect(() => {
return () => {
if (saveConfigTimeoutRef.current) {
clearTimeout(saveConfigTimeoutRef.current);
}
};
}, []);
// 页面首次加载时,若最后一条消息仍处于 LOADING/INCOMPLETE 状态,自动修复
useEffect(() => {
if (!Array.isArray(message) || message.length === 0) return;
const lastMsg = message[message.length - 1];
if (lastMsg.status === MESSAGE_STATUS.LOADING || lastMsg.status === MESSAGE_STATUS.INCOMPLETE) {
const processed = processIncompleteThinkTags(
lastMsg.content || '',
lastMsg.reasoningContent || ''
);
const fixedLastMsg = {
...lastMsg,
status: MESSAGE_STATUS.COMPLETE,
content: processed.content,
reasoningContent: processed.reasoningContent || null,
isThinkingComplete: true,
};
const updatedMessages = [...message.slice(0, -1), fixedLastMsg];
setMessage(updatedMessages);
// 保存修复后的消息列表
setTimeout(() => saveMessagesImmediately(updatedMessages), 0);
}
}, []);
return {
// 配置状态
inputs,
parameterEnabled,
showDebugPanel,
customRequestMode,
customRequestBody,
// UI状态
showSettings,
models,
groups,
status,
// 消息状态
message,
// 调试状态
debugData,
activeDebugTab,
previewPayload,
// 编辑状态
editingMessageId,
editValue,
// Refs
sseSourceRef,
chatRef,
saveConfigTimeoutRef,
// 更新函数
setInputs,
setParameterEnabled,
setShowDebugPanel,
setCustomRequestMode,
setCustomRequestBody,
setShowSettings,
setModels,
setGroups,
setStatus,
setMessage,
setDebugData,
setActiveDebugTab,
setPreviewPayload,
setEditingMessageId,
setEditValue,
// 处理函数
handleInputChange,
handleParameterToggle,
debouncedSaveConfig,
saveMessagesImmediately,
handleConfigImport,
handleConfigReset,
};
};

Some files were not shown because too many files have changed in this diff Show More