+13
-12
@@ -1,14 +1,15 @@
|
|||||||
name: Publish Docker image (arm64)
|
name: Publish Docker image (alpha)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
branches:
|
||||||
- '*'
|
- alpha
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
name:
|
name:
|
||||||
description: 'reason'
|
description: "reason"
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
push_to_registries:
|
push_to_registries:
|
||||||
name: Push Docker image to multiple registries
|
name: Push Docker image to multiple registries
|
||||||
@@ -22,13 +23,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Save version info
|
- name: Save version info
|
||||||
run: |
|
run: |
|
||||||
git describe --tags > VERSION
|
echo "alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" > VERSION
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -43,6 +38,9 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
@@ -50,6 +48,9 @@ jobs:
|
|||||||
images: |
|
images: |
|
||||||
calciumion/new-api
|
calciumion/new-api
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=alpha
|
||||||
|
type=raw,value=alpha-{{date 'YYYYMMDD'}}-{{sha}}
|
||||||
|
|
||||||
- name: Build and push Docker images
|
- name: Build and push Docker images
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
@@ -58,4 +59,4 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -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 }}
|
|
||||||
@@ -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 }}
|
|
||||||
@@ -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 }}
|
|
||||||
@@ -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 }}
|
|
||||||
@@ -189,6 +189,11 @@ func TokenAuth() func(c *gin.Context) {
|
|||||||
if skKey != "" {
|
if skKey != "" {
|
||||||
c.Request.Header.Set("Authorization", "Bearer "+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")
|
key := c.Request.Header.Get("Authorization")
|
||||||
parts := make([]string, 0)
|
parts := make([]string, 0)
|
||||||
|
|||||||
+12
-11
@@ -27,14 +27,9 @@ type FunctionCall struct {
|
|||||||
Arguments any `json:"args"`
|
Arguments any `json:"args"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeminiFunctionResponseContent struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Content any `json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type FunctionResponse struct {
|
type FunctionResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Response GeminiFunctionResponseContent `json:"response"`
|
Response map[string]interface{} `json:"response"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeminiPartExecutableCode struct {
|
type GeminiPartExecutableCode struct {
|
||||||
@@ -117,10 +112,16 @@ type GeminiChatResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GeminiUsageMetadata struct {
|
type GeminiUsageMetadata struct {
|
||||||
PromptTokenCount int `json:"promptTokenCount"`
|
PromptTokenCount int `json:"promptTokenCount"`
|
||||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||||
TotalTokenCount int `json:"totalTokenCount"`
|
TotalTokenCount int `json:"totalTokenCount"`
|
||||||
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
|
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
|
||||||
|
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeminiPromptTokensDetails struct {
|
||||||
|
Modality string `json:"modality"`
|
||||||
|
TokenCount int `json:"tokenCount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Imagen related structs
|
// Imagen related structs
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ func GeminiTextGenerationHandler(c *gin.Context, resp *http.Response, info *rela
|
|||||||
TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount,
|
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 响应
|
// 直接返回 Gemini 原生格式的 JSON 响应
|
||||||
jsonResponse, err := json.Marshal(geminiResponse)
|
jsonResponse, err := json.Marshal(geminiResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -100,6 +110,14 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info
|
|||||||
usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount
|
usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount
|
||||||
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
|
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
|
||||||
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
|
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 响应
|
// 直接发送 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
|
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
|
||||||
|
|
||||||
// 结束流式响应
|
// 移除流式响应结尾的[Done],因为Gemini API没有发送Done的行为
|
||||||
helper.Done(c)
|
//helper.Done(c)
|
||||||
|
|
||||||
return usage, nil
|
return usage, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,25 +57,63 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
|
|||||||
}
|
}
|
||||||
|
|
||||||
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
|
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
|
||||||
if strings.HasSuffix(info.OriginModelName, "-thinking") {
|
if strings.HasSuffix(info.OriginModelName, "-thinking") {
|
||||||
// 如果模型名以 gemini-2.5-pro 开头,不设置 ThinkingBudget
|
// 硬编码不支持 ThinkingBudget 的旧模型
|
||||||
if strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") {
|
unsupportedModels := []string{
|
||||||
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
|
"gemini-2.5-pro-preview-05-06",
|
||||||
IncludeThoughts: true,
|
"gemini-2.5-pro-preview-03-25",
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
|
isUnsupported := false
|
||||||
if budgetTokens == 0 || budgetTokens > 24576 {
|
for _, unsupportedModel := range unsupportedModels {
|
||||||
budgetTokens = 24576
|
if strings.HasPrefix(info.OriginModelName, unsupportedModel) {
|
||||||
}
|
isUnsupported = true
|
||||||
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
|
break
|
||||||
ThinkingBudget: common.GetPointer(int(budgetTokens)),
|
}
|
||||||
IncludeThoughts: true,
|
}
|
||||||
}
|
|
||||||
}
|
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") {
|
} else if strings.HasSuffix(info.OriginModelName, "-nothinking") {
|
||||||
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
|
// 检查是否为新的2.5pro模型(不支持-nothinking,因为最低值只能为128)
|
||||||
ThinkingBudget: common.GetPointer(0),
|
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 {
|
} else if val, exists := tool_call_ids[message.ToolCallId]; exists {
|
||||||
name = val
|
name = val
|
||||||
}
|
}
|
||||||
content := common.StrToMap(message.StringContent())
|
contentMap := common.StrToMap(message.StringContent())
|
||||||
functionResp := &FunctionResponse{
|
functionResp := &FunctionResponse{
|
||||||
Name: name,
|
Name: name,
|
||||||
Response: GeminiFunctionResponseContent{
|
Response: contentMap,
|
||||||
Name: name,
|
|
||||||
Content: content,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if content == nil {
|
|
||||||
functionResp.Response.Content = message.StringContent()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*parts = append(*parts, GeminiPart{
|
*parts = append(*parts, GeminiPart{
|
||||||
FunctionResponse: functionResp,
|
FunctionResponse: functionResp,
|
||||||
})
|
})
|
||||||
@@ -280,13 +313,13 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
|
|||||||
if part.GetInputAudio().Data == "" {
|
if part.GetInputAudio().Data == "" {
|
||||||
return nil, fmt.Errorf("only base64 audio is supported in gemini")
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
|
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
|
||||||
}
|
}
|
||||||
parts = append(parts, GeminiPart{
|
parts = append(parts, GeminiPart{
|
||||||
InlineData: &GeminiInlineData{
|
InlineData: &GeminiInlineData{
|
||||||
MimeType: format,
|
MimeType: "audio/" + part.GetInputAudio().Format,
|
||||||
Data: base64String,
|
Data: base64String,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -738,6 +771,13 @@ func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycom
|
|||||||
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
|
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
|
||||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||||
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
|
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)
|
err = helper.ObjectData(c, response)
|
||||||
if err != nil {
|
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.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||||
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
|
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
|
fullTextResponse.Usage = usage
|
||||||
jsonResponse, err := json.Marshal(fullTextResponse)
|
jsonResponse, err := json.Marshal(fullTextResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+38
-9
@@ -352,6 +352,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
|||||||
promptTokens := usage.PromptTokens
|
promptTokens := usage.PromptTokens
|
||||||
cacheTokens := usage.PromptTokensDetails.CachedTokens
|
cacheTokens := usage.PromptTokensDetails.CachedTokens
|
||||||
imageTokens := usage.PromptTokensDetails.ImageTokens
|
imageTokens := usage.PromptTokensDetails.ImageTokens
|
||||||
|
audioTokens := usage.PromptTokensDetails.AudioTokens
|
||||||
completionTokens := usage.CompletionTokens
|
completionTokens := usage.CompletionTokens
|
||||||
modelName := relayInfo.OriginModelName
|
modelName := relayInfo.OriginModelName
|
||||||
|
|
||||||
@@ -367,6 +368,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
|||||||
dPromptTokens := decimal.NewFromInt(int64(promptTokens))
|
dPromptTokens := decimal.NewFromInt(int64(promptTokens))
|
||||||
dCacheTokens := decimal.NewFromInt(int64(cacheTokens))
|
dCacheTokens := decimal.NewFromInt(int64(cacheTokens))
|
||||||
dImageTokens := decimal.NewFromInt(int64(imageTokens))
|
dImageTokens := decimal.NewFromInt(int64(imageTokens))
|
||||||
|
dAudioTokens := decimal.NewFromInt(int64(audioTokens))
|
||||||
dCompletionTokens := decimal.NewFromInt(int64(completionTokens))
|
dCompletionTokens := decimal.NewFromInt(int64(completionTokens))
|
||||||
dCompletionRatio := decimal.NewFromFloat(completionRatio)
|
dCompletionRatio := decimal.NewFromFloat(completionRatio)
|
||||||
dCacheRatio := decimal.NewFromFloat(cacheRatio)
|
dCacheRatio := decimal.NewFromFloat(cacheRatio)
|
||||||
@@ -412,23 +414,43 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
|||||||
dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice).
|
dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice).
|
||||||
Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))).
|
Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))).
|
||||||
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
|
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())
|
fileSearchTool.CallCount, dFileSearchQuota.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var quotaCalculateDecimal decimal.Decimal
|
var quotaCalculateDecimal decimal.Decimal
|
||||||
if !priceData.UsePrice {
|
|
||||||
nonCachedTokens := dPromptTokens.Sub(dCacheTokens)
|
|
||||||
cachedTokensWithRatio := dCacheTokens.Mul(dCacheRatio)
|
|
||||||
|
|
||||||
promptQuota := nonCachedTokens.Add(cachedTokensWithRatio)
|
var audioInputQuota decimal.Decimal
|
||||||
if imageTokens > 0 {
|
var audioInputPrice float64
|
||||||
nonImageTokens := dPromptTokens.Sub(dImageTokens)
|
if !priceData.UsePrice {
|
||||||
imageTokensWithRatio := dImageTokens.Mul(dImageRatio)
|
baseTokens := dPromptTokens
|
||||||
promptQuota = nonImageTokens.Add(imageTokensWithRatio)
|
// 减去 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)
|
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
|
||||||
|
|
||||||
quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio)
|
quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio)
|
||||||
@@ -442,6 +464,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
|||||||
// 添加 responses tools call 调用的配额
|
// 添加 responses tools call 调用的配额
|
||||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
|
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
|
||||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
|
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
|
||||||
|
// 添加 audio input 独立计费
|
||||||
|
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
|
||||||
|
|
||||||
quota := int(quotaCalculateDecimal.Round(0).IntPart())
|
quota := int(quotaCalculateDecimal.Round(0).IntPart())
|
||||||
totalTokens := promptTokens + completionTokens
|
totalTokens := promptTokens + completionTokens
|
||||||
@@ -512,6 +536,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
|||||||
other["file_search_price"] = fileSearchPrice
|
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,
|
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel,
|
||||||
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
|
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func parseAudio(audioBase64 string, format string) (duration float64, err error) {
|
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)
|
duration = float64(samplesCount) / float64(sampleRate)
|
||||||
return duration, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ const (
|
|||||||
FileSearchPrice = 2.5
|
FileSearchPrice = 2.5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Gemini Audio Input Price
|
||||||
|
Gemini25FlashPreviewInputAudioPrice = 1.00
|
||||||
|
Gemini25FlashNativeAudioInputAudioPrice = 3.00
|
||||||
|
Gemini20FlashInputAudioPrice = 0.70
|
||||||
|
)
|
||||||
|
|
||||||
func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 {
|
func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 {
|
||||||
// 确定模型类型
|
// 确定模型类型
|
||||||
// https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费
|
// 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 {
|
func GetFileSearchPricePerThousand() float64 {
|
||||||
return FileSearchPrice
|
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
@@ -6,27 +6,42 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-icons": "^2.63.1",
|
"@douyinfe/semi-icons": "^2.63.1",
|
||||||
"@douyinfe/semi-ui": "^2.69.1",
|
"@douyinfe/semi-ui": "^2.69.1",
|
||||||
|
"@lobehub/icons": "^2.0.0",
|
||||||
"@visactor/react-vchart": "~1.8.8",
|
"@visactor/react-vchart": "~1.8.8",
|
||||||
"@visactor/vchart": "~1.8.8",
|
"@visactor/vchart": "~1.8.8",
|
||||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"country-flag-icons": "^1.5.19",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"history": "^5.3.0",
|
"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",
|
"marked": "^4.1.1",
|
||||||
|
"mermaid": "^11.6.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-fireworks": "^1.0.4",
|
"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-router-dom": "^6.3.0",
|
||||||
"react-telegram-login": "^1.1.2",
|
"react-telegram-login": "^1.1.2",
|
||||||
"react-toastify": "^9.0.8",
|
"react-toastify": "^9.0.8",
|
||||||
"react-turnstile": "^1.0.5",
|
"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-offline": "^2.5.0",
|
||||||
"semantic-ui-react": "^2.1.3",
|
"semantic-ui-react": "^2.1.3",
|
||||||
"sse": "https://github.com/mpetazzoni/sse.js",
|
"sse": "https://github.com/mpetazzoni/sse.js",
|
||||||
"i18next": "^23.16.8",
|
"unist-util-visit": "^5.0.0",
|
||||||
"react-i18next": "^13.0.0",
|
"use-debounce": "^10.0.4"
|
||||||
"i18next-browser-languagedetector": "^7.2.0"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -54,9 +69,13 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@douyinfe/semi-webpack-plugin": "^2.78.0",
|
||||||
"@so1ve/prettier-config": "^3.1.0",
|
"@so1ve/prettier-config": "^3.1.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
|
"tailwindcss": "^3",
|
||||||
"typescript": "4.4.2",
|
"typescript": "4.4.2",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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 { 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 User from './pages/User';
|
||||||
import { PrivateRoute } from './components/PrivateRoute';
|
import { AuthRedirect, PrivateRoute } from './helpers';
|
||||||
import RegisterForm from './components/RegisterForm';
|
import RegisterForm from './components/auth/RegisterForm.js';
|
||||||
import LoginForm from './components/LoginForm';
|
import LoginForm from './components/auth/LoginForm.js';
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
import Setting from './pages/Setting';
|
import Setting from './pages/Setting';
|
||||||
import EditUser from './pages/User/EditUser';
|
import EditUser from './pages/User/EditUser';
|
||||||
import PasswordResetForm from './components/PasswordResetForm';
|
import PasswordResetForm from './components/auth/PasswordResetForm.js';
|
||||||
import PasswordResetConfirm from './components/PasswordResetConfirm';
|
import PasswordResetConfirm from './components/auth/PasswordResetConfirm.js';
|
||||||
import Channel from './pages/Channel';
|
import Channel from './pages/Channel';
|
||||||
import Token from './pages/Token';
|
import Token from './pages/Token';
|
||||||
import EditChannel from './pages/Channel/EditChannel';
|
import EditChannel from './pages/Channel/EditChannel';
|
||||||
@@ -18,15 +18,14 @@ import TopUp from './pages/TopUp';
|
|||||||
import Log from './pages/Log';
|
import Log from './pages/Log';
|
||||||
import Chat from './pages/Chat';
|
import Chat from './pages/Chat';
|
||||||
import Chat2Link from './pages/Chat2Link';
|
import Chat2Link from './pages/Chat2Link';
|
||||||
import { Layout } from '@douyinfe/semi-ui';
|
|
||||||
import Midjourney from './pages/Midjourney';
|
import Midjourney from './pages/Midjourney';
|
||||||
import Pricing from './pages/Pricing/index.js';
|
import Pricing from './pages/Pricing/index.js';
|
||||||
import Task from './pages/Task/index.js';
|
import Task from './pages/Task/index.js';
|
||||||
import Playground from './pages/Playground/Playground.js';
|
import Playground from './pages/Playground/index.js';
|
||||||
import OAuth2Callback from './components/OAuth2Callback.js';
|
import OAuth2Callback from './components/auth/OAuth2Callback.js';
|
||||||
import PersonalSetting from './components/PersonalSetting.js';
|
import PersonalSetting from './components/settings/PersonalSetting.js';
|
||||||
import Setup from './pages/Setup/index.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 Home = lazy(() => import('./pages/Home'));
|
||||||
const Detail = lazy(() => import('./pages/Detail'));
|
const Detail = lazy(() => import('./pages/Detail'));
|
||||||
@@ -55,7 +54,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/channel'
|
path='/console/channel'
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<Channel />
|
<Channel />
|
||||||
@@ -63,7 +62,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/channel/edit/:id'
|
path='/console/channel/edit/:id'
|
||||||
element={
|
element={
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||||
<EditChannel />
|
<EditChannel />
|
||||||
@@ -71,7 +70,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/channel/add'
|
path='/console/channel/add'
|
||||||
element={
|
element={
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||||
<EditChannel />
|
<EditChannel />
|
||||||
@@ -79,7 +78,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/token'
|
path='/console/token'
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<Token />
|
<Token />
|
||||||
@@ -87,7 +86,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/playground'
|
path='/console/playground'
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<Playground />
|
<Playground />
|
||||||
@@ -95,7 +94,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/redemption'
|
path='/console/redemption'
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<Redemption />
|
<Redemption />
|
||||||
@@ -103,7 +102,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/user'
|
path='/console/user'
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<User />
|
<User />
|
||||||
@@ -111,7 +110,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/user/edit/:id'
|
path='/console/user/edit/:id'
|
||||||
element={
|
element={
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||||
<EditUser />
|
<EditUser />
|
||||||
@@ -119,7 +118,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/user/edit'
|
path='/console/user/edit'
|
||||||
element={
|
element={
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||||
<EditUser />
|
<EditUser />
|
||||||
@@ -138,7 +137,9 @@ function App() {
|
|||||||
path='/login'
|
path='/login'
|
||||||
element={
|
element={
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||||
<LoginForm />
|
<AuthRedirect>
|
||||||
|
<LoginForm />
|
||||||
|
</AuthRedirect>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -146,7 +147,9 @@ function App() {
|
|||||||
path='/register'
|
path='/register'
|
||||||
element={
|
element={
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||||
<RegisterForm />
|
<AuthRedirect>
|
||||||
|
<RegisterForm />
|
||||||
|
</AuthRedirect>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -183,7 +186,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/setting'
|
path='/console/setting'
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||||
@@ -193,7 +196,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/personal'
|
path='/console/personal'
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||||
@@ -203,7 +206,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/topup'
|
path='/console/topup'
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||||
@@ -213,7 +216,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/log'
|
path='/console/log'
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<Log />
|
<Log />
|
||||||
@@ -221,7 +224,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/detail'
|
path='/console'
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||||
@@ -231,7 +234,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/midjourney'
|
path='/console/midjourney'
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||||
@@ -241,7 +244,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/task'
|
path='/console/task'
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||||
@@ -267,7 +270,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/chat/:id?'
|
path='/console/chat/:id?'
|
||||||
element={
|
element={
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||||
<Chat />
|
<Chat />
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
@@ -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 };
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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 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 { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { API, showError, showSuccess, updateAPI } from '../helpers';
|
import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
|
||||||
import { UserContext } from '../context/User';
|
import { UserContext } from '../../context/User';
|
||||||
import { setUserData } from '../helpers/data.js';
|
|
||||||
|
|
||||||
const OAuth2Callback = (props) => {
|
const OAuth2Callback = (props) => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -52,11 +51,15 @@ const OAuth2Callback = (props) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Segment style={{ minHeight: '300px' }}>
|
<div className="flex items-center justify-center min-h-[300px] w-full bg-white rounded-lg shadow p-6">
|
||||||
<Dimmer active inverted>
|
<Space vertical align="center">
|
||||||
<Loader size='large'>{prompt}</Loader>
|
<Spin size="large" spinning={processing}>
|
||||||
</Dimmer>
|
<div className="min-h-[200px] min-w-[200px] flex items-center justify-center">
|
||||||
</Segment>
|
<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;
|
||||||
@@ -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;
|
||||||
@@ -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'
|
version='1.1'
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
p-id='10969'
|
p-id='10969'
|
||||||
width='1em'
|
width='20'
|
||||||
height='1em'
|
height='20'
|
||||||
>
|
>
|
||||||
<path
|
<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'
|
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'
|
version='1.1'
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
p-id='5091'
|
p-id='5091'
|
||||||
width='16'
|
width='20'
|
||||||
height='16'
|
height='20'
|
||||||
>
|
>
|
||||||
<path
|
<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'
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 HeaderBar from './HeaderBar.js';
|
||||||
import { Layout } from '@douyinfe/semi-ui';
|
import { Layout } from '@douyinfe/semi-ui';
|
||||||
import SiderBar from './SiderBar.js';
|
import SiderBar from './SiderBar.js';
|
||||||
import App from '../App.js';
|
import App from '../../App.js';
|
||||||
import FooterBar from './Footer.js';
|
import FooterBar from './Footer.js';
|
||||||
import { ToastContainer } from 'react-toastify';
|
import { ToastContainer } from 'react-toastify';
|
||||||
import React, { useContext, useEffect } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { API, getLogo, getSystemName, showError } from '../helpers/index.js';
|
import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js';
|
||||||
import { setStatusData } from '../helpers/data.js';
|
import { UserContext } from '../../context/User/index.js';
|
||||||
import { UserContext } from '../context/User/index.js';
|
import { StatusContext } from '../../context/Status/index.js';
|
||||||
import { StatusContext } from '../context/Status/index.js';
|
import { useLocation } from 'react-router-dom';
|
||||||
const { Sider, Content, Header, Footer } = Layout;
|
const { Sider, Content, Header, Footer } = Layout;
|
||||||
|
|
||||||
const PageLayout = () => {
|
const PageLayout = () => {
|
||||||
const [userState, userDispatch] = useContext(UserContext);
|
const [userState, userDispatch] = useContext(UserContext);
|
||||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||||
const [styleState, styleDispatch] = useContext(StyleContext);
|
const { state: styleState } = useStyle();
|
||||||
const { i18n } = useTranslation();
|
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 = () => {
|
const loadUser = () => {
|
||||||
let user = localStorage.getItem('user');
|
let user = localStorage.getItem('user');
|
||||||
@@ -61,15 +68,8 @@ const PageLayout = () => {
|
|||||||
if (savedLang) {
|
if (savedLang) {
|
||||||
i18n.changeLanguage(savedLang);
|
i18n.changeLanguage(savedLang);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认显示侧边栏
|
|
||||||
styleDispatch({ type: 'SET_SIDER', payload: true });
|
|
||||||
}, [i18n]);
|
}, [i18n]);
|
||||||
|
|
||||||
// 获取侧边栏折叠状态
|
|
||||||
const isSidebarCollapsed =
|
|
||||||
localStorage.getItem('default_collapse_sidebar') === 'true';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
style={{
|
style={{
|
||||||
@@ -84,19 +84,18 @@ const PageLayout = () => {
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
lineHeight: 'normal',
|
lineHeight: 'normal',
|
||||||
position: styleState.isMobile ? 'sticky' : 'fixed',
|
position: 'fixed',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
top: 0,
|
top: 0,
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
boxShadow: '0 1px 6px rgba(0, 0, 0, 0.08)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<HeaderBar />
|
<HeaderBar />
|
||||||
</Header>
|
</Header>
|
||||||
<Layout
|
<Layout
|
||||||
style={{
|
style={{
|
||||||
marginTop: styleState.isMobile ? '0' : '56px',
|
marginTop: '64px',
|
||||||
height: styleState.isMobile ? 'auto' : 'calc(100vh - 56px)',
|
height: 'calc(100vh - 64px)',
|
||||||
overflow: styleState.isMobile ? 'visible' : 'auto',
|
overflow: styleState.isMobile ? 'visible' : 'auto',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -107,13 +106,11 @@ const PageLayout = () => {
|
|||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: 0,
|
left: 0,
|
||||||
top: '56px',
|
top: '64px',
|
||||||
zIndex: 99,
|
zIndex: 99,
|
||||||
background: 'var(--semi-color-bg-1)',
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
|
||||||
border: 'none',
|
border: 'none',
|
||||||
paddingRight: '0',
|
paddingRight: '0',
|
||||||
height: 'calc(100vh - 56px)',
|
height: 'calc(100vh - 64px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SiderBar />
|
<SiderBar />
|
||||||
@@ -126,7 +123,7 @@ const PageLayout = () => {
|
|||||||
: styleState.showSider
|
: styleState.showSider
|
||||||
? styleState.siderCollapsed
|
? styleState.siderCollapsed
|
||||||
? '60px'
|
? '60px'
|
||||||
: '200px'
|
: '180px'
|
||||||
: '0',
|
: '0',
|
||||||
transition: 'margin-left 0.3s ease',
|
transition: 'margin-left 0.3s ease',
|
||||||
flex: '1 1 auto',
|
flex: '1 1 auto',
|
||||||
@@ -139,21 +136,23 @@ const PageLayout = () => {
|
|||||||
flex: '1 0 auto',
|
flex: '1 0 auto',
|
||||||
overflowY: styleState.isMobile ? 'visible' : 'auto',
|
overflowY: styleState.isMobile ? 'visible' : 'auto',
|
||||||
WebkitOverflowScrolling: 'touch',
|
WebkitOverflowScrolling: 'touch',
|
||||||
padding: styleState.shouldInnerPadding ? '24px' : '0',
|
padding: shouldInnerPadding ? '24px' : '0',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
marginTop: styleState.isMobile ? '2px' : '0',
|
marginTop: styleState.isMobile ? '2px' : '0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<App />
|
<App />
|
||||||
</Content>
|
</Content>
|
||||||
<Layout.Footer
|
{!shouldHideFooter && (
|
||||||
style={{
|
<Layout.Footer
|
||||||
flex: '0 0 auto',
|
style={{
|
||||||
width: '100%',
|
flex: '0 0 auto',
|
||||||
}}
|
width: '100%',
|
||||||
>
|
}}
|
||||||
<FooterBar />
|
>
|
||||||
</Layout.Footer>
|
<FooterBar />
|
||||||
|
</Layout.Footer>
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useContext, useEffect } from 'react';
|
import React, { useContext, useEffect } from 'react';
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { StatusContext } from '../context/Status';
|
import { StatusContext } from '../../context/Status';
|
||||||
|
|
||||||
const SetupCheck = ({ children }) => {
|
const SetupCheck = ({ children }) => {
|
||||||
const [statusState] = useContext(StatusContext);
|
const [statusState] = useContext(StatusContext);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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 React, { useEffect, useState } from 'react';
|
||||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import SettingGeminiModel from '../pages/Setting/Model/SettingGeminiModel.js';
|
import SettingGeminiModel from '../../pages/Setting/Model/SettingGeminiModel.js';
|
||||||
import SettingClaudeModel from '../pages/Setting/Model/SettingClaudeModel.js';
|
import SettingClaudeModel from '../../pages/Setting/Model/SettingClaudeModel.js';
|
||||||
import SettingGlobalModel from '../pages/Setting/Model/SettingGlobalModel.js';
|
import SettingGlobalModel from '../../pages/Setting/Model/SettingGlobalModel.js';
|
||||||
|
|
||||||
const ModelSetting = () => {
|
const ModelSetting = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
+13
-13
@@ -1,20 +1,20 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||||
import SettingsGeneral from '../pages/Setting/Operation/SettingsGeneral.js';
|
import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral.js';
|
||||||
import SettingsDrawing from '../pages/Setting/Operation/SettingsDrawing.js';
|
import SettingsDrawing from '../../pages/Setting/Operation/SettingsDrawing.js';
|
||||||
import SettingsSensitiveWords from '../pages/Setting/Operation/SettingsSensitiveWords.js';
|
import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords.js';
|
||||||
import SettingsLog from '../pages/Setting/Operation/SettingsLog.js';
|
import SettingsLog from '../../pages/Setting/Operation/SettingsLog.js';
|
||||||
import SettingsDataDashboard from '../pages/Setting/Operation/SettingsDataDashboard.js';
|
import SettingsDataDashboard from '../../pages/Setting/Operation/SettingsDataDashboard.js';
|
||||||
import SettingsMonitoring from '../pages/Setting/Operation/SettingsMonitoring.js';
|
import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring.js';
|
||||||
import SettingsCreditLimit from '../pages/Setting/Operation/SettingsCreditLimit.js';
|
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit.js';
|
||||||
import ModelSettingsVisualEditor from '../pages/Setting/Operation/ModelSettingsVisualEditor.js';
|
import ModelSettingsVisualEditor from '../../pages/Setting/Operation/ModelSettingsVisualEditor.js';
|
||||||
import GroupRatioSettings from '../pages/Setting/Operation/GroupRatioSettings.js';
|
import GroupRatioSettings from '../../pages/Setting/Operation/GroupRatioSettings.js';
|
||||||
import ModelRatioSettings from '../pages/Setting/Operation/ModelRatioSettings.js';
|
import ModelRatioSettings from '../../pages/Setting/Operation/ModelRatioSettings.js';
|
||||||
|
|
||||||
import { API, showError, showSuccess } from '../helpers';
|
import { API, showError, showSuccess } from '../../helpers';
|
||||||
import SettingsChats from '../pages/Setting/Operation/SettingsChats.js';
|
import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import ModelRatioNotSetEditor from '../pages/Setting/Operation/ModelRationNotSetEditor.js';
|
import ModelRatioNotSetEditor from '../../pages/Setting/Operation/ModelRationNotSetEditor.js';
|
||||||
|
|
||||||
const OperationSetting = () => {
|
const OperationSetting = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -9,10 +9,10 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Card,
|
Card,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import { API, showError, showSuccess, timestamp2string } from '../helpers';
|
import { API, showError, showSuccess, timestamp2string } from '../../helpers';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { useTranslation } from 'react-i18next';
|
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';
|
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||||
|
|
||||||
const OtherSetting = () => {
|
const OtherSetting = () => {
|
||||||
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -1,10 +1,10 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
import { API, showError, showSuccess } from '../helpers';
|
import { API, showError, showSuccess } from '../../helpers/index.js';
|
||||||
import SettingsChats from '../pages/Setting/Operation/SettingsChats.js';
|
import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import RequestRateLimit from '../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
|
import RequestRateLimit from '../../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
|
||||||
|
|
||||||
const RateLimitSetting = () => {
|
const RateLimitSetting = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -13,12 +13,12 @@ import {
|
|||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
import {
|
import {
|
||||||
|
API,
|
||||||
removeTrailingSlash,
|
removeTrailingSlash,
|
||||||
showError,
|
showError,
|
||||||
showSuccess,
|
showSuccess,
|
||||||
verifyJSON,
|
verifyJSON
|
||||||
} from '../helpers/utils';
|
} from '../../helpers';
|
||||||
import { API } from '../helpers/api';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const SystemSetting = () => {
|
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 { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
API,
|
API,
|
||||||
@@ -8,26 +8,6 @@ import {
|
|||||||
showError,
|
showError,
|
||||||
showSuccess,
|
showSuccess,
|
||||||
timestamp2string,
|
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,
|
renderAudioModelPrice,
|
||||||
renderClaudeLogContent,
|
renderClaudeLogContent,
|
||||||
renderClaudeModelPrice,
|
renderClaudeModelPrice,
|
||||||
@@ -39,13 +19,34 @@ import {
|
|||||||
renderNumber,
|
renderNumber,
|
||||||
renderQuota,
|
renderQuota,
|
||||||
stringToColor,
|
stringToColor,
|
||||||
} from '../helpers/render';
|
getLogOther,
|
||||||
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
|
renderModelTag,
|
||||||
import { getLogOther } from '../helpers/other.js';
|
} from '../../helpers';
|
||||||
import { StyleContext } from '../context/Style/index.js';
|
|
||||||
import { IconInherit, IconRefresh, IconSetting } from '@douyinfe/semi-icons';
|
|
||||||
|
|
||||||
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) {
|
function renderTimestamp(timestamp) {
|
||||||
return <>{timestamp2string(timestamp)}</>;
|
return <>{timestamp2string(timestamp)}</>;
|
||||||
@@ -81,37 +82,37 @@ const LogsTable = () => {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case 1:
|
case 1:
|
||||||
return (
|
return (
|
||||||
<Tag color='cyan' size='large'>
|
<Tag color='cyan' size='large' shape='circle'>
|
||||||
{t('充值')}
|
{t('充值')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
return (
|
return (
|
||||||
<Tag color='lime' size='large'>
|
<Tag color='lime' size='large' shape='circle'>
|
||||||
{t('消费')}
|
{t('消费')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
case 3:
|
case 3:
|
||||||
return (
|
return (
|
||||||
<Tag color='orange' size='large'>
|
<Tag color='orange' size='large' shape='circle'>
|
||||||
{t('管理')}
|
{t('管理')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
case 4:
|
case 4:
|
||||||
return (
|
return (
|
||||||
<Tag color='purple' size='large'>
|
<Tag color='purple' size='large' shape='circle'>
|
||||||
{t('系统')}
|
{t('系统')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
case 5:
|
case 5:
|
||||||
return (
|
return (
|
||||||
<Tag color='red' size='large'>
|
<Tag color='red' size='large' shape='circle'>
|
||||||
{t('错误')}
|
{t('错误')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Tag color='grey' size='large'>
|
<Tag color='grey' size='large' shape='circle'>
|
||||||
{t('未知')}
|
{t('未知')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
@@ -121,13 +122,13 @@ const LogsTable = () => {
|
|||||||
function renderIsStream(bool) {
|
function renderIsStream(bool) {
|
||||||
if (bool) {
|
if (bool) {
|
||||||
return (
|
return (
|
||||||
<Tag color='blue' size='large'>
|
<Tag color='blue' size='large' shape='circle'>
|
||||||
{t('流')}
|
{t('流')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<Tag color='purple' size='large'>
|
<Tag color='purple' size='large' shape='circle'>
|
||||||
{t('非流')}
|
{t('非流')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
@@ -138,21 +139,21 @@ const LogsTable = () => {
|
|||||||
const time = parseInt(type);
|
const time = parseInt(type);
|
||||||
if (time < 101) {
|
if (time < 101) {
|
||||||
return (
|
return (
|
||||||
<Tag color='green' size='large'>
|
<Tag color='green' size='large' shape='circle'>
|
||||||
{' '}
|
{' '}
|
||||||
{time} s{' '}
|
{time} s{' '}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
} else if (time < 300) {
|
} else if (time < 300) {
|
||||||
return (
|
return (
|
||||||
<Tag color='orange' size='large'>
|
<Tag color='orange' size='large' shape='circle'>
|
||||||
{' '}
|
{' '}
|
||||||
{time} s{' '}
|
{time} s{' '}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<Tag color='red' size='large'>
|
<Tag color='red' size='large' shape='circle'>
|
||||||
{' '}
|
{' '}
|
||||||
{time} s{' '}
|
{time} s{' '}
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -165,21 +166,21 @@ const LogsTable = () => {
|
|||||||
time = time.toFixed(1);
|
time = time.toFixed(1);
|
||||||
if (time < 3) {
|
if (time < 3) {
|
||||||
return (
|
return (
|
||||||
<Tag color='green' size='large'>
|
<Tag color='green' size='large' shape='circle'>
|
||||||
{' '}
|
{' '}
|
||||||
{time} s{' '}
|
{time} s{' '}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
} else if (time < 10) {
|
} else if (time < 10) {
|
||||||
return (
|
return (
|
||||||
<Tag color='orange' size='large'>
|
<Tag color='orange' size='large' shape='circle'>
|
||||||
{' '}
|
{' '}
|
||||||
{time} s{' '}
|
{time} s{' '}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<Tag color='red' size='large'>
|
<Tag color='red' size='large' shape='circle'>
|
||||||
{' '}
|
{' '}
|
||||||
{time} s{' '}
|
{time} s{' '}
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -194,18 +195,11 @@ const LogsTable = () => {
|
|||||||
other?.upstream_model_name &&
|
other?.upstream_model_name &&
|
||||||
other?.upstream_model_name !== '';
|
other?.upstream_model_name !== '';
|
||||||
if (!modelMapped) {
|
if (!modelMapped) {
|
||||||
return (
|
return renderModelTag(record.model_name, {
|
||||||
<Tag
|
onClick: (event) => {
|
||||||
color={stringToColor(record.model_name)}
|
copyText(event, record.model_name).then((r) => { });
|
||||||
size='large'
|
},
|
||||||
onClick={(event) => {
|
});
|
||||||
copyText(event, record.model_name).then((r) => {});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
{record.model_name}{' '}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -214,57 +208,43 @@ const LogsTable = () => {
|
|||||||
content={
|
content={
|
||||||
<div style={{ padding: 10 }}>
|
<div style={{ padding: 10 }}>
|
||||||
<Space vertical align={'start'}>
|
<Space vertical align={'start'}>
|
||||||
<Tag
|
<div className='flex items-center'>
|
||||||
color={stringToColor(record.model_name)}
|
<Text strong style={{ marginRight: 8 }}>
|
||||||
size='large'
|
{t('请求并计费模型')}:
|
||||||
onClick={(event) => {
|
</Text>
|
||||||
copyText(event, record.model_name).then((r) => {});
|
{renderModelTag(record.model_name, {
|
||||||
}}
|
onClick: (event) => {
|
||||||
>
|
copyText(event, record.model_name).then((r) => { });
|
||||||
{t('请求并计费模型')} {record.model_name}{' '}
|
},
|
||||||
</Tag>
|
})}
|
||||||
<Tag
|
</div>
|
||||||
color={stringToColor(other.upstream_model_name)}
|
<div className='flex items-center'>
|
||||||
size='large'
|
<Text strong style={{ marginRight: 8 }}>
|
||||||
onClick={(event) => {
|
{t('实际模型')}:
|
||||||
copyText(event, other.upstream_model_name).then(
|
</Text>
|
||||||
(r) => {},
|
{renderModelTag(other.upstream_model_name, {
|
||||||
);
|
onClick: (event) => {
|
||||||
}}
|
copyText(event, other.upstream_model_name).then(
|
||||||
>
|
(r) => { },
|
||||||
{t('实际模型')} {other.upstream_model_name}{' '}
|
);
|
||||||
</Tag>
|
},
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tag
|
{renderModelTag(record.model_name, {
|
||||||
color={stringToColor(record.model_name)}
|
onClick: (event) => {
|
||||||
size='large'
|
copyText(event, record.model_name).then((r) => { });
|
||||||
onClick={(event) => {
|
},
|
||||||
copyText(event, record.model_name).then((r) => {});
|
suffixIcon: (
|
||||||
}}
|
<IconForward
|
||||||
suffixIcon={
|
style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }}
|
||||||
<IconRefresh
|
|
||||||
style={{ width: '0.8em', height: '0.8em', opacity: 0.6 }}
|
|
||||||
/>
|
/>
|
||||||
}
|
),
|
||||||
>
|
})}
|
||||||
{' '}
|
|
||||||
{record.model_name}{' '}
|
|
||||||
</Tag>
|
|
||||||
</Popover>
|
</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>
|
</Space>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -386,6 +366,7 @@ const LogsTable = () => {
|
|||||||
<Tag
|
<Tag
|
||||||
color={colors[parseInt(text) % colors.length]}
|
color={colors[parseInt(text) % colors.length]}
|
||||||
size='large'
|
size='large'
|
||||||
|
shape='circle'
|
||||||
>
|
>
|
||||||
{' '}
|
{' '}
|
||||||
{text}{' '}
|
{text}{' '}
|
||||||
@@ -437,6 +418,7 @@ const LogsTable = () => {
|
|||||||
<Tag
|
<Tag
|
||||||
color='grey'
|
color='grey'
|
||||||
size='large'
|
size='large'
|
||||||
|
shape='circle'
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
//cancel the row click event
|
//cancel the row click event
|
||||||
copyText(event, text);
|
copyText(event, text);
|
||||||
@@ -600,6 +582,7 @@ const LogsTable = () => {
|
|||||||
key: COLUMN_KEYS.DETAILS,
|
key: COLUMN_KEYS.DETAILS,
|
||||||
title: t('详情'),
|
title: t('详情'),
|
||||||
dataIndex: 'content',
|
dataIndex: 'content',
|
||||||
|
fixed: 'right',
|
||||||
render: (text, record, index) => {
|
render: (text, record, index) => {
|
||||||
let other = getLogOther(record.other);
|
let other = getLogOther(record.other);
|
||||||
if (other == null || record.type !== 2) {
|
if (other == null || record.type !== 2) {
|
||||||
@@ -620,21 +603,21 @@ const LogsTable = () => {
|
|||||||
}
|
}
|
||||||
let content = other?.claude
|
let content = other?.claude
|
||||||
? renderClaudeModelPriceSimple(
|
? renderClaudeModelPriceSimple(
|
||||||
other.model_ratio,
|
other.model_ratio,
|
||||||
other.model_price,
|
other.model_price,
|
||||||
other.group_ratio,
|
other.group_ratio,
|
||||||
other.cache_tokens || 0,
|
other.cache_tokens || 0,
|
||||||
other.cache_ratio || 1.0,
|
other.cache_ratio || 1.0,
|
||||||
other.cache_creation_tokens || 0,
|
other.cache_creation_tokens || 0,
|
||||||
other.cache_creation_ratio || 1.0,
|
other.cache_creation_ratio || 1.0,
|
||||||
)
|
)
|
||||||
: renderModelPriceSimple(
|
: renderModelPriceSimple(
|
||||||
other.model_ratio,
|
other.model_ratio,
|
||||||
other.model_price,
|
other.model_price,
|
||||||
other.group_ratio,
|
other.group_ratio,
|
||||||
other.cache_tokens || 0,
|
other.cache_tokens || 0,
|
||||||
other.cache_ratio || 1.0,
|
other.cache_ratio || 1.0,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Paragraph
|
<Paragraph
|
||||||
ellipsis={{
|
ellipsis={{
|
||||||
@@ -673,15 +656,29 @@ const LogsTable = () => {
|
|||||||
visible={showColumnSelector}
|
visible={showColumnSelector}
|
||||||
onCancel={() => setShowColumnSelector(false)}
|
onCancel={() => setShowColumnSelector(false)}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<div className='flex justify-end'>
|
||||||
<Button onClick={() => initDefaultColumns()}>{t('重置')}</Button>
|
<Button
|
||||||
<Button onClick={() => setShowColumnSelector(false)}>
|
theme='light'
|
||||||
|
onClick={() => initDefaultColumns()}
|
||||||
|
className='!rounded-full'
|
||||||
|
>
|
||||||
|
{t('重置')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme='light'
|
||||||
|
onClick={() => setShowColumnSelector(false)}
|
||||||
|
className='!rounded-full'
|
||||||
|
>
|
||||||
{t('取消')}
|
{t('取消')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type='primary' onClick={() => setShowColumnSelector(false)}>
|
<Button
|
||||||
|
type='primary'
|
||||||
|
onClick={() => setShowColumnSelector(false)}
|
||||||
|
className='!rounded-full'
|
||||||
|
>
|
||||||
{t('确定')}
|
{t('确定')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 20 }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
@@ -697,15 +694,8 @@ const LogsTable = () => {
|
|||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
|
||||||
display: 'flex',
|
style={{ border: '1px solid var(--semi-color-border)' }}
|
||||||
flexWrap: 'wrap',
|
|
||||||
maxHeight: '400px',
|
|
||||||
overflowY: 'auto',
|
|
||||||
border: '1px solid var(--semi-color-border)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
padding: '16px',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{allColumns.map((column) => {
|
{allColumns.map((column) => {
|
||||||
// Skip admin-only columns for non-admin users
|
// Skip admin-only columns for non-admin users
|
||||||
@@ -719,10 +709,7 @@ const LogsTable = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={column.key} className='w-1/2 mb-4 pr-2'>
|
||||||
key={column.key}
|
|
||||||
style={{ width: '50%', marginBottom: 16, paddingRight: 8 }}
|
|
||||||
>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={!!visibleColumns[column.key]}
|
checked={!!visibleColumns[column.key]}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -739,7 +726,6 @@ const LogsTable = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [styleState, styleDispatch] = useContext(StyleContext);
|
|
||||||
const [logs, setLogs] = useState([]);
|
const [logs, setLogs] = useState([]);
|
||||||
const [expandData, setExpandData] = useState({});
|
const [expandData, setExpandData] = useState({});
|
||||||
const [showStat, setShowStat] = useState(false);
|
const [showStat, setShowStat] = useState(false);
|
||||||
@@ -921,27 +907,27 @@ const LogsTable = () => {
|
|||||||
key: t('日志详情'),
|
key: t('日志详情'),
|
||||||
value: other?.claude
|
value: other?.claude
|
||||||
? renderClaudeLogContent(
|
? renderClaudeLogContent(
|
||||||
other?.model_ratio,
|
other?.model_ratio,
|
||||||
other.completion_ratio,
|
other.completion_ratio,
|
||||||
other.model_price,
|
other.model_price,
|
||||||
other.group_ratio,
|
other.group_ratio,
|
||||||
other.cache_ratio || 1.0,
|
other.cache_ratio || 1.0,
|
||||||
other.cache_creation_ratio || 1.0,
|
other.cache_creation_ratio || 1.0,
|
||||||
)
|
)
|
||||||
: renderLogContent(
|
: renderLogContent(
|
||||||
other?.model_ratio,
|
other?.model_ratio,
|
||||||
other.completion_ratio,
|
other.completion_ratio,
|
||||||
other.model_price,
|
other.model_price,
|
||||||
other.group_ratio,
|
other.group_ratio,
|
||||||
other?.user_group_ratio,
|
other?.user_group_ratio,
|
||||||
false,
|
false,
|
||||||
1.0,
|
1.0,
|
||||||
undefined,
|
undefined,
|
||||||
other.web_search || false,
|
other.web_search || false,
|
||||||
other.web_search_call_count || 0,
|
other.web_search_call_count || 0,
|
||||||
other.file_search || false,
|
other.file_search || false,
|
||||||
other.file_search_call_count || 0,
|
other.file_search_call_count || 0,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (logs[i].type === 2) {
|
if (logs[i].type === 2) {
|
||||||
@@ -1007,6 +993,9 @@ const LogsTable = () => {
|
|||||||
other?.file_search || false,
|
other?.file_search || false,
|
||||||
other?.file_search_call_count || 0,
|
other?.file_search_call_count || 0,
|
||||||
other?.file_search_price || 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({
|
expandDataLocal.push({
|
||||||
@@ -1056,7 +1045,7 @@ const LogsTable = () => {
|
|||||||
|
|
||||||
const handlePageChange = (page) => {
|
const handlePageChange = (page) => {
|
||||||
setActivePage(page);
|
setActivePage(page);
|
||||||
loadLogs(page, pageSize, logType).then((r) => {});
|
loadLogs(page, pageSize, logType).then((r) => { });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageSizeChange = async (size) => {
|
const handlePageSizeChange = async (size) => {
|
||||||
@@ -1101,89 +1090,73 @@ const LogsTable = () => {
|
|||||||
return <Descriptions data={expandData[record.key]} />;
|
return <Descriptions data={expandData[record.key]} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 检查是否有任何记录有展开内容
|
||||||
|
const hasExpandableRows = () => {
|
||||||
|
return logs.some(
|
||||||
|
(log) => expandData[log.key] && expandData[log.key].length > 0,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderColumnSelector()}
|
{renderColumnSelector()}
|
||||||
<Layout>
|
<Card
|
||||||
<Header>
|
className='!rounded-2xl mb-4'
|
||||||
<Spin spinning={loadingStat}>
|
title={
|
||||||
<Space>
|
<div className='flex flex-col w-full'>
|
||||||
<Tag
|
<Spin spinning={loadingStat}>
|
||||||
color='blue'
|
<Space>
|
||||||
size='large'
|
<Tag
|
||||||
style={{
|
color='blue'
|
||||||
padding: 15,
|
size='large'
|
||||||
borderRadius: '8px',
|
style={{
|
||||||
fontWeight: 500,
|
padding: 15,
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
borderRadius: '9999px',
|
||||||
}}
|
fontWeight: 500,
|
||||||
>
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
{t('消耗额度')}: {renderQuota(stat.quota)}
|
}}
|
||||||
</Tag>
|
>
|
||||||
<Tag
|
{t('消耗额度')}: {renderQuota(stat.quota)}
|
||||||
color='pink'
|
</Tag>
|
||||||
size='large'
|
<Tag
|
||||||
style={{
|
color='pink'
|
||||||
padding: 15,
|
size='large'
|
||||||
borderRadius: '8px',
|
style={{
|
||||||
fontWeight: 500,
|
padding: 15,
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
borderRadius: '9999px',
|
||||||
}}
|
fontWeight: 500,
|
||||||
>
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
RPM: {stat.rpm}
|
}}
|
||||||
</Tag>
|
>
|
||||||
<Tag
|
RPM: {stat.rpm}
|
||||||
color='white'
|
</Tag>
|
||||||
size='large'
|
<Tag
|
||||||
style={{
|
color='white'
|
||||||
padding: 15,
|
size='large'
|
||||||
border: 'none',
|
style={{
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
padding: 15,
|
||||||
borderRadius: '8px',
|
border: 'none',
|
||||||
fontWeight: 500,
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
}}
|
borderRadius: '9999px',
|
||||||
>
|
fontWeight: 500,
|
||||||
TPM: {stat.tpm}
|
}}
|
||||||
</Tag>
|
>
|
||||||
</Space>
|
TPM: {stat.tpm}
|
||||||
</Spin>
|
</Tag>
|
||||||
</Header>
|
</Space>
|
||||||
<Form layout='horizontal' style={{ marginTop: 10 }}>
|
</Spin>
|
||||||
<>
|
|
||||||
<Form.Section>
|
<Divider margin='12px' />
|
||||||
<div style={{ marginBottom: 10 }}>
|
|
||||||
{styleState.isMobile ? (
|
{/* 搜索表单区域 */}
|
||||||
<div>
|
<div className='flex flex-col gap-4'>
|
||||||
<Form.DatePicker
|
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
|
||||||
field='start_timestamp'
|
{/* 时间选择器 */}
|
||||||
label={t('起始时间')}
|
<div className='col-span-1 lg:col-span-2'>
|
||||||
style={{ width: 272 }}
|
<DatePicker
|
||||||
initValue={start_timestamp}
|
className='w-full'
|
||||||
type='dateTime'
|
value={[start_timestamp, end_timestamp]}
|
||||||
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]}
|
|
||||||
type='dateTimeRange'
|
type='dateTimeRange'
|
||||||
name='range_timestamp'
|
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (Array.isArray(value) && value.length === 2) {
|
if (Array.isArray(value) && value.length === 2) {
|
||||||
handleInputChange(value[0], 'start_timestamp');
|
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>
|
</div>
|
||||||
</Form.Section>
|
|
||||||
<Form.Input
|
{/* 操作按钮区域 */}
|
||||||
field='token_name'
|
<div className='flex justify-between items-center pt-2'>
|
||||||
label={t('令牌名称')}
|
<div></div>
|
||||||
value={token_name}
|
<div className='flex gap-2'>
|
||||||
placeholder={t('可选值')}
|
<Button
|
||||||
name='token_name'
|
type='primary'
|
||||||
onChange={(value) => handleInputChange(value, 'token_name')}
|
onClick={refresh}
|
||||||
/>
|
loading={loading}
|
||||||
<Form.Input
|
className='!rounded-full'
|
||||||
field='model_name'
|
>
|
||||||
label={t('模型名称')}
|
{t('查询')}
|
||||||
value={model_name}
|
</Button>
|
||||||
placeholder={t('可选值')}
|
<Button
|
||||||
name='model_name'
|
theme='light'
|
||||||
onChange={(value) => handleInputChange(value, 'model_name')}
|
type='tertiary'
|
||||||
/>
|
icon={<IconSetting />}
|
||||||
<Form.Input
|
onClick={() => setShowColumnSelector(true)}
|
||||||
field='group'
|
className='!rounded-full'
|
||||||
label={t('分组')}
|
>
|
||||||
value={group}
|
{t('列设置')}
|
||||||
placeholder={t('可选值')}
|
</Button>
|
||||||
name='group'
|
</div>
|
||||||
onChange={(value) => handleInputChange(value, 'group')}
|
</div>
|
||||||
/>
|
</div>
|
||||||
{isAdminUser && (
|
</div>
|
||||||
<>
|
}
|
||||||
<Form.Input
|
shadows='always'
|
||||||
field='channel'
|
bordered={false}
|
||||||
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>
|
|
||||||
<Table
|
<Table
|
||||||
style={{ marginTop: 5 }}
|
|
||||||
columns={getVisibleColumns()}
|
columns={getVisibleColumns()}
|
||||||
expandedRowRender={expandRowRender}
|
{...(hasExpandableRows() && {
|
||||||
expandRowByClick={true}
|
expandedRowRender: expandRowRender,
|
||||||
|
expandRowByClick: true,
|
||||||
|
rowExpandable: (record) => expandData[record.key] && expandData[record.key].length > 0
|
||||||
|
})}
|
||||||
dataSource={logs}
|
dataSource={logs}
|
||||||
rowKey='key'
|
rowKey='key'
|
||||||
|
loading={loading}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
className='rounded-xl overflow-hidden'
|
||||||
|
size='middle'
|
||||||
pagination={{
|
pagination={{
|
||||||
formatPageText: (page) =>
|
formatPageText: (page) =>
|
||||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||||
@@ -1295,7 +1286,7 @@ const LogsTable = () => {
|
|||||||
currentPage: activePage,
|
currentPage: activePage,
|
||||||
pageSize: pageSize,
|
pageSize: pageSize,
|
||||||
total: logCount,
|
total: logCount,
|
||||||
pageSizeOpts: [10, 20, 50, 100],
|
pageSizeOptions: [10, 20, 50, 100],
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
onPageSizeChange: (size) => {
|
onPageSizeChange: (size) => {
|
||||||
handlePageSizeChange(size);
|
handlePageSizeChange(size);
|
||||||
@@ -1303,7 +1294,7 @@ const LogsTable = () => {
|
|||||||
onPageChange: handlePageChange,
|
onPageChange: handlePageChange,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Layout>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
+249
-159
@@ -5,23 +5,39 @@ import {
|
|||||||
showError,
|
showError,
|
||||||
showSuccess,
|
showSuccess,
|
||||||
timestamp2string,
|
timestamp2string,
|
||||||
} from '../helpers';
|
renderQuota
|
||||||
|
} from '../../helpers';
|
||||||
|
|
||||||
import { ITEMS_PER_PAGE } from '../constants';
|
import { ITEMS_PER_PAGE } from '../../constants';
|
||||||
import { renderQuota } from '../helpers/render';
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Card,
|
||||||
Divider,
|
Divider,
|
||||||
Form,
|
Dropdown,
|
||||||
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
|
||||||
Popover,
|
Popover,
|
||||||
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
|
Typography,
|
||||||
} from '@douyinfe/semi-ui';
|
} 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';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
function renderTimestamp(timestamp) {
|
function renderTimestamp(timestamp) {
|
||||||
return <>{timestamp2string(timestamp)}</>;
|
return <>{timestamp2string(timestamp)}</>;
|
||||||
}
|
}
|
||||||
@@ -33,25 +49,25 @@ const RedemptionsTable = () => {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 1:
|
case 1:
|
||||||
return (
|
return (
|
||||||
<Tag color='green' size='large'>
|
<Tag color='green' size='large' shape='circle'>
|
||||||
{t('未使用')}
|
{t('未使用')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
return (
|
return (
|
||||||
<Tag color='red' size='large'>
|
<Tag color='red' size='large' shape='circle'>
|
||||||
{t('已禁用')}
|
{t('已禁用')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
case 3:
|
case 3:
|
||||||
return (
|
return (
|
||||||
<Tag color='grey' size='large'>
|
<Tag color='grey' size='large' shape='circle'>
|
||||||
{t('已使用')}
|
{t('已使用')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Tag color='black' size='large'>
|
<Tag color='black' size='large' shape='circle'>
|
||||||
{t('未知状态')}
|
{t('未知状态')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
@@ -99,76 +115,108 @@ const RedemptionsTable = () => {
|
|||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
dataIndex: 'operate',
|
dataIndex: 'operate',
|
||||||
render: (text, record, index) => (
|
fixed: 'right',
|
||||||
<div>
|
render: (text, record, index) => {
|
||||||
<Popover content={record.key} style={{ padding: 20 }} position='top'>
|
// 创建更多操作的下拉菜单项
|
||||||
<Button theme='light' type='tertiary' style={{ marginRight: 1 }}>
|
const moreMenuItems = [
|
||||||
{t('查看')}
|
{
|
||||||
</Button>
|
node: 'item',
|
||||||
</Popover>
|
name: t('删除'),
|
||||||
<Button
|
icon: <IconDelete />,
|
||||||
theme='light'
|
type: 'danger',
|
||||||
type='secondary'
|
onClick: () => {
|
||||||
style={{ marginRight: 1 }}
|
Modal.confirm({
|
||||||
onClick={async (text) => {
|
title: t('确定是否要删除此兑换码?'),
|
||||||
await copyText(record.key);
|
content: t('此修改将不可逆'),
|
||||||
}}
|
onOk: () => {
|
||||||
>
|
manageRedemption(record.id, 'delete', record).then(() => {
|
||||||
{t('复制')}
|
removeRecord(record.key);
|
||||||
</Button>
|
});
|
||||||
<Popconfirm
|
},
|
||||||
title={t('确定是否要删除此兑换码?')}
|
|
||||||
content={t('此修改将不可逆')}
|
|
||||||
okType={'danger'}
|
|
||||||
position={'left'}
|
|
||||||
onConfirm={() => {
|
|
||||||
manageRedemption(record.id, 'delete', record).then(() => {
|
|
||||||
removeRecord(record.key);
|
|
||||||
});
|
});
|
||||||
}}
|
},
|
||||||
>
|
}
|
||||||
<Button theme='light' type='danger' style={{ marginRight: 1 }}>
|
];
|
||||||
{t('删除')}
|
|
||||||
</Button>
|
// 动态添加启用/禁用按钮
|
||||||
</Popconfirm>
|
if (record.status === 1) {
|
||||||
{record.status === 1 ? (
|
moreMenuItems.push({
|
||||||
<Button
|
node: 'item',
|
||||||
theme='light'
|
name: t('禁用'),
|
||||||
type='warning'
|
icon: <IconStop />,
|
||||||
style={{ marginRight: 1 }}
|
type: 'warning',
|
||||||
onClick={async () => {
|
onClick: () => {
|
||||||
manageRedemption(record.id, 'disable', record);
|
manageRedemption(record.id, 'disable', record);
|
||||||
}}
|
},
|
||||||
>
|
});
|
||||||
{t('禁用')}
|
} else {
|
||||||
</Button>
|
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
|
<Button
|
||||||
|
icon={<IconCopy />}
|
||||||
theme='light'
|
theme='light'
|
||||||
type='secondary'
|
type='secondary'
|
||||||
style={{ marginRight: 1 }}
|
size="small"
|
||||||
|
className="!rounded-full"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
manageRedemption(record.id, 'enable', record);
|
await copyText(record.key);
|
||||||
}}
|
}}
|
||||||
disabled={record.status === 3}
|
|
||||||
>
|
>
|
||||||
{t('启用')}
|
{t('复制')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
<Button
|
||||||
<Button
|
icon={<IconEdit />}
|
||||||
theme='light'
|
theme='light'
|
||||||
type='tertiary'
|
type='tertiary'
|
||||||
style={{ marginRight: 1 }}
|
size="small"
|
||||||
onClick={() => {
|
className="!rounded-full"
|
||||||
setEditingRedemption(record);
|
onClick={() => {
|
||||||
setShowEdit(true);
|
setEditingRedemption(record);
|
||||||
}}
|
setShowEdit(true);
|
||||||
disabled={record.status !== 1}
|
}}
|
||||||
>
|
disabled={record.status !== 1}
|
||||||
{t('编辑')}
|
>
|
||||||
</Button>
|
{t('编辑')}
|
||||||
</div>
|
</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 = () => {
|
const closeEdit = () => {
|
||||||
setShowEdit(false);
|
setShowEdit(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setEditingRedemption({
|
||||||
|
id: undefined,
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setRedemptionFormat = (redeptions) => {
|
const setRedemptionFormat = (redeptions) => {
|
||||||
@@ -225,8 +278,11 @@ const RedemptionsTable = () => {
|
|||||||
if (await copy(text)) {
|
if (await copy(text)) {
|
||||||
showSuccess(t('已复制到剪贴板!'));
|
showSuccess(t('已复制到剪贴板!'));
|
||||||
} else {
|
} else {
|
||||||
// setSearchKeyword(text);
|
Modal.error({
|
||||||
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
|
title: t('无法复制到剪贴板,请手动复制'),
|
||||||
|
content: text,
|
||||||
|
size: 'large'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -245,13 +301,14 @@ const RedemptionsTable = () => {
|
|||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
showError(reason);
|
showError(reason);
|
||||||
});
|
});
|
||||||
}, []);
|
}, [pageSize]);
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
await loadRedemptions(activePage - 1, pageSize);
|
await loadRedemptions(activePage - 1, pageSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
const manageRedemption = async (id, action, record) => {
|
const manageRedemption = async (id, action, record) => {
|
||||||
|
setLoading(true);
|
||||||
let data = { id };
|
let data = { id };
|
||||||
let res;
|
let res;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -272,7 +329,6 @@ const RedemptionsTable = () => {
|
|||||||
showSuccess(t('操作成功完成!'));
|
showSuccess(t('操作成功完成!'));
|
||||||
let redemption = res.data.data;
|
let redemption = res.data.data;
|
||||||
let newRedemptions = [...redemptions];
|
let newRedemptions = [...redemptions];
|
||||||
// let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
|
|
||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
} else {
|
} else {
|
||||||
record.status = redemption.status;
|
record.status = redemption.status;
|
||||||
@@ -281,6 +337,7 @@ const RedemptionsTable = () => {
|
|||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchRedemptions = async (keyword, page, pageSize) => {
|
const searchRedemptions = async (keyword, page, pageSize) => {
|
||||||
@@ -333,8 +390,8 @@ const RedemptionsTable = () => {
|
|||||||
|
|
||||||
let pageData = redemptions;
|
let pageData = redemptions;
|
||||||
const rowSelection = {
|
const rowSelection = {
|
||||||
onSelect: (record, selected) => {},
|
onSelect: (record, selected) => { },
|
||||||
onSelectAll: (selected, selectedRows) => {},
|
onSelectAll: (selected, selectedRows) => { },
|
||||||
onChange: (selectedRowKeys, selectedRows) => {
|
onChange: (selectedRowKeys, selectedRows) => {
|
||||||
setSelectedKeys(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<EditRedemption
|
<EditRedemption
|
||||||
@@ -360,88 +491,47 @@ const RedemptionsTable = () => {
|
|||||||
visiable={showEdit}
|
visiable={showEdit}
|
||||||
handleClose={closeEdit}
|
handleClose={closeEdit}
|
||||||
></EditRedemption>
|
></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
|
<Card
|
||||||
style={{ marginTop: 20 }}
|
className="!rounded-2xl"
|
||||||
columns={columns}
|
title={renderHeader()}
|
||||||
dataSource={pageData}
|
shadows='always'
|
||||||
pagination={{
|
bordered={false}
|
||||||
currentPage: activePage,
|
>
|
||||||
pageSize: pageSize,
|
<Table
|
||||||
total: tokenCount,
|
columns={columns}
|
||||||
showSizeChanger: true,
|
dataSource={pageData}
|
||||||
pageSizeOpts: [10, 20, 50, 100],
|
scroll={{ x: 'max-content' }}
|
||||||
formatPageText: (page) =>
|
pagination={{
|
||||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
currentPage: activePage,
|
||||||
start: page.currentStart,
|
pageSize: pageSize,
|
||||||
end: page.currentEnd,
|
total: tokenCount,
|
||||||
total: tokenCount,
|
showSizeChanger: true,
|
||||||
}),
|
pageSizeOptions: [10, 20, 50, 100],
|
||||||
onPageSizeChange: (size) => {
|
formatPageText: (page) =>
|
||||||
setPageSize(size);
|
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||||
setActivePage(1);
|
start: page.currentStart,
|
||||||
if (searchKeyword === '') {
|
end: page.currentEnd,
|
||||||
loadRedemptions(1, size).then();
|
total: tokenCount,
|
||||||
} else {
|
}),
|
||||||
searchRedemptions(searchKeyword, 1, size).then();
|
onPageSizeChange: (size) => {
|
||||||
}
|
setPageSize(size);
|
||||||
},
|
setActivePage(1);
|
||||||
onPageChange: handlePageChange,
|
if (searchKeyword === '') {
|
||||||
}}
|
loadRedemptions(1, size).then();
|
||||||
loading={loading}
|
} else {
|
||||||
rowSelection={rowSelection}
|
searchRedemptions(searchKeyword, 1, size).then();
|
||||||
onRow={handleRow}
|
}
|
||||||
></Table>
|
},
|
||||||
|
onPageChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
loading={loading}
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
onRow={handleRow}
|
||||||
|
className="rounded-xl overflow-hidden"
|
||||||
|
size="middle"
|
||||||
|
></Table>
|
||||||
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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,
|
showError,
|
||||||
showSuccess,
|
showSuccess,
|
||||||
timestamp2string,
|
timestamp2string,
|
||||||
} from '../helpers';
|
renderGroup,
|
||||||
|
renderQuota
|
||||||
|
} from '../../helpers';
|
||||||
|
|
||||||
import { ITEMS_PER_PAGE } from '../constants';
|
import { ITEMS_PER_PAGE } from '../../constants';
|
||||||
import { renderGroup, renderQuota } from '../helpers/render';
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Divider,
|
Card,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Form,
|
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
|
||||||
Popover,
|
|
||||||
Space,
|
Space,
|
||||||
SplitButtonGroup,
|
SplitButtonGroup,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
|
Input,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
import { IconTreeTriangleDown } from '@douyinfe/semi-icons';
|
import {
|
||||||
import EditToken from '../pages/Token/EditToken';
|
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';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
function renderTimestamp(timestamp) {
|
function renderTimestamp(timestamp) {
|
||||||
@@ -39,38 +49,38 @@ const TokensTable = () => {
|
|||||||
case 1:
|
case 1:
|
||||||
if (model_limits_enabled) {
|
if (model_limits_enabled) {
|
||||||
return (
|
return (
|
||||||
<Tag color='green' size='large'>
|
<Tag color='green' size='large' shape='circle'>
|
||||||
{t('已启用:限制模型')}
|
{t('已启用:限制模型')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<Tag color='green' size='large'>
|
<Tag color='green' size='large' shape='circle'>
|
||||||
{t('已启用')}
|
{t('已启用')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 2:
|
case 2:
|
||||||
return (
|
return (
|
||||||
<Tag color='red' size='large'>
|
<Tag color='red' size='large' shape='circle'>
|
||||||
{t('已禁用')}
|
{t('已禁用')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
case 3:
|
case 3:
|
||||||
return (
|
return (
|
||||||
<Tag color='yellow' size='large'>
|
<Tag color='yellow' size='large' shape='circle'>
|
||||||
{t('已过期')}
|
{t('已过期')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
case 4:
|
case 4:
|
||||||
return (
|
return (
|
||||||
<Tag color='grey' size='large'>
|
<Tag color='grey' size='large' shape='circle'>
|
||||||
{t('已耗尽')}
|
{t('已耗尽')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Tag color='black' size='large'>
|
<Tag color='black' size='large' shape='circle'>
|
||||||
{t('未知状态')}
|
{t('未知状态')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
@@ -111,11 +121,11 @@ const TokensTable = () => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{record.unlimited_quota ? (
|
{record.unlimited_quota ? (
|
||||||
<Tag size={'large'} color={'white'}>
|
<Tag size={'large'} color={'white'} shape='circle'>
|
||||||
{t('无限制')}
|
{t('无限制')}
|
||||||
</Tag>
|
</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag size={'large'} color={'light-blue'}>
|
<Tag size={'large'} color={'light-blue'} shape='circle'>
|
||||||
{renderQuota(parseInt(text))}
|
{renderQuota(parseInt(text))}
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
@@ -144,6 +154,7 @@ const TokensTable = () => {
|
|||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
dataIndex: 'operate',
|
dataIndex: 'operate',
|
||||||
|
fixed: 'right',
|
||||||
render: (text, record, index) => {
|
render: (text, record, index) => {
|
||||||
let chats = localStorage.getItem('chats');
|
let chats = localStorage.getItem('chats');
|
||||||
let chatsArray = [];
|
let chatsArray = [];
|
||||||
@@ -151,16 +162,11 @@ const TokensTable = () => {
|
|||||||
|
|
||||||
if (shouldUseCustom) {
|
if (shouldUseCustom) {
|
||||||
try {
|
try {
|
||||||
// console.log(chats);
|
|
||||||
chats = JSON.parse(chats);
|
chats = JSON.parse(chats);
|
||||||
// check chats is array
|
|
||||||
if (Array.isArray(chats)) {
|
if (Array.isArray(chats)) {
|
||||||
for (let i = 0; i < chats.length; i++) {
|
for (let i = 0; i < chats.length; i++) {
|
||||||
let chat = {};
|
let chat = {};
|
||||||
chat.node = 'item';
|
chat.node = 'item';
|
||||||
// c is a map
|
|
||||||
// chat.key = chats[i].name;
|
|
||||||
// console.log(chats[i])
|
|
||||||
for (let key in chats[i]) {
|
for (let key in chats[i]) {
|
||||||
if (chats[i].hasOwnProperty(key)) {
|
if (chats[i].hasOwnProperty(key)) {
|
||||||
chat.key = i;
|
chat.key = i;
|
||||||
@@ -178,33 +184,72 @@ const TokensTable = () => {
|
|||||||
showError(t('聊天链接配置错误,请联系管理员'));
|
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 (
|
return (
|
||||||
<div>
|
<Space wrap>
|
||||||
<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>
|
|
||||||
<SplitButtonGroup
|
<SplitButtonGroup
|
||||||
style={{ marginRight: 1 }}
|
className="!rounded-full overflow-hidden"
|
||||||
aria-label={t('项目操作按钮组')}
|
aria-label={t('项目操作按钮组')}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
theme='light'
|
theme='light'
|
||||||
|
size="small"
|
||||||
style={{ color: 'rgba(var(--semi-teal-7), 1)' }}
|
style={{ color: 'rgba(var(--semi-teal-7), 1)' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (chatsArray.length === 0) {
|
if (chatsArray.length === 0) {
|
||||||
@@ -227,56 +272,35 @@ const TokensTable = () => {
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 4px',
|
padding: '4px 4px',
|
||||||
color: 'rgba(var(--semi-teal-7), 1)',
|
color: 'rgba(var(--semi-teal-7), 1)',
|
||||||
}}
|
}}
|
||||||
type='primary'
|
type='primary'
|
||||||
icon={<IconTreeTriangleDown />}
|
icon={<IconTreeTriangleDown />}
|
||||||
|
size="small"
|
||||||
></Button>
|
></Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</SplitButtonGroup>
|
</SplitButtonGroup>
|
||||||
<Popconfirm
|
|
||||||
title={t('确定是否要删除此令牌?')}
|
<Button
|
||||||
content={t('此修改将不可逆')}
|
icon={<IconCopy />}
|
||||||
okType={'danger'}
|
theme='light'
|
||||||
position={'left'}
|
type='secondary'
|
||||||
onConfirm={() => {
|
size="small"
|
||||||
manageToken(record.id, 'delete', record).then(() => {
|
className="!rounded-full"
|
||||||
removeRecord(record.key);
|
onClick={async (text) => {
|
||||||
});
|
await copyText('sk-' + record.key);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button theme='light' type='danger' style={{ marginRight: 1 }}>
|
{t('复制')}
|
||||||
{t('删除')}
|
</Button>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
|
icon={<IconEdit />}
|
||||||
theme='light'
|
theme='light'
|
||||||
type='tertiary'
|
type='tertiary'
|
||||||
style={{ marginRight: 1 }}
|
size="small"
|
||||||
|
className="!rounded-full"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingToken(record);
|
setEditingToken(record);
|
||||||
setShowEdit(true);
|
setShowEdit(true);
|
||||||
@@ -284,7 +308,21 @@ const TokensTable = () => {
|
|||||||
>
|
>
|
||||||
{t('编辑')}
|
{t('编辑')}
|
||||||
</Button>
|
</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) => {
|
const onOpenLink = async (type, url, record) => {
|
||||||
// console.log(type, url, key);
|
|
||||||
let status = localStorage.getItem('status');
|
let status = localStorage.getItem('status');
|
||||||
let serverAddress = '';
|
let serverAddress = '';
|
||||||
if (status) {
|
if (status) {
|
||||||
@@ -379,6 +416,8 @@ const TokensTable = () => {
|
|||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTokens(0)
|
loadTokens(0)
|
||||||
.then()
|
.then()
|
||||||
@@ -421,11 +460,9 @@ const TokensTable = () => {
|
|||||||
showSuccess('操作成功完成!');
|
showSuccess('操作成功完成!');
|
||||||
let token = res.data.data;
|
let token = res.data.data;
|
||||||
let newTokens = [...tokens];
|
let newTokens = [...tokens];
|
||||||
// let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
|
|
||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
} else {
|
} else {
|
||||||
record.status = token.status;
|
record.status = token.status;
|
||||||
// newTokens[realIdx].status = token.status;
|
|
||||||
}
|
}
|
||||||
setTokensFormat(newTokens);
|
setTokensFormat(newTokens);
|
||||||
} else {
|
} else {
|
||||||
@@ -436,7 +473,6 @@ const TokensTable = () => {
|
|||||||
|
|
||||||
const searchTokens = async () => {
|
const searchTokens = async () => {
|
||||||
if (searchKeyword === '' && searchToken === '') {
|
if (searchKeyword === '' && searchToken === '') {
|
||||||
// if keyword is blank, load files instead.
|
|
||||||
await loadTokens(0);
|
await loadTokens(0);
|
||||||
setActivePage(1);
|
setActivePage(1);
|
||||||
return;
|
return;
|
||||||
@@ -480,14 +516,13 @@ const TokensTable = () => {
|
|||||||
const handlePageChange = (page) => {
|
const handlePageChange = (page) => {
|
||||||
setActivePage(page);
|
setActivePage(page);
|
||||||
if (page === Math.ceil(tokens.length / pageSize) + 1) {
|
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 = {
|
const rowSelection = {
|
||||||
onSelect: (record, selected) => {},
|
onSelect: (record, selected) => { },
|
||||||
onSelectAll: (selected, selectedRows) => {},
|
onSelectAll: (selected, selectedRows) => { },
|
||||||
onChange: (selectedRowKeys, selectedRows) => {
|
onChange: (selectedRowKeys, selectedRows) => {
|
||||||
setSelectedKeys(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<EditToken
|
<EditToken
|
||||||
@@ -513,99 +622,42 @@ const TokensTable = () => {
|
|||||||
visiable={showEdit}
|
visiable={showEdit}
|
||||||
handleClose={closeEdit}
|
handleClose={closeEdit}
|
||||||
></EditToken>
|
></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
|
<Card
|
||||||
style={{ marginTop: 20 }}
|
className="!rounded-2xl"
|
||||||
columns={columns}
|
title={renderHeader()}
|
||||||
dataSource={pageData}
|
shadows='always'
|
||||||
pagination={{
|
bordered={false}
|
||||||
currentPage: activePage,
|
>
|
||||||
pageSize: pageSize,
|
<Table
|
||||||
total: tokenCount,
|
columns={columns}
|
||||||
showSizeChanger: true,
|
dataSource={pageData}
|
||||||
pageSizeOptions: [10, 20, 50, 100],
|
scroll={{ x: 'max-content' }}
|
||||||
formatPageText: (page) =>
|
pagination={{
|
||||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
currentPage: activePage,
|
||||||
start: page.currentStart,
|
pageSize: pageSize,
|
||||||
end: page.currentEnd,
|
total: tokenCount,
|
||||||
total: tokens.length,
|
showSizeChanger: true,
|
||||||
}),
|
pageSizeOptions: [10, 20, 50, 100],
|
||||||
onPageSizeChange: (size) => {
|
formatPageText: (page) =>
|
||||||
setPageSize(size);
|
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||||
setActivePage(1);
|
start: page.currentStart,
|
||||||
},
|
end: page.currentEnd,
|
||||||
onPageChange: handlePageChange,
|
total: tokens.length,
|
||||||
}}
|
}),
|
||||||
loading={loading}
|
onPageSizeChange: (size) => {
|
||||||
rowSelection={rowSelection}
|
setPageSize(size);
|
||||||
onRow={handleRow}
|
setActivePage(1);
|
||||||
></Table>
|
},
|
||||||
|
onPageChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
loading={loading}
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
onRow={handleRow}
|
||||||
|
className="rounded-xl overflow-hidden"
|
||||||
|
size="middle"
|
||||||
|
></Table>
|
||||||
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
@@ -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 [];
|
|
||||||
}
|
|
||||||
@@ -113,7 +113,7 @@ export const CHANNEL_OPTIONS = [
|
|||||||
{
|
{
|
||||||
value: 45,
|
value: 45,
|
||||||
color: 'blue',
|
color: 'blue',
|
||||||
label: '字节火山方舟、豆包、DeepSeek通用',
|
label: '字节火山方舟、豆包通用',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 48,
|
value: 48,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './toast.constants';
|
|
||||||
export * from './user.constants';
|
|
||||||
export * from './common.constant';
|
|
||||||
export * from './channel.constants';
|
export * from './channel.constants';
|
||||||
|
export * from './user.constants';
|
||||||
|
export * from './toast.constants';
|
||||||
|
export * from './common.constant';
|
||||||
|
export * from './playground.constants';
|
||||||
|
|||||||
@@ -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
@@ -1,106 +1,227 @@
|
|||||||
// contexts/User/index.jsx
|
// contexts/Style/index.js
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useReducer, useEffect, useMemo, createContext } from 'react';
|
||||||
import { isMobile } from '../../helpers/index.js';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { isMobile as getIsMobile } from '../../helpers';
|
||||||
|
|
||||||
export const StyleContext = React.createContext({
|
// Action Types
|
||||||
dispatch: () => null,
|
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 }) => {
|
// Constants
|
||||||
const [state, setState] = useState({
|
const STORAGE_KEYS = {
|
||||||
isMobile: isMobile(),
|
SIDEBAR_COLLAPSED: 'default_collapse_sidebar',
|
||||||
showSider: false,
|
};
|
||||||
siderCollapsed: false,
|
|
||||||
shouldInnerPadding: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dispatch = (action) => {
|
const ROUTE_PATTERNS = {
|
||||||
if ('type' in action) {
|
CONSOLE: '/console',
|
||||||
switch (action.type) {
|
};
|
||||||
case 'TOGGLE_SIDER':
|
|
||||||
setState((prev) => ({ ...prev, showSider: !prev.showSider }));
|
/**
|
||||||
break;
|
* 判断路径是否为控制台路由
|
||||||
case 'SET_SIDER':
|
* @param {string} pathname - 路由路径
|
||||||
setState((prev) => ({ ...prev, showSider: action.payload }));
|
* @returns {boolean} 是否为控制台路由
|
||||||
break;
|
*/
|
||||||
case 'SET_MOBILE':
|
const isConsoleRoute = (pathname) => {
|
||||||
setState((prev) => ({ ...prev, isMobile: action.payload }));
|
return pathname === ROUTE_PATTERNS.CONSOLE ||
|
||||||
break;
|
pathname.startsWith(ROUTE_PATTERNS.CONSOLE + '/');
|
||||||
case 'SET_SIDER_COLLAPSED':
|
};
|
||||||
setState((prev) => ({ ...prev, siderCollapsed: action.payload }));
|
|
||||||
break;
|
/**
|
||||||
case 'SET_INNER_PADDING':
|
* 获取初始状态
|
||||||
setState((prev) => ({ ...prev, shouldInnerPadding: action.payload }));
|
* @param {string} pathname - 当前路由路径
|
||||||
break;
|
* @returns {Object} 初始状态对象
|
||||||
default:
|
*/
|
||||||
setState((prev) => ({ ...prev, ...action }));
|
const getInitialState = (pathname) => {
|
||||||
}
|
const isMobile = getIsMobile();
|
||||||
} else {
|
const isConsole = isConsoleRoute(pathname);
|
||||||
setState((prev) => ({ ...prev, ...action }));
|
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(() => {
|
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 = () => {
|
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 () => {
|
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 (
|
return (
|
||||||
<StyleContext.Provider value={[state, dispatch]}>
|
<StyleContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
</StyleContext.Provider>
|
</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
@@ -1,5 +1,6 @@
|
|||||||
import { getUserIdFromLocalStorage, showError } from './utils';
|
import { getUserIdFromLocalStorage, showError, formatMessageForAPI, isValidMessage } from './utils';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { MESSAGE_ROLES } from '../constants/playground.constants';
|
||||||
|
|
||||||
export let API = axios.create({
|
export let API = axios.create({
|
||||||
baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL
|
baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL
|
||||||
@@ -29,3 +30,185 @@ API.interceptors.response.use(
|
|||||||
showError(error);
|
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 [];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 };
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
export * from './history';
|
export * from './history';
|
||||||
export * from './auth-header';
|
export * from './auth';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
export * from './api';
|
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);
|
let other = JSON.parse(otherStr);
|
||||||
return other;
|
return other;
|
||||||
}
|
}
|
||||||
+644
-215
@@ -1,6 +1,479 @@
|
|||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
import { Modal, Tag, Typography } from '@douyinfe/semi-ui';
|
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) {
|
export function renderText(text, limit) {
|
||||||
if (text.length > limit) {
|
if (text.length > limit) {
|
||||||
@@ -17,7 +490,7 @@ export function renderText(text, limit) {
|
|||||||
export function renderGroup(group) {
|
export function renderGroup(group) {
|
||||||
if (group === '') {
|
if (group === '') {
|
||||||
return (
|
return (
|
||||||
<Tag size='large' key='default' color='orange'>
|
<Tag size='large' key='default' color='orange' shape='circle'>
|
||||||
{i18next.t('用户分组')}
|
{i18next.t('用户分组')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
@@ -39,6 +512,7 @@ export function renderGroup(group) {
|
|||||||
size='large'
|
size='large'
|
||||||
color={tagColors[group] || stringToColor(group)}
|
color={tagColors[group] || stringToColor(group)}
|
||||||
key={group}
|
key={group}
|
||||||
|
shape='circle'
|
||||||
onClick={async (event) => {
|
onClick={async (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (await copy(group)) {
|
if (await copy(group)) {
|
||||||
@@ -323,6 +797,9 @@ export function renderModelPrice(
|
|||||||
fileSearch = false,
|
fileSearch = false,
|
||||||
fileSearchCallCount = 0,
|
fileSearchCallCount = 0,
|
||||||
fileSearchPrice = 0,
|
fileSearchPrice = 0,
|
||||||
|
audioInputSeperatePrice = false,
|
||||||
|
audioInputTokens = 0,
|
||||||
|
audioInputPrice = 0,
|
||||||
) {
|
) {
|
||||||
if (modelPrice !== -1) {
|
if (modelPrice !== -1) {
|
||||||
return i18next.t(
|
return i18next.t(
|
||||||
@@ -350,9 +827,12 @@ export function renderModelPrice(
|
|||||||
effectiveInputTokens =
|
effectiveInputTokens =
|
||||||
inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
|
inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
|
||||||
}
|
}
|
||||||
|
if (audioInputTokens > 0) {
|
||||||
|
effectiveInputTokens -= audioInputTokens;
|
||||||
|
}
|
||||||
let price =
|
let price =
|
||||||
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
|
(effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
|
||||||
|
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
|
||||||
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
|
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
|
||||||
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
|
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
|
||||||
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
|
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
|
||||||
@@ -361,8 +841,11 @@ export function renderModelPrice(
|
|||||||
<>
|
<>
|
||||||
<article>
|
<article>
|
||||||
<p>
|
<p>
|
||||||
{i18next.t('输入价格:${{price}} / 1M tokens', {
|
{i18next.t('输入价格:${{price}} / 1M tokens{{audioPrice}}', {
|
||||||
price: inputRatioPrice,
|
price: inputRatioPrice,
|
||||||
|
audioPrice: audioInputSeperatePrice
|
||||||
|
? `,音频 $${audioInputPrice} / 1M tokens`
|
||||||
|
: '',
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
@@ -416,96 +899,93 @@ export function renderModelPrice(
|
|||||||
)}
|
)}
|
||||||
<p></p>
|
<p></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,
|
nonCacheInput: inputTokens - cacheTokens,
|
||||||
cacheInput: cacheTokens,
|
cacheInput: cacheTokens,
|
||||||
cachePrice: inputRatioPrice * cacheRatio,
|
|
||||||
price: inputRatioPrice,
|
price: inputRatioPrice,
|
||||||
completion: completionTokens,
|
cachePrice: cacheRatioPrice,
|
||||||
compPrice: completionRatioPrice,
|
|
||||||
ratio: groupRatio,
|
|
||||||
total: price.toFixed(6),
|
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
: image && imageOutputTokens > 0 && !webSearch && !fileSearch
|
} else if (audioInputSeperatePrice && audioInputTokens > 0) {
|
||||||
? i18next.t(
|
inputDesc = i18next.t(
|
||||||
'输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
|
'(输入 {{nonAudioInput}} tokens / 1M tokens * ${{price}} + 音频输入 {{audioInput}} tokens / 1M tokens * ${{audioPrice}}',
|
||||||
{
|
{
|
||||||
nonImageInput: inputTokens - imageOutputTokens,
|
nonAudioInput: inputTokens - audioInputTokens,
|
||||||
imageInput: imageOutputTokens,
|
audioInput: audioInputTokens,
|
||||||
imageRatio: imageRatio,
|
price: inputRatioPrice,
|
||||||
price: inputRatioPrice,
|
audioPrice: audioInputPrice,
|
||||||
completion: completionTokens,
|
},
|
||||||
compPrice: completionRatioPrice,
|
);
|
||||||
ratio: groupRatio,
|
} else {
|
||||||
total: price.toFixed(6),
|
inputDesc = i18next.t(
|
||||||
},
|
'(输入 {{input}} tokens / 1M tokens * ${{price}}',
|
||||||
)
|
{
|
||||||
: webSearch && webSearchCallCount > 0 && !image && !fileSearch
|
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(
|
? 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,
|
count: webSearchCallCount,
|
||||||
price: inputRatioPrice,
|
price: webSearchPrice,
|
||||||
completion: completionTokens,
|
|
||||||
compPrice: completionRatioPrice,
|
|
||||||
ratio: groupRatio,
|
ratio: groupRatio,
|
||||||
webSearchCallCount,
|
|
||||||
webSearchPrice,
|
|
||||||
total: price.toFixed(6),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: fileSearch &&
|
: '',
|
||||||
fileSearchCallCount > 0 &&
|
fileSearch && fileSearchCallCount > 0
|
||||||
!image &&
|
? i18next.t(
|
||||||
!webSearch
|
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * 分组倍率 {{ratio}}',
|
||||||
? i18next.t(
|
{
|
||||||
'输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} + 文件搜索 {{fileSearchCallCount}}次 / 1K 次 * ${{fileSearchPrice}} * {{ratio}}= ${{total}}',
|
count: fileSearchCallCount,
|
||||||
{
|
price: fileSearchPrice,
|
||||||
input: inputTokens,
|
ratio: groupRatio,
|
||||||
price: inputRatioPrice,
|
},
|
||||||
completion: completionTokens,
|
)
|
||||||
compPrice: completionRatioPrice,
|
: '',
|
||||||
ratio: groupRatio,
|
].join('');
|
||||||
fileSearchCallCount,
|
|
||||||
fileSearchPrice,
|
return i18next.t(
|
||||||
total: price.toFixed(6),
|
'{{inputDesc}} + {{outputDesc}}{{extraServices}} = ${{total}}',
|
||||||
},
|
{
|
||||||
)
|
inputDesc,
|
||||||
: webSearch &&
|
outputDesc,
|
||||||
webSearchCallCount > 0 &&
|
extraServices,
|
||||||
fileSearch &&
|
total: price.toFixed(6),
|
||||||
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),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
|
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
|
||||||
</article>
|
</article>
|
||||||
@@ -798,137 +1278,6 @@ export function renderQuotaWithPrompt(quota, digits) {
|
|||||||
return '';
|
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(
|
export function renderClaudeModelPrice(
|
||||||
inputTokens,
|
inputTokens,
|
||||||
completionTokens,
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Toast } from '@douyinfe/semi-ui';
|
|||||||
import { toastConstants } from '../constants';
|
import { toastConstants } from '../constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import { THINK_TAG_REGEX, MESSAGE_ROLES } from '../constants/playground.constants';
|
||||||
|
|
||||||
const HTMLToastContent = ({ htmlContent }) => {
|
const HTMLToastContent = ({ htmlContent }) => {
|
||||||
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
|
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
|
||||||
@@ -95,6 +96,8 @@ export function showError(error) {
|
|||||||
if (error.name === 'AxiosError') {
|
if (error.name === 'AxiosError') {
|
||||||
switch (error.response.status) {
|
switch (error.response.status) {
|
||||||
case 401:
|
case 401:
|
||||||
|
// 清除用户状态
|
||||||
|
localStorage.removeItem('user');
|
||||||
// toast.error('错误:未登录或登录已过期,请重新登录!', showErrorOptions);
|
// toast.error('错误:未登录或登录已过期,请重新登录!', showErrorOptions);
|
||||||
window.location.href = '/login?expired=true';
|
window.location.href = '/login?expired=true';
|
||||||
break;
|
break;
|
||||||
@@ -281,3 +284,165 @@ export function compareObjects(oldObject, newObject) {
|
|||||||
|
|
||||||
return changedProperties;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
Reference in New Issue
Block a user