mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-31 02:13:41 +00:00
Compare commits
234 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8202c115f0 | ||
|
|
a26dbf5358 | ||
|
|
e8f78c739c | ||
|
|
4c1f341226 | ||
|
|
3721ac1522 | ||
|
|
4d18b263dd | ||
|
|
9496dac448 | ||
|
|
0d724af6e3 | ||
|
|
4da5e74d23 | ||
|
|
ac9f632aff | ||
|
|
83d58848bc | ||
|
|
2100d32bab | ||
|
|
b4be218af8 | ||
|
|
0f94b69c38 | ||
|
|
8fb549bd76 | ||
|
|
bc977909cc | ||
|
|
1c3cd7984c | ||
|
|
12f104337b | ||
|
|
4b3791e6dc | ||
|
|
f17b4f0760 | ||
|
|
a13f80f15b | ||
|
|
f81225788d | ||
|
|
b0a5d01e1d | ||
|
|
c2f209abb7 | ||
|
|
3217f0df3e | ||
|
|
2257714fdc | ||
|
|
a52a67176e | ||
|
|
f13e4bf486 | ||
|
|
cbda794143 | ||
|
|
5ef5664241 | ||
|
|
8e6039b995 | ||
|
|
3665ad672e | ||
|
|
eb812451f2 | ||
|
|
a8f4ae2a73 | ||
|
|
ac83041a44 | ||
|
|
01c84f9a45 | ||
|
|
a41a7e6511 | ||
|
|
0af047b18c | ||
|
|
49fc6ab474 | ||
|
|
a04674c72f | ||
|
|
4e20a747bc | ||
|
|
a4942062de | ||
|
|
c7e812361d | ||
|
|
c71255461d | ||
|
|
b366bf585c | ||
|
|
494c386ca8 | ||
|
|
7362047e51 | ||
|
|
f9ddec3b1c | ||
|
|
d39c9cbec6 | ||
|
|
9693df9bf3 | ||
|
|
5fa9966a4e | ||
|
|
ce88b75f61 | ||
|
|
933327baf7 | ||
|
|
7f3649996c | ||
|
|
2ff79fcf65 | ||
|
|
6704491906 | ||
|
|
4ccd36ea19 | ||
|
|
2eb1f65d3f | ||
|
|
e11a8514b1 | ||
|
|
943f21f3cb | ||
|
|
3f45153e75 | ||
|
|
d27981bd34 | ||
|
|
5a22f33bcf | ||
|
|
28d90f6754 | ||
|
|
a92373c78c | ||
|
|
b0a145fd5b | ||
|
|
64b565dc15 | ||
|
|
a7535aab99 | ||
|
|
8a65a4174a | ||
|
|
e548e411bd | ||
|
|
b0cbf71a1c | ||
|
|
3269926283 | ||
|
|
5894e18f4f | ||
|
|
2250f35a7e | ||
|
|
fe7cd5aa8d | ||
|
|
d459b03e84 | ||
|
|
e5d0f26fb9 | ||
|
|
e39391cfb0 | ||
|
|
f422a0588b | ||
|
|
2bfba7a479 | ||
|
|
0bafdf3381 | ||
|
|
40e640511b | ||
|
|
5930bb88bf | ||
|
|
8948e99eeb | ||
|
|
37caafc722 | ||
|
|
18c2e5cd98 | ||
|
|
f9c8a802ef | ||
|
|
07ffc36678 | ||
|
|
3a5013b876 | ||
|
|
bafb0078e2 | ||
|
|
148c974912 | ||
|
|
d534d4575d | ||
|
|
1d37867f39 | ||
|
|
70b673d12c | ||
|
|
e78523034a | ||
|
|
7874d27486 | ||
|
|
cc3f3cf033 | ||
|
|
90d4e0e41c | ||
|
|
7783fe802a | ||
|
|
2cc9e62852 | ||
|
|
26ef7aae38 | ||
|
|
efe4ea0e25 | ||
|
|
9fb9dfb905 | ||
|
|
aa49d2a360 | ||
|
|
5107f1b84a | ||
|
|
ffdedde6ac | ||
|
|
ee698ab5be | ||
|
|
f1ee9a301d | ||
|
|
611d77e1a9 | ||
|
|
418a7518d8 | ||
|
|
b05bb899f1 | ||
|
|
c51a30b862 | ||
|
|
533f9a0d84 | ||
|
|
9c4d3a6359 | ||
|
|
6936a795a6 | ||
|
|
74defce481 | ||
|
|
1c4d7fd84b | ||
|
|
2bc07c6b23 | ||
|
|
1a11e33749 | ||
|
|
135a93993b | ||
|
|
d1b192cd72 | ||
|
|
efed150910 | ||
|
|
6242cc31f2 | ||
|
|
71df716787 | ||
|
|
78353cb538 | ||
|
|
caff73a746 | ||
|
|
02bc3cde53 | ||
|
|
f7a16c6ca5 | ||
|
|
b548c6c827 | ||
|
|
4ae8bf2f71 | ||
|
|
0a848c2d6c | ||
|
|
eeb9fe9b7f | ||
|
|
fbb189ecd7 | ||
|
|
2abf2c464f | ||
|
|
9c5ab755c1 | ||
|
|
c5ed0753a6 | ||
|
|
faa7abcc7f | ||
|
|
bbf7fe2d1d | ||
|
|
96f338c964 | ||
|
|
21d68f61ea | ||
|
|
f47bc44dbc | ||
|
|
f907c25b21 | ||
|
|
1b64db5521 | ||
|
|
75c94d9374 | ||
|
|
31ece25252 | ||
|
|
d608a6f123 | ||
|
|
2dcd6fa2b9 | ||
|
|
19cd98cb99 | ||
|
|
5a370f17f2 | ||
|
|
cd5960686f | ||
|
|
c1a70ad690 | ||
|
|
361b0abec9 | ||
|
|
e01b517843 | ||
|
|
f613a79f3e | ||
|
|
6bb49ade76 | ||
|
|
16b9cb6ff4 | ||
|
|
22af6af9c7 | ||
|
|
d542b529cb | ||
|
|
a7c79a9e34 | ||
|
|
e85f687c6b | ||
|
|
acdfd86286 | ||
|
|
1e57317322 | ||
|
|
16ad2d48d8 | ||
|
|
21077d4696 | ||
|
|
d3a6f1cc46 | ||
|
|
5b5f10cadc | ||
|
|
fa06ea19a6 | ||
|
|
3454d6c29e | ||
|
|
d96eb6fb1c | ||
|
|
693dfd18f9 | ||
|
|
0298692852 | ||
|
|
91ff211ab1 | ||
|
|
156ad5c3fd | ||
|
|
d90e4bef63 | ||
|
|
39329fcd1c | ||
|
|
738a9a4558 | ||
|
|
96709dd9f3 | ||
|
|
072ac1b3c8 | ||
|
|
46a67e09f1 | ||
|
|
59de8e11ac | ||
|
|
dc5e53ec14 | ||
|
|
00c1ff05de | ||
|
|
33ae3479c4 | ||
|
|
18344ae580 | ||
|
|
de98d11d65 | ||
|
|
dadc2cf329 | ||
|
|
452853c1a4 | ||
|
|
c6ead4e5bd | ||
|
|
a044781423 | ||
|
|
e9af621d88 | ||
|
|
eadf9aad41 | ||
|
|
eb69ada880 | ||
|
|
1660c47db5 | ||
|
|
eba661ad1e | ||
|
|
6d11fbee89 | ||
|
|
9a6c540013 | ||
|
|
0befa28e8e | ||
|
|
ce91049827 | ||
|
|
e911eb7988 | ||
|
|
d95583ce1d | ||
|
|
67a65213d8 | ||
|
|
0f3216564d | ||
|
|
e07163c568 | ||
|
|
a5bccd02dc | ||
|
|
64973e6cff | ||
|
|
c6d7cc7c25 | ||
|
|
0118364059 | ||
|
|
28d401ec01 | ||
|
|
881ad57a02 | ||
|
|
c75421e2c6 | ||
|
|
23cf1d268c | ||
|
|
cb281dfc11 | ||
|
|
4afe7a29b1 | ||
|
|
a726818c17 | ||
|
|
26c3da3548 | ||
|
|
4640d0a4aa | ||
|
|
bcd673de3a | ||
|
|
55a49baed7 | ||
|
|
bcbb9bb16a | ||
|
|
305d7528da | ||
|
|
1b0d7fbd56 | ||
|
|
85c40424d5 | ||
|
|
c04a816e59 | ||
|
|
59b1e970fd | ||
|
|
7739219ca6 | ||
|
|
1242f35177 | ||
|
|
9247661849 | ||
|
|
16570909be | ||
|
|
1ea0dd8f06 | ||
|
|
a391ac29a0 | ||
|
|
350c29a054 | ||
|
|
1fa4ac69b2 | ||
|
|
19d1f7853f | ||
|
|
74572ab2ee |
62
.github/workflows/docker-image-alpha.yml
vendored
Normal file
62
.github/workflows/docker-image-alpha.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: Publish Docker image (alpha)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- alpha
|
||||
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: |
|
||||
echo "alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" > 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: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
calciumion/new-api
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=alpha
|
||||
type=raw,value=alpha-{{date 'YYYYMMDD'}}-{{sha}}
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -44,6 +44,9 @@
|
||||
|
||||
For detailed documentation, please visit our official Wiki: [https://docs.newapi.pro/](https://docs.newapi.pro/)
|
||||
|
||||
You can also access the AI-generated DeepWiki:
|
||||
[](https://deepwiki.com/QuantumNous/new-api)
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
New API offers a wide range of features, please refer to [Features Introduction](https://docs.newapi.pro/wiki/features-introduction) for details:
|
||||
@@ -110,6 +113,7 @@ For detailed configuration instructions, please refer to [Installation Guide-Env
|
||||
- `AZURE_DEFAULT_API_VERSION`: Azure channel default API version, default is `2025-04-01-preview`
|
||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE`: Notification limit duration, default is `10` minutes
|
||||
- `NOTIFY_LIMIT_COUNT`: Maximum number of user notifications within the specified duration, default is `2`
|
||||
- `ERROR_LOG_ENABLED=true`: Whether to record and display error logs, default is `false`
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
|
||||
详细文档请访问我们的官方Wiki:[https://docs.newapi.pro/](https://docs.newapi.pro/)
|
||||
|
||||
也可访问AI生成的DeepWiki:
|
||||
[](https://deepwiki.com/QuantumNous/new-api)
|
||||
|
||||
## ✨ 主要特性
|
||||
|
||||
New API提供了丰富的功能,详细特性请参考[特性说明](https://docs.newapi.pro/wiki/features-introduction):
|
||||
@@ -110,6 +113,7 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
|
||||
- `AZURE_DEFAULT_API_VERSION`:Azure渠道默认API版本,默认 `2025-04-01-preview`
|
||||
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:通知限制持续时间,默认 `10`分钟
|
||||
- `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2`
|
||||
- `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false`
|
||||
|
||||
## 部署
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ func relayHandler(c *gin.Context, relayMode int) *dto.OpenAIErrorWithStatusCode
|
||||
err = relay.EmbeddingHelper(c)
|
||||
case relayconstant.RelayModeResponses:
|
||||
err = relay.ResponsesHelper(c)
|
||||
case relayconstant.RelayModeGemini:
|
||||
err = relay.GeminiHelper(c)
|
||||
default:
|
||||
err = relay.TextHelper(c)
|
||||
}
|
||||
|
||||
@@ -7,17 +7,18 @@ type ClaudeMetadata struct {
|
||||
}
|
||||
|
||||
type ClaudeMediaMessage struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Source *ClaudeMessageSource `json:"source,omitempty"`
|
||||
Usage *ClaudeUsage `json:"usage,omitempty"`
|
||||
StopReason *string `json:"stop_reason,omitempty"`
|
||||
PartialJson *string `json:"partial_json,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Delta string `json:"delta,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Source *ClaudeMessageSource `json:"source,omitempty"`
|
||||
Usage *ClaudeUsage `json:"usage,omitempty"`
|
||||
StopReason *string `json:"stop_reason,omitempty"`
|
||||
PartialJson *string `json:"partial_json,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Delta string `json:"delta,omitempty"`
|
||||
CacheControl json.RawMessage `json:"cache_control,omitempty"`
|
||||
// tool_calls
|
||||
Id string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
@@ -2,6 +2,7 @@ package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"one-api/common"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -28,7 +29,6 @@ type GeneralOpenAIRequest struct {
|
||||
MaxTokens uint `json:"max_tokens,omitempty"`
|
||||
MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
//Reasoning json.RawMessage `json:"reasoning,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"top_p,omitempty"`
|
||||
TopK int `json:"top_k,omitempty"`
|
||||
@@ -43,7 +43,7 @@ type GeneralOpenAIRequest struct {
|
||||
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
|
||||
EncodingFormat any `json:"encoding_format,omitempty"`
|
||||
Seed float64 `json:"seed,omitempty"`
|
||||
ParallelTooCalls bool `json:"parallel_tool_calls,omitempty"`
|
||||
ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"`
|
||||
Tools []ToolCallRequest `json:"tools,omitempty"`
|
||||
ToolChoice any `json:"tool_choice,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
@@ -55,6 +55,15 @@ type GeneralOpenAIRequest struct {
|
||||
EnableThinking any `json:"enable_thinking,omitempty"` // ali
|
||||
ExtraBody any `json:"extra_body,omitempty"`
|
||||
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
|
||||
// OpenRouter Params
|
||||
Reasoning json.RawMessage `json:"reasoning,omitempty"`
|
||||
}
|
||||
|
||||
func (r *GeneralOpenAIRequest) ToMap() map[string]any {
|
||||
result := make(map[string]any)
|
||||
data, _ := common.EncodeJson(r)
|
||||
_ = common.DecodeJson(data, &result)
|
||||
return result
|
||||
}
|
||||
|
||||
type ToolCallRequest struct {
|
||||
@@ -74,11 +83,11 @@ type StreamOptions struct {
|
||||
IncludeUsage bool `json:"include_usage,omitempty"`
|
||||
}
|
||||
|
||||
func (r GeneralOpenAIRequest) GetMaxTokens() int {
|
||||
func (r *GeneralOpenAIRequest) GetMaxTokens() int {
|
||||
return int(r.MaxTokens)
|
||||
}
|
||||
|
||||
func (r GeneralOpenAIRequest) ParseInput() []string {
|
||||
func (r *GeneralOpenAIRequest) ParseInput() []string {
|
||||
if r.Input == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -117,6 +126,8 @@ type MediaContent struct {
|
||||
InputAudio any `json:"input_audio,omitempty"`
|
||||
File any `json:"file,omitempty"`
|
||||
VideoUrl any `json:"video_url,omitempty"`
|
||||
// OpenRouter Params
|
||||
CacheControl json.RawMessage `json:"cache_control,omitempty"`
|
||||
}
|
||||
|
||||
func (m *MediaContent) GetImageMedia() *MessageImageUrl {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func validUserInfo(username string, role int) bool {
|
||||
@@ -182,6 +183,18 @@ func TokenAuth() func(c *gin.Context) {
|
||||
c.Request.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
}
|
||||
// gemini api 从query中获取key
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") {
|
||||
skKey := c.Query("key")
|
||||
if skKey != "" {
|
||||
c.Request.Header.Set("Authorization", "Bearer "+skKey)
|
||||
}
|
||||
// 从x-goog-api-key header中获取key
|
||||
xGoogKey := c.Request.Header.Get("x-goog-api-key")
|
||||
if xGoogKey != "" {
|
||||
c.Request.Header.Set("Authorization", "Bearer "+xGoogKey)
|
||||
}
|
||||
}
|
||||
key := c.Request.Header.Get("Authorization")
|
||||
parts := make([]string, 0)
|
||||
key = strings.TrimPrefix(key, "Bearer ")
|
||||
|
||||
@@ -162,6 +162,14 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
}
|
||||
c.Set("platform", string(constant.TaskPlatformSuno))
|
||||
c.Set("relay_mode", relayMode)
|
||||
} else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") {
|
||||
// Gemini API 路径处理: /v1beta/models/gemini-2.0-flash:generateContent
|
||||
relayMode := relayconstant.RelayModeGemini
|
||||
modelName := extractModelNameFromGeminiPath(c.Request.URL.Path)
|
||||
if modelName != "" {
|
||||
modelRequest.Model = modelName
|
||||
}
|
||||
c.Set("relay_mode", relayMode)
|
||||
} else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") {
|
||||
err = common.UnmarshalBodyReusable(c, &modelRequest)
|
||||
}
|
||||
@@ -244,3 +252,31 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
|
||||
c.Set("bot_id", channel.Other)
|
||||
}
|
||||
}
|
||||
|
||||
// extractModelNameFromGeminiPath 从 Gemini API URL 路径中提取模型名
|
||||
// 输入格式: /v1beta/models/gemini-2.0-flash:generateContent
|
||||
// 输出: gemini-2.0-flash
|
||||
func extractModelNameFromGeminiPath(path string) string {
|
||||
// 查找 "/models/" 的位置
|
||||
modelsPrefix := "/models/"
|
||||
modelsIndex := strings.Index(path, modelsPrefix)
|
||||
if modelsIndex == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 从 "/models/" 之后开始提取
|
||||
startIndex := modelsIndex + len(modelsPrefix)
|
||||
if startIndex >= len(path) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 查找 ":" 的位置,模型名在 ":" 之前
|
||||
colonIndex := strings.Index(path[startIndex:], ":")
|
||||
if colonIndex == -1 {
|
||||
// 如果没有找到 ":",返回从 "/models/" 到路径结尾的部分
|
||||
return path[startIndex:]
|
||||
}
|
||||
|
||||
// 返回模型名部分
|
||||
return path[startIndex : startIndex+colonIndex]
|
||||
}
|
||||
|
||||
@@ -104,6 +104,65 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
|
||||
return targetConn, nil
|
||||
}
|
||||
|
||||
func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.CancelFunc {
|
||||
pingerCtx, stopPinger := context.WithCancel(context.Background())
|
||||
|
||||
gopool.Go(func() {
|
||||
defer func() {
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine stopped.")
|
||||
}
|
||||
}()
|
||||
|
||||
if pingInterval <= 0 {
|
||||
pingInterval = helper.DefaultPingInterval
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(pingInterval)
|
||||
// 退出时清理 ticker
|
||||
defer ticker.Stop()
|
||||
|
||||
var pingMutex sync.Mutex
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine started")
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
// 发送 ping 数据
|
||||
case <-ticker.C:
|
||||
if err := sendPingData(c, &pingMutex); err != nil {
|
||||
return
|
||||
}
|
||||
// 收到退出信号
|
||||
case <-pingerCtx.Done():
|
||||
return
|
||||
// request 结束
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return stopPinger
|
||||
}
|
||||
|
||||
func sendPingData(c *gin.Context, mutex *sync.Mutex) error {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
err := helper.PingData(c)
|
||||
if err != nil {
|
||||
common2.LogError(c, "SSE ping error: "+err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping data sent.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) {
|
||||
var client *http.Client
|
||||
var err error
|
||||
@@ -115,68 +174,28 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
|
||||
} else {
|
||||
client = service.GetHttpClient()
|
||||
}
|
||||
// 流式请求 ping 保活
|
||||
var stopPinger func()
|
||||
generalSettings := operation_setting.GetGeneralSetting()
|
||||
pingEnabled := generalSettings.PingIntervalEnabled
|
||||
var pingerWg sync.WaitGroup
|
||||
|
||||
if info.IsStream {
|
||||
helper.SetEventStreamHeaders(c)
|
||||
pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second
|
||||
var pingerCtx context.Context
|
||||
pingerCtx, stopPinger = context.WithCancel(c.Request.Context())
|
||||
|
||||
if pingEnabled {
|
||||
pingerWg.Add(1)
|
||||
gopool.Go(func() {
|
||||
defer pingerWg.Done()
|
||||
if pingInterval <= 0 {
|
||||
pingInterval = helper.DefaultPingInterval
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(pingInterval)
|
||||
defer ticker.Stop()
|
||||
var pingMutex sync.Mutex
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine started")
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
pingMutex.Lock()
|
||||
err2 := helper.PingData(c)
|
||||
pingMutex.Unlock()
|
||||
if err2 != nil {
|
||||
common2.LogError(c, "SSE ping error: "+err.Error())
|
||||
return
|
||||
}
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping data sent.")
|
||||
}
|
||||
case <-pingerCtx.Done():
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine stopped.")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
// 处理流式请求的 ping 保活
|
||||
generalSettings := operation_setting.GetGeneralSetting()
|
||||
if generalSettings.PingIntervalEnabled {
|
||||
pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second
|
||||
stopPinger := startPingKeepAlive(c, pingInterval)
|
||||
defer stopPinger()
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
// request结束后停止ping
|
||||
if info.IsStream && pingEnabled {
|
||||
stopPinger()
|
||||
pingerWg.Wait()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("resp is nil")
|
||||
}
|
||||
|
||||
_ = req.Body.Close()
|
||||
_ = c.Request.Body.Close()
|
||||
return resp, nil
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"one-api/relay/channel"
|
||||
"one-api/relay/channel/openai"
|
||||
relaycommon "one-api/relay/common"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -49,6 +50,18 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
if request == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
if strings.HasSuffix(info.UpstreamModelName, "-search") {
|
||||
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-search")
|
||||
request.Model = info.UpstreamModelName
|
||||
toMap := request.ToMap()
|
||||
toMap["web_search"] = map[string]any{
|
||||
"enable": true,
|
||||
"enable_citation": true,
|
||||
"enable_trace": true,
|
||||
"enable_status": false,
|
||||
}
|
||||
return toMap, nil
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"one-api/dto"
|
||||
"one-api/relay/channel"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/constant"
|
||||
"one-api/service"
|
||||
"one-api/setting/model_setting"
|
||||
"strings"
|
||||
@@ -165,6 +166,14 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *dto.OpenAIErrorWithStatusCode) {
|
||||
if info.RelayMode == constant.RelayModeGemini {
|
||||
if info.IsStream {
|
||||
return GeminiTextGenerationStreamHandler(c, resp, info)
|
||||
} else {
|
||||
return GeminiTextGenerationHandler(c, resp, info)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(info.UpstreamModelName, "imagen") {
|
||||
return GeminiImageHandler(c, resp, info)
|
||||
}
|
||||
|
||||
@@ -27,14 +27,9 @@ type FunctionCall struct {
|
||||
Arguments any `json:"args"`
|
||||
}
|
||||
|
||||
type GeminiFunctionResponseContent struct {
|
||||
Name string `json:"name"`
|
||||
Content any `json:"content"`
|
||||
}
|
||||
|
||||
type FunctionResponse struct {
|
||||
Name string `json:"name"`
|
||||
Response GeminiFunctionResponseContent `json:"response"`
|
||||
Name string `json:"name"`
|
||||
Response map[string]interface{} `json:"response"`
|
||||
}
|
||||
|
||||
type GeminiPartExecutableCode struct {
|
||||
@@ -117,10 +112,16 @@ type GeminiChatResponse struct {
|
||||
}
|
||||
|
||||
type GeminiUsageMetadata struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
|
||||
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
|
||||
}
|
||||
|
||||
type GeminiPromptTokensDetails struct {
|
||||
Modality string `json:"modality"`
|
||||
TokenCount int `json:"tokenCount"`
|
||||
}
|
||||
|
||||
// Imagen related structs
|
||||
|
||||
145
relay/channel/gemini/relay-gemini-native.go
Normal file
145
relay/channel/gemini/relay-gemini-native.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/helper"
|
||||
"one-api/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GeminiTextGenerationHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *dto.OpenAIErrorWithStatusCode) {
|
||||
// 读取响应体
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError)
|
||||
}
|
||||
err = resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println(string(responseBody))
|
||||
}
|
||||
|
||||
// 解析为 Gemini 原生响应格式
|
||||
var geminiResponse GeminiChatResponse
|
||||
err = common.DecodeJson(responseBody, &geminiResponse)
|
||||
if err != nil {
|
||||
return nil, service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// 检查是否有候选响应
|
||||
if len(geminiResponse.Candidates) == 0 {
|
||||
return nil, &dto.OpenAIErrorWithStatusCode{
|
||||
Error: dto.OpenAIError{
|
||||
Message: "No candidates returned",
|
||||
Type: "server_error",
|
||||
Param: "",
|
||||
Code: 500,
|
||||
},
|
||||
StatusCode: resp.StatusCode,
|
||||
}
|
||||
}
|
||||
|
||||
// 计算使用量(基于 UsageMetadata)
|
||||
usage := dto.Usage{
|
||||
PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount,
|
||||
}
|
||||
|
||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
|
||||
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
|
||||
} else if detail.Modality == "TEXT" {
|
||||
usage.PromptTokensDetails.TextTokens = detail.TokenCount
|
||||
}
|
||||
}
|
||||
|
||||
// 直接返回 Gemini 原生格式的 JSON 响应
|
||||
jsonResponse, err := json.Marshal(geminiResponse)
|
||||
if err != nil {
|
||||
return nil, service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// 设置响应头并写入响应
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
_, err = c.Writer.Write(jsonResponse)
|
||||
if err != nil {
|
||||
return nil, service.OpenAIErrorWrapper(err, "write_response_failed", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
return &usage, nil
|
||||
}
|
||||
|
||||
func GeminiTextGenerationStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *dto.OpenAIErrorWithStatusCode) {
|
||||
var usage = &dto.Usage{}
|
||||
var imageCount int
|
||||
|
||||
helper.SetEventStreamHeaders(c)
|
||||
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
|
||||
var geminiResponse GeminiChatResponse
|
||||
err := common.DecodeJsonStr(data, &geminiResponse)
|
||||
if err != nil {
|
||||
common.LogError(c, "error unmarshalling stream response: "+err.Error())
|
||||
return false
|
||||
}
|
||||
|
||||
// 统计图片数量
|
||||
for _, candidate := range geminiResponse.Candidates {
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil && part.InlineData.MimeType != "" {
|
||||
imageCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新使用量统计
|
||||
if geminiResponse.UsageMetadata.TotalTokenCount != 0 {
|
||||
usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount
|
||||
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
|
||||
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
|
||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
|
||||
} else if detail.Modality == "TEXT" {
|
||||
usage.PromptTokensDetails.TextTokens = detail.TokenCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 直接发送 GeminiChatResponse 响应
|
||||
err = helper.ObjectData(c, geminiResponse)
|
||||
if err != nil {
|
||||
common.LogError(c, err.Error())
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if imageCount != 0 {
|
||||
if usage.CompletionTokens == 0 {
|
||||
usage.CompletionTokens = imageCount * 258
|
||||
}
|
||||
}
|
||||
|
||||
// 计算最终使用量
|
||||
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
|
||||
|
||||
// 移除流式响应结尾的[Done],因为Gemini API没有发送Done的行为
|
||||
//helper.Done(c)
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
@@ -18,6 +18,24 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var geminiSupportedMimeTypes = map[string]bool{
|
||||
"application/pdf": true,
|
||||
"audio/mpeg": true,
|
||||
"audio/mp3": true,
|
||||
"audio/wav": true,
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"text/plain": true,
|
||||
"video/mov": true,
|
||||
"video/mpeg": true,
|
||||
"video/mp4": true,
|
||||
"video/mpg": true,
|
||||
"video/avi": true,
|
||||
"video/wmv": true,
|
||||
"video/mpegps": true,
|
||||
"video/flv": true,
|
||||
}
|
||||
|
||||
// Setting safety to the lowest possible values since Gemini is already powerless enough
|
||||
func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) {
|
||||
|
||||
@@ -40,17 +58,62 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
|
||||
|
||||
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
|
||||
if strings.HasSuffix(info.OriginModelName, "-thinking") {
|
||||
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
|
||||
if budgetTokens == 0 || budgetTokens > 24576 {
|
||||
budgetTokens = 24576
|
||||
// 硬编码不支持 ThinkingBudget 的旧模型
|
||||
unsupportedModels := []string{
|
||||
"gemini-2.5-pro-preview-05-06",
|
||||
"gemini-2.5-pro-preview-03-25",
|
||||
}
|
||||
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
|
||||
ThinkingBudget: common.GetPointer(int(budgetTokens)),
|
||||
IncludeThoughts: true,
|
||||
|
||||
isUnsupported := false
|
||||
for _, unsupportedModel := range unsupportedModels {
|
||||
if strings.HasPrefix(info.OriginModelName, unsupportedModel) {
|
||||
isUnsupported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isUnsupported {
|
||||
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
|
||||
IncludeThoughts: true,
|
||||
}
|
||||
} else {
|
||||
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens)
|
||||
|
||||
// 检查是否为新的2.5pro模型(支持ThinkingBudget但有特殊范围)
|
||||
isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") &&
|
||||
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") &&
|
||||
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25")
|
||||
|
||||
if isNew25Pro {
|
||||
// 新的2.5pro模型:ThinkingBudget范围为128-32768
|
||||
if budgetTokens == 0 || budgetTokens < 128 {
|
||||
budgetTokens = 128
|
||||
} else if budgetTokens > 32768 {
|
||||
budgetTokens = 32768
|
||||
}
|
||||
} else {
|
||||
// 其他模型:ThinkingBudget范围为0-24576
|
||||
if budgetTokens == 0 || budgetTokens > 24576 {
|
||||
budgetTokens = 24576
|
||||
}
|
||||
}
|
||||
|
||||
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
|
||||
ThinkingBudget: common.GetPointer(int(budgetTokens)),
|
||||
IncludeThoughts: true,
|
||||
}
|
||||
}
|
||||
} else if strings.HasSuffix(info.OriginModelName, "-nothinking") {
|
||||
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
|
||||
ThinkingBudget: common.GetPointer(0),
|
||||
// 检查是否为新的2.5pro模型(不支持-nothinking,因为最低值只能为128)
|
||||
isNew25Pro := strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro") &&
|
||||
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-05-06") &&
|
||||
!strings.HasPrefix(info.OriginModelName, "gemini-2.5-pro-preview-03-25")
|
||||
|
||||
if !isNew25Pro {
|
||||
// 只有非新2.5pro模型才支持-nothinking
|
||||
geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{
|
||||
ThinkingBudget: common.GetPointer(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,17 +211,12 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
|
||||
} else if val, exists := tool_call_ids[message.ToolCallId]; exists {
|
||||
name = val
|
||||
}
|
||||
content := common.StrToMap(message.StringContent())
|
||||
contentMap := common.StrToMap(message.StringContent())
|
||||
functionResp := &FunctionResponse{
|
||||
Name: name,
|
||||
Response: GeminiFunctionResponseContent{
|
||||
Name: name,
|
||||
Content: content,
|
||||
},
|
||||
}
|
||||
if content == nil {
|
||||
functionResp.Response.Content = message.StringContent()
|
||||
Name: name,
|
||||
Response: contentMap,
|
||||
}
|
||||
|
||||
*parts = append(*parts, GeminiPart{
|
||||
FunctionResponse: functionResp,
|
||||
})
|
||||
@@ -208,14 +266,20 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
|
||||
}
|
||||
// 判断是否是url
|
||||
if strings.HasPrefix(part.GetImageMedia().Url, "http") {
|
||||
// 是url,获取图片的类型和base64编码的数据
|
||||
// 是url,获取文件的类型和base64编码的数据
|
||||
fileData, err := service.GetFileBase64FromUrl(part.GetImageMedia().Url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file base64 from url failed: %s", err.Error())
|
||||
return nil, fmt.Errorf("get file base64 from url '%s' failed: %w", part.GetImageMedia().Url, err)
|
||||
}
|
||||
|
||||
// 校验 MimeType 是否在 Gemini 支持的白名单中
|
||||
if _, ok := geminiSupportedMimeTypes[strings.ToLower(fileData.MimeType)]; !ok {
|
||||
return nil, fmt.Errorf("MIME type '%s' from URL '%s' is not supported by Gemini. Supported types are: %v", fileData.MimeType, part.GetImageMedia().Url, getSupportedMimeTypesList())
|
||||
}
|
||||
|
||||
parts = append(parts, GeminiPart{
|
||||
InlineData: &GeminiInlineData{
|
||||
MimeType: fileData.MimeType,
|
||||
MimeType: fileData.MimeType, // 使用原始的 MimeType,因为大小写可能对API有意义
|
||||
Data: fileData.Base64Data,
|
||||
},
|
||||
})
|
||||
@@ -249,13 +313,13 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
|
||||
if part.GetInputAudio().Data == "" {
|
||||
return nil, fmt.Errorf("only base64 audio is supported in gemini")
|
||||
}
|
||||
format, base64String, err := service.DecodeBase64FileData(part.GetInputAudio().Data)
|
||||
base64String, err := service.DecodeBase64AudioData(part.GetInputAudio().Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
|
||||
}
|
||||
parts = append(parts, GeminiPart{
|
||||
InlineData: &GeminiInlineData{
|
||||
MimeType: format,
|
||||
MimeType: "audio/" + part.GetInputAudio().Format,
|
||||
Data: base64String,
|
||||
},
|
||||
})
|
||||
@@ -284,100 +348,126 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
|
||||
return &geminiRequest, nil
|
||||
}
|
||||
|
||||
// Helper function to get a list of supported MIME types for error messages
|
||||
func getSupportedMimeTypesList() []string {
|
||||
keys := make([]string, 0, len(geminiSupportedMimeTypes))
|
||||
for k := range geminiSupportedMimeTypes {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// cleanFunctionParameters recursively removes unsupported fields from Gemini function parameters.
|
||||
func cleanFunctionParameters(params interface{}) interface{} {
|
||||
if params == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
paramMap, ok := params.(map[string]interface{})
|
||||
if !ok {
|
||||
// Not a map, return as is (e.g., could be an array or primitive)
|
||||
return params
|
||||
}
|
||||
switch v := params.(type) {
|
||||
case map[string]interface{}:
|
||||
// Create a copy to avoid modifying the original
|
||||
cleanedMap := make(map[string]interface{})
|
||||
for k, val := range v {
|
||||
cleanedMap[k] = val
|
||||
}
|
||||
|
||||
// Create a copy to avoid modifying the original
|
||||
cleanedMap := make(map[string]interface{})
|
||||
for k, v := range paramMap {
|
||||
cleanedMap[k] = v
|
||||
}
|
||||
// Remove unsupported root-level fields
|
||||
delete(cleanedMap, "default")
|
||||
delete(cleanedMap, "exclusiveMaximum")
|
||||
delete(cleanedMap, "exclusiveMinimum")
|
||||
delete(cleanedMap, "$schema")
|
||||
delete(cleanedMap, "additionalProperties")
|
||||
|
||||
// Remove unsupported root-level fields
|
||||
delete(cleanedMap, "default")
|
||||
delete(cleanedMap, "exclusiveMaximum")
|
||||
delete(cleanedMap, "exclusiveMinimum")
|
||||
delete(cleanedMap, "$schema")
|
||||
delete(cleanedMap, "additionalProperties")
|
||||
|
||||
// Clean properties
|
||||
if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil {
|
||||
cleanedProps := make(map[string]interface{})
|
||||
for propName, propValue := range props {
|
||||
propMap, ok := propValue.(map[string]interface{})
|
||||
if !ok {
|
||||
cleanedProps[propName] = propValue // Keep non-map properties
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a copy of the property map
|
||||
cleanedPropMap := make(map[string]interface{})
|
||||
for k, v := range propMap {
|
||||
cleanedPropMap[k] = v
|
||||
}
|
||||
|
||||
// Remove unsupported fields
|
||||
delete(cleanedPropMap, "default")
|
||||
delete(cleanedPropMap, "exclusiveMaximum")
|
||||
delete(cleanedPropMap, "exclusiveMinimum")
|
||||
delete(cleanedPropMap, "$schema")
|
||||
delete(cleanedPropMap, "additionalProperties")
|
||||
|
||||
// Check and clean 'format' for string types
|
||||
if propType, typeExists := cleanedPropMap["type"].(string); typeExists && propType == "string" {
|
||||
if formatValue, formatExists := cleanedPropMap["format"].(string); formatExists {
|
||||
if formatValue != "enum" && formatValue != "date-time" {
|
||||
delete(cleanedPropMap, "format")
|
||||
}
|
||||
// Check and clean 'format' for string types
|
||||
if propType, typeExists := cleanedMap["type"].(string); typeExists && propType == "string" {
|
||||
if formatValue, formatExists := cleanedMap["format"].(string); formatExists {
|
||||
if formatValue != "enum" && formatValue != "date-time" {
|
||||
delete(cleanedMap, "format")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively clean nested properties within this property if it's an object/array
|
||||
// Check the type before recursing
|
||||
if propType, typeExists := cleanedPropMap["type"].(string); typeExists && (propType == "object" || propType == "array") {
|
||||
cleanedProps[propName] = cleanFunctionParameters(cleanedPropMap)
|
||||
} else {
|
||||
cleanedProps[propName] = cleanedPropMap // Assign the cleaned map back if not recursing
|
||||
// Clean properties
|
||||
if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil {
|
||||
cleanedProps := make(map[string]interface{})
|
||||
for propName, propValue := range props {
|
||||
cleanedProps[propName] = cleanFunctionParameters(propValue)
|
||||
}
|
||||
|
||||
cleanedMap["properties"] = cleanedProps
|
||||
}
|
||||
cleanedMap["properties"] = cleanedProps
|
||||
}
|
||||
|
||||
// Recursively clean items in arrays if needed (e.g., type: array, items: { ... })
|
||||
if items, ok := cleanedMap["items"].(map[string]interface{}); ok && items != nil {
|
||||
cleanedMap["items"] = cleanFunctionParameters(items)
|
||||
}
|
||||
// Also handle items if it's an array of schemas
|
||||
if itemsArray, ok := cleanedMap["items"].([]interface{}); ok {
|
||||
cleanedItemsArray := make([]interface{}, len(itemsArray))
|
||||
for i, item := range itemsArray {
|
||||
cleanedItemsArray[i] = cleanFunctionParameters(item)
|
||||
// Recursively clean items in arrays
|
||||
if items, ok := cleanedMap["items"].(map[string]interface{}); ok && items != nil {
|
||||
cleanedMap["items"] = cleanFunctionParameters(items)
|
||||
}
|
||||
cleanedMap["items"] = cleanedItemsArray
|
||||
}
|
||||
|
||||
// Recursively clean other schema composition keywords if necessary
|
||||
for _, field := range []string{"allOf", "anyOf", "oneOf"} {
|
||||
if nested, ok := cleanedMap[field].([]interface{}); ok {
|
||||
cleanedNested := make([]interface{}, len(nested))
|
||||
for i, item := range nested {
|
||||
cleanedNested[i] = cleanFunctionParameters(item)
|
||||
// Also handle items if it's an array of schemas
|
||||
if itemsArray, ok := cleanedMap["items"].([]interface{}); ok {
|
||||
cleanedItemsArray := make([]interface{}, len(itemsArray))
|
||||
for i, item := range itemsArray {
|
||||
cleanedItemsArray[i] = cleanFunctionParameters(item)
|
||||
}
|
||||
cleanedMap[field] = cleanedNested
|
||||
cleanedMap["items"] = cleanedItemsArray
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedMap
|
||||
// Recursively clean other schema composition keywords
|
||||
for _, field := range []string{"allOf", "anyOf", "oneOf"} {
|
||||
if nested, ok := cleanedMap[field].([]interface{}); ok {
|
||||
cleanedNested := make([]interface{}, len(nested))
|
||||
for i, item := range nested {
|
||||
cleanedNested[i] = cleanFunctionParameters(item)
|
||||
}
|
||||
cleanedMap[field] = cleanedNested
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively clean patternProperties
|
||||
if patternProps, ok := cleanedMap["patternProperties"].(map[string]interface{}); ok {
|
||||
cleanedPatternProps := make(map[string]interface{})
|
||||
for pattern, schema := range patternProps {
|
||||
cleanedPatternProps[pattern] = cleanFunctionParameters(schema)
|
||||
}
|
||||
cleanedMap["patternProperties"] = cleanedPatternProps
|
||||
}
|
||||
|
||||
// Recursively clean definitions
|
||||
if definitions, ok := cleanedMap["definitions"].(map[string]interface{}); ok {
|
||||
cleanedDefinitions := make(map[string]interface{})
|
||||
for defName, defSchema := range definitions {
|
||||
cleanedDefinitions[defName] = cleanFunctionParameters(defSchema)
|
||||
}
|
||||
cleanedMap["definitions"] = cleanedDefinitions
|
||||
}
|
||||
|
||||
// Recursively clean $defs (newer JSON Schema draft)
|
||||
if defs, ok := cleanedMap["$defs"].(map[string]interface{}); ok {
|
||||
cleanedDefs := make(map[string]interface{})
|
||||
for defName, defSchema := range defs {
|
||||
cleanedDefs[defName] = cleanFunctionParameters(defSchema)
|
||||
}
|
||||
cleanedMap["$defs"] = cleanedDefs
|
||||
}
|
||||
|
||||
// Clean conditional keywords
|
||||
for _, field := range []string{"if", "then", "else", "not"} {
|
||||
if nested, ok := cleanedMap[field]; ok {
|
||||
cleanedMap[field] = cleanFunctionParameters(nested)
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedMap
|
||||
|
||||
case []interface{}:
|
||||
// Handle arrays of schemas
|
||||
cleanedArray := make([]interface{}, len(v))
|
||||
for i, item := range v {
|
||||
cleanedArray[i] = cleanFunctionParameters(item)
|
||||
}
|
||||
return cleanedArray
|
||||
|
||||
default:
|
||||
// Not a map or array, return as is (e.g., could be a primitive)
|
||||
return params
|
||||
}
|
||||
}
|
||||
|
||||
func removeAdditionalPropertiesWithDepth(schema interface{}, depth int) interface{} {
|
||||
@@ -681,6 +771,13 @@ func GeminiChatStreamHandler(c *gin.Context, resp *http.Response, info *relaycom
|
||||
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount
|
||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
|
||||
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
|
||||
} else if detail.Modality == "TEXT" {
|
||||
usage.PromptTokensDetails.TextTokens = detail.TokenCount
|
||||
}
|
||||
}
|
||||
}
|
||||
err = helper.ObjectData(c, response)
|
||||
if err != nil {
|
||||
@@ -755,6 +852,14 @@ func GeminiChatHandler(c *gin.Context, resp *http.Response, info *relaycommon.Re
|
||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
|
||||
|
||||
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
|
||||
} else if detail.Modality == "TEXT" {
|
||||
usage.PromptTokensDetails.TextTokens = detail.TokenCount
|
||||
}
|
||||
}
|
||||
|
||||
fullTextResponse.Usage = usage
|
||||
jsonResponse, err := json.Marshal(fullTextResponse)
|
||||
if err != nil {
|
||||
|
||||
@@ -273,36 +273,25 @@ func OpenaiHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI
|
||||
}
|
||||
|
||||
func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.OpenAIErrorWithStatusCode, *dto.Usage) {
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError), nil
|
||||
}
|
||||
err = resp.Body.Close()
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
|
||||
}
|
||||
// Reset response body
|
||||
resp.Body = io.NopCloser(bytes.NewBuffer(responseBody))
|
||||
// We shouldn't set the header before we parse the response body, because the parse part may fail.
|
||||
// And then we will have to send an error response, but in this case, the header has already been set.
|
||||
// So the httpClient will be confused by the response.
|
||||
// For example, Postman will report error, and we cannot check the response at all.
|
||||
// the status code has been judged before, if there is a body reading failure,
|
||||
// it should be regarded as a non-recoverable error, so it should not return err for external retry.
|
||||
// Analogous to nginx's load balancing, it will only retry if it can't be requested or
|
||||
// if the upstream returns a specific status code, once the upstream has already written the header,
|
||||
// the subsequent failure of the response body should be regarded as a non-recoverable error,
|
||||
// and can be terminated directly.
|
||||
defer resp.Body.Close()
|
||||
usage := &dto.Usage{}
|
||||
usage.PromptTokens = info.PromptTokens
|
||||
usage.TotalTokens = info.PromptTokens
|
||||
for k, v := range resp.Header {
|
||||
c.Writer.Header().Set(k, v[0])
|
||||
}
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
_, err = io.Copy(c.Writer, resp.Body)
|
||||
c.Writer.WriteHeaderNow()
|
||||
_, err := io.Copy(c.Writer, resp.Body)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError), nil
|
||||
common.LogError(c, err.Error())
|
||||
}
|
||||
err = resp.Body.Close()
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "close_response_body_failed", http.StatusInternalServerError), nil
|
||||
}
|
||||
|
||||
usage := &dto.Usage{}
|
||||
usage.PromptTokens = info.PromptTokens
|
||||
usage.TotalTokens = info.PromptTokens
|
||||
return nil, usage
|
||||
}
|
||||
|
||||
|
||||
9
relay/channel/openrouter/dto.go
Normal file
9
relay/channel/openrouter/dto.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package openrouter
|
||||
|
||||
type RequestReasoning struct {
|
||||
// One of the following (not both):
|
||||
Effort string `json:"effort,omitempty"` // Can be "high", "medium", or "low" (OpenAI-style)
|
||||
MaxTokens int `json:"max_tokens,omitempty"` // Specific token limit (Anthropic-style)
|
||||
// Optional: Default is false. All models support this.
|
||||
Exclude bool `json:"exclude,omitempty"` // Set to true to exclude reasoning tokens from response
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"one-api/relay/channel/gemini"
|
||||
"one-api/relay/channel/openai"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/constant"
|
||||
"one-api/setting/model_setting"
|
||||
"strings"
|
||||
|
||||
@@ -201,7 +202,11 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
case RequestModeClaude:
|
||||
err, usage = claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
|
||||
case RequestModeGemini:
|
||||
err, usage = gemini.GeminiChatStreamHandler(c, resp, info)
|
||||
if info.RelayMode == constant.RelayModeGemini {
|
||||
usage, err = gemini.GeminiTextGenerationStreamHandler(c, resp, info)
|
||||
} else {
|
||||
err, usage = gemini.GeminiChatStreamHandler(c, resp, info)
|
||||
}
|
||||
case RequestModeLlama:
|
||||
err, usage = openai.OaiStreamHandler(c, resp, info)
|
||||
}
|
||||
@@ -210,7 +215,11 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
case RequestModeClaude:
|
||||
err, usage = claude.ClaudeHandler(c, resp, claude.RequestModeMessage, info)
|
||||
case RequestModeGemini:
|
||||
err, usage = gemini.GeminiChatHandler(c, resp, info)
|
||||
if info.RelayMode == constant.RelayModeGemini {
|
||||
usage, err = gemini.GeminiTextGenerationHandler(c, resp, info)
|
||||
} else {
|
||||
err, usage = gemini.GeminiChatHandler(c, resp, info)
|
||||
}
|
||||
case RequestModeLlama:
|
||||
err, usage = openai.OpenaiHandler(c, resp, info)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ const (
|
||||
RelayModeResponses
|
||||
|
||||
RelayModeRealtime
|
||||
|
||||
RelayModeGemini
|
||||
)
|
||||
|
||||
func Path2RelayMode(path string) int {
|
||||
@@ -75,6 +77,8 @@ func Path2RelayMode(path string) int {
|
||||
relayMode = RelayModeRerank
|
||||
} else if strings.HasPrefix(path, "/v1/realtime") {
|
||||
relayMode = RelayModeRealtime
|
||||
} else if strings.HasPrefix(path, "/v1beta/models") {
|
||||
relayMode = RelayModeGemini
|
||||
}
|
||||
return relayMode
|
||||
}
|
||||
|
||||
157
relay/relay-gemini.go
Normal file
157
relay/relay-gemini.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/relay/channel/gemini"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/relay/helper"
|
||||
"one-api/service"
|
||||
"one-api/setting"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func getAndValidateGeminiRequest(c *gin.Context) (*gemini.GeminiChatRequest, error) {
|
||||
request := &gemini.GeminiChatRequest{}
|
||||
err := common.UnmarshalBodyReusable(c, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(request.Contents) == 0 {
|
||||
return nil, errors.New("contents is required")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// 流模式
|
||||
// /v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse&key=xxx
|
||||
func checkGeminiStreamMode(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
|
||||
if c.Query("alt") == "sse" {
|
||||
relayInfo.IsStream = true
|
||||
}
|
||||
|
||||
// if strings.Contains(c.Request.URL.Path, "streamGenerateContent") {
|
||||
// relayInfo.IsStream = true
|
||||
// }
|
||||
}
|
||||
|
||||
func checkGeminiInputSensitive(textRequest *gemini.GeminiChatRequest) ([]string, error) {
|
||||
var inputTexts []string
|
||||
for _, content := range textRequest.Contents {
|
||||
for _, part := range content.Parts {
|
||||
if part.Text != "" {
|
||||
inputTexts = append(inputTexts, part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(inputTexts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sensitiveWords, err := service.CheckSensitiveInput(inputTexts)
|
||||
return sensitiveWords, err
|
||||
}
|
||||
|
||||
func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.RelayInfo) (int, error) {
|
||||
// 计算输入 token 数量
|
||||
var inputTexts []string
|
||||
for _, content := range req.Contents {
|
||||
for _, part := range content.Parts {
|
||||
if part.Text != "" {
|
||||
inputTexts = append(inputTexts, part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inputText := strings.Join(inputTexts, "\n")
|
||||
inputTokens, err := service.CountTokenInput(inputText, info.UpstreamModelName)
|
||||
info.PromptTokens = inputTokens
|
||||
return inputTokens, err
|
||||
}
|
||||
|
||||
func GeminiHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
|
||||
req, err := getAndValidateGeminiRequest(c)
|
||||
if err != nil {
|
||||
common.LogError(c, fmt.Sprintf("getAndValidateGeminiRequest error: %s", err.Error()))
|
||||
return service.OpenAIErrorWrapperLocal(err, "invalid_gemini_request", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
relayInfo := relaycommon.GenRelayInfo(c)
|
||||
|
||||
// 检查 Gemini 流式模式
|
||||
checkGeminiStreamMode(c, relayInfo)
|
||||
|
||||
if setting.ShouldCheckPromptSensitive() {
|
||||
sensitiveWords, err := checkGeminiInputSensitive(req)
|
||||
if err != nil {
|
||||
common.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(sensitiveWords, ", ")))
|
||||
return service.OpenAIErrorWrapperLocal(err, "check_request_sensitive_error", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// model mapped 模型映射
|
||||
err = helper.ModelMappedHelper(c, relayInfo)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapperLocal(err, "model_mapped_error", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if value, exists := c.Get("prompt_tokens"); exists {
|
||||
promptTokens := value.(int)
|
||||
relayInfo.SetPromptTokens(promptTokens)
|
||||
} else {
|
||||
promptTokens, err := getGeminiInputTokens(req, relayInfo)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapperLocal(err, "count_input_tokens_error", http.StatusBadRequest)
|
||||
}
|
||||
c.Set("prompt_tokens", promptTokens)
|
||||
}
|
||||
|
||||
priceData, err := helper.ModelPriceHelper(c, relayInfo, relayInfo.PromptTokens, int(req.GenerationConfig.MaxOutputTokens))
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapperLocal(err, "model_price_error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// pre consume quota
|
||||
preConsumedQuota, userQuota, openaiErr := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
|
||||
if openaiErr != nil {
|
||||
return openaiErr
|
||||
}
|
||||
defer func() {
|
||||
if openaiErr != nil {
|
||||
returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota)
|
||||
}
|
||||
}()
|
||||
|
||||
adaptor := GetAdaptor(relayInfo.ApiType)
|
||||
if adaptor == nil {
|
||||
return service.OpenAIErrorWrapperLocal(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), "invalid_api_type", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
adaptor.Init(relayInfo)
|
||||
|
||||
requestBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapperLocal(err, "marshal_text_request_failed", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
resp, err := adaptor.DoRequest(c, relayInfo, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
common.LogError(c, "Do gemini request failed: "+err.Error())
|
||||
return service.OpenAIErrorWrapperLocal(err, "do_request_failed", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
usage, openaiErr := adaptor.DoResponse(c, resp.(*http.Response), relayInfo)
|
||||
if openaiErr != nil {
|
||||
return openaiErr
|
||||
}
|
||||
|
||||
postConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "")
|
||||
return nil
|
||||
}
|
||||
@@ -41,16 +41,31 @@ func getAndValidImageRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto.
|
||||
imageRequest.Quality = "standard"
|
||||
}
|
||||
}
|
||||
if imageRequest.N == 0 {
|
||||
imageRequest.N = 1
|
||||
}
|
||||
default:
|
||||
err := common.UnmarshalBodyReusable(c, imageRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if imageRequest.Model == "" {
|
||||
imageRequest.Model = "dall-e-3"
|
||||
}
|
||||
|
||||
if strings.Contains(imageRequest.Size, "×") {
|
||||
return nil, errors.New("size an unexpected error occurred in the parameter, please use 'x' instead of the multiplication sign '×'")
|
||||
}
|
||||
|
||||
// Not "256x256", "512x512", or "1024x1024"
|
||||
if imageRequest.Model == "dall-e-2" || imageRequest.Model == "dall-e" {
|
||||
if imageRequest.Size != "" && imageRequest.Size != "256x256" && imageRequest.Size != "512x512" && imageRequest.Size != "1024x1024" {
|
||||
return nil, errors.New("size must be one of 256x256, 512x512, or 1024x1024 for dall-e-2 or dall-e")
|
||||
}
|
||||
if imageRequest.Size == "" {
|
||||
imageRequest.Size = "1024x1024"
|
||||
}
|
||||
} else if imageRequest.Model == "dall-e-3" {
|
||||
if imageRequest.Size != "" && imageRequest.Size != "1024x1024" && imageRequest.Size != "1024x1792" && imageRequest.Size != "1792x1024" {
|
||||
return nil, errors.New("size must be one of 1024x1024, 1024x1792 or 1792x1024 for dall-e-3")
|
||||
@@ -58,74 +73,24 @@ func getAndValidImageRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto.
|
||||
if imageRequest.Quality == "" {
|
||||
imageRequest.Quality = "standard"
|
||||
}
|
||||
// N should between 1 and 10
|
||||
//if imageRequest.N != 0 && (imageRequest.N < 1 || imageRequest.N > 10) {
|
||||
// return service.OpenAIErrorWrapper(errors.New("n must be between 1 and 10"), "invalid_field_value", http.StatusBadRequest)
|
||||
//}
|
||||
if imageRequest.Size == "" {
|
||||
imageRequest.Size = "1024x1024"
|
||||
}
|
||||
} else if imageRequest.Model == "gpt-image-1" {
|
||||
if imageRequest.Quality == "" {
|
||||
imageRequest.Quality = "auto"
|
||||
}
|
||||
}
|
||||
|
||||
if imageRequest.Prompt == "" {
|
||||
return nil, errors.New("prompt is required")
|
||||
}
|
||||
|
||||
if imageRequest.N == 0 {
|
||||
imageRequest.N = 1
|
||||
}
|
||||
}
|
||||
|
||||
if imageRequest.Prompt == "" {
|
||||
return nil, errors.New("prompt is required")
|
||||
}
|
||||
|
||||
if imageRequest.Model == "" {
|
||||
imageRequest.Model = "dall-e-2"
|
||||
}
|
||||
if strings.Contains(imageRequest.Size, "×") {
|
||||
return nil, errors.New("size an unexpected error occurred in the parameter, please use 'x' instead of the multiplication sign '×'")
|
||||
}
|
||||
if imageRequest.N == 0 {
|
||||
imageRequest.N = 1
|
||||
}
|
||||
if imageRequest.Size == "" {
|
||||
imageRequest.Size = "1024x1024"
|
||||
}
|
||||
|
||||
err := common.UnmarshalBodyReusable(c, imageRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if imageRequest.Prompt == "" {
|
||||
return nil, errors.New("prompt is required")
|
||||
}
|
||||
if strings.Contains(imageRequest.Size, "×") {
|
||||
return nil, errors.New("size an unexpected error occurred in the parameter, please use 'x' instead of the multiplication sign '×'")
|
||||
}
|
||||
if imageRequest.N == 0 {
|
||||
imageRequest.N = 1
|
||||
}
|
||||
if imageRequest.Size == "" {
|
||||
imageRequest.Size = "1024x1024"
|
||||
}
|
||||
if imageRequest.Model == "" {
|
||||
imageRequest.Model = "dall-e-2"
|
||||
}
|
||||
// x.ai grok-2-image not support size, quality or style
|
||||
if imageRequest.Size == "empty" {
|
||||
imageRequest.Size = ""
|
||||
}
|
||||
|
||||
// Not "256x256", "512x512", or "1024x1024"
|
||||
if imageRequest.Model == "dall-e-2" || imageRequest.Model == "dall-e" {
|
||||
if imageRequest.Size != "" && imageRequest.Size != "256x256" && imageRequest.Size != "512x512" && imageRequest.Size != "1024x1024" {
|
||||
return nil, errors.New("size must be one of 256x256, 512x512, or 1024x1024, dall-e-3 1024x1792 or 1792x1024")
|
||||
}
|
||||
} else if imageRequest.Model == "dall-e-3" {
|
||||
if imageRequest.Size != "" && imageRequest.Size != "1024x1024" && imageRequest.Size != "1024x1792" && imageRequest.Size != "1792x1024" {
|
||||
return nil, errors.New("size must be one of 256x256, 512x512, or 1024x1024, dall-e-3 1024x1792 or 1792x1024")
|
||||
}
|
||||
if imageRequest.Quality == "" {
|
||||
imageRequest.Quality = "standard"
|
||||
}
|
||||
//if imageRequest.N != 1 {
|
||||
// return nil, errors.New("n must be 1")
|
||||
//}
|
||||
}
|
||||
// N should between 1 and 10
|
||||
//if imageRequest.N != 0 && (imageRequest.N < 1 || imageRequest.N > 10) {
|
||||
// return service.OpenAIErrorWrapper(errors.New("n must be between 1 and 10"), "invalid_field_value", http.StatusBadRequest)
|
||||
//}
|
||||
if setting.ShouldCheckPromptSensitive() {
|
||||
words, err := service.CheckSensitiveInput(imageRequest.Prompt)
|
||||
if err != nil {
|
||||
@@ -229,6 +194,10 @@ func ImageHelper(c *gin.Context) *dto.OpenAIErrorWithStatusCode {
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println(fmt.Sprintf("image request body: %s", requestBody))
|
||||
}
|
||||
|
||||
statusCodeMappingStr := c.GetString("status_code_mapping")
|
||||
|
||||
resp, err := adaptor.DoRequest(c, relayInfo, requestBody)
|
||||
|
||||
@@ -352,6 +352,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
promptTokens := usage.PromptTokens
|
||||
cacheTokens := usage.PromptTokensDetails.CachedTokens
|
||||
imageTokens := usage.PromptTokensDetails.ImageTokens
|
||||
audioTokens := usage.PromptTokensDetails.AudioTokens
|
||||
completionTokens := usage.CompletionTokens
|
||||
modelName := relayInfo.OriginModelName
|
||||
|
||||
@@ -367,6 +368,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
dPromptTokens := decimal.NewFromInt(int64(promptTokens))
|
||||
dCacheTokens := decimal.NewFromInt(int64(cacheTokens))
|
||||
dImageTokens := decimal.NewFromInt(int64(imageTokens))
|
||||
dAudioTokens := decimal.NewFromInt(int64(audioTokens))
|
||||
dCompletionTokens := decimal.NewFromInt(int64(completionTokens))
|
||||
dCompletionRatio := decimal.NewFromFloat(completionRatio)
|
||||
dCacheRatio := decimal.NewFromFloat(cacheRatio)
|
||||
@@ -412,23 +414,43 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice).
|
||||
Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))).
|
||||
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
|
||||
extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 $%s",
|
||||
extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 %s",
|
||||
fileSearchTool.CallCount, dFileSearchQuota.String())
|
||||
}
|
||||
}
|
||||
|
||||
var quotaCalculateDecimal decimal.Decimal
|
||||
if !priceData.UsePrice {
|
||||
nonCachedTokens := dPromptTokens.Sub(dCacheTokens)
|
||||
cachedTokensWithRatio := dCacheTokens.Mul(dCacheRatio)
|
||||
|
||||
promptQuota := nonCachedTokens.Add(cachedTokensWithRatio)
|
||||
if imageTokens > 0 {
|
||||
nonImageTokens := dPromptTokens.Sub(dImageTokens)
|
||||
imageTokensWithRatio := dImageTokens.Mul(dImageRatio)
|
||||
promptQuota = nonImageTokens.Add(imageTokensWithRatio)
|
||||
var audioInputQuota decimal.Decimal
|
||||
var audioInputPrice float64
|
||||
if !priceData.UsePrice {
|
||||
baseTokens := dPromptTokens
|
||||
// 减去 cached tokens
|
||||
var cachedTokensWithRatio decimal.Decimal
|
||||
if !dCacheTokens.IsZero() {
|
||||
baseTokens = baseTokens.Sub(dCacheTokens)
|
||||
cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio)
|
||||
}
|
||||
|
||||
// 减去 image tokens
|
||||
var imageTokensWithRatio decimal.Decimal
|
||||
if !dImageTokens.IsZero() {
|
||||
baseTokens = baseTokens.Sub(dImageTokens)
|
||||
imageTokensWithRatio = dImageTokens.Mul(dImageRatio)
|
||||
}
|
||||
|
||||
// 减去 Gemini audio tokens
|
||||
if !dAudioTokens.IsZero() {
|
||||
audioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(modelName)
|
||||
if audioInputPrice > 0 {
|
||||
// 重新计算 base tokens
|
||||
baseTokens = baseTokens.Sub(dAudioTokens)
|
||||
audioInputQuota = decimal.NewFromFloat(audioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit)
|
||||
extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String())
|
||||
}
|
||||
}
|
||||
promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio)
|
||||
|
||||
completionQuota := dCompletionTokens.Mul(dCompletionRatio)
|
||||
|
||||
quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio)
|
||||
@@ -442,6 +464,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
// 添加 responses tools call 调用的配额
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
|
||||
// 添加 audio input 独立计费
|
||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
|
||||
|
||||
quota := int(quotaCalculateDecimal.Round(0).IntPart())
|
||||
totalTokens := promptTokens + completionTokens
|
||||
@@ -512,6 +536,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
other["file_search_price"] = fileSearchPrice
|
||||
}
|
||||
}
|
||||
if !audioInputQuota.IsZero() {
|
||||
other["audio_input_seperate_price"] = true
|
||||
other["audio_input_token_count"] = audioTokens
|
||||
other["audio_input_price"] = audioInputPrice
|
||||
}
|
||||
model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel,
|
||||
tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,14 @@ func SetRelayRouter(router *gin.Engine) {
|
||||
relaySunoRouter.GET("/fetch/:id", controller.RelayTask)
|
||||
}
|
||||
|
||||
relayGeminiRouter := router.Group("/v1beta")
|
||||
relayGeminiRouter.Use(middleware.TokenAuth())
|
||||
relayGeminiRouter.Use(middleware.ModelRequestRateLimit())
|
||||
relayGeminiRouter.Use(middleware.Distribute())
|
||||
{
|
||||
// Gemini API 路径格式: /v1beta/models/{model_name}:{action}
|
||||
relayGeminiRouter.POST("/models/*path", controller.Relay)
|
||||
}
|
||||
}
|
||||
|
||||
func registerMjRouterGroup(relayMjRouter *gin.RouterGroup) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func parseAudio(audioBase64 string, format string) (duration float64, err error) {
|
||||
@@ -29,3 +30,19 @@ func parseAudio(audioBase64 string, format string) (duration float64, err error)
|
||||
duration = float64(samplesCount) / float64(sampleRate)
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
func DecodeBase64AudioData(audioBase64 string) (string, error) {
|
||||
// 检查并移除 data:audio/xxx;base64, 前缀
|
||||
idx := strings.Index(audioBase64, ",")
|
||||
if idx != -1 {
|
||||
audioBase64 = audioBase64[idx+1:]
|
||||
}
|
||||
|
||||
// 解码 Base64 数据
|
||||
_, err := base64.StdEncoding.DecodeString(audioBase64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("base64 decode error: %v", err)
|
||||
}
|
||||
|
||||
return audioBase64, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"one-api/relay/channel/openrouter"
|
||||
relaycommon "one-api/relay/common"
|
||||
"strings"
|
||||
)
|
||||
@@ -18,10 +19,24 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re
|
||||
Stream: claudeRequest.Stream,
|
||||
}
|
||||
|
||||
isOpenRouter := info.ChannelType == common.ChannelTypeOpenRouter
|
||||
|
||||
if claudeRequest.Thinking != nil {
|
||||
if strings.HasSuffix(info.OriginModelName, "-thinking") &&
|
||||
!strings.HasSuffix(claudeRequest.Model, "-thinking") {
|
||||
openAIRequest.Model = openAIRequest.Model + "-thinking"
|
||||
if isOpenRouter {
|
||||
reasoning := openrouter.RequestReasoning{
|
||||
MaxTokens: claudeRequest.Thinking.BudgetTokens,
|
||||
}
|
||||
reasoningJSON, err := json.Marshal(reasoning)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
|
||||
}
|
||||
openAIRequest.Reasoning = reasoningJSON
|
||||
} else {
|
||||
thinkingSuffix := "-thinking"
|
||||
if strings.HasSuffix(info.OriginModelName, thinkingSuffix) &&
|
||||
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
|
||||
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,16 +77,30 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re
|
||||
} else {
|
||||
systems := claudeRequest.ParseSystem()
|
||||
if len(systems) > 0 {
|
||||
systemStr := ""
|
||||
openAIMessage := dto.Message{
|
||||
Role: "system",
|
||||
}
|
||||
for _, system := range systems {
|
||||
if system.Text != nil {
|
||||
systemStr += *system.Text
|
||||
isOpenRouterClaude := isOpenRouter && strings.HasPrefix(info.UpstreamModelName, "anthropic/claude")
|
||||
if isOpenRouterClaude {
|
||||
systemMediaMessages := make([]dto.MediaContent, 0, len(systems))
|
||||
for _, system := range systems {
|
||||
message := dto.MediaContent{
|
||||
Type: "text",
|
||||
Text: system.GetText(),
|
||||
CacheControl: system.CacheControl,
|
||||
}
|
||||
systemMediaMessages = append(systemMediaMessages, message)
|
||||
}
|
||||
openAIMessage.SetMediaContent(systemMediaMessages)
|
||||
} else {
|
||||
systemStr := ""
|
||||
for _, system := range systems {
|
||||
if system.Text != nil {
|
||||
systemStr += *system.Text
|
||||
}
|
||||
}
|
||||
openAIMessage.SetStringContent(systemStr)
|
||||
}
|
||||
openAIMessage.SetStringContent(systemStr)
|
||||
openAIMessages = append(openAIMessages, openAIMessage)
|
||||
}
|
||||
}
|
||||
@@ -97,8 +126,9 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re
|
||||
switch mediaMsg.Type {
|
||||
case "text":
|
||||
message := dto.MediaContent{
|
||||
Type: "text",
|
||||
Text: mediaMsg.GetText(),
|
||||
Type: "text",
|
||||
Text: mediaMsg.GetText(),
|
||||
CacheControl: mediaMsg.CacheControl,
|
||||
}
|
||||
mediaMessages = append(mediaMessages, message)
|
||||
case "image":
|
||||
|
||||
@@ -14,6 +14,13 @@ const (
|
||||
FileSearchPrice = 2.5
|
||||
)
|
||||
|
||||
const (
|
||||
// Gemini Audio Input Price
|
||||
Gemini25FlashPreviewInputAudioPrice = 1.00
|
||||
Gemini25FlashNativeAudioInputAudioPrice = 3.00
|
||||
Gemini20FlashInputAudioPrice = 0.70
|
||||
)
|
||||
|
||||
func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 {
|
||||
// 确定模型类型
|
||||
// https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费
|
||||
@@ -55,3 +62,14 @@ func GetWebSearchPricePerThousand(modelName string, contextSize string) float64
|
||||
func GetFileSearchPricePerThousand() float64 {
|
||||
return FileSearchPrice
|
||||
}
|
||||
|
||||
func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
|
||||
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") {
|
||||
return Gemini25FlashPreviewInputAudioPrice
|
||||
} else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") {
|
||||
return Gemini25FlashNativeAudioInputAudioPrice
|
||||
} else if strings.HasPrefix(modelName, "gemini-2.0-flash") {
|
||||
return Gemini20FlashInputAudioPrice
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -6,27 +6,42 @@
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.63.1",
|
||||
"@douyinfe/semi-ui": "^2.69.1",
|
||||
"@lobehub/icons": "^2.0.0",
|
||||
"@visactor/react-vchart": "~1.8.8",
|
||||
"@visactor/vchart": "~1.8.8",
|
||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||
"axios": "^0.27.2",
|
||||
"clsx": "^2.1.1",
|
||||
"country-flag-icons": "^1.5.19",
|
||||
"dayjs": "^1.11.11",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^7.2.0",
|
||||
"katex": "^0.16.22",
|
||||
"lucide-react": "^0.511.0",
|
||||
"marked": "^4.1.1",
|
||||
"mermaid": "^11.6.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-fireworks": "^1.0.4",
|
||||
"react-i18next": "^13.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-telegram-login": "^1.1.2",
|
||||
"react-toastify": "^9.0.8",
|
||||
"react-turnstile": "^1.0.5",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"semantic-ui-offline": "^2.5.0",
|
||||
"semantic-ui-react": "^2.1.3",
|
||||
"sse": "https://github.com/mpetazzoni/sse.js",
|
||||
"i18next": "^23.16.8",
|
||||
"react-i18next": "^13.0.0",
|
||||
"i18next-browser-languagedetector": "^7.2.0"
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-debounce": "^10.0.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -54,9 +69,13 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@douyinfe/semi-webpack-plugin": "^2.78.0",
|
||||
"@so1ve/prettier-config": "^3.1.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.0.0",
|
||||
"tailwindcss": "^3",
|
||||
"typescript": "4.4.2",
|
||||
"vite": "^5.2.0"
|
||||
},
|
||||
|
||||
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
web/public/example.png
Normal file
BIN
web/public/example.png
Normal file
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 |
@@ -1,15 +1,15 @@
|
||||
import React, { lazy, Suspense, useContext, useEffect } from 'react';
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||
import Loading from './components/Loading';
|
||||
import Loading from './components/common/Loading.js';
|
||||
import User from './pages/User';
|
||||
import { PrivateRoute } from './components/PrivateRoute';
|
||||
import RegisterForm from './components/RegisterForm';
|
||||
import LoginForm from './components/LoginForm';
|
||||
import { AuthRedirect, PrivateRoute } from './helpers';
|
||||
import RegisterForm from './components/auth/RegisterForm.js';
|
||||
import LoginForm from './components/auth/LoginForm.js';
|
||||
import NotFound from './pages/NotFound';
|
||||
import Setting from './pages/Setting';
|
||||
import EditUser from './pages/User/EditUser';
|
||||
import PasswordResetForm from './components/PasswordResetForm';
|
||||
import PasswordResetConfirm from './components/PasswordResetConfirm';
|
||||
import PasswordResetForm from './components/auth/PasswordResetForm.js';
|
||||
import PasswordResetConfirm from './components/auth/PasswordResetConfirm.js';
|
||||
import Channel from './pages/Channel';
|
||||
import Token from './pages/Token';
|
||||
import EditChannel from './pages/Channel/EditChannel';
|
||||
@@ -18,15 +18,14 @@ import TopUp from './pages/TopUp';
|
||||
import Log from './pages/Log';
|
||||
import Chat from './pages/Chat';
|
||||
import Chat2Link from './pages/Chat2Link';
|
||||
import { Layout } from '@douyinfe/semi-ui';
|
||||
import Midjourney from './pages/Midjourney';
|
||||
import Pricing from './pages/Pricing/index.js';
|
||||
import Task from './pages/Task/index.js';
|
||||
import Playground from './pages/Playground/Playground.js';
|
||||
import OAuth2Callback from './components/OAuth2Callback.js';
|
||||
import PersonalSetting from './components/PersonalSetting.js';
|
||||
import Playground from './pages/Playground/index.js';
|
||||
import OAuth2Callback from './components/auth/OAuth2Callback.js';
|
||||
import PersonalSetting from './components/settings/PersonalSetting.js';
|
||||
import Setup from './pages/Setup/index.js';
|
||||
import SetupCheck from './components/SetupCheck';
|
||||
import SetupCheck from './components/layout/SetupCheck.js';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Detail = lazy(() => import('./pages/Detail'));
|
||||
@@ -55,7 +54,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/channel'
|
||||
path='/console/channel'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Channel />
|
||||
@@ -63,7 +62,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/channel/edit/:id'
|
||||
path='/console/channel/edit/:id'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<EditChannel />
|
||||
@@ -71,7 +70,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/channel/add'
|
||||
path='/console/channel/add'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<EditChannel />
|
||||
@@ -79,7 +78,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/token'
|
||||
path='/console/token'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Token />
|
||||
@@ -87,7 +86,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/playground'
|
||||
path='/console/playground'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Playground />
|
||||
@@ -95,7 +94,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/redemption'
|
||||
path='/console/redemption'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Redemption />
|
||||
@@ -103,7 +102,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/user'
|
||||
path='/console/user'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<User />
|
||||
@@ -111,7 +110,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/user/edit/:id'
|
||||
path='/console/user/edit/:id'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<EditUser />
|
||||
@@ -119,7 +118,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/user/edit'
|
||||
path='/console/user/edit'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<EditUser />
|
||||
@@ -138,7 +137,9 @@ function App() {
|
||||
path='/login'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<LoginForm />
|
||||
<AuthRedirect>
|
||||
<LoginForm />
|
||||
</AuthRedirect>
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
@@ -146,7 +147,9 @@ function App() {
|
||||
path='/register'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<RegisterForm />
|
||||
<AuthRedirect>
|
||||
<RegisterForm />
|
||||
</AuthRedirect>
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
@@ -183,7 +186,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/setting'
|
||||
path='/console/setting'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
@@ -193,7 +196,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/personal'
|
||||
path='/console/personal'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
@@ -203,7 +206,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/topup'
|
||||
path='/console/topup'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
@@ -213,7 +216,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/log'
|
||||
path='/console/log'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Log />
|
||||
@@ -221,7 +224,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/detail'
|
||||
path='/console'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
@@ -231,7 +234,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/midjourney'
|
||||
path='/console/midjourney'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
@@ -241,7 +244,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/task'
|
||||
path='/console/task'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
@@ -267,7 +270,7 @@ function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/chat/:id?'
|
||||
path='/console/chat/:id?'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<Chat />
|
||||
|
||||
@@ -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;
|
||||
536
web/src/components/auth/LoginForm.js
Normal file
536
web/src/components/auth/LoginForm.js
Normal file
@@ -0,0 +1,536 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import {
|
||||
API,
|
||||
getLogo,
|
||||
showError,
|
||||
showInfo,
|
||||
showSuccess,
|
||||
updateAPI,
|
||||
getSystemName,
|
||||
setUserData,
|
||||
onGitHubOAuthClicked,
|
||||
onOIDCClicked,
|
||||
onLinuxDOOAuthClicked
|
||||
} from '../../helpers/index.js';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Form,
|
||||
Icon,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import TelegramLoginButton from 'react-telegram-login';
|
||||
|
||||
import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons';
|
||||
import OIDCIcon from '../common/logo/OIDCIcon.js';
|
||||
import WeChatIcon from '../common/logo/WeChatIcon.js';
|
||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Background from '/example.png';
|
||||
|
||||
const LoginForm = () => {
|
||||
const [inputs, setInputs] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
wechat_verification_code: '',
|
||||
});
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const { username, password } = inputs;
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
let navigate = useNavigate();
|
||||
const [status, setStatus] = useState({});
|
||||
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
|
||||
const [showEmailLogin, setShowEmailLogin] = useState(false);
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [githubLoading, setGithubLoading] = useState(false);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
const [linuxdoLoading, setLinuxdoLoading] = useState(false);
|
||||
const [emailLoginLoading, setEmailLoginLoading] = useState(false);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
||||
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
|
||||
let affCode = new URLSearchParams(window.location.search).get('aff');
|
||||
if (affCode) {
|
||||
localStorage.setItem('aff', affCode);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('expired')) {
|
||||
showError(t('未登录或登录已过期,请重新登录'));
|
||||
}
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
setStatus(status);
|
||||
if (status.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onWeChatLoginClicked = () => {
|
||||
setWechatLoading(true);
|
||||
setShowWeChatLoginModal(true);
|
||||
setWechatLoading(false);
|
||||
};
|
||||
|
||||
const onSubmitWeChatVerificationCode = async () => {
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
}
|
||||
setWechatCodeSubmitLoading(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
navigate('/');
|
||||
showSuccess('登录成功!');
|
||||
setShowWeChatLoginModal(false);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('登录失败,请重试');
|
||||
} finally {
|
||||
setWechatCodeSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
function handleChange(name, value) {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
}
|
||||
setSubmitted(true);
|
||||
setLoginLoading(true);
|
||||
try {
|
||||
if (username && password) {
|
||||
const res = await API.post(
|
||||
`/api/user/login?turnstile=${turnstileToken}`,
|
||||
{
|
||||
username,
|
||||
password,
|
||||
},
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
showSuccess('登录成功!');
|
||||
if (username === 'root' && password === '123456') {
|
||||
Modal.error({
|
||||
title: '您正在使用默认密码!',
|
||||
content: '请立刻修改默认密码!',
|
||||
centered: true,
|
||||
});
|
||||
}
|
||||
navigate('/console');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} else {
|
||||
showError('请输入用户名和密码!');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('登录失败,请重试');
|
||||
} finally {
|
||||
setLoginLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加Telegram登录处理函数
|
||||
const onTelegramLoginClicked = async (response) => {
|
||||
const fields = [
|
||||
'id',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'username',
|
||||
'photo_url',
|
||||
'auth_date',
|
||||
'hash',
|
||||
'lang',
|
||||
];
|
||||
const params = {};
|
||||
fields.forEach((field) => {
|
||||
if (response[field]) {
|
||||
params[field] = response[field];
|
||||
}
|
||||
});
|
||||
try {
|
||||
const res = await API.get(`/api/oauth/telegram/login`, { params });
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
showSuccess('登录成功!');
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
navigate('/');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('登录失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 包装的GitHub登录点击处理
|
||||
const handleGitHubClick = () => {
|
||||
setGithubLoading(true);
|
||||
try {
|
||||
onGitHubOAuthClicked(status.github_client_id);
|
||||
} finally {
|
||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
||||
setTimeout(() => setGithubLoading(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// 包装的OIDC登录点击处理
|
||||
const handleOIDCClick = () => {
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
onOIDCClicked(
|
||||
status.oidc_authorization_endpoint,
|
||||
status.oidc_client_id
|
||||
);
|
||||
} finally {
|
||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
||||
setTimeout(() => setOidcLoading(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// 包装的LinuxDO登录点击处理
|
||||
const handleLinuxDOClick = () => {
|
||||
setLinuxdoLoading(true);
|
||||
try {
|
||||
onLinuxDOOAuthClicked(status.linuxdo_client_id);
|
||||
} finally {
|
||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
||||
setTimeout(() => setLinuxdoLoading(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// 包装的邮箱登录选项点击处理
|
||||
const handleEmailLoginClick = () => {
|
||||
setEmailLoginLoading(true);
|
||||
setShowEmailLogin(true);
|
||||
setEmailLoginLoading(false);
|
||||
};
|
||||
|
||||
// 包装的重置密码点击处理
|
||||
const handleResetPasswordClick = () => {
|
||||
setResetPasswordLoading(true);
|
||||
navigate('/reset');
|
||||
setResetPasswordLoading(false);
|
||||
};
|
||||
|
||||
// 包装的其他登录选项点击处理
|
||||
const handleOtherLoginOptionsClick = () => {
|
||||
setOtherLoginOptionsLoading(true);
|
||||
setShowEmailLogin(false);
|
||||
setOtherLoginOptionsLoading(false);
|
||||
};
|
||||
|
||||
const renderOAuthOptions = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-white'>{systemName}</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<div className="space-y-3">
|
||||
{status.wechat_login && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />}
|
||||
size="large"
|
||||
onClick={onWeChatLoginClicked}
|
||||
loading={wechatLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 微信 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.github_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<IconGithubLogo size="large" />}
|
||||
size="large"
|
||||
onClick={handleGitHubClick}
|
||||
loading={githubLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 GitHub 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.oidc_enabled && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
|
||||
size="large"
|
||||
onClick={handleOIDCClick}
|
||||
loading={oidcLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 OIDC 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.linuxdo_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />}
|
||||
size="large"
|
||||
onClick={handleLinuxDOClick}
|
||||
loading={linuxdoLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 LinuxDO 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.telegram_oauth && (
|
||||
<div className="flex justify-center my-2">
|
||||
<TelegramLoginButton
|
||||
dataOnauth={onTelegramLoginClicked}
|
||||
botName={status.telegram_bot_name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors"
|
||||
icon={<IconMail size="large" />}
|
||||
size="large"
|
||||
onClick={handleEmailLoginClick}
|
||||
loading={emailLoginLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 邮箱或用户名 登录')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>{t('没有账户?')} <Link to="/register" className="text-blue-600 hover:text-blue-800 font-medium">{t('注册')}</Link></Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmailLoginForm = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3}>{systemName}</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<Form className="space-y-3">
|
||||
<Form.Input
|
||||
field="username"
|
||||
label={t('用户名或邮箱')}
|
||||
placeholder={t('请输入您的用户名或邮箱地址')}
|
||||
name="username"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
onChange={(value) => handleChange('username', value)}
|
||||
prefix={<IconMail />}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field="password"
|
||||
label={t('密码')}
|
||||
placeholder={t('请输入您的密码')}
|
||||
name="password"
|
||||
mode="password"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
onChange={(value) => handleChange('password', value)}
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<Button
|
||||
theme="solid"
|
||||
className="w-full !rounded-full"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
onClick={handleSubmit}
|
||||
loading={loginLoading}
|
||||
>
|
||||
{t('继续')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
theme="borderless"
|
||||
type='tertiary'
|
||||
className="w-full !rounded-full"
|
||||
size="large"
|
||||
onClick={handleResetPasswordClick}
|
||||
loading={resetPasswordLoading}
|
||||
>
|
||||
{t('忘记密码?')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
|
||||
<>
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
theme="outline"
|
||||
type="tertiary"
|
||||
className="w-full !rounded-full"
|
||||
size="large"
|
||||
onClick={handleOtherLoginOptionsClick}
|
||||
loading={otherLoginOptionsLoading}
|
||||
>
|
||||
{t('其他登录选项')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>{t('没有账户?')} <Link to="/register" className="text-blue-600 hover:text-blue-800 font-medium">{t('注册')}</Link></Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 微信登录模态框
|
||||
const renderWeChatLoginModal = () => {
|
||||
return (
|
||||
<Modal
|
||||
title={t('微信扫码登录')}
|
||||
visible={showWeChatLoginModal}
|
||||
maskClosable={true}
|
||||
onOk={onSubmitWeChatVerificationCode}
|
||||
onCancel={() => setShowWeChatLoginModal(false)}
|
||||
okText={t('登录')}
|
||||
size="small"
|
||||
centered={true}
|
||||
okButtonProps={{
|
||||
loading: wechatCodeSubmitLoading,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" />
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-4">
|
||||
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
|
||||
</div>
|
||||
|
||||
<Form size="large">
|
||||
<Form.Input
|
||||
field="wechat_verification_code"
|
||||
placeholder={t('验证码')}
|
||||
label={t('验证码')}
|
||||
value={inputs.wechat_verification_code}
|
||||
onChange={(value) => handleChange('wechat_verification_code', value)}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
|
||||
{/* 背景图片容器 - 放大并保持居中 */}
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
|
||||
style={{
|
||||
backgroundImage: `url(${Background})`
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* 半透明遮罩层 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
|
||||
|
||||
<div className="w-full max-w-sm relative z-10">
|
||||
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
|
||||
? renderEmailLoginForm()
|
||||
: renderOAuthOptions()}
|
||||
{renderWeChatLoginModal()}
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Dimmer, Loader, Segment } from 'semantic-ui-react';
|
||||
import { Spin, Typography, Space } from '@douyinfe/semi-ui';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { API, showError, showSuccess, updateAPI } from '../helpers';
|
||||
import { UserContext } from '../context/User';
|
||||
import { setUserData } from '../helpers/data.js';
|
||||
import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
|
||||
const OAuth2Callback = (props) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -52,11 +51,15 @@ const OAuth2Callback = (props) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Segment style={{ minHeight: '300px' }}>
|
||||
<Dimmer active inverted>
|
||||
<Loader size='large'>{prompt}</Loader>
|
||||
</Dimmer>
|
||||
</Segment>
|
||||
<div className="flex items-center justify-center min-h-[300px] w-full bg-white rounded-lg shadow p-6">
|
||||
<Space vertical align="center">
|
||||
<Spin size="large" spinning={processing}>
|
||||
<div className="min-h-[200px] min-w-[200px] flex items-center justify-center">
|
||||
<Typography.Text type="secondary">{prompt}</Typography.Text>
|
||||
</div>
|
||||
</Spin>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
155
web/src/components/auth/PasswordResetConfirm.js
Normal file
155
web/src/components/auth/PasswordResetConfirm.js
Normal file
@@ -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;
|
||||
156
web/src/components/auth/PasswordResetForm.js
Normal file
156
web/src/components/auth/PasswordResetForm.js
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
|
||||
import { IconMail } from '@douyinfe/semi-icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Background from '/example.png';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const PasswordResetForm = () => {
|
||||
const { t } = useTranslation();
|
||||
const [inputs, setInputs] = useState({
|
||||
email: '',
|
||||
});
|
||||
const { email } = inputs;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
|
||||
useEffect(() => {
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
if (status.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let countdownInterval = null;
|
||||
if (disableButton && countdown > 0) {
|
||||
countdownInterval = setInterval(() => {
|
||||
setCountdown(countdown - 1);
|
||||
}, 1000);
|
||||
} else if (countdown === 0) {
|
||||
setDisableButton(false);
|
||||
setCountdown(30);
|
||||
}
|
||||
return () => clearInterval(countdownInterval);
|
||||
}, [disableButton, countdown]);
|
||||
|
||||
function handleChange(value) {
|
||||
setInputs((inputs) => ({ ...inputs, email: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
if (!email) return;
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!'));
|
||||
return;
|
||||
}
|
||||
setDisableButton(true);
|
||||
setLoading(true);
|
||||
const res = await API.get(
|
||||
`/api/reset_password?email=${email}&turnstile=${turnstileToken}`,
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('重置邮件发送成功,请检查邮箱!'));
|
||||
setInputs({ ...inputs, email: '' });
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
|
||||
{/* 背景图片容器 - 放大并保持居中 */}
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
|
||||
style={{
|
||||
backgroundImage: `url(${Background})`
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* 半透明遮罩层 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
|
||||
|
||||
<div className="w-full max-w-sm relative z-10">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-white'>{systemName}</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置')}</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<Form className="space-y-3">
|
||||
<Form.Input
|
||||
field="email"
|
||||
label={t('邮箱')}
|
||||
placeholder={t('请输入您的邮箱地址')}
|
||||
name="email"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
value={email}
|
||||
onChange={handleChange}
|
||||
prefix={<IconMail />}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<Button
|
||||
theme="solid"
|
||||
className="w-full !rounded-full"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={disableButton}
|
||||
>
|
||||
{disableButton ? `${t('重试')} (${countdown})` : t('提交')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>{t('想起来了?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordResetForm;
|
||||
576
web/src/components/auth/RegisterForm.js
Normal file
576
web/src/components/auth/RegisterForm.js
Normal file
@@ -0,0 +1,576 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
API,
|
||||
getLogo,
|
||||
showError,
|
||||
showInfo,
|
||||
showSuccess,
|
||||
updateAPI,
|
||||
getSystemName,
|
||||
setUserData
|
||||
} from '../../helpers/index.js';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Form,
|
||||
Icon,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import { IconGithubLogo, IconMail, IconUser, IconLock, IconKey } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
onGitHubOAuthClicked,
|
||||
onLinuxDOOAuthClicked,
|
||||
onOIDCClicked,
|
||||
} from '../../helpers/index.js';
|
||||
import OIDCIcon from '../common/logo/OIDCIcon.js';
|
||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
|
||||
import WeChatIcon from '../common/logo/WeChatIcon.js';
|
||||
import TelegramLoginButton from 'react-telegram-login/src';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Background from '/example.png';
|
||||
|
||||
const RegisterForm = () => {
|
||||
const { t } = useTranslation();
|
||||
const [inputs, setInputs] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
password2: '',
|
||||
email: '',
|
||||
verification_code: '',
|
||||
wechat_verification_code: '',
|
||||
});
|
||||
const { username, password, password2 } = inputs;
|
||||
const [showEmailVerification, setShowEmailVerification] = useState(false);
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
|
||||
const [showEmailRegister, setShowEmailRegister] = useState(false);
|
||||
const [status, setStatus] = useState({});
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [githubLoading, setGithubLoading] = useState(false);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
const [linuxdoLoading, setLinuxdoLoading] = useState(false);
|
||||
const [emailRegisterLoading, setEmailRegisterLoading] = useState(false);
|
||||
const [registerLoading, setRegisterLoading] = useState(false);
|
||||
const [verificationCodeLoading, setVerificationCodeLoading] = useState(false);
|
||||
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
let navigate = useNavigate();
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
|
||||
let affCode = new URLSearchParams(window.location.search).get('aff');
|
||||
if (affCode) {
|
||||
localStorage.setItem('aff', affCode);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
setStatus(status);
|
||||
setShowEmailVerification(status.email_verification);
|
||||
if (status.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onWeChatLoginClicked = () => {
|
||||
setWechatLoading(true);
|
||||
setShowWeChatLoginModal(true);
|
||||
setWechatLoading(false);
|
||||
};
|
||||
|
||||
const onSubmitWeChatVerificationCode = async () => {
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
}
|
||||
setWechatCodeSubmitLoading(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
navigate('/');
|
||||
showSuccess('登录成功!');
|
||||
setShowWeChatLoginModal(false);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('登录失败,请重试');
|
||||
} finally {
|
||||
setWechatCodeSubmitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
function handleChange(name, value) {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
if (password.length < 8) {
|
||||
showInfo('密码长度不得小于 8 位!');
|
||||
return;
|
||||
}
|
||||
if (password !== password2) {
|
||||
showInfo('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
if (username && password) {
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
}
|
||||
setRegisterLoading(true);
|
||||
try {
|
||||
if (!affCode) {
|
||||
affCode = localStorage.getItem('aff');
|
||||
}
|
||||
inputs.aff_code = affCode;
|
||||
const res = await API.post(
|
||||
`/api/user/register?turnstile=${turnstileToken}`,
|
||||
inputs,
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
navigate('/login');
|
||||
showSuccess('注册成功!');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('注册失败,请重试');
|
||||
} finally {
|
||||
setRegisterLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sendVerificationCode = async () => {
|
||||
if (inputs.email === '') return;
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
||||
return;
|
||||
}
|
||||
setVerificationCodeLoading(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('验证码发送成功,请检查你的邮箱!');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('发送验证码失败,请重试');
|
||||
} finally {
|
||||
setVerificationCodeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitHubClick = () => {
|
||||
setGithubLoading(true);
|
||||
try {
|
||||
onGitHubOAuthClicked(status.github_client_id);
|
||||
} finally {
|
||||
setTimeout(() => setGithubLoading(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOIDCClick = () => {
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
onOIDCClicked(
|
||||
status.oidc_authorization_endpoint,
|
||||
status.oidc_client_id
|
||||
);
|
||||
} finally {
|
||||
setTimeout(() => setOidcLoading(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinuxDOClick = () => {
|
||||
setLinuxdoLoading(true);
|
||||
try {
|
||||
onLinuxDOOAuthClicked(status.linuxdo_client_id);
|
||||
} finally {
|
||||
setTimeout(() => setLinuxdoLoading(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailRegisterClick = () => {
|
||||
setEmailRegisterLoading(true);
|
||||
setShowEmailRegister(true);
|
||||
setEmailRegisterLoading(false);
|
||||
};
|
||||
|
||||
const handleOtherRegisterOptionsClick = () => {
|
||||
setOtherRegisterOptionsLoading(true);
|
||||
setShowEmailRegister(false);
|
||||
setOtherRegisterOptionsLoading(false);
|
||||
};
|
||||
|
||||
const onTelegramLoginClicked = async (response) => {
|
||||
const fields = [
|
||||
'id',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'username',
|
||||
'photo_url',
|
||||
'auth_date',
|
||||
'hash',
|
||||
'lang',
|
||||
];
|
||||
const params = {};
|
||||
fields.forEach((field) => {
|
||||
if (response[field]) {
|
||||
params[field] = response[field];
|
||||
}
|
||||
});
|
||||
try {
|
||||
const res = await API.get(`/api/oauth/telegram/login`, { params });
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
showSuccess('登录成功!');
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
navigate('/');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('登录失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const renderOAuthOptions = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-white'>{systemName}</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<div className="space-y-3">
|
||||
{status.wechat_login && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />}
|
||||
size="large"
|
||||
onClick={onWeChatLoginClicked}
|
||||
loading={wechatLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 微信 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.github_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<IconGithubLogo size="large" />}
|
||||
size="large"
|
||||
onClick={handleGitHubClick}
|
||||
loading={githubLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 GitHub 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.oidc_enabled && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
|
||||
size="large"
|
||||
onClick={handleOIDCClick}
|
||||
loading={oidcLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 OIDC 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.linuxdo_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />}
|
||||
size="large"
|
||||
onClick={handleLinuxDOClick}
|
||||
loading={linuxdoLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 LinuxDO 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.telegram_oauth && (
|
||||
<div className="flex justify-center my-2">
|
||||
<TelegramLoginButton
|
||||
dataOnauth={onTelegramLoginClicked}
|
||||
botName={status.telegram_bot_name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors"
|
||||
icon={<IconMail size="large" />}
|
||||
size="large"
|
||||
onClick={handleEmailRegisterClick}
|
||||
loading={emailRegisterLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 用户名 注册')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmailRegisterForm = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-white'>{systemName}</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<Form className="space-y-3">
|
||||
<Form.Input
|
||||
field="username"
|
||||
label={t('用户名')}
|
||||
placeholder={t('请输入用户名')}
|
||||
name="username"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
onChange={(value) => handleChange('username', value)}
|
||||
prefix={<IconUser />}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field="password"
|
||||
label={t('密码')}
|
||||
placeholder={t('输入密码,最短 8 位,最长 20 位')}
|
||||
name="password"
|
||||
mode="password"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
onChange={(value) => handleChange('password', value)}
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field="password2"
|
||||
label={t('确认密码')}
|
||||
placeholder={t('确认密码')}
|
||||
name="password2"
|
||||
mode="password"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
onChange={(value) => handleChange('password2', value)}
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
|
||||
{showEmailVerification && (
|
||||
<>
|
||||
<Form.Input
|
||||
field="email"
|
||||
label={t('邮箱')}
|
||||
placeholder={t('输入邮箱地址')}
|
||||
name="email"
|
||||
type="email"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
onChange={(value) => handleChange('email', value)}
|
||||
prefix={<IconMail />}
|
||||
suffix={
|
||||
<Button
|
||||
onClick={sendVerificationCode}
|
||||
loading={verificationCodeLoading}
|
||||
size="small"
|
||||
className="!rounded-md mr-2"
|
||||
>
|
||||
{t('获取验证码')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Form.Input
|
||||
field="verification_code"
|
||||
label={t('验证码')}
|
||||
placeholder={t('输入验证码')}
|
||||
name="verification_code"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
onChange={(value) => handleChange('verification_code', value)}
|
||||
prefix={<IconKey />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<Button
|
||||
theme="solid"
|
||||
className="w-full !rounded-full"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
onClick={handleSubmit}
|
||||
loading={registerLoading}
|
||||
>
|
||||
{t('注册')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
|
||||
<>
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
theme="outline"
|
||||
type="tertiary"
|
||||
className="w-full !rounded-full"
|
||||
size="large"
|
||||
onClick={handleOtherRegisterOptionsClick}
|
||||
loading={otherRegisterOptionsLoading}
|
||||
>
|
||||
{t('其他注册选项')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWeChatLoginModal = () => {
|
||||
return (
|
||||
<Modal
|
||||
title={t('微信扫码登录')}
|
||||
visible={showWeChatLoginModal}
|
||||
maskClosable={true}
|
||||
onOk={onSubmitWeChatVerificationCode}
|
||||
onCancel={() => setShowWeChatLoginModal(false)}
|
||||
okText={t('登录')}
|
||||
size="small"
|
||||
centered={true}
|
||||
okButtonProps={{
|
||||
loading: wechatCodeSubmitLoading,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" />
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-4">
|
||||
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
|
||||
</div>
|
||||
|
||||
<Form size="large">
|
||||
<Form.Input
|
||||
field="wechat_verification_code"
|
||||
placeholder={t('验证码')}
|
||||
label={t('验证码')}
|
||||
value={inputs.wechat_verification_code}
|
||||
onChange={(value) => handleChange('wechat_verification_code', value)}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center scale-125 opacity-100"
|
||||
style={{
|
||||
backgroundImage: `url(${Background})`
|
||||
}}
|
||||
></div>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/30 via-blue-500/30 to-purple-500/30 backdrop-blur-sm z-0"></div>
|
||||
|
||||
<div className="w-full max-w-sm relative z-10">
|
||||
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
|
||||
? renderEmailRegisterForm()
|
||||
: renderOAuthOptions()}
|
||||
{renderWeChatLoginModal()}
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterForm;
|
||||
24
web/src/components/common/Loading.js
Normal file
24
web/src/components/common/Loading.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Spin } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const Loading = ({ prompt: name = '', size = 'large' }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center bg-white/80 z-[1000]">
|
||||
<div className="flex flex-col items-center">
|
||||
<Spin
|
||||
size={size}
|
||||
spinning={true}
|
||||
tip={null}
|
||||
/>
|
||||
<span className="whitespace-nowrap mt-2 text-center" style={{ color: 'var(--semi-color-primary)' }}>
|
||||
{name ? t('加载{{name}}中...', { name }) : t('加载中...')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
@@ -11,8 +11,8 @@ const OIDCIcon = (props) => {
|
||||
version='1.1'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
p-id='10969'
|
||||
width='1em'
|
||||
height='1em'
|
||||
width='20'
|
||||
height='20'
|
||||
>
|
||||
<path
|
||||
d='M512 960C265 960 64 759 64 512S265 64 512 64s448 201 448 448-201 448-448 448z m0-882.6c-239.7 0-434.6 195-434.6 434.6s195 434.6 434.6 434.6 434.6-195 434.6-434.6S751.7 77.4 512 77.4z'
|
||||
@@ -11,8 +11,8 @@ const WeChatIcon = () => {
|
||||
version='1.1'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
p-id='5091'
|
||||
width='16'
|
||||
height='16'
|
||||
width='20'
|
||||
height='20'
|
||||
>
|
||||
<path
|
||||
d='M690.1 377.4c5.9 0 11.8 0.2 17.6 0.5-24.4-128.7-158.3-227.1-319.9-227.1C209 150.8 64 271.4 64 420.2c0 81.1 43.6 154.2 111.9 203.6 5.5 3.9 9.1 10.3 9.1 17.6 0 2.4-0.5 4.6-1.1 6.9-5.5 20.3-14.2 52.8-14.6 54.3-0.7 2.6-1.7 5.2-1.7 7.9 0 5.9 4.8 10.8 10.8 10.8 2.3 0 4.2-0.9 6.2-2l70.9-40.9c5.3-3.1 11-5 17.2-5 3.2 0 6.4 0.5 9.5 1.4 33.1 9.5 68.8 14.8 105.7 14.8 6 0 11.9-0.1 17.8-0.4-7.1-21-10.9-43.1-10.9-66 0-135.8 132.2-245.8 295.3-245.8z m-194.3-86.5c23.8 0 43.2 19.3 43.2 43.1s-19.3 43.1-43.2 43.1c-23.8 0-43.2-19.3-43.2-43.1s19.4-43.1 43.2-43.1z m-215.9 86.2c-23.8 0-43.2-19.3-43.2-43.1s19.3-43.1 43.2-43.1 43.2 19.3 43.2 43.1-19.4 43.1-43.2 43.1z'
|
||||
513
web/src/components/common/markdown/MarkdownRenderer.js
Normal file
513
web/src/components/common/markdown/MarkdownRenderer.js
Normal file
@@ -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;
|
||||
444
web/src/components/common/markdown/markdown.css
Normal file
444
web/src/components/common/markdown/markdown.css
Normal file
@@ -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 };
|
||||
}
|
||||
114
web/src/components/layout/Footer.js
Normal file
114
web/src/components/layout/Footer.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useEffect, useState, useMemo, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { getFooterHTML, getLogo, getSystemName } from '../../helpers';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
|
||||
const FooterBar = () => {
|
||||
const { t } = useTranslation();
|
||||
const [footer, setFooter] = useState(getFooterHTML());
|
||||
const systemName = getSystemName();
|
||||
const logo = getLogo();
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
|
||||
const loadFooter = () => {
|
||||
let footer_html = localStorage.getItem('footer_html');
|
||||
if (footer_html) {
|
||||
setFooter(footer_html);
|
||||
}
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const customFooter = useMemo(() => (
|
||||
<footer className="relative bg-semi-color-bg-2 h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden">
|
||||
<div className="absolute hidden md:block top-[204px] left-[-100px] w-[151px] h-[151px] rounded-full bg-[#FFD166]"></div>
|
||||
<div className="absolute md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60"></div>
|
||||
|
||||
{isDemoSiteMode && (
|
||||
<div className="flex flex-col md:flex-row justify-between w-full max-w-[1110px] mb-10 gap-8">
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={logo}
|
||||
alt={systemName}
|
||||
className="w-16 h-16 rounded-full bg-gray-800 p-1.5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full">
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('关于我们')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://docs.newapi.pro/wiki/project-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('关于项目')}</a>
|
||||
<a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('联系我们')}</a>
|
||||
<a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('功能特性')}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('文档')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://docs.newapi.pro/getting-started/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('快速开始')}</a>
|
||||
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('安装指南')}</a>
|
||||
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">{t('API 文档')}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('相关项目')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">One API</a>
|
||||
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">Midjourney-Proxy</a>
|
||||
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">chatnio</a>
|
||||
<a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">neko-api-key-tool</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('基于New API的项目')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://github.com/Calcium-Ion/new-api-horizon" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">new-api-horizon</a>
|
||||
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1 hover:!text-semi-color-primary transition-colors">VoAPI</a> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Typography.Text className="text-sm !text-semi-color-text-1">© {currentYear} {systemName}. {t('版权所有')}</Typography.Text>
|
||||
</div>
|
||||
|
||||
{isDemoSiteMode && (
|
||||
<div className="text-sm">
|
||||
<span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
|
||||
<span className="!text-semi-color-primary">Douyin FE</span>
|
||||
<span className="!text-semi-color-text-1"> & </span>
|
||||
<a href="https://github.com/QuantumNous" target="_blank" rel="noreferrer" className="!text-semi-color-primary hover:!text-semi-color-primary-hover transition-colors">QuantumNous</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
), [logo, systemName, t, currentYear, isDemoSiteMode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFooter();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{footer ? (
|
||||
<div
|
||||
className="custom-footer"
|
||||
dangerouslySetInnerHTML={{ __html: footer }}
|
||||
></div>
|
||||
) : (
|
||||
customFooter
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterBar;
|
||||
536
web/src/components/layout/HeaderBar.js
Normal file
536
web/src/components/layout/HeaderBar.js
Normal file
@@ -0,0 +1,536 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { useSetTheme, useTheme } from '../../context/Theme/index.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, getLogo, getSystemName, showSuccess, stringToColor } from '../../helpers/index.js';
|
||||
import fireworks from 'react-fireworks';
|
||||
import { CN, GB } from 'country-flag-icons/react/3x2';
|
||||
import NoticeModal from './NoticeModal.js';
|
||||
|
||||
import {
|
||||
IconClose,
|
||||
IconMenu,
|
||||
IconLanguage,
|
||||
IconChevronDown,
|
||||
IconSun,
|
||||
IconMoon,
|
||||
IconExit,
|
||||
IconUserSetting,
|
||||
IconCreditCard,
|
||||
IconKey,
|
||||
IconBell,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Dropdown,
|
||||
Tag,
|
||||
Typography,
|
||||
Skeleton,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { useStyle, styleActions } from '../../context/Style/index.js';
|
||||
|
||||
const HeaderBar = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
const { state: styleState, dispatch: styleDispatch } = useStyle();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
let navigate = useNavigate();
|
||||
const [currentLang, setCurrentLang] = useState(i18n.language);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||
|
||||
const systemName = getSystemName();
|
||||
const logo = getLogo();
|
||||
const currentDate = new Date();
|
||||
const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1;
|
||||
|
||||
const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false;
|
||||
const docsLink = statusState?.status?.docs_link || '';
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
|
||||
const theme = useTheme();
|
||||
const setTheme = useSetTheme();
|
||||
|
||||
const mainNavLinks = [
|
||||
{
|
||||
text: t('首页'),
|
||||
itemKey: 'home',
|
||||
to: '/',
|
||||
},
|
||||
{
|
||||
text: t('控制台'),
|
||||
itemKey: 'console',
|
||||
to: '/console',
|
||||
},
|
||||
{
|
||||
text: t('定价'),
|
||||
itemKey: 'pricing',
|
||||
to: '/pricing',
|
||||
},
|
||||
...(docsLink
|
||||
? [
|
||||
{
|
||||
text: t('文档'),
|
||||
itemKey: 'docs',
|
||||
isExternal: true,
|
||||
externalLink: docsLink,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
text: t('关于'),
|
||||
itemKey: 'about',
|
||||
to: '/about',
|
||||
},
|
||||
];
|
||||
|
||||
async function logout() {
|
||||
await API.get('/api/user/logout');
|
||||
showSuccess(t('注销成功!'));
|
||||
userDispatch({ type: 'logout' });
|
||||
localStorage.removeItem('user');
|
||||
navigate('/login');
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
|
||||
const handleNewYearClick = () => {
|
||||
fireworks.init('root', {});
|
||||
fireworks.start();
|
||||
setTimeout(() => {
|
||||
fireworks.stop();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'dark') {
|
||||
document.body.setAttribute('theme-mode', 'dark');
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.body.removeAttribute('theme-mode');
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe) {
|
||||
iframe.contentWindow.postMessage({ themeMode: theme }, '*');
|
||||
}
|
||||
|
||||
}, [theme, isNewYear]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleLanguageChanged = (lng) => {
|
||||
setCurrentLang(lng);
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe) {
|
||||
iframe.contentWindow.postMessage({ lang: lng }, '*');
|
||||
}
|
||||
};
|
||||
|
||||
i18n.on('languageChanged', handleLanguageChanged);
|
||||
return () => {
|
||||
i18n.off('languageChanged', handleLanguageChanged);
|
||||
};
|
||||
}, [i18n]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleLanguageChange = (lang) => {
|
||||
i18n.changeLanguage(lang);
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleNavLinkClick = (itemKey) => {
|
||||
if (itemKey === 'home') {
|
||||
styleDispatch(styleActions.setSider(false));
|
||||
}
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const renderNavLinks = (isMobileView = false, isLoading = false) => {
|
||||
if (isLoading) {
|
||||
const skeletonLinkClasses = isMobileView
|
||||
? 'flex items-center gap-1 p-3 w-full rounded-md'
|
||||
: 'flex items-center gap-1 p-2 rounded-md';
|
||||
return Array(4)
|
||||
.fill(null)
|
||||
.map((_, index) => (
|
||||
<div key={index} className={skeletonLinkClasses}>
|
||||
<Skeleton.Title style={{ width: isMobileView ? 100 : 60, height: 16 }} />
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
return mainNavLinks.map((link) => {
|
||||
const commonLinkClasses = isMobileView
|
||||
? 'flex items-center gap-1 p-3 w-full text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors font-semibold'
|
||||
: 'flex items-center gap-1 p-2 text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors rounded-md font-semibold';
|
||||
|
||||
const linkContent = (
|
||||
<span>{link.text}</span>
|
||||
);
|
||||
|
||||
if (link.isExternal) {
|
||||
return (
|
||||
<a
|
||||
key={link.itemKey}
|
||||
href={link.externalLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={commonLinkClasses}
|
||||
onClick={() => handleNavLinkClick(link.itemKey)}
|
||||
>
|
||||
{linkContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
let targetPath = link.to;
|
||||
if (link.itemKey === 'console' && !userState.user) {
|
||||
targetPath = '/login';
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.itemKey}
|
||||
to={targetPath}
|
||||
className={commonLinkClasses}
|
||||
onClick={() => handleNavLinkClick(link.itemKey)}
|
||||
>
|
||||
{linkContent}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const renderUserArea = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1">
|
||||
<Skeleton.Avatar size="extra-small" className="shadow-sm" />
|
||||
<div className="ml-1.5 mr-1">
|
||||
<Skeleton.Title style={{ width: styleState.isMobile ? 15 : 50, height: 12 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (userState.user) {
|
||||
return (
|
||||
<Dropdown
|
||||
position="bottomRight"
|
||||
render={
|
||||
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/personal');
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconUserSetting size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('个人设置')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/token');
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconKey size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('API令牌')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/topup');
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconCreditCard size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('钱包')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={logout} className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconExit size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('退出')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
>
|
||||
<Avatar
|
||||
size="extra-small"
|
||||
color={stringToColor(userState.user.username)}
|
||||
className="mr-1"
|
||||
>
|
||||
{userState.user.username[0].toUpperCase()}
|
||||
</Avatar>
|
||||
<span className="hidden md:inline">
|
||||
<Typography.Text className="!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1">
|
||||
{userState.user.username}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<IconChevronDown className="text-xs text-semi-color-text-2 dark:text-gray-400" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
} else {
|
||||
const showRegisterButton = !isSelfUseMode;
|
||||
|
||||
const commonSizingAndLayoutClass = "flex items-center justify-center !py-[10px] !px-1.5";
|
||||
|
||||
const loginButtonSpecificStyling = "!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors";
|
||||
let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`;
|
||||
|
||||
let registerButtonClasses = `${commonSizingAndLayoutClass}`;
|
||||
|
||||
const loginButtonTextSpanClass = "!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5";
|
||||
const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5";
|
||||
|
||||
if (showRegisterButton) {
|
||||
if (styleState.isMobile) {
|
||||
loginButtonClasses += " !rounded-full";
|
||||
} else {
|
||||
loginButtonClasses += " !rounded-l-full !rounded-r-none";
|
||||
}
|
||||
registerButtonClasses += " !rounded-r-full !rounded-l-none";
|
||||
} else {
|
||||
loginButtonClasses += " !rounded-full";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Link to="/login" onClick={() => handleNavLinkClick('login')} className="flex">
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className={loginButtonClasses}
|
||||
>
|
||||
<span className={loginButtonTextSpanClass}>
|
||||
{t('登录')}
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
{showRegisterButton && (
|
||||
<div className="hidden md:block">
|
||||
<Link to="/register" onClick={() => handleNavLinkClick('register')} className="flex -ml-px">
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
className={registerButtonClasses}
|
||||
>
|
||||
<span className={registerButtonTextSpanClass}>
|
||||
{t('注册')}
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 检查当前路由是否以/console开头
|
||||
const isConsoleRoute = location.pathname.startsWith('/console');
|
||||
|
||||
return (
|
||||
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
|
||||
<NoticeModal
|
||||
visible={noticeVisible}
|
||||
onClose={() => setNoticeVisible(false)}
|
||||
isMobile={styleState.isMobile}
|
||||
/>
|
||||
<div className="w-full px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<div className="md:hidden">
|
||||
<Button
|
||||
icon={
|
||||
isConsoleRoute
|
||||
? (styleState.showSider ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
|
||||
: (mobileMenuOpen ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
|
||||
}
|
||||
aria-label={
|
||||
isConsoleRoute
|
||||
? (styleState.showSider ? t('关闭侧边栏') : t('打开侧边栏'))
|
||||
: (mobileMenuOpen ? t('关闭菜单') : t('打开菜单'))
|
||||
}
|
||||
onClick={() => {
|
||||
if (isConsoleRoute) {
|
||||
// 控制侧边栏的显示/隐藏,无论是否移动设备
|
||||
styleDispatch(styleActions.toggleSider());
|
||||
} else {
|
||||
// 控制HeaderBar自己的移动菜单
|
||||
setMobileMenuOpen(!mobileMenuOpen);
|
||||
}
|
||||
}}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<Link to="/" onClick={() => handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2">
|
||||
{isLoading ? (
|
||||
<Skeleton.Image className="h-7 md:h-8 !rounded-full" style={{ width: 32, height: 32 }} />
|
||||
) : (
|
||||
<img src={logo} alt="logo" className="h-7 md:h-8 transition-transform duration-300 ease-in-out group-hover:scale-105 rounded-full" />
|
||||
)}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading ? (
|
||||
<Skeleton.Title style={{ width: 120, height: 24 }} />
|
||||
) : (
|
||||
<Typography.Title heading={4} className="!text-lg !font-semibold !mb-0
|
||||
bg-gradient-to-r from-blue-500 to-purple-500 dark:from-blue-400 dark:to-purple-400
|
||||
bg-clip-text text-transparent">
|
||||
{systemName}
|
||||
</Typography.Title>
|
||||
)}
|
||||
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
|
||||
<Tag
|
||||
color={isSelfUseMode ? 'purple' : 'blue'}
|
||||
className="text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm"
|
||||
size="small"
|
||||
shape='circle'
|
||||
>
|
||||
{isSelfUseMode ? t('自用模式') : t('演示站点')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
|
||||
<div className="md:hidden">
|
||||
<Tag
|
||||
color={isSelfUseMode ? 'purple' : 'blue'}
|
||||
className="ml-2 text-xs px-1 py-0.5 rounded whitespace-nowrap shadow-sm"
|
||||
size="small"
|
||||
shape='circle'
|
||||
>
|
||||
{isSelfUseMode ? t('自用模式') : t('演示站点')}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1 lg:gap-2 ml-6">
|
||||
{renderNavLinks(false, isLoading)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
{isNewYear && (
|
||||
<Dropdown
|
||||
position="bottomRight"
|
||||
render={
|
||||
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
|
||||
<Dropdown.Item onClick={handleNewYearClick} className="!text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-gray-600">
|
||||
Happy New Year!!! 🎉
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
icon={<span className="text-xl">🎉</span>}
|
||||
aria-label="New Year"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
<Button
|
||||
icon={<IconBell className="text-lg" />}
|
||||
aria-label={t('系统公告')}
|
||||
onClick={() => setNoticeVisible(true)}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
/>
|
||||
|
||||
<Button
|
||||
icon={theme === 'dark' ? <IconSun size="large" className="text-yellow-500" /> : <IconMoon size="large" className="text-gray-300" />}
|
||||
aria-label={t('切换主题')}
|
||||
onClick={() => setTheme(theme === 'dark' ? false : true)}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
position="bottomRight"
|
||||
render={
|
||||
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
|
||||
<Dropdown.Item
|
||||
onClick={() => handleLanguageChange('zh')}
|
||||
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
||||
>
|
||||
<CN title="中文" className="!w-5 !h-auto" />
|
||||
<span>中文</span>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'en' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
||||
>
|
||||
<GB title="English" className="!w-5 !h-auto" />
|
||||
<span>English</span>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={<IconLanguage className="text-lg" />}
|
||||
aria-label={t('切换语言')}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
/>
|
||||
</Dropdown>
|
||||
|
||||
{renderUserArea()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden">
|
||||
<div
|
||||
className={`
|
||||
absolute top-16 left-0 right-0 bg-semi-color-bg-0
|
||||
shadow-lg p-3
|
||||
transform transition-all duration-300 ease-in-out
|
||||
${(!isConsoleRoute && mobileMenuOpen) ? 'translate-y-0 opacity-100 visible' : '-translate-y-4 opacity-0 invisible'}
|
||||
`}
|
||||
>
|
||||
<nav className="flex flex-col gap-1">
|
||||
{renderNavLinks(true, isLoading)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderBar;
|
||||
94
web/src/components/layout/NoticeModal.js
Normal file
94
web/src/components/layout/NoticeModal.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Modal, Empty } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError } from '../../helpers';
|
||||
import { marked } from 'marked';
|
||||
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
|
||||
|
||||
const NoticeModal = ({ visible, onClose, isMobile }) => {
|
||||
const { t } = useTranslation();
|
||||
const [noticeContent, setNoticeContent] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleCloseTodayNotice = () => {
|
||||
const today = new Date().toDateString();
|
||||
localStorage.setItem('notice_close_date', today);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const displayNotice = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/notice');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (data !== '') {
|
||||
const htmlNotice = marked.parse(data);
|
||||
setNoticeContent(htmlNotice);
|
||||
} else {
|
||||
setNoticeContent('');
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
displayNotice();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <div className="py-12"><Empty description={t('加载中...')} /></div>;
|
||||
}
|
||||
|
||||
if (!noticeContent) {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<Empty
|
||||
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('暂无公告')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: noticeContent }}
|
||||
className="max-h-[60vh] overflow-y-auto pr-2"
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'var(--semi-color-tertiary) transparent'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('系统公告')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={(
|
||||
<div className="flex justify-end">
|
||||
<Button type='secondary' className='!rounded-full' onClick={handleCloseTodayNotice}>{t('今日关闭')}</Button>
|
||||
<Button type="primary" className='!rounded-full' onClick={onClose}>{t('关闭公告')}</Button>
|
||||
</div>
|
||||
)}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoticeModal;
|
||||
@@ -1,23 +1,30 @@
|
||||
import HeaderBar from './HeaderBar.js';
|
||||
import { Layout } from '@douyinfe/semi-ui';
|
||||
import SiderBar from './SiderBar.js';
|
||||
import App from '../App.js';
|
||||
import App from '../../App.js';
|
||||
import FooterBar from './Footer.js';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { StyleContext } from '../context/Style/index.js';
|
||||
import { useStyle } from '../../context/Style/index.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, getLogo, getSystemName, showError } from '../helpers/index.js';
|
||||
import { setStatusData } from '../helpers/data.js';
|
||||
import { UserContext } from '../context/User/index.js';
|
||||
import { StatusContext } from '../context/Status/index.js';
|
||||
import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
const { Sider, Content, Header, Footer } = Layout;
|
||||
|
||||
const PageLayout = () => {
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
const [styleState, styleDispatch] = useContext(StyleContext);
|
||||
const { state: styleState } = useStyle();
|
||||
const { i18n } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const shouldHideFooter = location.pathname === '/console/playground' || location.pathname.startsWith('/console/chat');
|
||||
|
||||
const shouldInnerPadding = location.pathname.includes('/console') &&
|
||||
!location.pathname.startsWith('/console/chat') &&
|
||||
location.pathname !== '/console/playground';
|
||||
|
||||
const loadUser = () => {
|
||||
let user = localStorage.getItem('user');
|
||||
@@ -61,15 +68,8 @@ const PageLayout = () => {
|
||||
if (savedLang) {
|
||||
i18n.changeLanguage(savedLang);
|
||||
}
|
||||
|
||||
// 默认显示侧边栏
|
||||
styleDispatch({ type: 'SET_SIDER', payload: true });
|
||||
}, [i18n]);
|
||||
|
||||
// 获取侧边栏折叠状态
|
||||
const isSidebarCollapsed =
|
||||
localStorage.getItem('default_collapse_sidebar') === 'true';
|
||||
|
||||
return (
|
||||
<Layout
|
||||
style={{
|
||||
@@ -84,19 +84,18 @@ const PageLayout = () => {
|
||||
padding: 0,
|
||||
height: 'auto',
|
||||
lineHeight: 'normal',
|
||||
position: styleState.isMobile ? 'sticky' : 'fixed',
|
||||
position: 'fixed',
|
||||
width: '100%',
|
||||
top: 0,
|
||||
zIndex: 100,
|
||||
boxShadow: '0 1px 6px rgba(0, 0, 0, 0.08)',
|
||||
}}
|
||||
>
|
||||
<HeaderBar />
|
||||
</Header>
|
||||
<Layout
|
||||
style={{
|
||||
marginTop: styleState.isMobile ? '0' : '56px',
|
||||
height: styleState.isMobile ? 'auto' : 'calc(100vh - 56px)',
|
||||
marginTop: '64px',
|
||||
height: 'calc(100vh - 64px)',
|
||||
overflow: styleState.isMobile ? 'visible' : 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -107,13 +106,11 @@ const PageLayout = () => {
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: '56px',
|
||||
top: '64px',
|
||||
zIndex: 99,
|
||||
background: 'var(--semi-color-bg-1)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
border: 'none',
|
||||
paddingRight: '0',
|
||||
height: 'calc(100vh - 56px)',
|
||||
height: 'calc(100vh - 64px)',
|
||||
}}
|
||||
>
|
||||
<SiderBar />
|
||||
@@ -126,7 +123,7 @@ const PageLayout = () => {
|
||||
: styleState.showSider
|
||||
? styleState.siderCollapsed
|
||||
? '60px'
|
||||
: '200px'
|
||||
: '180px'
|
||||
: '0',
|
||||
transition: 'margin-left 0.3s ease',
|
||||
flex: '1 1 auto',
|
||||
@@ -139,21 +136,23 @@ const PageLayout = () => {
|
||||
flex: '1 0 auto',
|
||||
overflowY: styleState.isMobile ? 'visible' : 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
padding: styleState.shouldInnerPadding ? '24px' : '0',
|
||||
padding: shouldInnerPadding ? '24px' : '0',
|
||||
position: 'relative',
|
||||
marginTop: styleState.isMobile ? '2px' : '0',
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</Content>
|
||||
<Layout.Footer
|
||||
style={{
|
||||
flex: '0 0 auto',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<FooterBar />
|
||||
</Layout.Footer>
|
||||
{!shouldHideFooter && (
|
||||
<Layout.Footer
|
||||
style={{
|
||||
flex: '0 0 auto',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<FooterBar />
|
||||
</Layout.Footer>
|
||||
)}
|
||||
</Layout>
|
||||
</Layout>
|
||||
<ToastContainer />
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { StatusContext } from '../context/Status';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
|
||||
const SetupCheck = ({ children }) => {
|
||||
const [statusState] = useContext(StatusContext);
|
||||
448
web/src/components/layout/SiderBar.js
Normal file
448
web/src/components/layout/SiderBar.js
Normal file
@@ -0,0 +1,448 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useStyle, styleActions } from '../../context/Style/index.js';
|
||||
import {
|
||||
isAdmin,
|
||||
isRoot,
|
||||
showError
|
||||
} from '../../helpers/index.js';
|
||||
|
||||
import {
|
||||
Nav,
|
||||
Divider,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
|
||||
const routerMap = {
|
||||
home: '/',
|
||||
channel: '/console/channel',
|
||||
token: '/console/token',
|
||||
redemption: '/console/redemption',
|
||||
topup: '/console/topup',
|
||||
user: '/console/user',
|
||||
log: '/console/log',
|
||||
midjourney: '/console/midjourney',
|
||||
setting: '/console/setting',
|
||||
about: '/about',
|
||||
detail: '/console',
|
||||
pricing: '/pricing',
|
||||
task: '/console/task',
|
||||
playground: '/console/playground',
|
||||
personal: '/console/personal',
|
||||
};
|
||||
|
||||
const SiderBar = () => {
|
||||
const { t } = useTranslation();
|
||||
const { state: styleState, dispatch: styleDispatch } = useStyle();
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useState(['home']);
|
||||
const [isCollapsed, setIsCollapsed] = useState(styleState.siderCollapsed);
|
||||
const [chatItems, setChatItems] = useState([]);
|
||||
const [openedKeys, setOpenedKeys] = useState([]);
|
||||
const location = useLocation();
|
||||
const [routerMapState, setRouterMapState] = useState(routerMap);
|
||||
|
||||
const workspaceItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: t('数据看板'),
|
||||
itemKey: 'detail',
|
||||
to: '/detail',
|
||||
className:
|
||||
localStorage.getItem('enable_data_export') === 'true'
|
||||
? ''
|
||||
: 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('API令牌'),
|
||||
itemKey: 'token',
|
||||
to: '/token',
|
||||
},
|
||||
{
|
||||
text: t('使用日志'),
|
||||
itemKey: 'log',
|
||||
to: '/log',
|
||||
},
|
||||
{
|
||||
text: t('绘图日志'),
|
||||
itemKey: 'midjourney',
|
||||
to: '/midjourney',
|
||||
className:
|
||||
localStorage.getItem('enable_drawing') === 'true'
|
||||
? ''
|
||||
: 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('任务日志'),
|
||||
itemKey: 'task',
|
||||
to: '/task',
|
||||
className:
|
||||
localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
|
||||
},
|
||||
],
|
||||
[
|
||||
localStorage.getItem('enable_data_export'),
|
||||
localStorage.getItem('enable_drawing'),
|
||||
localStorage.getItem('enable_task'),
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const financeItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: t('钱包'),
|
||||
itemKey: 'topup',
|
||||
to: '/topup',
|
||||
},
|
||||
{
|
||||
text: t('个人设置'),
|
||||
itemKey: 'personal',
|
||||
to: '/personal',
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const adminItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: t('渠道'),
|
||||
itemKey: 'channel',
|
||||
to: '/channel',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('兑换码'),
|
||||
itemKey: 'redemption',
|
||||
to: '/redemption',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('用户管理'),
|
||||
itemKey: 'user',
|
||||
to: '/user',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('系统设置'),
|
||||
itemKey: 'setting',
|
||||
to: '/setting',
|
||||
className: isRoot() ? '' : 'tableHiddle',
|
||||
},
|
||||
],
|
||||
[isAdmin(), isRoot(), t],
|
||||
);
|
||||
|
||||
const chatMenuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: t('操练场'),
|
||||
itemKey: 'playground',
|
||||
to: '/playground',
|
||||
},
|
||||
{
|
||||
text: t('聊天'),
|
||||
itemKey: 'chat',
|
||||
items: chatItems,
|
||||
},
|
||||
],
|
||||
[chatItems, t],
|
||||
);
|
||||
|
||||
// 更新路由映射,添加聊天路由
|
||||
const updateRouterMapWithChats = (chats) => {
|
||||
const newRouterMap = { ...routerMap };
|
||||
|
||||
if (Array.isArray(chats) && chats.length > 0) {
|
||||
for (let i = 0; i < chats.length; i++) {
|
||||
newRouterMap['chat' + i] = '/console/chat/' + i;
|
||||
}
|
||||
}
|
||||
|
||||
setRouterMapState(newRouterMap);
|
||||
return newRouterMap;
|
||||
};
|
||||
|
||||
// 加载聊天项
|
||||
useEffect(() => {
|
||||
let chats = localStorage.getItem('chats');
|
||||
if (chats) {
|
||||
try {
|
||||
chats = JSON.parse(chats);
|
||||
if (Array.isArray(chats)) {
|
||||
let chatItems = [];
|
||||
for (let i = 0; i < chats.length; i++) {
|
||||
let chat = {};
|
||||
for (let key in chats[i]) {
|
||||
chat.text = key;
|
||||
chat.itemKey = 'chat' + i;
|
||||
chat.to = '/console/chat/' + i;
|
||||
}
|
||||
chatItems.push(chat);
|
||||
}
|
||||
setChatItems(chatItems);
|
||||
updateRouterMapWithChats(chats);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showError('聊天数据解析失败');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 根据当前路径设置选中的菜单项
|
||||
useEffect(() => {
|
||||
const currentPath = location.pathname;
|
||||
let matchingKey = Object.keys(routerMapState).find(
|
||||
(key) => routerMapState[key] === currentPath,
|
||||
);
|
||||
|
||||
// 处理聊天路由
|
||||
if (!matchingKey && currentPath.startsWith('/console/chat/')) {
|
||||
const chatIndex = currentPath.split('/').pop();
|
||||
if (!isNaN(chatIndex)) {
|
||||
matchingKey = 'chat' + chatIndex;
|
||||
} else {
|
||||
matchingKey = 'chat';
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到匹配的键,更新选中的键
|
||||
if (matchingKey) {
|
||||
setSelectedKeys([matchingKey]);
|
||||
}
|
||||
}, [location.pathname, routerMapState]);
|
||||
|
||||
// 同步折叠状态
|
||||
useEffect(() => {
|
||||
setIsCollapsed(styleState.siderCollapsed);
|
||||
}, [styleState.siderCollapsed]);
|
||||
|
||||
// 获取菜单项对应的颜色
|
||||
const getItemColor = (itemKey) => {
|
||||
switch (itemKey) {
|
||||
case 'detail': return sidebarIconColors.dashboard;
|
||||
case 'playground': return sidebarIconColors.terminal;
|
||||
case 'chat': return sidebarIconColors.message;
|
||||
case 'token': return sidebarIconColors.key;
|
||||
case 'log': return sidebarIconColors.chart;
|
||||
case 'midjourney': return sidebarIconColors.image;
|
||||
case 'task': return sidebarIconColors.check;
|
||||
case 'topup': return sidebarIconColors.credit;
|
||||
case 'channel': return sidebarIconColors.layers;
|
||||
case 'redemption': return sidebarIconColors.gift;
|
||||
case 'user':
|
||||
case 'personal': return sidebarIconColors.user;
|
||||
case 'setting': return sidebarIconColors.settings;
|
||||
default:
|
||||
// 处理聊天项
|
||||
if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message;
|
||||
return 'currentColor';
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染自定义菜单项
|
||||
const renderNavItem = (item) => {
|
||||
// 跳过隐藏的项目
|
||||
if (item.className === 'tableHiddle') return null;
|
||||
|
||||
const isSelected = selectedKeys.includes(item.itemKey);
|
||||
const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
|
||||
|
||||
return (
|
||||
<Nav.Item
|
||||
key={item.itemKey}
|
||||
itemKey={item.itemKey}
|
||||
text={
|
||||
<div className="flex items-center">
|
||||
<span className="truncate font-medium text-sm" style={{ color: textColor }}>
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
icon={
|
||||
<div className="sidebar-icon-container flex-shrink-0">
|
||||
{getLucideIcon(item.itemKey, isSelected)}
|
||||
</div>
|
||||
}
|
||||
className={item.className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染子菜单项
|
||||
const renderSubItem = (item) => {
|
||||
if (item.items && item.items.length > 0) {
|
||||
const isSelected = selectedKeys.includes(item.itemKey);
|
||||
const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
|
||||
|
||||
return (
|
||||
<Nav.Sub
|
||||
key={item.itemKey}
|
||||
itemKey={item.itemKey}
|
||||
text={
|
||||
<div className="flex items-center">
|
||||
<span className="truncate font-medium text-sm" style={{ color: textColor }}>
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
icon={
|
||||
<div className="sidebar-icon-container flex-shrink-0">
|
||||
{getLucideIcon(item.itemKey, isSelected)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{item.items.map((subItem) => {
|
||||
const isSubSelected = selectedKeys.includes(subItem.itemKey);
|
||||
const subTextColor = isSubSelected ? getItemColor(subItem.itemKey) : 'inherit';
|
||||
|
||||
return (
|
||||
<Nav.Item
|
||||
key={subItem.itemKey}
|
||||
itemKey={subItem.itemKey}
|
||||
text={
|
||||
<span className="truncate font-medium text-sm" style={{ color: subTextColor }}>
|
||||
{subItem.text}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Nav.Sub>
|
||||
);
|
||||
} else {
|
||||
return renderNavItem(item);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="sidebar-container"
|
||||
style={{ width: isCollapsed ? '60px' : '180px' }}
|
||||
>
|
||||
<Nav
|
||||
className="sidebar-nav custom-sidebar-nav"
|
||||
defaultIsCollapsed={styleState.siderCollapsed}
|
||||
isCollapsed={isCollapsed}
|
||||
onCollapseChange={(collapsed) => {
|
||||
setIsCollapsed(collapsed);
|
||||
styleDispatch(styleActions.setSiderCollapsed(collapsed));
|
||||
|
||||
// 确保在收起侧边栏时有选中的项目
|
||||
if (selectedKeys.length === 0) {
|
||||
const currentPath = location.pathname;
|
||||
const matchingKey = Object.keys(routerMapState).find(
|
||||
(key) => routerMapState[key] === currentPath,
|
||||
);
|
||||
|
||||
if (matchingKey) {
|
||||
setSelectedKeys([matchingKey]);
|
||||
} else if (currentPath.startsWith('/console/chat/')) {
|
||||
setSelectedKeys(['chat']);
|
||||
} else {
|
||||
setSelectedKeys(['detail']); // 默认选中首页
|
||||
}
|
||||
}
|
||||
}}
|
||||
selectedKeys={selectedKeys}
|
||||
itemStyle="sidebar-nav-item"
|
||||
hoverStyle="sidebar-nav-item:hover"
|
||||
selectedStyle="sidebar-nav-item-selected"
|
||||
renderWrapper={({ itemElement, props }) => {
|
||||
const to = routerMapState[props.itemKey] || routerMap[props.itemKey];
|
||||
|
||||
// 如果没有路由,直接返回元素
|
||||
if (!to) return itemElement;
|
||||
|
||||
return (
|
||||
<Link
|
||||
style={{ textDecoration: 'none' }}
|
||||
to={to}
|
||||
>
|
||||
{itemElement}
|
||||
</Link>
|
||||
);
|
||||
}}
|
||||
onSelect={(key) => {
|
||||
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
|
||||
if (openedKeys.includes(key.itemKey)) {
|
||||
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
|
||||
}
|
||||
|
||||
setSelectedKeys([key.itemKey]);
|
||||
}}
|
||||
openKeys={openedKeys}
|
||||
onOpenChange={(data) => {
|
||||
setOpenedKeys(data.openKeys);
|
||||
}}
|
||||
>
|
||||
{/* 聊天区域 */}
|
||||
<div className="sidebar-section">
|
||||
{!isCollapsed && (
|
||||
<div className="sidebar-group-label">{t('聊天')}</div>
|
||||
)}
|
||||
{chatMenuItems.map((item) => renderSubItem(item))}
|
||||
</div>
|
||||
|
||||
{/* 控制台区域 */}
|
||||
<Divider className="sidebar-divider" />
|
||||
<div>
|
||||
{!isCollapsed && (
|
||||
<div className="sidebar-group-label">{t('控制台')}</div>
|
||||
)}
|
||||
{workspaceItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
|
||||
{/* 管理员区域 - 只在管理员时显示 */}
|
||||
{isAdmin() && (
|
||||
<>
|
||||
<Divider className="sidebar-divider" />
|
||||
<div>
|
||||
{!isCollapsed && (
|
||||
<div className="sidebar-group-label">{t('管理员')}</div>
|
||||
)}
|
||||
{adminItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 个人中心区域 */}
|
||||
<Divider className="sidebar-divider" />
|
||||
<div>
|
||||
{!isCollapsed && (
|
||||
<div className="sidebar-group-label">{t('个人中心')}</div>
|
||||
)}
|
||||
{financeItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
</Nav>
|
||||
|
||||
{/* 底部折叠按钮 */}
|
||||
<div
|
||||
className="sidebar-collapse-button"
|
||||
onClick={() => {
|
||||
const newCollapsed = !isCollapsed;
|
||||
setIsCollapsed(newCollapsed);
|
||||
styleDispatch(styleActions.setSiderCollapsed(newCollapsed));
|
||||
}}
|
||||
>
|
||||
<Tooltip content={isCollapsed ? t('展开侧边栏') : t('收起侧边栏')} position="right">
|
||||
<div className="sidebar-collapse-button-inner">
|
||||
<span
|
||||
className="sidebar-collapse-icon-container"
|
||||
style={{ transform: isCollapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
<ChevronLeft size={16} strokeWidth={2.5} color="var(--semi-color-text-2)" />
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiderBar;
|
||||
113
web/src/components/playground/ChatArea.js
Normal file
113
web/src/components/playground/ChatArea.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
Chat,
|
||||
Typography,
|
||||
Button,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
MessageSquare,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CustomInputRender from './CustomInputRender';
|
||||
|
||||
const ChatArea = ({
|
||||
chatRef,
|
||||
message,
|
||||
inputs,
|
||||
styleState,
|
||||
showDebugPanel,
|
||||
roleInfo,
|
||||
onMessageSend,
|
||||
onMessageCopy,
|
||||
onMessageReset,
|
||||
onMessageDelete,
|
||||
onStopGenerator,
|
||||
onClearMessages,
|
||||
onToggleDebugPanel,
|
||||
renderCustomChatContent,
|
||||
renderChatBoxAction,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderInputArea = React.useCallback((props) => {
|
||||
return <CustomInputRender {...props} />;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="h-full"
|
||||
bordered={false}
|
||||
bodyStyle={{ padding: 0, height: 'calc(100vh - 66px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
|
||||
>
|
||||
{/* 聊天头部 */}
|
||||
{styleState.isMobile ? (
|
||||
<div className="pt-4"></div>
|
||||
) : (
|
||||
<div className="px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center">
|
||||
<MessageSquare size={20} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title heading={5} className="!text-white mb-0">
|
||||
{t('AI 对话')}
|
||||
</Typography.Title>
|
||||
<Typography.Text className="!text-white/80 text-sm hidden sm:inline">
|
||||
{inputs.model || t('选择模型开始对话')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
onClick={onToggleDebugPanel}
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
size="small"
|
||||
className="!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10"
|
||||
>
|
||||
{showDebugPanel ? t('隐藏调试') : t('显示调试')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 聊天内容区域 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Chat
|
||||
ref={chatRef}
|
||||
chatBoxRenderConfig={{
|
||||
renderChatBoxContent: renderCustomChatContent,
|
||||
renderChatBoxAction: renderChatBoxAction,
|
||||
renderChatBoxTitle: () => null,
|
||||
}}
|
||||
renderInputArea={renderInputArea}
|
||||
roleConfig={roleInfo}
|
||||
style={{
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
chats={message}
|
||||
onMessageSend={onMessageSend}
|
||||
onMessageCopy={onMessageCopy}
|
||||
onMessageReset={onMessageReset}
|
||||
onMessageDelete={onMessageDelete}
|
||||
showClearContext
|
||||
showStopGenerate
|
||||
onStopGenerator={onStopGenerator}
|
||||
onClear={onClearMessages}
|
||||
className="h-full"
|
||||
placeholder={t('请输入您的问题...')}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatArea;
|
||||
313
web/src/components/playground/CodeViewer.js
Normal file
313
web/src/components/playground/CodeViewer.js
Normal file
@@ -0,0 +1,313 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
|
||||
import { Copy, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { copy } from '../../helpers';
|
||||
|
||||
const PERFORMANCE_CONFIG = {
|
||||
MAX_DISPLAY_LENGTH: 50000, // 最大显示字符数
|
||||
PREVIEW_LENGTH: 5000, // 预览长度
|
||||
VERY_LARGE_MULTIPLIER: 2, // 超大内容倍数
|
||||
};
|
||||
|
||||
const codeThemeStyles = {
|
||||
container: {
|
||||
backgroundColor: '#1e1e1e',
|
||||
color: '#d4d4d4',
|
||||
fontFamily: 'Consolas, "Courier New", Monaco, "SF Mono", monospace',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.4',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #3c3c3c',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
content: {
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'auto',
|
||||
padding: '16px',
|
||||
margin: 0,
|
||||
whiteSpace: 'pre',
|
||||
wordBreak: 'normal',
|
||||
background: '#1e1e1e',
|
||||
},
|
||||
actionButton: {
|
||||
position: 'absolute',
|
||||
zIndex: 10,
|
||||
backgroundColor: 'rgba(45, 45, 45, 0.9)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
color: '#d4d4d4',
|
||||
borderRadius: '6px',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
actionButtonHover: {
|
||||
backgroundColor: 'rgba(60, 60, 60, 0.95)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
noContent: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
fontStyle: 'italic',
|
||||
backgroundColor: 'var(--semi-color-fill-0)',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
performanceWarning: {
|
||||
padding: '8px 12px',
|
||||
backgroundColor: 'rgba(255, 193, 7, 0.1)',
|
||||
border: '1px solid rgba(255, 193, 7, 0.3)',
|
||||
borderRadius: '6px',
|
||||
color: '#ffc107',
|
||||
fontSize: '12px',
|
||||
marginBottom: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
},
|
||||
};
|
||||
|
||||
const highlightJson = (str) => {
|
||||
return str.replace(
|
||||
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
|
||||
(match) => {
|
||||
let color = '#b5cea8';
|
||||
if (/^"/.test(match)) {
|
||||
color = /:$/.test(match) ? '#9cdcfe' : '#ce9178';
|
||||
} else if (/true|false|null/.test(match)) {
|
||||
color = '#569cd6';
|
||||
}
|
||||
return `<span style="color: ${color}">${match}</span>`;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const isJsonLike = (content, language) => {
|
||||
if (language === 'json') return true;
|
||||
const trimmed = content.trim();
|
||||
return (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'));
|
||||
};
|
||||
|
||||
const formatContent = (content) => {
|
||||
if (!content) return '';
|
||||
|
||||
if (typeof content === 'object') {
|
||||
try {
|
||||
return JSON.stringify(content, null, 2);
|
||||
} catch (e) {
|
||||
return String(content);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
return String(content);
|
||||
};
|
||||
|
||||
const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isHoveringCopy, setIsHoveringCopy] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const formattedContent = useMemo(() => formatContent(content), [content]);
|
||||
|
||||
const contentMetrics = useMemo(() => {
|
||||
const length = formattedContent.length;
|
||||
const isLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH;
|
||||
const isVeryLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH * PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER;
|
||||
return { length, isLarge, isVeryLarge };
|
||||
}, [formattedContent.length]);
|
||||
|
||||
const displayContent = useMemo(() => {
|
||||
if (!contentMetrics.isLarge || isExpanded) {
|
||||
return formattedContent;
|
||||
}
|
||||
return formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) +
|
||||
'\n\n// ... 内容被截断以提升性能 ...';
|
||||
}, [formattedContent, contentMetrics.isLarge, isExpanded]);
|
||||
|
||||
const highlightedContent = useMemo(() => {
|
||||
if (contentMetrics.isVeryLarge && !isExpanded) {
|
||||
return displayContent;
|
||||
}
|
||||
|
||||
if (isJsonLike(displayContent, language)) {
|
||||
return highlightJson(displayContent);
|
||||
}
|
||||
|
||||
return displayContent;
|
||||
}, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
const textToCopy = typeof content === 'object' && content !== null
|
||||
? JSON.stringify(content, null, 2)
|
||||
: content;
|
||||
|
||||
const success = await copy(textToCopy);
|
||||
setCopied(true);
|
||||
Toast.success(t('已复制到剪贴板'));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
|
||||
if (!success) {
|
||||
throw new Error('Copy operation failed');
|
||||
}
|
||||
} catch (err) {
|
||||
Toast.error(t('复制失败'));
|
||||
console.error('Copy failed:', err);
|
||||
}
|
||||
}, [content, t]);
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
if (contentMetrics.isVeryLarge && !isExpanded) {
|
||||
setIsProcessing(true);
|
||||
setTimeout(() => {
|
||||
setIsExpanded(true);
|
||||
setIsProcessing(false);
|
||||
}, 100);
|
||||
} else {
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
}, [isExpanded, contentMetrics.isVeryLarge]);
|
||||
|
||||
if (!content) {
|
||||
const placeholderText = {
|
||||
preview: t('正在构造请求体预览...'),
|
||||
request: t('暂无请求数据'),
|
||||
response: t('暂无响应数据')
|
||||
}[title] || t('暂无数据');
|
||||
|
||||
return (
|
||||
<div style={codeThemeStyles.noContent}>
|
||||
<span>{placeholderText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const warningTop = contentMetrics.isLarge ? '52px' : '12px';
|
||||
const contentPadding = contentMetrics.isLarge ? '52px' : '16px';
|
||||
|
||||
return (
|
||||
<div style={codeThemeStyles.container} className="h-full">
|
||||
{/* 性能警告 */}
|
||||
{contentMetrics.isLarge && (
|
||||
<div style={codeThemeStyles.performanceWarning}>
|
||||
<span>⚡</span>
|
||||
<span>
|
||||
{contentMetrics.isVeryLarge
|
||||
? t('内容较大,已启用性能优化模式')
|
||||
: t('内容较大,部分功能可能受限')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 复制按钮 */}
|
||||
<div
|
||||
style={{
|
||||
...codeThemeStyles.actionButton,
|
||||
...(isHoveringCopy ? codeThemeStyles.actionButtonHover : {}),
|
||||
top: warningTop,
|
||||
right: '12px',
|
||||
}}
|
||||
onMouseEnter={() => setIsHoveringCopy(true)}
|
||||
onMouseLeave={() => setIsHoveringCopy(false)}
|
||||
>
|
||||
<Tooltip content={copied ? t('已复制') : t('复制代码')}>
|
||||
<Button
|
||||
icon={<Copy size={14} />}
|
||||
onClick={handleCopy}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: copied ? '#4ade80' : '#d4d4d4',
|
||||
padding: '6px',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 代码内容 */}
|
||||
<div
|
||||
style={{
|
||||
...codeThemeStyles.content,
|
||||
paddingTop: contentPadding,
|
||||
}}
|
||||
className="model-settings-scroll"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '200px',
|
||||
color: '#888'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #444',
|
||||
borderTop: '2px solid #888',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginRight: '8px'
|
||||
}} />
|
||||
{t('正在处理大内容...')}
|
||||
</div>
|
||||
) : (
|
||||
<div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 展开/收起按钮 */}
|
||||
{contentMetrics.isLarge && !isProcessing && (
|
||||
<div style={{
|
||||
...codeThemeStyles.actionButton,
|
||||
bottom: '12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}}>
|
||||
<Tooltip content={isExpanded ? t('收起内容') : t('显示完整内容')}>
|
||||
<Button
|
||||
icon={isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
onClick={handleToggleExpand}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#d4d4d4',
|
||||
padding: '6px 12px',
|
||||
}}
|
||||
>
|
||||
{isExpanded ? t('收起') : t('展开')}
|
||||
{!isExpanded && (
|
||||
<span style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}>
|
||||
(+{Math.round((contentMetrics.length - PERFORMANCE_CONFIG.PREVIEW_LENGTH) / 1000)}K)
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeViewer;
|
||||
260
web/src/components/playground/ConfigManager.js
Normal file
260
web/src/components/playground/ConfigManager.js
Normal file
@@ -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;
|
||||
58
web/src/components/playground/CustomInputRender.js
Normal file
58
web/src/components/playground/CustomInputRender.js
Normal file
@@ -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;
|
||||
190
web/src/components/playground/CustomRequestEditor.js
Normal file
190
web/src/components/playground/CustomRequestEditor.js
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
TextArea,
|
||||
Typography,
|
||||
Button,
|
||||
Switch,
|
||||
Banner,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Code,
|
||||
Edit,
|
||||
Check,
|
||||
X,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const CustomRequestEditor = ({
|
||||
customRequestMode,
|
||||
customRequestBody,
|
||||
onCustomRequestModeChange,
|
||||
onCustomRequestBodyChange,
|
||||
defaultPayload,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [localValue, setLocalValue] = useState(customRequestBody || '');
|
||||
|
||||
// 当切换到自定义模式时,用默认payload初始化
|
||||
useEffect(() => {
|
||||
if (customRequestMode && (!customRequestBody || customRequestBody.trim() === '')) {
|
||||
const defaultJson = defaultPayload ? JSON.stringify(defaultPayload, null, 2) : '';
|
||||
setLocalValue(defaultJson);
|
||||
onCustomRequestBodyChange(defaultJson);
|
||||
}
|
||||
}, [customRequestMode, defaultPayload, customRequestBody, onCustomRequestBodyChange]);
|
||||
|
||||
// 同步外部传入的customRequestBody到本地状态
|
||||
useEffect(() => {
|
||||
if (customRequestBody !== localValue) {
|
||||
setLocalValue(customRequestBody || '');
|
||||
validateJson(customRequestBody || '');
|
||||
}
|
||||
}, [customRequestBody]);
|
||||
|
||||
// 验证JSON格式
|
||||
const validateJson = (value) => {
|
||||
if (!value.trim()) {
|
||||
setIsValid(true);
|
||||
setErrorMessage('');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(value);
|
||||
setIsValid(true);
|
||||
setErrorMessage('');
|
||||
return true;
|
||||
} catch (error) {
|
||||
setIsValid(false);
|
||||
setErrorMessage(`JSON格式错误: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleValueChange = (value) => {
|
||||
setLocalValue(value);
|
||||
validateJson(value);
|
||||
// 始终保存用户输入,让预览逻辑处理JSON解析错误
|
||||
onCustomRequestBodyChange(value);
|
||||
};
|
||||
|
||||
const handleModeToggle = (enabled) => {
|
||||
onCustomRequestModeChange(enabled);
|
||||
if (enabled && defaultPayload) {
|
||||
const defaultJson = JSON.stringify(defaultPayload, null, 2);
|
||||
setLocalValue(defaultJson);
|
||||
onCustomRequestBodyChange(defaultJson);
|
||||
}
|
||||
};
|
||||
|
||||
const formatJson = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(localValue);
|
||||
const formatted = JSON.stringify(parsed, null, 2);
|
||||
setLocalValue(formatted);
|
||||
onCustomRequestBodyChange(formatted);
|
||||
setIsValid(true);
|
||||
setErrorMessage('');
|
||||
} catch (error) {
|
||||
// 如果格式化失败,保持原样
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 自定义模式开关 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
自定义请求体模式
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={customRequestMode}
|
||||
onChange={handleModeToggle}
|
||||
checkedText="开"
|
||||
uncheckedText="关"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{customRequestMode && (
|
||||
<>
|
||||
{/* 提示信息 */}
|
||||
<Banner
|
||||
type="warning"
|
||||
description="启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
className="!rounded-lg"
|
||||
closable={false}
|
||||
/>
|
||||
|
||||
{/* JSON编辑器 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Typography.Text strong className="text-sm">
|
||||
请求体 JSON
|
||||
</Typography.Text>
|
||||
<div className="flex items-center gap-2">
|
||||
{isValid ? (
|
||||
<div className="flex items-center gap-1 text-green-600">
|
||||
<Check size={14} />
|
||||
<Typography.Text className="text-xs">
|
||||
格式正确
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-red-600">
|
||||
<X size={14} />
|
||||
<Typography.Text className="text-xs">
|
||||
格式错误
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
icon={<Edit size={14} />}
|
||||
onClick={formatJson}
|
||||
disabled={!isValid}
|
||||
className="!rounded-lg"
|
||||
>
|
||||
格式化
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
value={localValue}
|
||||
onChange={handleValueChange}
|
||||
placeholder='{"model": "gpt-4o", "messages": [...], ...}'
|
||||
autosize={{ minRows: 8, maxRows: 20 }}
|
||||
className={`custom-request-textarea !rounded-lg font-mono text-sm ${!isValid ? '!border-red-500' : ''}`}
|
||||
style={{
|
||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
/>
|
||||
|
||||
{!isValid && errorMessage && (
|
||||
<Typography.Text type="danger" className="text-xs mt-1 block">
|
||||
{errorMessage}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
<Typography.Text className="text-xs text-gray-500 mt-2 block">
|
||||
请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomRequestEditor;
|
||||
193
web/src/components/playground/DebugPanel.js
Normal file
193
web/src/components/playground/DebugPanel.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Typography,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Button,
|
||||
Dropdown,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Code,
|
||||
Zap,
|
||||
Clock,
|
||||
X,
|
||||
Eye,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CodeViewer from './CodeViewer';
|
||||
|
||||
const DebugPanel = ({
|
||||
debugData,
|
||||
activeDebugTab,
|
||||
onActiveDebugTabChange,
|
||||
styleState,
|
||||
onCloseDebugPanel,
|
||||
customRequestMode,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [activeKey, setActiveKey] = useState(activeDebugTab);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveKey(activeDebugTab);
|
||||
}, [activeDebugTab]);
|
||||
|
||||
const handleTabChange = (key) => {
|
||||
setActiveKey(key);
|
||||
onActiveDebugTabChange(key);
|
||||
};
|
||||
|
||||
const renderArrow = (items, pos, handleArrowClick, defaultNode) => {
|
||||
const style = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
margin: '0 12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: '100%',
|
||||
background: 'rgba(var(--semi-grey-1), 1)',
|
||||
color: 'var(--semi-color-text)',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
{items.map(item => {
|
||||
return (
|
||||
<Dropdown.Item
|
||||
key={item.itemKey}
|
||||
onClick={() => handleTabChange(item.itemKey)}
|
||||
>
|
||||
{item.tab}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
})}
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
{pos === 'start' ? (
|
||||
<div style={style} onClick={handleArrowClick}>
|
||||
←
|
||||
</div>
|
||||
) : (
|
||||
<div style={style} onClick={handleArrowClick}>
|
||||
→
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="h-full flex flex-col"
|
||||
bordered={false}
|
||||
bodyStyle={{
|
||||
padding: styleState.isMobile ? '16px' : '24px',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6 flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3">
|
||||
<Code size={20} className="text-white" />
|
||||
</div>
|
||||
<Typography.Title heading={5} className="mb-0">
|
||||
{t('调试信息')}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
{styleState.isMobile && onCloseDebugPanel && (
|
||||
<Button
|
||||
icon={<X size={16} />}
|
||||
onClick={onCloseDebugPanel}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden debug-panel">
|
||||
<Tabs
|
||||
renderArrow={renderArrow}
|
||||
type="card"
|
||||
collapsible
|
||||
className="h-full"
|
||||
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
|
||||
activeKey={activeKey}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
<TabPane tab={
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye size={16} />
|
||||
{t('预览请求体')}
|
||||
{customRequestMode && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full">
|
||||
自定义
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
} itemKey="preview">
|
||||
<CodeViewer
|
||||
content={debugData.previewRequest}
|
||||
title="preview"
|
||||
language="json"
|
||||
/>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab={
|
||||
<div className="flex items-center gap-2">
|
||||
<Send size={16} />
|
||||
{t('实际请求体')}
|
||||
</div>
|
||||
} itemKey="request">
|
||||
<CodeViewer
|
||||
content={debugData.request}
|
||||
title="request"
|
||||
language="json"
|
||||
/>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab={
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap size={16} />
|
||||
{t('响应')}
|
||||
</div>
|
||||
} itemKey="response">
|
||||
<CodeViewer
|
||||
content={debugData.response}
|
||||
title="response"
|
||||
language="json"
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-4 pt-4 flex-shrink-0">
|
||||
{(debugData.timestamp || debugData.previewTimestamp) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={14} className="text-gray-500" />
|
||||
<Typography.Text className="text-xs text-gray-500">
|
||||
{activeKey === 'preview' && debugData.previewTimestamp
|
||||
? `${t('预览更新')}: ${new Date(debugData.previewTimestamp).toLocaleString()}`
|
||||
: debugData.timestamp
|
||||
? `${t('最后请求')}: ${new Date(debugData.timestamp).toLocaleString()}`
|
||||
: ''}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebugPanel;
|
||||
71
web/src/components/playground/FloatingButtons.js
Normal file
71
web/src/components/playground/FloatingButtons.js
Normal file
@@ -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;
|
||||
113
web/src/components/playground/ImageUrlInput.js
Normal file
113
web/src/components/playground/ImageUrlInput.js
Normal file
@@ -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;
|
||||
121
web/src/components/playground/MessageActions.js
Normal file
121
web/src/components/playground/MessageActions.js
Normal file
@@ -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;
|
||||
313
web/src/components/playground/MessageContent.js
Normal file
313
web/src/components/playground/MessageContent.js
Normal file
@@ -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;
|
||||
60
web/src/components/playground/OptimizedComponents.js
Normal file
60
web/src/components/playground/OptimizedComponents.js
Normal file
@@ -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
|
||||
);
|
||||
});
|
||||
241
web/src/components/playground/ParameterControl.js
Normal file
241
web/src/components/playground/ParameterControl.js
Normal file
@@ -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;
|
||||
234
web/src/components/playground/SettingsPanel.js
Normal file
234
web/src/components/playground/SettingsPanel.js
Normal file
@@ -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;
|
||||
125
web/src/components/playground/ThinkingContent.js
Normal file
125
web/src/components/playground/ThinkingContent.js
Normal file
@@ -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;
|
||||
203
web/src/components/playground/configStorage.js
Normal file
203
web/src/components/playground/configStorage.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../constants/playground.constants';
|
||||
|
||||
const MESSAGES_STORAGE_KEY = 'playground_messages';
|
||||
|
||||
/**
|
||||
* 保存配置到 localStorage
|
||||
* @param {Object} config - 要保存的配置对象
|
||||
*/
|
||||
export const saveConfig = (config) => {
|
||||
try {
|
||||
const configToSave = {
|
||||
...config,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(configToSave));
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存消息到 localStorage
|
||||
* @param {Array} messages - 要保存的消息数组
|
||||
*/
|
||||
export const saveMessages = (messages) => {
|
||||
try {
|
||||
const messagesToSave = {
|
||||
messages,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEYS.MESSAGES, JSON.stringify(messagesToSave));
|
||||
} catch (error) {
|
||||
console.error('保存消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载配置
|
||||
* @returns {Object} 配置对象,如果不存在则返回默认配置
|
||||
*/
|
||||
export const loadConfig = () => {
|
||||
try {
|
||||
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
|
||||
if (savedConfig) {
|
||||
const parsedConfig = JSON.parse(savedConfig);
|
||||
|
||||
const mergedConfig = {
|
||||
inputs: {
|
||||
...DEFAULT_CONFIG.inputs,
|
||||
...parsedConfig.inputs,
|
||||
},
|
||||
parameterEnabled: {
|
||||
...DEFAULT_CONFIG.parameterEnabled,
|
||||
...parsedConfig.parameterEnabled,
|
||||
},
|
||||
showDebugPanel: parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
|
||||
customRequestMode: parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
|
||||
customRequestBody: parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
|
||||
};
|
||||
|
||||
return mergedConfig;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error);
|
||||
}
|
||||
|
||||
return DEFAULT_CONFIG;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载消息
|
||||
* @returns {Array} 消息数组,如果不存在则返回 null
|
||||
*/
|
||||
export const loadMessages = () => {
|
||||
try {
|
||||
const savedMessages = localStorage.getItem(STORAGE_KEYS.MESSAGES);
|
||||
if (savedMessages) {
|
||||
const parsedMessages = JSON.parse(savedMessages);
|
||||
return parsedMessages.messages || null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载消息失败:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除保存的配置
|
||||
*/
|
||||
export const clearConfig = () => {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEYS.CONFIG);
|
||||
localStorage.removeItem(STORAGE_KEYS.MESSAGES); // 同时清除消息
|
||||
} catch (error) {
|
||||
console.error('清除配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除保存的消息
|
||||
*/
|
||||
export const clearMessages = () => {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEYS.MESSAGES);
|
||||
} catch (error) {
|
||||
console.error('清除消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否有保存的配置
|
||||
* @returns {boolean} 是否存在保存的配置
|
||||
*/
|
||||
export const hasStoredConfig = () => {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEYS.CONFIG) !== null;
|
||||
} catch (error) {
|
||||
console.error('检查配置失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取配置的最后保存时间
|
||||
* @returns {string|null} 最后保存时间的 ISO 字符串
|
||||
*/
|
||||
export const getConfigTimestamp = () => {
|
||||
try {
|
||||
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
|
||||
if (savedConfig) {
|
||||
const parsedConfig = JSON.parse(savedConfig);
|
||||
return parsedConfig.timestamp || null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取配置时间戳失败:', error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出配置为 JSON 文件(包含消息)
|
||||
* @param {Object} config - 要导出的配置
|
||||
* @param {Array} messages - 要导出的消息
|
||||
*/
|
||||
export const exportConfig = (config, messages = null) => {
|
||||
try {
|
||||
const configToExport = {
|
||||
...config,
|
||||
messages: messages || loadMessages(), // 包含消息数据
|
||||
exportTime: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
};
|
||||
|
||||
const dataStr = JSON.stringify(configToExport, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(dataBlob);
|
||||
link.download = `playground-config-${new Date().toISOString().split('T')[0]}.json`;
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(link.href);
|
||||
|
||||
} catch (error) {
|
||||
console.error('导出配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从文件导入配置(包含消息)
|
||||
* @param {File} file - 包含配置的 JSON 文件
|
||||
* @returns {Promise<Object>} 导入的配置对象
|
||||
*/
|
||||
export const importConfig = (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const importedConfig = JSON.parse(e.target.result);
|
||||
|
||||
if (importedConfig.inputs && importedConfig.parameterEnabled) {
|
||||
// 如果导入的配置包含消息,也一起导入
|
||||
if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
|
||||
saveMessages(importedConfig.messages);
|
||||
}
|
||||
|
||||
resolve(importedConfig);
|
||||
} else {
|
||||
reject(new Error('配置文件格式无效'));
|
||||
}
|
||||
} catch (parseError) {
|
||||
reject(new Error('解析配置文件失败: ' + parseError.message));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('读取文件失败'));
|
||||
reader.readAsText(file);
|
||||
} catch (error) {
|
||||
reject(new Error('导入配置失败: ' + error.message));
|
||||
}
|
||||
});
|
||||
};
|
||||
20
web/src/components/playground/index.js
Normal file
20
web/src/components/playground/index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
export { default as SettingsPanel } from './SettingsPanel';
|
||||
export { default as ChatArea } from './ChatArea';
|
||||
export { default as DebugPanel } from './DebugPanel';
|
||||
export { default as MessageContent } from './MessageContent';
|
||||
export { default as MessageActions } from './MessageActions';
|
||||
export { default as CustomInputRender } from './CustomInputRender';
|
||||
export { default as ParameterControl } from './ParameterControl';
|
||||
export { default as ImageUrlInput } from './ImageUrlInput';
|
||||
export { default as FloatingButtons } from './FloatingButtons';
|
||||
export { default as ConfigManager } from './ConfigManager';
|
||||
|
||||
export {
|
||||
saveConfig,
|
||||
loadConfig,
|
||||
clearConfig,
|
||||
hasStoredConfig,
|
||||
getConfigTimestamp,
|
||||
exportConfig,
|
||||
importConfig,
|
||||
} from './configStorage';
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||
|
||||
import { API, showError, showSuccess } from '../helpers';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SettingGeminiModel from '../pages/Setting/Model/SettingGeminiModel.js';
|
||||
import SettingClaudeModel from '../pages/Setting/Model/SettingClaudeModel.js';
|
||||
import SettingGlobalModel from '../pages/Setting/Model/SettingGlobalModel.js';
|
||||
import SettingGeminiModel from '../../pages/Setting/Model/SettingGeminiModel.js';
|
||||
import SettingClaudeModel from '../../pages/Setting/Model/SettingClaudeModel.js';
|
||||
import SettingGlobalModel from '../../pages/Setting/Model/SettingGlobalModel.js';
|
||||
|
||||
const ModelSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -1,20 +1,20 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||
import SettingsGeneral from '../pages/Setting/Operation/SettingsGeneral.js';
|
||||
import SettingsDrawing from '../pages/Setting/Operation/SettingsDrawing.js';
|
||||
import SettingsSensitiveWords from '../pages/Setting/Operation/SettingsSensitiveWords.js';
|
||||
import SettingsLog from '../pages/Setting/Operation/SettingsLog.js';
|
||||
import SettingsDataDashboard from '../pages/Setting/Operation/SettingsDataDashboard.js';
|
||||
import SettingsMonitoring from '../pages/Setting/Operation/SettingsMonitoring.js';
|
||||
import SettingsCreditLimit from '../pages/Setting/Operation/SettingsCreditLimit.js';
|
||||
import ModelSettingsVisualEditor from '../pages/Setting/Operation/ModelSettingsVisualEditor.js';
|
||||
import GroupRatioSettings from '../pages/Setting/Operation/GroupRatioSettings.js';
|
||||
import ModelRatioSettings from '../pages/Setting/Operation/ModelRatioSettings.js';
|
||||
import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral.js';
|
||||
import SettingsDrawing from '../../pages/Setting/Operation/SettingsDrawing.js';
|
||||
import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords.js';
|
||||
import SettingsLog from '../../pages/Setting/Operation/SettingsLog.js';
|
||||
import SettingsDataDashboard from '../../pages/Setting/Operation/SettingsDataDashboard.js';
|
||||
import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring.js';
|
||||
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit.js';
|
||||
import ModelSettingsVisualEditor from '../../pages/Setting/Operation/ModelSettingsVisualEditor.js';
|
||||
import GroupRatioSettings from '../../pages/Setting/Operation/GroupRatioSettings.js';
|
||||
import ModelRatioSettings from '../../pages/Setting/Operation/ModelRatioSettings.js';
|
||||
|
||||
import { API, showError, showSuccess } from '../helpers';
|
||||
import SettingsChats from '../pages/Setting/Operation/SettingsChats.js';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ModelRatioNotSetEditor from '../pages/Setting/Operation/ModelRationNotSetEditor.js';
|
||||
import ModelRatioNotSetEditor from '../../pages/Setting/Operation/ModelRationNotSetEditor.js';
|
||||
|
||||
const OperationSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
Space,
|
||||
Card,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess, timestamp2string } from '../helpers';
|
||||
import { API, showError, showSuccess, timestamp2string } from '../../helpers';
|
||||
import { marked } from 'marked';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatusContext } from '../context/Status/index.js';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
|
||||
const OtherSetting = () => {
|
||||
1552
web/src/components/settings/PersonalSetting.js
Normal file
1552
web/src/components/settings/PersonalSetting.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||
|
||||
import { API, showError, showSuccess } from '../helpers';
|
||||
import SettingsChats from '../pages/Setting/Operation/SettingsChats.js';
|
||||
import { API, showError, showSuccess } from '../../helpers/index.js';
|
||||
import SettingsChats from '../../pages/Setting/Operation/SettingsChats.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RequestRateLimit from '../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
|
||||
import RequestRateLimit from '../../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
|
||||
|
||||
const RateLimitSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
} from '@douyinfe/semi-ui';
|
||||
const { Text } = Typography;
|
||||
import {
|
||||
API,
|
||||
removeTrailingSlash,
|
||||
showError,
|
||||
showSuccess,
|
||||
verifyJSON,
|
||||
} from '../helpers/utils';
|
||||
import { API } from '../helpers/api';
|
||||
verifyJSON
|
||||
} from '../../helpers';
|
||||
import axios from 'axios';
|
||||
|
||||
const SystemSetting = () => {
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
@@ -8,26 +8,6 @@ import {
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
} from '../helpers';
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Descriptions,
|
||||
Form,
|
||||
Layout,
|
||||
Modal,
|
||||
Popover,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Checkbox,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { ITEMS_PER_PAGE } from '../constants';
|
||||
import {
|
||||
renderAudioModelPrice,
|
||||
renderClaudeLogContent,
|
||||
renderClaudeModelPrice,
|
||||
@@ -39,13 +19,34 @@ import {
|
||||
renderNumber,
|
||||
renderQuota,
|
||||
stringToColor,
|
||||
} from '../helpers/render';
|
||||
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
|
||||
import { getLogOther } from '../helpers/other.js';
|
||||
import { StyleContext } from '../context/Style/index.js';
|
||||
import { IconInherit, IconRefresh, IconSetting } from '@douyinfe/semi-icons';
|
||||
getLogOther,
|
||||
renderModelTag,
|
||||
} from '../../helpers';
|
||||
|
||||
const { Header } = Layout;
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Descriptions,
|
||||
Modal,
|
||||
Popover,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Checkbox,
|
||||
Card,
|
||||
Typography,
|
||||
Divider,
|
||||
Input,
|
||||
DatePicker,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
|
||||
import { IconSetting, IconSearch, IconForward } from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function renderTimestamp(timestamp) {
|
||||
return <>{timestamp2string(timestamp)}</>;
|
||||
@@ -81,37 +82,37 @@ const LogsTable = () => {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='cyan' size='large'>
|
||||
<Tag color='cyan' size='large' shape='circle'>
|
||||
{t('充值')}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='lime' size='large'>
|
||||
<Tag color='lime' size='large' shape='circle'>
|
||||
{t('消费')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag color='orange' size='large'>
|
||||
<Tag color='orange' size='large' shape='circle'>
|
||||
{t('管理')}
|
||||
</Tag>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<Tag color='purple' size='large'>
|
||||
<Tag color='purple' size='large' shape='circle'>
|
||||
{t('系统')}
|
||||
</Tag>
|
||||
);
|
||||
case 5:
|
||||
return (
|
||||
<Tag color='red' size='large'>
|
||||
<Tag color='red' size='large' shape='circle'>
|
||||
{t('错误')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='grey' size='large'>
|
||||
<Tag color='grey' size='large' shape='circle'>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -121,13 +122,13 @@ const LogsTable = () => {
|
||||
function renderIsStream(bool) {
|
||||
if (bool) {
|
||||
return (
|
||||
<Tag color='blue' size='large'>
|
||||
<Tag color='blue' size='large' shape='circle'>
|
||||
{t('流')}
|
||||
</Tag>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Tag color='purple' size='large'>
|
||||
<Tag color='purple' size='large' shape='circle'>
|
||||
{t('非流')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -138,21 +139,21 @@ const LogsTable = () => {
|
||||
const time = parseInt(type);
|
||||
if (time < 101) {
|
||||
return (
|
||||
<Tag color='green' size='large'>
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
{' '}
|
||||
{time} s{' '}
|
||||
</Tag>
|
||||
);
|
||||
} else if (time < 300) {
|
||||
return (
|
||||
<Tag color='orange' size='large'>
|
||||
<Tag color='orange' size='large' shape='circle'>
|
||||
{' '}
|
||||
{time} s{' '}
|
||||
</Tag>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Tag color='red' size='large'>
|
||||
<Tag color='red' size='large' shape='circle'>
|
||||
{' '}
|
||||
{time} s{' '}
|
||||
</Tag>
|
||||
@@ -165,21 +166,21 @@ const LogsTable = () => {
|
||||
time = time.toFixed(1);
|
||||
if (time < 3) {
|
||||
return (
|
||||
<Tag color='green' size='large'>
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
{' '}
|
||||
{time} s{' '}
|
||||
</Tag>
|
||||
);
|
||||
} else if (time < 10) {
|
||||
return (
|
||||
<Tag color='orange' size='large'>
|
||||
<Tag color='orange' size='large' shape='circle'>
|
||||
{' '}
|
||||
{time} s{' '}
|
||||
</Tag>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Tag color='red' size='large'>
|
||||
<Tag color='red' size='large' shape='circle'>
|
||||
{' '}
|
||||
{time} s{' '}
|
||||
</Tag>
|
||||
@@ -194,18 +195,11 @@ const LogsTable = () => {
|
||||
other?.upstream_model_name &&
|
||||
other?.upstream_model_name !== '';
|
||||
if (!modelMapped) {
|
||||
return (
|
||||
<Tag
|
||||
color={stringToColor(record.model_name)}
|
||||
size='large'
|
||||
onClick={(event) => {
|
||||
copyText(event, record.model_name).then((r) => {});
|
||||
}}
|
||||
>
|
||||
{' '}
|
||||
{record.model_name}{' '}
|
||||
</Tag>
|
||||
);
|
||||
return renderModelTag(record.model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, record.model_name).then((r) => { });
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
@@ -214,57 +208,43 @@ const LogsTable = () => {
|
||||
content={
|
||||
<div style={{ padding: 10 }}>
|
||||
<Space vertical align={'start'}>
|
||||
<Tag
|
||||
color={stringToColor(record.model_name)}
|
||||
size='large'
|
||||
onClick={(event) => {
|
||||
copyText(event, record.model_name).then((r) => {});
|
||||
}}
|
||||
>
|
||||
{t('请求并计费模型')} {record.model_name}{' '}
|
||||
</Tag>
|
||||
<Tag
|
||||
color={stringToColor(other.upstream_model_name)}
|
||||
size='large'
|
||||
onClick={(event) => {
|
||||
copyText(event, other.upstream_model_name).then(
|
||||
(r) => {},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('实际模型')} {other.upstream_model_name}{' '}
|
||||
</Tag>
|
||||
<div className='flex items-center'>
|
||||
<Text strong style={{ marginRight: 8 }}>
|
||||
{t('请求并计费模型')}:
|
||||
</Text>
|
||||
{renderModelTag(record.model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, record.model_name).then((r) => { });
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<Text strong style={{ marginRight: 8 }}>
|
||||
{t('实际模型')}:
|
||||
</Text>
|
||||
{renderModelTag(other.upstream_model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, other.upstream_model_name).then(
|
||||
(r) => { },
|
||||
);
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag
|
||||
color={stringToColor(record.model_name)}
|
||||
size='large'
|
||||
onClick={(event) => {
|
||||
copyText(event, record.model_name).then((r) => {});
|
||||
}}
|
||||
suffixIcon={
|
||||
<IconRefresh
|
||||
style={{ width: '0.8em', height: '0.8em', opacity: 0.6 }}
|
||||
{renderModelTag(record.model_name, {
|
||||
onClick: (event) => {
|
||||
copyText(event, record.model_name).then((r) => { });
|
||||
},
|
||||
suffixIcon: (
|
||||
<IconForward
|
||||
style={{ width: '0.9em', height: '0.9em', opacity: 0.75 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
{record.model_name}{' '}
|
||||
</Tag>
|
||||
),
|
||||
})}
|
||||
</Popover>
|
||||
{/*<Tooltip content={t('实际模型')}>*/}
|
||||
{/* <Tag*/}
|
||||
{/* color={stringToColor(other.upstream_model_name)}*/}
|
||||
{/* size='large'*/}
|
||||
{/* onClick={(event) => {*/}
|
||||
{/* copyText(event, other.upstream_model_name).then(r => {});*/}
|
||||
{/* }}*/}
|
||||
{/* >*/}
|
||||
{/* {' '}{other.upstream_model_name}{' '}*/}
|
||||
{/* </Tag>*/}
|
||||
{/*</Tooltip>*/}
|
||||
</Space>
|
||||
</>
|
||||
);
|
||||
@@ -386,6 +366,7 @@ const LogsTable = () => {
|
||||
<Tag
|
||||
color={colors[parseInt(text) % colors.length]}
|
||||
size='large'
|
||||
shape='circle'
|
||||
>
|
||||
{' '}
|
||||
{text}{' '}
|
||||
@@ -437,6 +418,7 @@ const LogsTable = () => {
|
||||
<Tag
|
||||
color='grey'
|
||||
size='large'
|
||||
shape='circle'
|
||||
onClick={(event) => {
|
||||
//cancel the row click event
|
||||
copyText(event, text);
|
||||
@@ -600,6 +582,7 @@ const LogsTable = () => {
|
||||
key: COLUMN_KEYS.DETAILS,
|
||||
title: t('详情'),
|
||||
dataIndex: 'content',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
let other = getLogOther(record.other);
|
||||
if (other == null || record.type !== 2) {
|
||||
@@ -620,21 +603,21 @@ const LogsTable = () => {
|
||||
}
|
||||
let content = other?.claude
|
||||
? renderClaudeModelPriceSimple(
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_tokens || 0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
)
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_tokens || 0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
)
|
||||
: renderModelPriceSimple(
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
);
|
||||
other.model_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other.cache_tokens || 0,
|
||||
other.cache_ratio || 1.0,
|
||||
);
|
||||
return (
|
||||
<Paragraph
|
||||
ellipsis={{
|
||||
@@ -673,15 +656,29 @@ const LogsTable = () => {
|
||||
visible={showColumnSelector}
|
||||
onCancel={() => setShowColumnSelector(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => initDefaultColumns()}>{t('重置')}</Button>
|
||||
<Button onClick={() => setShowColumnSelector(false)}>
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
theme='light'
|
||||
onClick={() => initDefaultColumns()}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
onClick={() => setShowColumnSelector(false)}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button type='primary' onClick={() => setShowColumnSelector(false)}>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => setShowColumnSelector(false)}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('确定')}
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
@@ -697,15 +694,8 @@ const LogsTable = () => {
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: '6px',
|
||||
padding: '16px',
|
||||
}}
|
||||
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
|
||||
style={{ border: '1px solid var(--semi-color-border)' }}
|
||||
>
|
||||
{allColumns.map((column) => {
|
||||
// Skip admin-only columns for non-admin users
|
||||
@@ -719,10 +709,7 @@ const LogsTable = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.key}
|
||||
style={{ width: '50%', marginBottom: 16, paddingRight: 8 }}
|
||||
>
|
||||
<div key={column.key} className='w-1/2 mb-4 pr-2'>
|
||||
<Checkbox
|
||||
checked={!!visibleColumns[column.key]}
|
||||
onChange={(e) =>
|
||||
@@ -739,7 +726,6 @@ const LogsTable = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const [styleState, styleDispatch] = useContext(StyleContext);
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [expandData, setExpandData] = useState({});
|
||||
const [showStat, setShowStat] = useState(false);
|
||||
@@ -921,27 +907,27 @@ const LogsTable = () => {
|
||||
key: t('日志详情'),
|
||||
value: other?.claude
|
||||
? renderClaudeLogContent(
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
)
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other.cache_ratio || 1.0,
|
||||
other.cache_creation_ratio || 1.0,
|
||||
)
|
||||
: renderLogContent(
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
false,
|
||||
1.0,
|
||||
undefined,
|
||||
other.web_search || false,
|
||||
other.web_search_call_count || 0,
|
||||
other.file_search || false,
|
||||
other.file_search_call_count || 0,
|
||||
),
|
||||
other?.model_ratio,
|
||||
other.completion_ratio,
|
||||
other.model_price,
|
||||
other.group_ratio,
|
||||
other?.user_group_ratio,
|
||||
false,
|
||||
1.0,
|
||||
undefined,
|
||||
other.web_search || false,
|
||||
other.web_search_call_count || 0,
|
||||
other.file_search || false,
|
||||
other.file_search_call_count || 0,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (logs[i].type === 2) {
|
||||
@@ -1007,6 +993,9 @@ const LogsTable = () => {
|
||||
other?.file_search || false,
|
||||
other?.file_search_call_count || 0,
|
||||
other?.file_search_price || 0,
|
||||
other?.audio_input_seperate_price || false,
|
||||
other?.audio_input_token_count || 0,
|
||||
other?.audio_input_price || 0,
|
||||
);
|
||||
}
|
||||
expandDataLocal.push({
|
||||
@@ -1056,7 +1045,7 @@ const LogsTable = () => {
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
loadLogs(page, pageSize, logType).then((r) => {});
|
||||
loadLogs(page, pageSize, logType).then((r) => { });
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
@@ -1101,89 +1090,73 @@ const LogsTable = () => {
|
||||
return <Descriptions data={expandData[record.key]} />;
|
||||
};
|
||||
|
||||
// 检查是否有任何记录有展开内容
|
||||
const hasExpandableRows = () => {
|
||||
return logs.some(
|
||||
(log) => expandData[log.key] && expandData[log.key].length > 0,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderColumnSelector()}
|
||||
<Layout>
|
||||
<Header>
|
||||
<Spin spinning={loadingStat}>
|
||||
<Space>
|
||||
<Tag
|
||||
color='blue'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
borderRadius: '8px',
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
{t('消耗额度')}: {renderQuota(stat.quota)}
|
||||
</Tag>
|
||||
<Tag
|
||||
color='pink'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
borderRadius: '8px',
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
RPM: {stat.rpm}
|
||||
</Tag>
|
||||
<Tag
|
||||
color='white'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
border: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
TPM: {stat.tpm}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Spin>
|
||||
</Header>
|
||||
<Form layout='horizontal' style={{ marginTop: 10 }}>
|
||||
<>
|
||||
<Form.Section>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
{styleState.isMobile ? (
|
||||
<div>
|
||||
<Form.DatePicker
|
||||
field='start_timestamp'
|
||||
label={t('起始时间')}
|
||||
style={{ width: 272 }}
|
||||
initValue={start_timestamp}
|
||||
type='dateTime'
|
||||
onChange={(value) => {
|
||||
console.log(value);
|
||||
handleInputChange(value, 'start_timestamp');
|
||||
}}
|
||||
/>
|
||||
<Form.DatePicker
|
||||
field='end_timestamp'
|
||||
fluid
|
||||
label={t('结束时间')}
|
||||
style={{ width: 272 }}
|
||||
initValue={end_timestamp}
|
||||
type='dateTime'
|
||||
onChange={(value) =>
|
||||
handleInputChange(value, 'end_timestamp')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Form.DatePicker
|
||||
field='range_timestamp'
|
||||
label={t('时间范围')}
|
||||
initValue={[start_timestamp, end_timestamp]}
|
||||
<Card
|
||||
className='!rounded-2xl mb-4'
|
||||
title={
|
||||
<div className='flex flex-col w-full'>
|
||||
<Spin spinning={loadingStat}>
|
||||
<Space>
|
||||
<Tag
|
||||
color='blue'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
{t('消耗额度')}: {renderQuota(stat.quota)}
|
||||
</Tag>
|
||||
<Tag
|
||||
color='pink'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
RPM: {stat.rpm}
|
||||
</Tag>
|
||||
<Tag
|
||||
color='white'
|
||||
size='large'
|
||||
style={{
|
||||
padding: 15,
|
||||
border: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
TPM: {stat.tpm}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Spin>
|
||||
|
||||
<Divider margin='12px' />
|
||||
|
||||
{/* 搜索表单区域 */}
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
|
||||
{/* 时间选择器 */}
|
||||
<div className='col-span-1 lg:col-span-2'>
|
||||
<DatePicker
|
||||
className='w-full'
|
||||
value={[start_timestamp, end_timestamp]}
|
||||
type='dateTimeRange'
|
||||
name='range_timestamp'
|
||||
onChange={(value) => {
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
handleInputChange(value[0], 'start_timestamp');
|
||||
@@ -1191,100 +1164,118 @@ const LogsTable = () => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 日志类型选择器 */}
|
||||
<Select
|
||||
value={logType.toString()}
|
||||
placeholder={t('日志类型')}
|
||||
className='!rounded-full'
|
||||
onChange={(value) => {
|
||||
setLogType(parseInt(value));
|
||||
loadLogs(0, pageSize, parseInt(value));
|
||||
}}
|
||||
>
|
||||
<Select.Option value='0'>{t('全部')}</Select.Option>
|
||||
<Select.Option value='1'>{t('充值')}</Select.Option>
|
||||
<Select.Option value='2'>{t('消费')}</Select.Option>
|
||||
<Select.Option value='3'>{t('管理')}</Select.Option>
|
||||
<Select.Option value='4'>{t('系统')}</Select.Option>
|
||||
<Select.Option value='5'>{t('错误')}</Select.Option>
|
||||
</Select>
|
||||
|
||||
{/* 其他搜索字段 */}
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('令牌名称')}
|
||||
value={token_name}
|
||||
onChange={(value) => handleInputChange(value, 'token_name')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模型名称')}
|
||||
value={model_name}
|
||||
onChange={(value) => handleInputChange(value, 'model_name')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('分组')}
|
||||
value={group}
|
||||
onChange={(value) => handleInputChange(value, 'group')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
/>
|
||||
|
||||
{isAdminUser && (
|
||||
<>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道 ID')}
|
||||
value={channel}
|
||||
onChange={(value) => handleInputChange(value, 'channel')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
/>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('用户名称')}
|
||||
value={username}
|
||||
onChange={(value) => handleInputChange(value, 'username')}
|
||||
className='!rounded-full'
|
||||
showClear
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Form.Section>
|
||||
<Form.Input
|
||||
field='token_name'
|
||||
label={t('令牌名称')}
|
||||
value={token_name}
|
||||
placeholder={t('可选值')}
|
||||
name='token_name'
|
||||
onChange={(value) => handleInputChange(value, 'token_name')}
|
||||
/>
|
||||
<Form.Input
|
||||
field='model_name'
|
||||
label={t('模型名称')}
|
||||
value={model_name}
|
||||
placeholder={t('可选值')}
|
||||
name='model_name'
|
||||
onChange={(value) => handleInputChange(value, 'model_name')}
|
||||
/>
|
||||
<Form.Input
|
||||
field='group'
|
||||
label={t('分组')}
|
||||
value={group}
|
||||
placeholder={t('可选值')}
|
||||
name='group'
|
||||
onChange={(value) => handleInputChange(value, 'group')}
|
||||
/>
|
||||
{isAdminUser && (
|
||||
<>
|
||||
<Form.Input
|
||||
field='channel'
|
||||
label={t('渠道 ID')}
|
||||
value={channel}
|
||||
placeholder={t('可选值')}
|
||||
name='channel'
|
||||
onChange={(value) => handleInputChange(value, 'channel')}
|
||||
/>
|
||||
<Form.Input
|
||||
field='username'
|
||||
label={t('用户名称')}
|
||||
value={username}
|
||||
placeholder={t('可选值')}
|
||||
name='username'
|
||||
onChange={(value) => handleInputChange(value, 'username')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
label={t('查询')}
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
className='btn-margin-right'
|
||||
onClick={refresh}
|
||||
loading={loading}
|
||||
style={{ marginTop: 24 }}
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Form.Section></Form.Section>
|
||||
</>
|
||||
</Form>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Select
|
||||
defaultValue='0'
|
||||
style={{ width: 120 }}
|
||||
onChange={(value) => {
|
||||
setLogType(parseInt(value));
|
||||
loadLogs(0, pageSize, parseInt(value));
|
||||
}}
|
||||
>
|
||||
<Select.Option value='0'>{t('全部')}</Select.Option>
|
||||
<Select.Option value='1'>{t('充值')}</Select.Option>
|
||||
<Select.Option value='2'>{t('消费')}</Select.Option>
|
||||
<Select.Option value='3'>{t('管理')}</Select.Option>
|
||||
<Select.Option value='4'>{t('系统')}</Select.Option>
|
||||
<Select.Option value='5'>{t('错误')}</Select.Option>
|
||||
</Select>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconSetting />}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className='flex justify-between items-center pt-2'>
|
||||
<div></div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={refresh}
|
||||
loading={loading}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconSetting />}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className='!rounded-full'
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
shadows='always'
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
style={{ marginTop: 5 }}
|
||||
columns={getVisibleColumns()}
|
||||
expandedRowRender={expandRowRender}
|
||||
expandRowByClick={true}
|
||||
{...(hasExpandableRows() && {
|
||||
expandedRowRender: expandRowRender,
|
||||
expandRowByClick: true,
|
||||
rowExpandable: (record) => expandData[record.key] && expandData[record.key].length > 0
|
||||
})}
|
||||
dataSource={logs}
|
||||
rowKey='key'
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
className='rounded-xl overflow-hidden'
|
||||
size='middle'
|
||||
pagination={{
|
||||
formatPageText: (page) =>
|
||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
@@ -1295,7 +1286,7 @@ const LogsTable = () => {
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: logCount,
|
||||
pageSizeOpts: [10, 20, 50, 100],
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showSizeChanger: true,
|
||||
onPageSizeChange: (size) => {
|
||||
handlePageSizeChange(size);
|
||||
@@ -1303,7 +1294,7 @@ const LogsTable = () => {
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
</Layout>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
908
web/src/components/table/MjLogsTable.js
Normal file
908
web/src/components/table/MjLogsTable.js
Normal file
@@ -0,0 +1,908 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
isAdmin,
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
} from '../../helpers';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Divider,
|
||||
ImagePreview,
|
||||
Input,
|
||||
Layout,
|
||||
Modal,
|
||||
Progress,
|
||||
Skeleton,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
IconEyeOpened,
|
||||
IconSearch,
|
||||
IconSetting,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const colors = [
|
||||
'amber',
|
||||
'blue',
|
||||
'cyan',
|
||||
'green',
|
||||
'grey',
|
||||
'indigo',
|
||||
'light-blue',
|
||||
'lime',
|
||||
'orange',
|
||||
'pink',
|
||||
'purple',
|
||||
'red',
|
||||
'teal',
|
||||
'violet',
|
||||
'yellow',
|
||||
];
|
||||
|
||||
// 定义列键值常量
|
||||
const COLUMN_KEYS = {
|
||||
SUBMIT_TIME: 'submit_time',
|
||||
DURATION: 'duration',
|
||||
CHANNEL: 'channel',
|
||||
TYPE: 'type',
|
||||
TASK_ID: 'task_id',
|
||||
SUBMIT_RESULT: 'submit_result',
|
||||
TASK_STATUS: 'task_status',
|
||||
PROGRESS: 'progress',
|
||||
IMAGE: 'image',
|
||||
PROMPT: 'prompt',
|
||||
PROMPT_EN: 'prompt_en',
|
||||
FAIL_REASON: 'fail_reason',
|
||||
};
|
||||
|
||||
const LogsTable = () => {
|
||||
const { t } = useTranslation();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [modalContent, setModalContent] = useState('');
|
||||
|
||||
// 列可见性状态
|
||||
const [visibleColumns, setVisibleColumns] = useState({});
|
||||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||||
const isAdminUser = isAdmin();
|
||||
|
||||
// 加载保存的列偏好设置
|
||||
useEffect(() => {
|
||||
const savedColumns = localStorage.getItem('mj-logs-table-columns');
|
||||
if (savedColumns) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedColumns);
|
||||
const defaults = getDefaultColumnVisibility();
|
||||
const merged = { ...defaults, ...parsed };
|
||||
setVisibleColumns(merged);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved column preferences', e);
|
||||
initDefaultColumns();
|
||||
}
|
||||
} else {
|
||||
initDefaultColumns();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 获取默认列可见性
|
||||
const getDefaultColumnVisibility = () => {
|
||||
return {
|
||||
[COLUMN_KEYS.SUBMIT_TIME]: true,
|
||||
[COLUMN_KEYS.DURATION]: true,
|
||||
[COLUMN_KEYS.CHANNEL]: isAdminUser,
|
||||
[COLUMN_KEYS.TYPE]: true,
|
||||
[COLUMN_KEYS.TASK_ID]: true,
|
||||
[COLUMN_KEYS.SUBMIT_RESULT]: isAdminUser,
|
||||
[COLUMN_KEYS.TASK_STATUS]: true,
|
||||
[COLUMN_KEYS.PROGRESS]: true,
|
||||
[COLUMN_KEYS.IMAGE]: true,
|
||||
[COLUMN_KEYS.PROMPT]: true,
|
||||
[COLUMN_KEYS.PROMPT_EN]: true,
|
||||
[COLUMN_KEYS.FAIL_REASON]: true,
|
||||
};
|
||||
};
|
||||
|
||||
// 初始化默认列可见性
|
||||
const initDefaultColumns = () => {
|
||||
const defaults = getDefaultColumnVisibility();
|
||||
setVisibleColumns(defaults);
|
||||
localStorage.setItem('mj-logs-table-columns', JSON.stringify(defaults));
|
||||
};
|
||||
|
||||
// 处理列可见性变化
|
||||
const handleColumnVisibilityChange = (columnKey, checked) => {
|
||||
const updatedColumns = { ...visibleColumns, [columnKey]: checked };
|
||||
setVisibleColumns(updatedColumns);
|
||||
};
|
||||
|
||||
// 处理全选
|
||||
const handleSelectAll = (checked) => {
|
||||
const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
|
||||
const updatedColumns = {};
|
||||
|
||||
allKeys.forEach((key) => {
|
||||
if ((key === COLUMN_KEYS.CHANNEL || key === COLUMN_KEYS.SUBMIT_RESULT) && !isAdminUser) {
|
||||
updatedColumns[key] = false;
|
||||
} else {
|
||||
updatedColumns[key] = checked;
|
||||
}
|
||||
});
|
||||
|
||||
setVisibleColumns(updatedColumns);
|
||||
};
|
||||
|
||||
// 更新表格时保存列可见性
|
||||
useEffect(() => {
|
||||
if (Object.keys(visibleColumns).length > 0) {
|
||||
localStorage.setItem('mj-logs-table-columns', JSON.stringify(visibleColumns));
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
function renderType(type) {
|
||||
switch (type) {
|
||||
case 'IMAGINE':
|
||||
return (
|
||||
<Tag color='blue' size='large' shape='circle'>
|
||||
{t('绘图')}
|
||||
</Tag>
|
||||
);
|
||||
case 'UPSCALE':
|
||||
return (
|
||||
<Tag color='orange' size='large' shape='circle'>
|
||||
{t('放大')}
|
||||
</Tag>
|
||||
);
|
||||
case 'VARIATION':
|
||||
return (
|
||||
<Tag color='purple' size='large' shape='circle'>
|
||||
{t('变换')}
|
||||
</Tag>
|
||||
);
|
||||
case 'HIGH_VARIATION':
|
||||
return (
|
||||
<Tag color='purple' size='large' shape='circle'>
|
||||
{t('强变换')}
|
||||
</Tag>
|
||||
);
|
||||
case 'LOW_VARIATION':
|
||||
return (
|
||||
<Tag color='purple' size='large' shape='circle'>
|
||||
{t('弱变换')}
|
||||
</Tag>
|
||||
);
|
||||
case 'PAN':
|
||||
return (
|
||||
<Tag color='cyan' size='large' shape='circle'>
|
||||
{t('平移')}
|
||||
</Tag>
|
||||
);
|
||||
case 'DESCRIBE':
|
||||
return (
|
||||
<Tag color='yellow' size='large' shape='circle'>
|
||||
{t('图生文')}
|
||||
</Tag>
|
||||
);
|
||||
case 'BLEND':
|
||||
return (
|
||||
<Tag color='lime' size='large' shape='circle'>
|
||||
{t('图混合')}
|
||||
</Tag>
|
||||
);
|
||||
case 'UPLOAD':
|
||||
return (
|
||||
<Tag color='blue' size='large' shape='circle'>
|
||||
上传文件
|
||||
</Tag>
|
||||
);
|
||||
case 'SHORTEN':
|
||||
return (
|
||||
<Tag color='pink' size='large' shape='circle'>
|
||||
{t('缩词')}
|
||||
</Tag>
|
||||
);
|
||||
case 'REROLL':
|
||||
return (
|
||||
<Tag color='indigo' size='large' shape='circle'>
|
||||
{t('重绘')}
|
||||
</Tag>
|
||||
);
|
||||
case 'INPAINT':
|
||||
return (
|
||||
<Tag color='violet' size='large' shape='circle'>
|
||||
{t('局部重绘-提交')}
|
||||
</Tag>
|
||||
);
|
||||
case 'ZOOM':
|
||||
return (
|
||||
<Tag color='teal' size='large' shape='circle'>
|
||||
{t('变焦')}
|
||||
</Tag>
|
||||
);
|
||||
case 'CUSTOM_ZOOM':
|
||||
return (
|
||||
<Tag color='teal' size='large' shape='circle'>
|
||||
{t('自定义变焦-提交')}
|
||||
</Tag>
|
||||
);
|
||||
case 'MODAL':
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
{t('窗口处理')}
|
||||
</Tag>
|
||||
);
|
||||
case 'SWAP_FACE':
|
||||
return (
|
||||
<Tag color='light-green' size='large' shape='circle'>
|
||||
{t('换脸')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle'>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCode(code) {
|
||||
switch (code) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
{t('已提交')}
|
||||
</Tag>
|
||||
);
|
||||
case 21:
|
||||
return (
|
||||
<Tag color='lime' size='large' shape='circle'>
|
||||
{t('等待中')}
|
||||
</Tag>
|
||||
);
|
||||
case 22:
|
||||
return (
|
||||
<Tag color='orange' size='large' shape='circle'>
|
||||
{t('重复提交')}
|
||||
</Tag>
|
||||
);
|
||||
case 0:
|
||||
return (
|
||||
<Tag color='yellow' size='large' shape='circle'>
|
||||
{t('未提交')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle'>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatus(type) {
|
||||
switch (type) {
|
||||
case 'SUCCESS':
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
{t('成功')}
|
||||
</Tag>
|
||||
);
|
||||
case 'NOT_START':
|
||||
return (
|
||||
<Tag color='grey' size='large' shape='circle'>
|
||||
{t('未启动')}
|
||||
</Tag>
|
||||
);
|
||||
case 'SUBMITTED':
|
||||
return (
|
||||
<Tag color='yellow' size='large' shape='circle'>
|
||||
{t('队列中')}
|
||||
</Tag>
|
||||
);
|
||||
case 'IN_PROGRESS':
|
||||
return (
|
||||
<Tag color='blue' size='large' shape='circle'>
|
||||
{t('执行中')}
|
||||
</Tag>
|
||||
);
|
||||
case 'FAILURE':
|
||||
return (
|
||||
<Tag color='red' size='large' shape='circle'>
|
||||
{t('失败')}
|
||||
</Tag>
|
||||
);
|
||||
case 'MODAL':
|
||||
return (
|
||||
<Tag color='yellow' size='large' shape='circle'>
|
||||
{t('窗口等待')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle'>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const renderTimestamp = (timestampInSeconds) => {
|
||||
const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
|
||||
|
||||
const year = date.getFullYear(); // 获取年份
|
||||
const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
|
||||
const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
|
||||
const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
|
||||
const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
|
||||
const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
|
||||
};
|
||||
// 修改renderDuration函数以包含颜色逻辑
|
||||
function renderDuration(submit_time, finishTime) {
|
||||
if (!submit_time || !finishTime) return 'N/A';
|
||||
|
||||
const start = new Date(submit_time);
|
||||
const finish = new Date(finishTime);
|
||||
const durationMs = finish - start;
|
||||
const durationSec = (durationMs / 1000).toFixed(1);
|
||||
const color = durationSec > 60 ? 'red' : 'green';
|
||||
|
||||
return (
|
||||
<Tag color={color} size='large' shape='circle'>
|
||||
{durationSec} {t('秒')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
// 定义所有列
|
||||
const allColumns = [
|
||||
{
|
||||
key: COLUMN_KEYS.SUBMIT_TIME,
|
||||
title: t('提交时间'),
|
||||
dataIndex: 'submit_time',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderTimestamp(text / 1000)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.DURATION,
|
||||
title: t('花费时间'),
|
||||
dataIndex: 'finish_time',
|
||||
render: (finish, record) => {
|
||||
return renderDuration(record.submit_time, finish);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.CHANNEL,
|
||||
title: t('渠道'),
|
||||
dataIndex: 'channel_id',
|
||||
className: isAdmin() ? 'tableShow' : 'tableHiddle',
|
||||
render: (text, record, index) => {
|
||||
return isAdminUser ? (
|
||||
<div>
|
||||
<Tag
|
||||
color={colors[parseInt(text) % colors.length]}
|
||||
size='large'
|
||||
shape='circle'
|
||||
onClick={() => {
|
||||
copyText(text);
|
||||
}}
|
||||
>
|
||||
{' '}
|
||||
{text}{' '}
|
||||
</Tag>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.TYPE,
|
||||
title: t('类型'),
|
||||
dataIndex: 'action',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderType(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.TASK_ID,
|
||||
title: t('任务ID'),
|
||||
dataIndex: 'mj_id',
|
||||
render: (text, record, index) => {
|
||||
return <div>{text}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.SUBMIT_RESULT,
|
||||
title: t('提交结果'),
|
||||
dataIndex: 'code',
|
||||
className: isAdmin() ? 'tableShow' : 'tableHiddle',
|
||||
render: (text, record, index) => {
|
||||
return isAdminUser ? <div>{renderCode(text)}</div> : <></>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.TASK_STATUS,
|
||||
title: t('任务状态'),
|
||||
dataIndex: 'status',
|
||||
className: isAdmin() ? 'tableShow' : 'tableHiddle',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderStatus(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PROGRESS,
|
||||
title: t('进度'),
|
||||
dataIndex: 'progress',
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
<Progress
|
||||
stroke={
|
||||
record.status === 'FAILURE'
|
||||
? 'var(--semi-color-warning)'
|
||||
: null
|
||||
}
|
||||
percent={text ? parseInt(text.replace('%', '')) : 0}
|
||||
showInfo={true}
|
||||
aria-label='drawing progress'
|
||||
style={{ minWidth: '200px' }}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.IMAGE,
|
||||
title: t('结果图片'),
|
||||
dataIndex: 'image_url',
|
||||
render: (text, record, index) => {
|
||||
if (!text) {
|
||||
return t('无');
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setModalImageUrl(text);
|
||||
setIsModalOpenurl(true);
|
||||
}}
|
||||
>
|
||||
{t('查看图片')}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PROMPT,
|
||||
title: 'Prompt',
|
||||
dataIndex: 'prompt',
|
||||
render: (text, record, index) => {
|
||||
if (!text) {
|
||||
return t('无');
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography.Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
style={{ width: 100 }}
|
||||
onClick={() => {
|
||||
setModalContent(text);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PROMPT_EN,
|
||||
title: 'PromptEn',
|
||||
dataIndex: 'prompt_en',
|
||||
render: (text, record, index) => {
|
||||
if (!text) {
|
||||
return t('无');
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography.Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
style={{ width: 100 }}
|
||||
onClick={() => {
|
||||
setModalContent(text);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.FAIL_REASON,
|
||||
title: t('失败原因'),
|
||||
dataIndex: 'fail_reason',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
if (!text) {
|
||||
return t('无');
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography.Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
style={{ width: 100 }}
|
||||
onClick={() => {
|
||||
setModalContent(text);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 根据可见性设置过滤列
|
||||
const getVisibleColumns = () => {
|
||||
return allColumns.filter((column) => visibleColumns[column.key]);
|
||||
};
|
||||
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
|
||||
const [logType, setLogType] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
|
||||
// 定义模态框图片URL的状态和更新函数
|
||||
const [modalImageUrl, setModalImageUrl] = useState('');
|
||||
let now = new Date();
|
||||
// 初始化start_timestamp为前一天
|
||||
const [inputs, setInputs] = useState({
|
||||
channel_id: '',
|
||||
mj_id: '',
|
||||
start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000),
|
||||
end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
|
||||
});
|
||||
const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs;
|
||||
|
||||
const [stat, setStat] = useState({
|
||||
quota: 0,
|
||||
token: 0,
|
||||
});
|
||||
|
||||
const handleInputChange = (value, name) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
|
||||
const setLogsFormat = (logs) => {
|
||||
for (let i = 0; i < logs.length; i++) {
|
||||
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
|
||||
logs[i].key = '' + logs[i].id;
|
||||
}
|
||||
// data.key = '' + data.id
|
||||
setLogs(logs);
|
||||
setLogCount(logs.length + pageSize);
|
||||
// console.log(logCount);
|
||||
};
|
||||
|
||||
const loadLogs = async (startIdx, pageSize = ITEMS_PER_PAGE) => {
|
||||
setLoading(true);
|
||||
|
||||
let url = '';
|
||||
let localStartTimestamp = Date.parse(start_timestamp);
|
||||
let localEndTimestamp = Date.parse(end_timestamp);
|
||||
if (isAdminUser) {
|
||||
url = `/api/mj/?p=${startIdx}&page_size=${pageSize}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
} else {
|
||||
url = `/api/mj/self/?p=${startIdx}&page_size=${pageSize}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
}
|
||||
const res = await API.get(url);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (startIdx === 0) {
|
||||
setLogsFormat(data);
|
||||
} else {
|
||||
let newLogs = [...logs];
|
||||
newLogs.splice(startIdx * pageSize, data.length, ...data);
|
||||
setLogsFormat(newLogs);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const pageData = logs.slice(
|
||||
(activePage - 1) * pageSize,
|
||||
activePage * pageSize,
|
||||
);
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
if (page === Math.ceil(logs.length / pageSize) + 1) {
|
||||
// In this case we have to load more data and then append them.
|
||||
loadLogs(page - 1, pageSize).then((r) => { });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
localStorage.setItem('mj-page-size', size + '');
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
await loadLogs(0, size);
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
// setLoading(true);
|
||||
setActivePage(1);
|
||||
await loadLogs(0, pageSize);
|
||||
};
|
||||
|
||||
const copyText = async (text) => {
|
||||
if (await copy(text)) {
|
||||
showSuccess(t('已复制:') + text);
|
||||
} else {
|
||||
// setSearchKeyword(text);
|
||||
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const localPageSize = parseInt(localStorage.getItem('mj-page-size')) || ITEMS_PER_PAGE;
|
||||
setPageSize(localPageSize);
|
||||
loadLogs(0, localPageSize).then();
|
||||
}, [logType]);
|
||||
|
||||
useEffect(() => {
|
||||
const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');
|
||||
if (mjNotifyEnabled !== 'true') {
|
||||
setShowBanner(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 列选择器模态框
|
||||
const renderColumnSelector = () => {
|
||||
return (
|
||||
<Modal
|
||||
title={t('列设置')}
|
||||
visible={showColumnSelector}
|
||||
onCancel={() => setShowColumnSelector(false)}
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
theme="light"
|
||||
onClick={() => initDefaultColumns()}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
onClick={() => setShowColumnSelector(false)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => setShowColumnSelector(false)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('确定')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Checkbox
|
||||
checked={Object.values(visibleColumns).every((v) => v === true)}
|
||||
indeterminate={
|
||||
Object.values(visibleColumns).some((v) => v === true) &&
|
||||
!Object.values(visibleColumns).every((v) => v === true)
|
||||
}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
>
|
||||
{t('全选')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4" style={{ border: '1px solid var(--semi-color-border)' }}>
|
||||
{allColumns.map((column) => {
|
||||
// 为非管理员用户跳过管理员专用列
|
||||
if (
|
||||
!isAdminUser &&
|
||||
(column.key === COLUMN_KEYS.CHANNEL ||
|
||||
column.key === COLUMN_KEYS.SUBMIT_RESULT)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={column.key} className="w-1/2 mb-4 pr-2">
|
||||
<Checkbox
|
||||
checked={!!visibleColumns[column.key]}
|
||||
onChange={(e) =>
|
||||
handleColumnVisibilityChange(column.key, e.target.checked)
|
||||
}
|
||||
>
|
||||
{column.title}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderColumnSelector()}
|
||||
<Layout>
|
||||
<Card
|
||||
className="!rounded-2xl mb-4"
|
||||
title={
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<div className="flex items-center text-orange-500 mb-2 md:mb-0">
|
||||
<IconEyeOpened className="mr-2" />
|
||||
{loading ? (
|
||||
<Skeleton.Title
|
||||
style={{
|
||||
width: 300,
|
||||
marginBottom: 0,
|
||||
marginTop: 0
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Text>
|
||||
{isAdminUser && showBanner
|
||||
? t('当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。')
|
||||
: t('Midjourney 任务记录')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider margin="12px" />
|
||||
|
||||
{/* 搜索表单区域 */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 时间选择器 */}
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<DatePicker
|
||||
className="w-full"
|
||||
value={[start_timestamp, end_timestamp]}
|
||||
type='dateTimeRange'
|
||||
onChange={(value) => {
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
handleInputChange(value[0], 'start_timestamp');
|
||||
handleInputChange(value[1], 'end_timestamp');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 任务 ID */}
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('任务 ID')}
|
||||
value={mj_id}
|
||||
onChange={(value) => handleInputChange(value, 'mj_id')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
|
||||
{/* 渠道 ID - 仅管理员可见 */}
|
||||
{isAdminUser && (
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道 ID')}
|
||||
value={channel_id}
|
||||
onChange={(value) => handleInputChange(value, 'channel_id')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div></div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={refresh}
|
||||
loading={loading}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconSetting />}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
shadows='always'
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={getVisibleColumns()}
|
||||
dataSource={pageData}
|
||||
rowKey='key'
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
pagination={{
|
||||
formatPageText: (page) =>
|
||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
start: page.currentStart,
|
||||
end: page.currentEnd,
|
||||
total: logCount,
|
||||
}),
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: logCount,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showSizeChanger: true,
|
||||
onPageSizeChange: (size) => {
|
||||
handlePageSizeChange(size);
|
||||
},
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
visible={isModalOpen}
|
||||
onOk={() => setIsModalOpen(false)}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
closable={null}
|
||||
bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
|
||||
width={800} // 设置模态框宽度
|
||||
>
|
||||
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
|
||||
</Modal>
|
||||
<ImagePreview
|
||||
src={modalImageUrl}
|
||||
visible={isModalOpenurl}
|
||||
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
|
||||
/>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogsTable;
|
||||
637
web/src/components/table/ModelPricing.js
Normal file
637
web/src/components/table/ModelPricing.js
Normal file
@@ -0,0 +1,637 @@
|
||||
import React, { useContext, useEffect, useRef, useMemo, useState } from 'react';
|
||||
import { API, copy, showError, showInfo, showSuccess, getModelCategories, renderModelTag } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Input,
|
||||
Layout,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Popover,
|
||||
ImagePreview,
|
||||
Button,
|
||||
Card,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Dropdown,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconVerify,
|
||||
IconHelpCircle,
|
||||
IconSearch,
|
||||
IconCopy,
|
||||
IconInfoCircle,
|
||||
IconLayers,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
const ModelPricing = () => {
|
||||
const { t } = useTranslation();
|
||||
const [filteredValue, setFilteredValue] = useState([]);
|
||||
const compositionRef = useRef({ isComposition: false });
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
const [modalImageUrl, setModalImageUrl] = useState('');
|
||||
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState('default');
|
||||
const [activeKey, setActiveKey] = useState('all');
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const rowSelection = useMemo(
|
||||
() => ({
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedRowKeys(selectedRowKeys);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleChange = (value) => {
|
||||
if (compositionRef.current.isComposition) {
|
||||
return;
|
||||
}
|
||||
const newFilteredValue = value ? [value] : [];
|
||||
setFilteredValue(newFilteredValue);
|
||||
};
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
compositionRef.current.isComposition = true;
|
||||
};
|
||||
|
||||
const handleCompositionEnd = (event) => {
|
||||
compositionRef.current.isComposition = false;
|
||||
const value = event.target.value;
|
||||
const newFilteredValue = value ? [value] : [];
|
||||
setFilteredValue(newFilteredValue);
|
||||
};
|
||||
|
||||
function renderQuotaType(type) {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='teal' size='large' shape='circle'>
|
||||
{t('按次计费')}
|
||||
</Tag>
|
||||
);
|
||||
case 0:
|
||||
return (
|
||||
<Tag color='violet' size='large' shape='circle'>
|
||||
{t('按量计费')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return t('未知');
|
||||
}
|
||||
}
|
||||
|
||||
function renderAvailable(available) {
|
||||
return available ? (
|
||||
<Popover
|
||||
content={
|
||||
<div style={{ padding: 8 }}>{t('您的分组可以使用该模型')}</div>
|
||||
}
|
||||
position='top'
|
||||
key={available}
|
||||
className="bg-green-50"
|
||||
>
|
||||
<IconVerify style={{ color: 'rgb(22 163 74)' }} size='large' />
|
||||
</Popover>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('可用性'),
|
||||
dataIndex: 'available',
|
||||
render: (text, record, index) => {
|
||||
return renderAvailable(record.enable_groups.includes(selectedGroup));
|
||||
},
|
||||
sorter: (a, b) => {
|
||||
const aAvailable = a.enable_groups.includes(selectedGroup);
|
||||
const bAvailable = b.enable_groups.includes(selectedGroup);
|
||||
return Number(aAvailable) - Number(bAvailable);
|
||||
},
|
||||
defaultSortOrder: 'descend',
|
||||
},
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model_name',
|
||||
render: (text, record, index) => {
|
||||
return renderModelTag(text, {
|
||||
onClick: () => {
|
||||
copyText(text);
|
||||
}
|
||||
});
|
||||
},
|
||||
onFilter: (value, record) =>
|
||||
record.model_name.toLowerCase().includes(value.toLowerCase()),
|
||||
filteredValue,
|
||||
},
|
||||
{
|
||||
title: t('计费类型'),
|
||||
dataIndex: 'quota_type',
|
||||
render: (text, record, index) => {
|
||||
return renderQuotaType(parseInt(text));
|
||||
},
|
||||
sorter: (a, b) => a.quota_type - b.quota_type,
|
||||
},
|
||||
{
|
||||
title: t('可用分组'),
|
||||
dataIndex: 'enable_groups',
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<Space wrap>
|
||||
{text.map((group) => {
|
||||
if (usableGroup[group]) {
|
||||
if (group === selectedGroup) {
|
||||
return (
|
||||
<Tag color='blue' size='large' shape='circle' prefixIcon={<IconVerify />}>
|
||||
{group}
|
||||
</Tag>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Tag
|
||||
color='blue'
|
||||
size='large'
|
||||
onClick={() => {
|
||||
setSelectedGroup(group);
|
||||
showInfo(
|
||||
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
|
||||
group: group,
|
||||
ratio: groupRatio[group],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity !rounded-full"
|
||||
>
|
||||
{group}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>{t('倍率')}</span>
|
||||
<Tooltip content={t('倍率是为了方便换算不同价格的模型')}>
|
||||
<IconHelpCircle
|
||||
className="text-blue-500 cursor-pointer"
|
||||
onClick={() => {
|
||||
setModalImageUrl('/ratio.png');
|
||||
setIsModalOpenurl(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'model_ratio',
|
||||
render: (text, record, index) => {
|
||||
let content = text;
|
||||
let completionRatio = parseFloat(record.completion_ratio.toFixed(3));
|
||||
content = (
|
||||
<div className="space-y-1">
|
||||
<div className="text-gray-700">
|
||||
{t('模型倍率')}:{record.quota_type === 0 ? text : t('无')}
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
{t('补全倍率')}:
|
||||
{record.quota_type === 0 ? completionRatio : t('无')}
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
{t('分组倍率')}:{groupRatio[selectedGroup]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return content;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('模型价格'),
|
||||
dataIndex: 'model_price',
|
||||
render: (text, record, index) => {
|
||||
let content = text;
|
||||
if (record.quota_type === 0) {
|
||||
let inputRatioPrice =
|
||||
record.model_ratio * 2 * groupRatio[selectedGroup];
|
||||
let completionRatioPrice =
|
||||
record.model_ratio *
|
||||
record.completion_ratio *
|
||||
2 *
|
||||
groupRatio[selectedGroup];
|
||||
content = (
|
||||
<div className="space-y-1">
|
||||
<div className="text-gray-700">
|
||||
{t('提示')} ${inputRatioPrice.toFixed(3)} / 1M tokens
|
||||
</div>
|
||||
<div className="text-gray-700">
|
||||
{t('补全')} ${completionRatioPrice.toFixed(3)} / 1M tokens
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
let price = parseFloat(text) * groupRatio[selectedGroup];
|
||||
content = (
|
||||
<div className="text-gray-700">
|
||||
{t('模型价格')}:${price.toFixed(3)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return content;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [models, setModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [groupRatio, setGroupRatio] = useState({});
|
||||
const [usableGroup, setUsableGroup] = useState({});
|
||||
|
||||
const setModelsFormat = (models, groupRatio) => {
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
models[i].key = models[i].model_name;
|
||||
models[i].group_ratio = groupRatio[models[i].model_name];
|
||||
}
|
||||
models.sort((a, b) => {
|
||||
return a.quota_type - b.quota_type;
|
||||
});
|
||||
|
||||
models.sort((a, b) => {
|
||||
if (a.model_name.startsWith('gpt') && !b.model_name.startsWith('gpt')) {
|
||||
return -1;
|
||||
} else if (
|
||||
!a.model_name.startsWith('gpt') &&
|
||||
b.model_name.startsWith('gpt')
|
||||
) {
|
||||
return 1;
|
||||
} else {
|
||||
return a.model_name.localeCompare(b.model_name);
|
||||
}
|
||||
});
|
||||
|
||||
setModels(models);
|
||||
};
|
||||
|
||||
const loadPricing = async () => {
|
||||
setLoading(true);
|
||||
let url = '/api/pricing';
|
||||
const res = await API.get(url);
|
||||
const { success, message, data, group_ratio, usable_group } = res.data;
|
||||
if (success) {
|
||||
setGroupRatio(group_ratio);
|
||||
setUsableGroup(usable_group);
|
||||
setSelectedGroup(userState.user ? userState.user.group : 'default');
|
||||
setModelsFormat(data, group_ratio);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
await loadPricing();
|
||||
};
|
||||
|
||||
const copyText = async (text) => {
|
||||
if (await copy(text)) {
|
||||
showSuccess(t('已复制:') + text);
|
||||
} else {
|
||||
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh().then();
|
||||
}, []);
|
||||
|
||||
const modelCategories = getModelCategories(t);
|
||||
|
||||
const categoryCounts = useMemo(() => {
|
||||
const counts = {};
|
||||
if (models.length > 0) {
|
||||
counts['all'] = models.length;
|
||||
|
||||
Object.entries(modelCategories).forEach(([key, category]) => {
|
||||
if (key !== 'all') {
|
||||
counts[key] = models.filter(model => category.filter(model)).length;
|
||||
}
|
||||
});
|
||||
}
|
||||
return counts;
|
||||
}, [models, modelCategories]);
|
||||
|
||||
const renderArrow = (items, pos, handleArrowClick) => {
|
||||
const style = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
margin: '0 12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: '100%',
|
||||
background: 'rgba(var(--semi-grey-1), 1)',
|
||||
color: 'var(--semi-color-text)',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
return (
|
||||
<Dropdown
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
{items.map(item => {
|
||||
const key = item.itemKey;
|
||||
const modelCount = categoryCounts[key] || 0;
|
||||
|
||||
return (
|
||||
<Dropdown.Item
|
||||
key={item.itemKey}
|
||||
onClick={() => setActiveKey(item.itemKey)}
|
||||
icon={modelCategories[item.itemKey]?.icon}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{modelCategories[item.itemKey]?.label || item.itemKey}
|
||||
<Tag
|
||||
color={activeKey === item.itemKey ? 'red' : 'grey'}
|
||||
size='small'
|
||||
shape='circle'
|
||||
>
|
||||
{modelCount}
|
||||
</Tag>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
);
|
||||
})}
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<div style={style} onClick={handleArrowClick}>
|
||||
{pos === 'start' ? '←' : '→'}
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
// 检查分类是否有对应的模型
|
||||
const availableCategories = useMemo(() => {
|
||||
if (!models.length) return ['all'];
|
||||
|
||||
return Object.entries(modelCategories).filter(([key, category]) => {
|
||||
if (key === 'all') return true;
|
||||
return models.some(model => category.filter(model));
|
||||
}).map(([key]) => key);
|
||||
}, [models]);
|
||||
|
||||
// 渲染标签页
|
||||
const renderTabs = () => {
|
||||
return (
|
||||
<Tabs
|
||||
renderArrow={renderArrow}
|
||||
activeKey={activeKey}
|
||||
type="card"
|
||||
collapsible
|
||||
onChange={key => setActiveKey(key)}
|
||||
className="mt-2"
|
||||
>
|
||||
{Object.entries(modelCategories)
|
||||
.filter(([key]) => availableCategories.includes(key))
|
||||
.map(([key, category]) => {
|
||||
const modelCount = categoryCounts[key] || 0;
|
||||
|
||||
return (
|
||||
<TabPane
|
||||
tab={
|
||||
<span className="flex items-center gap-2">
|
||||
{category.icon && <span className="w-4 h-4">{category.icon}</span>}
|
||||
{category.label}
|
||||
<Tag
|
||||
color={activeKey === key ? 'red' : 'grey'}
|
||||
size='small'
|
||||
shape='circle'
|
||||
>
|
||||
{modelCount}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
itemKey={key}
|
||||
key={key}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
// 优化过滤逻辑
|
||||
const filteredModels = useMemo(() => {
|
||||
let result = models;
|
||||
|
||||
// 先按分类过滤
|
||||
if (activeKey !== 'all') {
|
||||
result = result.filter(model => modelCategories[activeKey].filter(model));
|
||||
}
|
||||
|
||||
// 再按搜索词过滤
|
||||
if (filteredValue.length > 0) {
|
||||
const searchTerm = filteredValue[0].toLowerCase();
|
||||
result = result.filter(model =>
|
||||
model.model_name.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [activeKey, models, filteredValue]);
|
||||
|
||||
// 搜索和操作区组件
|
||||
const SearchAndActions = useMemo(() => (
|
||||
<Card className="!rounded-xl mb-6" bordered={false}>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('模糊搜索模型名称')}
|
||||
className="!rounded-lg"
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onChange={handleChange}
|
||||
showClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<IconCopy />}
|
||||
onClick={() => copyText(selectedRowKeys)}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
className="!rounded-lg !bg-blue-500 hover:!bg-blue-600 text-white"
|
||||
size="large"
|
||||
>
|
||||
{t('复制选中模型')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
), [selectedRowKeys, t]);
|
||||
|
||||
// 表格组件
|
||||
const ModelTable = useMemo(() => (
|
||||
<Card className="!rounded-xl overflow-hidden" bordered={false}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredModels}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
className="custom-table"
|
||||
pagination={{
|
||||
defaultPageSize: 10,
|
||||
pageSize: pageSize,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
formatPageText: (page) =>
|
||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
start: page.currentStart,
|
||||
end: page.currentEnd,
|
||||
total: filteredModels.length,
|
||||
}),
|
||||
onPageSizeChange: (size) => setPageSize(size),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
), [filteredModels, loading, columns, rowSelection, pageSize, t]);
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50">
|
||||
<Layout>
|
||||
<Layout.Content>
|
||||
<div className="flex justify-center p-4 sm:p-6 md:p-8">
|
||||
<div className="w-full">
|
||||
{/* 主卡片容器 */}
|
||||
<Card className="!rounded-2xl shadow-lg border-0">
|
||||
{/* 顶部状态卡片 */}
|
||||
<Card
|
||||
className="!rounded-2xl !border-0 !shadow-md overflow-hidden mb-6"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 25%, #a855f7 50%, #c084fc 75%, #d8b4fe 100%)',
|
||||
position: 'relative'
|
||||
}}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-16 -left-16 w-48 h-48 bg-white opacity-3 rounded-full"></div>
|
||||
<div className="absolute top-1/2 right-1/4 w-24 h-24 bg-yellow-400 opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative p-6 sm:p-8" style={{ color: 'white' }}>
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 lg:gap-6">
|
||||
<div className="flex items-start">
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-white/10 flex items-center justify-center mr-3 sm:mr-4">
|
||||
<IconLayers size="extra-large" className="text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base sm:text-lg font-semibold mb-1 sm:mb-2">
|
||||
{t('模型定价')}
|
||||
</div>
|
||||
<div className="text-sm text-white/80">
|
||||
{userState.user ? (
|
||||
<div className="flex items-center">
|
||||
<IconVerify className="mr-1.5 flex-shrink-0" size="small" />
|
||||
<span className="truncate">
|
||||
{t('当前分组')}: {userState.user.group},{t('倍率')}: {groupRatio[userState.user.group]}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<AlertCircle size={14} className="mr-1.5 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{t('未登录,使用默认分组倍率')}: {groupRatio['default']}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 sm:gap-3 mt-2 lg:mt-0">
|
||||
<div
|
||||
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
|
||||
style={{ backdropFilter: 'blur(10px)' }}
|
||||
>
|
||||
<div className="text-xs text-white/70 mb-0.5">{t('分组倍率')}</div>
|
||||
<div className="text-sm sm:text-base font-semibold">{groupRatio[selectedGroup] || '1.0'}x</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
|
||||
style={{ backdropFilter: 'blur(10px)' }}
|
||||
>
|
||||
<div className="text-xs text-white/70 mb-0.5">{t('可用模型')}</div>
|
||||
<div className="text-sm sm:text-base font-semibold">
|
||||
{models.filter(m => m.enable_groups.includes(selectedGroup)).length}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center px-2 py-2 sm:px-3 sm:py-2.5 bg-white/10 rounded-lg backdrop-blur-sm hover:bg-white/20 transition-colors duration-200"
|
||||
style={{ backdropFilter: 'blur(10px)' }}
|
||||
>
|
||||
<div className="text-xs text-white/70 mb-0.5">{t('计费类型')}</div>
|
||||
<div className="text-sm sm:text-base font-semibold">2</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 计费说明 */}
|
||||
<div className="mt-4 sm:mt-5">
|
||||
<div className="flex items-start">
|
||||
<div
|
||||
className="w-full flex items-start space-x-2 px-3 py-2 sm:px-4 sm:py-2.5 rounded-lg text-xs sm:text-sm"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
>
|
||||
<IconInfoCircle className="flex-shrink-0 mt-0.5" size="small" />
|
||||
<span>
|
||||
{t('按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-yellow-400 via-orange-400 to-red-400" style={{ opacity: 0.6 }}></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 模型分类 Tabs */}
|
||||
<div className="mb-6">
|
||||
{renderTabs()}
|
||||
|
||||
{/* 搜索和表格区域 */}
|
||||
{SearchAndActions}
|
||||
{ModelTable}
|
||||
</div>
|
||||
|
||||
{/* 倍率说明图预览 */}
|
||||
<ImagePreview
|
||||
src={modalImageUrl}
|
||||
visible={isModalOpenurl}
|
||||
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelPricing;
|
||||
@@ -5,23 +5,39 @@ import {
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
} from '../helpers';
|
||||
renderQuota
|
||||
} from '../../helpers';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../constants';
|
||||
import { renderQuota } from '../helpers/render';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Form,
|
||||
Dropdown,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Popover,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import EditRedemption from '../pages/Redemption/EditRedemption';
|
||||
import {
|
||||
IconPlus,
|
||||
IconCopy,
|
||||
IconSearch,
|
||||
IconEyeOpened,
|
||||
IconEdit,
|
||||
IconDelete,
|
||||
IconStop,
|
||||
IconPlay,
|
||||
IconMore,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import EditRedemption from '../../pages/Redemption/EditRedemption';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function renderTimestamp(timestamp) {
|
||||
return <>{timestamp2string(timestamp)}</>;
|
||||
}
|
||||
@@ -33,25 +49,25 @@ const RedemptionsTable = () => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='green' size='large'>
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
{t('未使用')}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' size='large'>
|
||||
<Tag color='red' size='large' shape='circle'>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag color='grey' size='large'>
|
||||
<Tag color='grey' size='large' shape='circle'>
|
||||
{t('已使用')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='black' size='large'>
|
||||
<Tag color='black' size='large' shape='circle'>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -99,76 +115,108 @@ const RedemptionsTable = () => {
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
render: (text, record, index) => (
|
||||
<div>
|
||||
<Popover content={record.key} style={{ padding: 20 }} position='top'>
|
||||
<Button theme='light' type='tertiary' style={{ marginRight: 1 }}>
|
||||
{t('查看')}
|
||||
</Button>
|
||||
</Popover>
|
||||
<Button
|
||||
theme='light'
|
||||
type='secondary'
|
||||
style={{ marginRight: 1 }}
|
||||
onClick={async (text) => {
|
||||
await copyText(record.key);
|
||||
}}
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('确定是否要删除此兑换码?')}
|
||||
content={t('此修改将不可逆')}
|
||||
okType={'danger'}
|
||||
position={'left'}
|
||||
onConfirm={() => {
|
||||
manageRedemption(record.id, 'delete', record).then(() => {
|
||||
removeRecord(record.key);
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
// 创建更多操作的下拉菜单项
|
||||
const moreMenuItems = [
|
||||
{
|
||||
node: 'item',
|
||||
name: t('删除'),
|
||||
icon: <IconDelete />,
|
||||
type: 'danger',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除此兑换码?'),
|
||||
content: t('此修改将不可逆'),
|
||||
onOk: () => {
|
||||
manageRedemption(record.id, 'delete', record).then(() => {
|
||||
removeRecord(record.key);
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button theme='light' type='danger' style={{ marginRight: 1 }}>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
{record.status === 1 ? (
|
||||
<Button
|
||||
theme='light'
|
||||
type='warning'
|
||||
style={{ marginRight: 1 }}
|
||||
onClick={async () => {
|
||||
manageRedemption(record.id, 'disable', record);
|
||||
}}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
// 动态添加启用/禁用按钮
|
||||
if (record.status === 1) {
|
||||
moreMenuItems.push({
|
||||
node: 'item',
|
||||
name: t('禁用'),
|
||||
icon: <IconStop />,
|
||||
type: 'warning',
|
||||
onClick: () => {
|
||||
manageRedemption(record.id, 'disable', record);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
moreMenuItems.push({
|
||||
node: 'item',
|
||||
name: t('启用'),
|
||||
icon: <IconPlay />,
|
||||
type: 'secondary',
|
||||
onClick: () => {
|
||||
manageRedemption(record.id, 'enable', record);
|
||||
},
|
||||
disabled: record.status === 3,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<Popover content={record.key} style={{ padding: 20 }} position='top'>
|
||||
<Button
|
||||
icon={<IconEyeOpened />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('查看')}
|
||||
</Button>
|
||||
</Popover>
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
theme='light'
|
||||
type='secondary'
|
||||
style={{ marginRight: 1 }}
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
onClick={async () => {
|
||||
manageRedemption(record.id, 'enable', record);
|
||||
await copyText(record.key);
|
||||
}}
|
||||
disabled={record.status === 3}
|
||||
>
|
||||
{t('启用')}
|
||||
{t('复制')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
style={{ marginRight: 1 }}
|
||||
onClick={() => {
|
||||
setEditingRedemption(record);
|
||||
setShowEdit(true);
|
||||
}}
|
||||
disabled={record.status !== 1}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
<Button
|
||||
icon={<IconEdit />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
onClick={() => {
|
||||
setEditingRedemption(record);
|
||||
setShowEdit(true);
|
||||
}}
|
||||
disabled={record.status !== 1}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger='click'
|
||||
position='bottomRight'
|
||||
menu={moreMenuItems}
|
||||
>
|
||||
<Button
|
||||
icon={<IconMore />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
/>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -187,6 +235,11 @@ const RedemptionsTable = () => {
|
||||
|
||||
const closeEdit = () => {
|
||||
setShowEdit(false);
|
||||
setTimeout(() => {
|
||||
setEditingRedemption({
|
||||
id: undefined,
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const setRedemptionFormat = (redeptions) => {
|
||||
@@ -225,8 +278,11 @@ const RedemptionsTable = () => {
|
||||
if (await copy(text)) {
|
||||
showSuccess(t('已复制到剪贴板!'));
|
||||
} else {
|
||||
// setSearchKeyword(text);
|
||||
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
|
||||
Modal.error({
|
||||
title: t('无法复制到剪贴板,请手动复制'),
|
||||
content: text,
|
||||
size: 'large'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -245,13 +301,14 @@ const RedemptionsTable = () => {
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
}, []);
|
||||
}, [pageSize]);
|
||||
|
||||
const refresh = async () => {
|
||||
await loadRedemptions(activePage - 1, pageSize);
|
||||
};
|
||||
|
||||
const manageRedemption = async (id, action, record) => {
|
||||
setLoading(true);
|
||||
let data = { id };
|
||||
let res;
|
||||
switch (action) {
|
||||
@@ -272,7 +329,6 @@ const RedemptionsTable = () => {
|
||||
showSuccess(t('操作成功完成!'));
|
||||
let redemption = res.data.data;
|
||||
let newRedemptions = [...redemptions];
|
||||
// let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
|
||||
if (action === 'delete') {
|
||||
} else {
|
||||
record.status = redemption.status;
|
||||
@@ -281,6 +337,7 @@ const RedemptionsTable = () => {
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const searchRedemptions = async (keyword, page, pageSize) => {
|
||||
@@ -333,8 +390,8 @@ const RedemptionsTable = () => {
|
||||
|
||||
let pageData = redemptions;
|
||||
const rowSelection = {
|
||||
onSelect: (record, selected) => {},
|
||||
onSelectAll: (selected, selectedRows) => {},
|
||||
onSelect: (record, selected) => { },
|
||||
onSelectAll: (selected, selectedRows) => { },
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedKeys(selectedRows);
|
||||
},
|
||||
@@ -352,6 +409,80 @@ const RedemptionsTable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center text-orange-500">
|
||||
<IconEyeOpened className="mr-2" />
|
||||
<Text>{t('兑换码可以批量生成和分发,适合用于推广活动或批量充值。')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider margin="12px" />
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
|
||||
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<IconPlus />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={() => {
|
||||
setEditingRedemption({
|
||||
id: undefined,
|
||||
});
|
||||
setShowEdit(true);
|
||||
}}
|
||||
>
|
||||
{t('添加兑换码')}
|
||||
</Button>
|
||||
<Button
|
||||
type='warning'
|
||||
icon={<IconCopy />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={async () => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个兑换码!'));
|
||||
return;
|
||||
}
|
||||
let keys = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
keys +=
|
||||
selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(keys);
|
||||
}}
|
||||
>
|
||||
{t('复制所选兑换码到剪贴板')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
|
||||
<div className="relative w-full md:w-64">
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('关键字(id或者名称)')}
|
||||
value={searchKeyword}
|
||||
onChange={handleKeywordChange}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
searchRedemptions(searchKeyword, 1, pageSize).then();
|
||||
}}
|
||||
loading={searching}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditRedemption
|
||||
@@ -360,88 +491,47 @@ const RedemptionsTable = () => {
|
||||
visiable={showEdit}
|
||||
handleClose={closeEdit}
|
||||
></EditRedemption>
|
||||
<Form
|
||||
onSubmit={() => {
|
||||
searchRedemptions(searchKeyword, activePage, pageSize).then();
|
||||
}}
|
||||
>
|
||||
<Form.Input
|
||||
label={t('搜索关键字')}
|
||||
field='keyword'
|
||||
icon='search'
|
||||
iconPosition='left'
|
||||
placeholder={t('关键字(id或者名称)')}
|
||||
value={searchKeyword}
|
||||
loading={searching}
|
||||
onChange={handleKeywordChange}
|
||||
/>
|
||||
</Form>
|
||||
<Divider style={{ margin: '5px 0 15px 0' }} />
|
||||
<div>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
style={{ marginRight: 8 }}
|
||||
onClick={() => {
|
||||
setEditingRedemption({
|
||||
id: undefined,
|
||||
});
|
||||
setShowEdit(true);
|
||||
}}
|
||||
>
|
||||
{t('添加兑换码')}
|
||||
</Button>
|
||||
<Button
|
||||
label={t('复制所选兑换码')}
|
||||
type='warning'
|
||||
onClick={async () => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个兑换码!'));
|
||||
return;
|
||||
}
|
||||
let keys = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
keys +=
|
||||
selectedKeys[i].name + ' ' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(keys);
|
||||
}}
|
||||
>
|
||||
{t('复制所选兑换码到剪贴板')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
style={{ marginTop: 20 }}
|
||||
columns={columns}
|
||||
dataSource={pageData}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: tokenCount,
|
||||
showSizeChanger: true,
|
||||
pageSizeOpts: [10, 20, 50, 100],
|
||||
formatPageText: (page) =>
|
||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
start: page.currentStart,
|
||||
end: page.currentEnd,
|
||||
total: tokenCount,
|
||||
}),
|
||||
onPageSizeChange: (size) => {
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
if (searchKeyword === '') {
|
||||
loadRedemptions(1, size).then();
|
||||
} else {
|
||||
searchRedemptions(searchKeyword, 1, size).then();
|
||||
}
|
||||
},
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
onRow={handleRow}
|
||||
></Table>
|
||||
<Card
|
||||
className="!rounded-2xl"
|
||||
title={renderHeader()}
|
||||
shadows='always'
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pageData}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: tokenCount,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
formatPageText: (page) =>
|
||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
start: page.currentStart,
|
||||
end: page.currentEnd,
|
||||
total: tokenCount,
|
||||
}),
|
||||
onPageSizeChange: (size) => {
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
if (searchKeyword === '') {
|
||||
loadRedemptions(1, size).then();
|
||||
} else {
|
||||
searchRedemptions(searchKeyword, 1, size).then();
|
||||
}
|
||||
},
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
onRow={handleRow}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
></Table>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
743
web/src/components/table/TaskLogsTable.js
Normal file
743
web/src/components/table/TaskLogsTable.js
Normal file
@@ -0,0 +1,743 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
isAdmin,
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
} from '../../helpers';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Input,
|
||||
Layout,
|
||||
Modal,
|
||||
Progress,
|
||||
Skeleton,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
IconEyeOpened,
|
||||
IconSearch,
|
||||
IconSetting,
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const colors = [
|
||||
'amber',
|
||||
'blue',
|
||||
'cyan',
|
||||
'green',
|
||||
'grey',
|
||||
'indigo',
|
||||
'light-blue',
|
||||
'lime',
|
||||
'orange',
|
||||
'pink',
|
||||
'purple',
|
||||
'red',
|
||||
'teal',
|
||||
'violet',
|
||||
'yellow',
|
||||
];
|
||||
|
||||
// 定义列键值常量
|
||||
const COLUMN_KEYS = {
|
||||
SUBMIT_TIME: 'submit_time',
|
||||
FINISH_TIME: 'finish_time',
|
||||
DURATION: 'duration',
|
||||
CHANNEL: 'channel',
|
||||
PLATFORM: 'platform',
|
||||
TYPE: 'type',
|
||||
TASK_ID: 'task_id',
|
||||
TASK_STATUS: 'task_status',
|
||||
PROGRESS: 'progress',
|
||||
FAIL_REASON: 'fail_reason',
|
||||
};
|
||||
|
||||
const renderTimestamp = (timestampInSeconds) => {
|
||||
const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
|
||||
|
||||
const year = date.getFullYear(); // 获取年份
|
||||
const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
|
||||
const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
|
||||
const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
|
||||
const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
|
||||
const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
|
||||
};
|
||||
|
||||
function renderDuration(submit_time, finishTime) {
|
||||
// 确保startTime和finishTime都是有效的时间戳
|
||||
if (!submit_time || !finishTime) return 'N/A';
|
||||
|
||||
// 将时间戳转换为Date对象
|
||||
const start = new Date(submit_time);
|
||||
const finish = new Date(finishTime);
|
||||
|
||||
// 计算时间差(毫秒)
|
||||
const durationMs = finish - start;
|
||||
|
||||
// 将时间差转换为秒,并保留一位小数
|
||||
const durationSec = (durationMs / 1000).toFixed(1);
|
||||
|
||||
// 设置颜色:大于60秒则为红色,小于等于60秒则为绿色
|
||||
const color = durationSec > 60 ? 'red' : 'green';
|
||||
|
||||
// 返回带有样式的颜色标签
|
||||
return (
|
||||
<Tag color={color} size='large'>
|
||||
{durationSec} 秒
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const LogsTable = () => {
|
||||
const { t } = useTranslation();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [modalContent, setModalContent] = useState('');
|
||||
|
||||
// 列可见性状态
|
||||
const [visibleColumns, setVisibleColumns] = useState({});
|
||||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||||
const isAdminUser = isAdmin();
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
|
||||
// 加载保存的列偏好设置
|
||||
useEffect(() => {
|
||||
const savedColumns = localStorage.getItem('task-logs-table-columns');
|
||||
if (savedColumns) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedColumns);
|
||||
const defaults = getDefaultColumnVisibility();
|
||||
const merged = { ...defaults, ...parsed };
|
||||
setVisibleColumns(merged);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved column preferences', e);
|
||||
initDefaultColumns();
|
||||
}
|
||||
} else {
|
||||
initDefaultColumns();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 获取默认列可见性
|
||||
const getDefaultColumnVisibility = () => {
|
||||
return {
|
||||
[COLUMN_KEYS.SUBMIT_TIME]: true,
|
||||
[COLUMN_KEYS.FINISH_TIME]: true,
|
||||
[COLUMN_KEYS.DURATION]: true,
|
||||
[COLUMN_KEYS.CHANNEL]: isAdminUser,
|
||||
[COLUMN_KEYS.PLATFORM]: true,
|
||||
[COLUMN_KEYS.TYPE]: true,
|
||||
[COLUMN_KEYS.TASK_ID]: true,
|
||||
[COLUMN_KEYS.TASK_STATUS]: true,
|
||||
[COLUMN_KEYS.PROGRESS]: true,
|
||||
[COLUMN_KEYS.FAIL_REASON]: true,
|
||||
};
|
||||
};
|
||||
|
||||
// 初始化默认列可见性
|
||||
const initDefaultColumns = () => {
|
||||
const defaults = getDefaultColumnVisibility();
|
||||
setVisibleColumns(defaults);
|
||||
localStorage.setItem('task-logs-table-columns', JSON.stringify(defaults));
|
||||
};
|
||||
|
||||
// 处理列可见性变化
|
||||
const handleColumnVisibilityChange = (columnKey, checked) => {
|
||||
const updatedColumns = { ...visibleColumns, [columnKey]: checked };
|
||||
setVisibleColumns(updatedColumns);
|
||||
};
|
||||
|
||||
// 处理全选
|
||||
const handleSelectAll = (checked) => {
|
||||
const allKeys = Object.keys(COLUMN_KEYS).map((key) => COLUMN_KEYS[key]);
|
||||
const updatedColumns = {};
|
||||
|
||||
allKeys.forEach((key) => {
|
||||
if (key === COLUMN_KEYS.CHANNEL && !isAdminUser) {
|
||||
updatedColumns[key] = false;
|
||||
} else {
|
||||
updatedColumns[key] = checked;
|
||||
}
|
||||
});
|
||||
|
||||
setVisibleColumns(updatedColumns);
|
||||
};
|
||||
|
||||
// 更新表格时保存列可见性
|
||||
useEffect(() => {
|
||||
if (Object.keys(visibleColumns).length > 0) {
|
||||
localStorage.setItem('task-logs-table-columns', JSON.stringify(visibleColumns));
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
const renderType = (type) => {
|
||||
switch (type) {
|
||||
case 'MUSIC':
|
||||
return (
|
||||
<Tag color='grey' size='large' shape='circle'>
|
||||
{t('生成音乐')}
|
||||
</Tag>
|
||||
);
|
||||
case 'LYRICS':
|
||||
return (
|
||||
<Tag color='pink' size='large' shape='circle'>
|
||||
{t('生成歌词')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle'>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderPlatform = (type) => {
|
||||
switch (type) {
|
||||
case 'suno':
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
Suno
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle'>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStatus = (type) => {
|
||||
switch (type) {
|
||||
case 'SUCCESS':
|
||||
return (
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
{t('成功')}
|
||||
</Tag>
|
||||
);
|
||||
case 'NOT_START':
|
||||
return (
|
||||
<Tag color='grey' size='large' shape='circle'>
|
||||
{t('未启动')}
|
||||
</Tag>
|
||||
);
|
||||
case 'SUBMITTED':
|
||||
return (
|
||||
<Tag color='yellow' size='large' shape='circle'>
|
||||
{t('队列中')}
|
||||
</Tag>
|
||||
);
|
||||
case 'IN_PROGRESS':
|
||||
return (
|
||||
<Tag color='blue' size='large' shape='circle'>
|
||||
{t('执行中')}
|
||||
</Tag>
|
||||
);
|
||||
case 'FAILURE':
|
||||
return (
|
||||
<Tag color='red' size='large' shape='circle'>
|
||||
{t('失败')}
|
||||
</Tag>
|
||||
);
|
||||
case 'QUEUED':
|
||||
return (
|
||||
<Tag color='orange' size='large' shape='circle'>
|
||||
{t('排队中')}
|
||||
</Tag>
|
||||
);
|
||||
case 'UNKNOWN':
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle'>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
case '':
|
||||
return (
|
||||
<Tag color='grey' size='large' shape='circle'>
|
||||
{t('正在提交')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' size='large' shape='circle'>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 定义所有列
|
||||
const allColumns = [
|
||||
{
|
||||
key: COLUMN_KEYS.SUBMIT_TIME,
|
||||
title: t('提交时间'),
|
||||
dataIndex: 'submit_time',
|
||||
render: (text, record, index) => {
|
||||
return <div>{text ? renderTimestamp(text) : '-'}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.FINISH_TIME,
|
||||
title: t('结束时间'),
|
||||
dataIndex: 'finish_time',
|
||||
render: (text, record, index) => {
|
||||
return <div>{text ? renderTimestamp(text) : '-'}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.DURATION,
|
||||
title: t('花费时间'),
|
||||
dataIndex: 'finish_time',
|
||||
render: (finish, record) => {
|
||||
return <>{finish ? renderDuration(record.submit_time, finish) : '-'}</>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.CHANNEL,
|
||||
title: t('渠道'),
|
||||
dataIndex: 'channel_id',
|
||||
className: isAdminUser ? 'tableShow' : 'tableHiddle',
|
||||
render: (text, record, index) => {
|
||||
return isAdminUser ? (
|
||||
<div>
|
||||
<Tag
|
||||
color={colors[parseInt(text) % colors.length]}
|
||||
size='large'
|
||||
shape='circle'
|
||||
onClick={() => {
|
||||
copyText(text);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Tag>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PLATFORM,
|
||||
title: t('平台'),
|
||||
dataIndex: 'platform',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderPlatform(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.TYPE,
|
||||
title: t('类型'),
|
||||
dataIndex: 'action',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderType(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.TASK_ID,
|
||||
title: t('任务ID'),
|
||||
dataIndex: 'task_id',
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<Typography.Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
onClick={() => {
|
||||
setModalContent(JSON.stringify(record, null, 2));
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<div>{text}</div>
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.TASK_STATUS,
|
||||
title: t('任务状态'),
|
||||
dataIndex: 'status',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderStatus(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.PROGRESS,
|
||||
title: t('进度'),
|
||||
dataIndex: 'progress',
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
isNaN(text?.replace('%', '')) ? (
|
||||
text || '-'
|
||||
) : (
|
||||
<Progress
|
||||
stroke={
|
||||
record.status === 'FAILURE'
|
||||
? 'var(--semi-color-warning)'
|
||||
: null
|
||||
}
|
||||
percent={text ? parseInt(text.replace('%', '')) : 0}
|
||||
showInfo={true}
|
||||
aria-label='task progress'
|
||||
style={{ minWidth: '200px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: COLUMN_KEYS.FAIL_REASON,
|
||||
title: t('失败原因'),
|
||||
dataIndex: 'fail_reason',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
if (!text) {
|
||||
return t('无');
|
||||
}
|
||||
return (
|
||||
<Typography.Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
style={{ width: 100 }}
|
||||
onClick={() => {
|
||||
setModalContent(text);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 根据可见性设置过滤列
|
||||
const getVisibleColumns = () => {
|
||||
return allColumns.filter((column) => visibleColumns[column.key]);
|
||||
};
|
||||
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
|
||||
const [logType] = useState(0);
|
||||
|
||||
let now = new Date();
|
||||
// 初始化start_timestamp为前一天
|
||||
let zeroNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const [inputs, setInputs] = useState({
|
||||
channel_id: '',
|
||||
task_id: '',
|
||||
start_timestamp: timestamp2string(zeroNow.getTime() / 1000),
|
||||
end_timestamp: '',
|
||||
});
|
||||
const { channel_id, task_id, start_timestamp, end_timestamp } = inputs;
|
||||
|
||||
const handleInputChange = (value, name) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
|
||||
const setLogsFormat = (logs) => {
|
||||
for (let i = 0; i < logs.length; i++) {
|
||||
logs[i].timestamp2string = timestamp2string(logs[i].created_at);
|
||||
logs[i].key = '' + logs[i].id;
|
||||
}
|
||||
// data.key = '' + data.id
|
||||
setLogs(logs);
|
||||
setLogCount(logs.length + ITEMS_PER_PAGE);
|
||||
// console.log(logCount);
|
||||
};
|
||||
|
||||
const loadLogs = async (startIdx, pageSize = ITEMS_PER_PAGE) => {
|
||||
setLoading(true);
|
||||
|
||||
let url = '';
|
||||
let localStartTimestamp = parseInt(Date.parse(start_timestamp) / 1000);
|
||||
let localEndTimestamp = parseInt(Date.parse(end_timestamp) / 1000);
|
||||
if (isAdminUser) {
|
||||
url = `/api/task/?p=${startIdx}&page_size=${pageSize}&channel_id=${channel_id}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
} else {
|
||||
url = `/api/task/self?p=${startIdx}&page_size=${pageSize}&task_id=${task_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
|
||||
}
|
||||
const res = await API.get(url);
|
||||
let { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (startIdx === 0) {
|
||||
setLogsFormat(data);
|
||||
} else {
|
||||
let newLogs = [...logs];
|
||||
newLogs.splice(startIdx * pageSize, data.length, ...data);
|
||||
setLogsFormat(newLogs);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const pageData = logs.slice(
|
||||
(activePage - 1) * pageSize,
|
||||
activePage * pageSize,
|
||||
);
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
if (page === Math.ceil(logs.length / pageSize) + 1) {
|
||||
loadLogs(page - 1, pageSize).then((r) => { });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
localStorage.setItem('task-page-size', size + '');
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
await loadLogs(0, size);
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
setActivePage(1);
|
||||
await loadLogs(0, pageSize);
|
||||
};
|
||||
|
||||
const copyText = async (text) => {
|
||||
if (await copy(text)) {
|
||||
showSuccess(t('已复制:') + text);
|
||||
} else {
|
||||
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const localPageSize = parseInt(localStorage.getItem('task-page-size')) || ITEMS_PER_PAGE;
|
||||
setPageSize(localPageSize);
|
||||
loadLogs(0, localPageSize).then();
|
||||
}, [logType]);
|
||||
|
||||
// 列选择器模态框
|
||||
const renderColumnSelector = () => {
|
||||
return (
|
||||
<Modal
|
||||
title={t('列设置')}
|
||||
visible={showColumnSelector}
|
||||
onCancel={() => setShowColumnSelector(false)}
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
theme="light"
|
||||
onClick={() => initDefaultColumns()}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
onClick={() => setShowColumnSelector(false)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => setShowColumnSelector(false)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('确定')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Checkbox
|
||||
checked={Object.values(visibleColumns).every((v) => v === true)}
|
||||
indeterminate={
|
||||
Object.values(visibleColumns).some((v) => v === true) &&
|
||||
!Object.values(visibleColumns).every((v) => v === true)
|
||||
}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
>
|
||||
{t('全选')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className="flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4" style={{ border: '1px solid var(--semi-color-border)' }}>
|
||||
{allColumns.map((column) => {
|
||||
// 为非管理员用户跳过管理员专用列
|
||||
if (!isAdminUser && column.key === COLUMN_KEYS.CHANNEL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={column.key} className="w-1/2 mb-4 pr-2">
|
||||
<Checkbox
|
||||
checked={!!visibleColumns[column.key]}
|
||||
onChange={(e) =>
|
||||
handleColumnVisibilityChange(column.key, e.target.checked)
|
||||
}
|
||||
>
|
||||
{column.title}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderColumnSelector()}
|
||||
<Layout>
|
||||
<Card
|
||||
className="!rounded-2xl mb-4"
|
||||
title={
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<div className="flex items-center text-orange-500 mb-2 md:mb-0">
|
||||
<IconEyeOpened className="mr-2" />
|
||||
{loading ? (
|
||||
<Skeleton.Title
|
||||
style={{
|
||||
width: 300,
|
||||
marginBottom: 0,
|
||||
marginTop: 0
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Text>{t('任务记录')}</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider margin="12px" />
|
||||
|
||||
{/* 搜索表单区域 */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 时间选择器 */}
|
||||
<div className="col-span-1 lg:col-span-2">
|
||||
<DatePicker
|
||||
className="w-full"
|
||||
value={[start_timestamp, end_timestamp]}
|
||||
type='dateTimeRange'
|
||||
onChange={(value) => {
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
handleInputChange(value[0], 'start_timestamp');
|
||||
handleInputChange(value[1], 'end_timestamp');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 任务 ID */}
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('任务 ID')}
|
||||
value={task_id}
|
||||
onChange={(value) => handleInputChange(value, 'task_id')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
|
||||
{/* 渠道 ID - 仅管理员可见 */}
|
||||
{isAdminUser && (
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('渠道 ID')}
|
||||
value={channel_id}
|
||||
onChange={(value) => handleInputChange(value, 'channel_id')}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div></div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={refresh}
|
||||
loading={loading}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={<IconSetting />}
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
shadows='always'
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={getVisibleColumns()}
|
||||
dataSource={pageData}
|
||||
rowKey='key'
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
pagination={{
|
||||
formatPageText: (page) =>
|
||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
start: page.currentStart,
|
||||
end: page.currentEnd,
|
||||
total: logCount,
|
||||
}),
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: logCount,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showSizeChanger: true,
|
||||
onPageSizeChange: (size) => {
|
||||
handlePageSizeChange(size);
|
||||
},
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
visible={isModalOpen}
|
||||
onOk={() => setIsModalOpen(false)}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
closable={null}
|
||||
bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
|
||||
width={800} // 设置模态框宽度
|
||||
>
|
||||
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
|
||||
</Modal>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogsTable;
|
||||
@@ -5,26 +5,36 @@ import {
|
||||
showError,
|
||||
showSuccess,
|
||||
timestamp2string,
|
||||
} from '../helpers';
|
||||
renderGroup,
|
||||
renderQuota
|
||||
} from '../../helpers';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../constants';
|
||||
import { renderGroup, renderQuota } from '../helpers/render';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Card,
|
||||
Dropdown,
|
||||
Form,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Popover,
|
||||
Space,
|
||||
SplitButtonGroup,
|
||||
Table,
|
||||
Tag,
|
||||
Input,
|
||||
} from '@douyinfe/semi-ui';
|
||||
|
||||
import { IconTreeTriangleDown } from '@douyinfe/semi-icons';
|
||||
import EditToken from '../pages/Token/EditToken';
|
||||
import {
|
||||
IconPlus,
|
||||
IconCopy,
|
||||
IconSearch,
|
||||
IconTreeTriangleDown,
|
||||
IconEyeOpened,
|
||||
IconEdit,
|
||||
IconDelete,
|
||||
IconStop,
|
||||
IconPlay,
|
||||
IconMore,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import EditToken from '../../pages/Token/EditToken';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function renderTimestamp(timestamp) {
|
||||
@@ -39,38 +49,38 @@ const TokensTable = () => {
|
||||
case 1:
|
||||
if (model_limits_enabled) {
|
||||
return (
|
||||
<Tag color='green' size='large'>
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
{t('已启用:限制模型')}
|
||||
</Tag>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Tag color='green' size='large'>
|
||||
<Tag color='green' size='large' shape='circle'>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' size='large'>
|
||||
<Tag color='red' size='large' shape='circle'>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag color='yellow' size='large'>
|
||||
<Tag color='yellow' size='large' shape='circle'>
|
||||
{t('已过期')}
|
||||
</Tag>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<Tag color='grey' size='large'>
|
||||
<Tag color='grey' size='large' shape='circle'>
|
||||
{t('已耗尽')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='black' size='large'>
|
||||
<Tag color='black' size='large' shape='circle'>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -111,11 +121,11 @@ const TokensTable = () => {
|
||||
return (
|
||||
<div>
|
||||
{record.unlimited_quota ? (
|
||||
<Tag size={'large'} color={'white'}>
|
||||
<Tag size={'large'} color={'white'} shape='circle'>
|
||||
{t('无限制')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag size={'large'} color={'light-blue'}>
|
||||
<Tag size={'large'} color={'light-blue'} shape='circle'>
|
||||
{renderQuota(parseInt(text))}
|
||||
</Tag>
|
||||
)}
|
||||
@@ -144,6 +154,7 @@ const TokensTable = () => {
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
let chats = localStorage.getItem('chats');
|
||||
let chatsArray = [];
|
||||
@@ -151,16 +162,11 @@ const TokensTable = () => {
|
||||
|
||||
if (shouldUseCustom) {
|
||||
try {
|
||||
// console.log(chats);
|
||||
chats = JSON.parse(chats);
|
||||
// check chats is array
|
||||
if (Array.isArray(chats)) {
|
||||
for (let i = 0; i < chats.length; i++) {
|
||||
let chat = {};
|
||||
chat.node = 'item';
|
||||
// c is a map
|
||||
// chat.key = chats[i].name;
|
||||
// console.log(chats[i])
|
||||
for (let key in chats[i]) {
|
||||
if (chats[i].hasOwnProperty(key)) {
|
||||
chat.key = i;
|
||||
@@ -178,33 +184,72 @@ const TokensTable = () => {
|
||||
showError(t('聊天链接配置错误,请联系管理员'));
|
||||
}
|
||||
}
|
||||
|
||||
// 创建更多操作的下拉菜单项
|
||||
const moreMenuItems = [
|
||||
{
|
||||
node: 'item',
|
||||
name: t('查看'),
|
||||
icon: <IconEyeOpened />,
|
||||
onClick: () => {
|
||||
Modal.info({
|
||||
title: t('令牌详情'),
|
||||
content: 'sk-' + record.key,
|
||||
size: 'large',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
node: 'item',
|
||||
name: t('删除'),
|
||||
icon: <IconDelete />,
|
||||
type: 'danger',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除此令牌?'),
|
||||
content: t('此修改将不可逆'),
|
||||
onOk: () => {
|
||||
manageToken(record.id, 'delete', record).then(() => {
|
||||
removeRecord(record.key);
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
// 动态添加启用/禁用按钮
|
||||
if (record.status === 1) {
|
||||
moreMenuItems.push({
|
||||
node: 'item',
|
||||
name: t('禁用'),
|
||||
icon: <IconStop />,
|
||||
type: 'warning',
|
||||
onClick: () => {
|
||||
manageToken(record.id, 'disable', record);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
moreMenuItems.push({
|
||||
node: 'item',
|
||||
name: t('启用'),
|
||||
icon: <IconPlay />,
|
||||
type: 'secondary',
|
||||
onClick: () => {
|
||||
manageToken(record.id, 'enable', record);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Popover
|
||||
content={'sk-' + record.key}
|
||||
style={{ padding: 20 }}
|
||||
position='top'
|
||||
>
|
||||
<Button theme='light' type='tertiary' style={{ marginRight: 1 }}>
|
||||
{t('查看')}
|
||||
</Button>
|
||||
</Popover>
|
||||
<Button
|
||||
theme='light'
|
||||
type='secondary'
|
||||
style={{ marginRight: 1 }}
|
||||
onClick={async (text) => {
|
||||
await copyText('sk-' + record.key);
|
||||
}}
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
<Space wrap>
|
||||
<SplitButtonGroup
|
||||
style={{ marginRight: 1 }}
|
||||
className="!rounded-full overflow-hidden"
|
||||
aria-label={t('项目操作按钮组')}
|
||||
>
|
||||
<Button
|
||||
theme='light'
|
||||
size="small"
|
||||
style={{ color: 'rgba(var(--semi-teal-7), 1)' }}
|
||||
onClick={() => {
|
||||
if (chatsArray.length === 0) {
|
||||
@@ -227,56 +272,35 @@ const TokensTable = () => {
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
padding: '8px 4px',
|
||||
padding: '4px 4px',
|
||||
color: 'rgba(var(--semi-teal-7), 1)',
|
||||
}}
|
||||
type='primary'
|
||||
icon={<IconTreeTriangleDown />}
|
||||
size="small"
|
||||
></Button>
|
||||
</Dropdown>
|
||||
</SplitButtonGroup>
|
||||
<Popconfirm
|
||||
title={t('确定是否要删除此令牌?')}
|
||||
content={t('此修改将不可逆')}
|
||||
okType={'danger'}
|
||||
position={'left'}
|
||||
onConfirm={() => {
|
||||
manageToken(record.id, 'delete', record).then(() => {
|
||||
removeRecord(record.key);
|
||||
});
|
||||
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
theme='light'
|
||||
type='secondary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
onClick={async (text) => {
|
||||
await copyText('sk-' + record.key);
|
||||
}}
|
||||
>
|
||||
<Button theme='light' type='danger' style={{ marginRight: 1 }}>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
{record.status === 1 ? (
|
||||
<Button
|
||||
theme='light'
|
||||
type='warning'
|
||||
style={{ marginRight: 1 }}
|
||||
onClick={async () => {
|
||||
manageToken(record.id, 'disable', record);
|
||||
}}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
theme='light'
|
||||
type='secondary'
|
||||
style={{ marginRight: 1 }}
|
||||
onClick={async () => {
|
||||
manageToken(record.id, 'enable', record);
|
||||
}}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
{t('复制')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
icon={<IconEdit />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
style={{ marginRight: 1 }}
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
onClick={() => {
|
||||
setEditingToken(record);
|
||||
setShowEdit(true);
|
||||
@@ -284,7 +308,21 @@ const TokensTable = () => {
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dropdown
|
||||
trigger='click'
|
||||
position='bottomRight'
|
||||
menu={moreMenuItems}
|
||||
>
|
||||
<Button
|
||||
icon={<IconMore />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
/>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -362,7 +400,6 @@ const TokensTable = () => {
|
||||
};
|
||||
|
||||
const onOpenLink = async (type, url, record) => {
|
||||
// console.log(type, url, key);
|
||||
let status = localStorage.getItem('status');
|
||||
let serverAddress = '';
|
||||
if (status) {
|
||||
@@ -379,6 +416,8 @@ const TokensTable = () => {
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
loadTokens(0)
|
||||
.then()
|
||||
@@ -421,11 +460,9 @@ const TokensTable = () => {
|
||||
showSuccess('操作成功完成!');
|
||||
let token = res.data.data;
|
||||
let newTokens = [...tokens];
|
||||
// let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
|
||||
if (action === 'delete') {
|
||||
} else {
|
||||
record.status = token.status;
|
||||
// newTokens[realIdx].status = token.status;
|
||||
}
|
||||
setTokensFormat(newTokens);
|
||||
} else {
|
||||
@@ -436,7 +473,6 @@ const TokensTable = () => {
|
||||
|
||||
const searchTokens = async () => {
|
||||
if (searchKeyword === '' && searchToken === '') {
|
||||
// if keyword is blank, load files instead.
|
||||
await loadTokens(0);
|
||||
setActivePage(1);
|
||||
return;
|
||||
@@ -480,14 +516,13 @@ const TokensTable = () => {
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
if (page === Math.ceil(tokens.length / pageSize) + 1) {
|
||||
// In this case we have to load more data and then append them.
|
||||
loadTokens(page - 1).then((r) => {});
|
||||
loadTokens(page - 1).then((r) => { });
|
||||
}
|
||||
};
|
||||
|
||||
const rowSelection = {
|
||||
onSelect: (record, selected) => {},
|
||||
onSelectAll: (selected, selectedRows) => {},
|
||||
onSelect: (record, selected) => { },
|
||||
onSelectAll: (selected, selectedRows) => { },
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedKeys(selectedRows);
|
||||
},
|
||||
@@ -505,6 +540,80 @@ const TokensTable = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
|
||||
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<Button
|
||||
theme="light"
|
||||
type="primary"
|
||||
icon={<IconPlus />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={() => {
|
||||
setEditingToken({
|
||||
id: undefined,
|
||||
});
|
||||
setShowEdit(true);
|
||||
}}
|
||||
>
|
||||
{t('添加令牌')}
|
||||
</Button>
|
||||
<Button
|
||||
theme="light"
|
||||
type="warning"
|
||||
icon={<IconCopy />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={async () => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个令牌!'));
|
||||
return;
|
||||
}
|
||||
let keys = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
keys +=
|
||||
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(keys);
|
||||
}}
|
||||
>
|
||||
{t('复制所选令牌到剪贴板')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
|
||||
<div className="relative w-full md:w-56">
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索关键字')}
|
||||
value={searchKeyword}
|
||||
onChange={handleKeywordChange}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-full md:w-56">
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('密钥')}
|
||||
value={searchToken}
|
||||
onChange={handleSearchTokenChange}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={searchTokens}
|
||||
loading={searching}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditToken
|
||||
@@ -513,99 +622,42 @@ const TokensTable = () => {
|
||||
visiable={showEdit}
|
||||
handleClose={closeEdit}
|
||||
></EditToken>
|
||||
<Form
|
||||
layout='horizontal'
|
||||
style={{ marginTop: 10 }}
|
||||
labelPosition={'left'}
|
||||
>
|
||||
<Form.Input
|
||||
field='keyword'
|
||||
label={t('搜索关键字')}
|
||||
placeholder={t('令牌名称')}
|
||||
value={searchKeyword}
|
||||
loading={searching}
|
||||
onChange={handleKeywordChange}
|
||||
/>
|
||||
<Form.Input
|
||||
field='token'
|
||||
label={t('密钥')}
|
||||
placeholder={t('密钥')}
|
||||
value={searchToken}
|
||||
loading={searching}
|
||||
onChange={handleSearchTokenChange}
|
||||
/>
|
||||
<Button
|
||||
label={t('查询')}
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
className='btn-margin-right'
|
||||
onClick={searchTokens}
|
||||
style={{ marginRight: 8 }}
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
</Form>
|
||||
<Divider style={{ margin: '15px 0' }} />
|
||||
<div>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
style={{ marginRight: 8 }}
|
||||
onClick={() => {
|
||||
setEditingToken({
|
||||
id: undefined,
|
||||
});
|
||||
setShowEdit(true);
|
||||
}}
|
||||
>
|
||||
{t('添加令牌')}
|
||||
</Button>
|
||||
<Button
|
||||
label={t('复制所选令牌')}
|
||||
type='warning'
|
||||
onClick={async () => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个令牌!'));
|
||||
return;
|
||||
}
|
||||
let keys = '';
|
||||
for (let i = 0; i < selectedKeys.length; i++) {
|
||||
keys +=
|
||||
selectedKeys[i].name + ' sk-' + selectedKeys[i].key + '\n';
|
||||
}
|
||||
await copyText(keys);
|
||||
}}
|
||||
>
|
||||
{t('复制所选令牌到剪贴板')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
style={{ marginTop: 20 }}
|
||||
columns={columns}
|
||||
dataSource={pageData}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: tokenCount,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
formatPageText: (page) =>
|
||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
start: page.currentStart,
|
||||
end: page.currentEnd,
|
||||
total: tokens.length,
|
||||
}),
|
||||
onPageSizeChange: (size) => {
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
},
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
onRow={handleRow}
|
||||
></Table>
|
||||
<Card
|
||||
className="!rounded-2xl"
|
||||
title={renderHeader()}
|
||||
shadows='always'
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pageData}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: tokenCount,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
formatPageText: (page) =>
|
||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
start: page.currentStart,
|
||||
end: page.currentEnd,
|
||||
total: tokens.length,
|
||||
}),
|
||||
onPageSizeChange: (size) => {
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
},
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
onRow={handleRow}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
></Table>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
581
web/src/components/table/UsersTable.js
Normal file
581
web/src/components/table/UsersTable.js
Normal file
@@ -0,0 +1,581 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API, showError, showSuccess, renderGroup, renderNumber, renderQuota } from '../../helpers';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconEdit,
|
||||
IconDelete,
|
||||
IconStop,
|
||||
IconPlay,
|
||||
IconMore,
|
||||
IconUserAdd,
|
||||
IconArrowUp,
|
||||
IconArrowDown,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import AddUser from '../../pages/User/AddUser';
|
||||
import EditUser from '../../pages/User/EditUser';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const UsersTable = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
function renderRole(role) {
|
||||
switch (role) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag size='large' color='blue' shape='circle'>
|
||||
{t('普通用户')}
|
||||
</Tag>
|
||||
);
|
||||
case 10:
|
||||
return (
|
||||
<Tag color='yellow' size='large' shape='circle'>
|
||||
{t('管理员')}
|
||||
</Tag>
|
||||
);
|
||||
case 100:
|
||||
return (
|
||||
<Tag color='orange' size='large' shape='circle'>
|
||||
{t('超级管理员')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='red' size='large' shape='circle'>
|
||||
{t('未知身份')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const renderStatus = (status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return <Tag size='large' color='green' shape='circle'>{t('已激活')}</Tag>;
|
||||
case 2:
|
||||
return (
|
||||
<Tag size='large' color='red' shape='circle'>
|
||||
{t('已封禁')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag size='large' color='grey' shape='circle'>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
},
|
||||
{
|
||||
title: t('用户名'),
|
||||
dataIndex: 'username',
|
||||
},
|
||||
{
|
||||
title: t('分组'),
|
||||
dataIndex: 'group',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderGroup(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('统计信息'),
|
||||
dataIndex: 'info',
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<div>
|
||||
<Space spacing={1}>
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs">
|
||||
{t('剩余')}: {renderQuota(record.quota)}
|
||||
</Tag>
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs">
|
||||
{t('已用')}: {renderQuota(record.used_quota)}
|
||||
</Tag>
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs">
|
||||
{t('调用')}: {renderNumber(record.request_count)}
|
||||
</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('邀请信息'),
|
||||
dataIndex: 'invite',
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<div>
|
||||
<Space spacing={1}>
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs">
|
||||
{t('邀请')}: {renderNumber(record.aff_count)}
|
||||
</Tag>
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs">
|
||||
{t('收益')}: {renderQuota(record.aff_history_quota)}
|
||||
</Tag>
|
||||
<Tag color='white' size='large' shape='circle' className="!text-xs">
|
||||
{record.inviter_id === 0 ? t('无邀请人') : `邀请人: ${record.inviter_id}`}
|
||||
</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('角色'),
|
||||
dataIndex: 'role',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderRole(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
render: (text, record, index) => {
|
||||
return (
|
||||
<div>
|
||||
{record.DeletedAt !== null ? (
|
||||
<Tag color='red' shape='circle'>{t('已注销')}</Tag>
|
||||
) : (
|
||||
renderStatus(text)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
if (record.DeletedAt !== null) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
// 创建更多操作的下拉菜单项
|
||||
const moreMenuItems = [
|
||||
{
|
||||
node: 'item',
|
||||
name: t('提升'),
|
||||
icon: <IconArrowUp />,
|
||||
type: 'warning',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: t('确定要提升此用户吗?'),
|
||||
content: t('此操作将提升用户的权限级别'),
|
||||
onOk: () => {
|
||||
manageUser(record.id, 'promote', record);
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
node: 'item',
|
||||
name: t('降级'),
|
||||
icon: <IconArrowDown />,
|
||||
type: 'secondary',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: t('确定要降级此用户吗?'),
|
||||
content: t('此操作将降低用户的权限级别'),
|
||||
onOk: () => {
|
||||
manageUser(record.id, 'demote', record);
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
node: 'item',
|
||||
name: t('注销'),
|
||||
icon: <IconDelete />,
|
||||
type: 'danger',
|
||||
onClick: () => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要注销此用户?'),
|
||||
content: t('相当于删除用户,此修改将不可逆'),
|
||||
onOk: () => {
|
||||
manageUser(record.id, 'delete', record).then(() => {
|
||||
removeRecord(record.id);
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
// 动态添加启用/禁用按钮
|
||||
if (record.status === 1) {
|
||||
moreMenuItems.splice(-1, 0, {
|
||||
node: 'item',
|
||||
name: t('禁用'),
|
||||
icon: <IconStop />,
|
||||
type: 'warning',
|
||||
onClick: () => {
|
||||
manageUser(record.id, 'disable', record);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
moreMenuItems.splice(-1, 0, {
|
||||
node: 'item',
|
||||
name: t('启用'),
|
||||
icon: <IconPlay />,
|
||||
type: 'secondary',
|
||||
onClick: () => {
|
||||
manageUser(record.id, 'enable', record);
|
||||
},
|
||||
disabled: record.status === 3,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<IconEdit />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
onClick={() => {
|
||||
setEditingUser(record);
|
||||
setShowEditUser(true);
|
||||
}}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger='click'
|
||||
position='bottomRight'
|
||||
menu={moreMenuItems}
|
||||
>
|
||||
<Button
|
||||
icon={<IconMore />}
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size="small"
|
||||
className="!rounded-full"
|
||||
/>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchGroup, setSearchGroup] = useState('');
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);
|
||||
const [showAddUser, setShowAddUser] = useState(false);
|
||||
const [showEditUser, setShowEditUser] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState({
|
||||
id: undefined,
|
||||
});
|
||||
|
||||
const removeRecord = (key) => {
|
||||
let newDataSource = [...users];
|
||||
if (key != null) {
|
||||
let idx = newDataSource.findIndex((data) => data.id === key);
|
||||
|
||||
if (idx > -1) {
|
||||
// update deletedAt
|
||||
newDataSource[idx].DeletedAt = new Date();
|
||||
setUsers(newDataSource);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setUserFormat = (users) => {
|
||||
for (let i = 0; i < users.length; i++) {
|
||||
users[i].key = users[i].id;
|
||||
}
|
||||
setUsers(users);
|
||||
};
|
||||
|
||||
const loadUsers = async (startIdx, pageSize) => {
|
||||
const res = await API.get(`/api/user/?p=${startIdx}&page_size=${pageSize}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const newPageData = data.items;
|
||||
setActivePage(data.page);
|
||||
setUserCount(data.total);
|
||||
setUserFormat(newPageData);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers(0, pageSize)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
fetchGroups().then();
|
||||
}, []);
|
||||
|
||||
const manageUser = async (userId, action, record) => {
|
||||
const res = await API.post('/api/user/manage', {
|
||||
id: userId,
|
||||
action,
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('操作成功完成!');
|
||||
let user = res.data.data;
|
||||
let newUsers = [...users];
|
||||
if (action === 'delete') {
|
||||
} else {
|
||||
record.status = user.status;
|
||||
record.role = user.role;
|
||||
}
|
||||
setUsers(newUsers);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const searchUsers = async (
|
||||
startIdx,
|
||||
pageSize,
|
||||
searchKeyword,
|
||||
searchGroup,
|
||||
) => {
|
||||
if (searchKeyword === '' && searchGroup === '') {
|
||||
// if keyword is blank, load files instead.
|
||||
await loadUsers(startIdx, pageSize);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const res = await API.get(
|
||||
`/api/user/search?keyword=${searchKeyword}&group=${searchGroup}&p=${startIdx}&page_size=${pageSize}`,
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const newPageData = data.items;
|
||||
setActivePage(data.page);
|
||||
setUserCount(data.total);
|
||||
setUserFormat(newPageData);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleKeywordChange = async (value) => {
|
||||
setSearchKeyword(value.trim());
|
||||
};
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
if (searchKeyword === '' && searchGroup === '') {
|
||||
loadUsers(page, pageSize).then();
|
||||
} else {
|
||||
searchUsers(page, pageSize, searchKeyword, searchGroup).then();
|
||||
}
|
||||
};
|
||||
|
||||
const closeAddUser = () => {
|
||||
setShowAddUser(false);
|
||||
};
|
||||
|
||||
const closeEditUser = () => {
|
||||
setShowEditUser(false);
|
||||
setEditingUser({
|
||||
id: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
setActivePage(1);
|
||||
if (searchKeyword === '') {
|
||||
await loadUsers(activePage, pageSize);
|
||||
} else {
|
||||
await searchUsers(activePage, pageSize, searchKeyword, searchGroup);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
let res = await API.get(`/api/group/`);
|
||||
// add 'all' option
|
||||
// res.data.data.unshift('all');
|
||||
if (res === undefined) {
|
||||
return;
|
||||
}
|
||||
setGroupOptions(
|
||||
res.data.data.map((group) => ({
|
||||
label: group,
|
||||
value: group,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (size) => {
|
||||
localStorage.setItem('page-size', size + '');
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
loadUsers(activePage, size)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRow = (record, index) => {
|
||||
if (record.DeletedAt !== null || record.status !== 1) {
|
||||
return {
|
||||
style: {
|
||||
background: 'var(--semi-color-disabled-border)',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center text-blue-500">
|
||||
<IconUserAdd className="mr-2" />
|
||||
<Text>{t('用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider margin="12px" />
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
|
||||
<div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<IconPlus />}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
onClick={() => {
|
||||
setShowAddUser(true);
|
||||
}}
|
||||
>
|
||||
{t('添加用户')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto order-1 md:order-2">
|
||||
<div className="relative w-full md:w-64">
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('支持搜索用户的 ID、用户名、显示名称和邮箱地址')}
|
||||
value={searchKeyword}
|
||||
onChange={handleKeywordChange}
|
||||
className="!rounded-full"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-48">
|
||||
<Select
|
||||
placeholder={t('选择分组')}
|
||||
optionList={groupOptions}
|
||||
value={searchGroup}
|
||||
onChange={(value) => {
|
||||
setSearchGroup(value);
|
||||
searchUsers(activePage, pageSize, searchKeyword, value);
|
||||
}}
|
||||
className="!rounded-full w-full"
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
searchUsers(activePage, pageSize, searchKeyword, searchGroup);
|
||||
}}
|
||||
loading={searching}
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddUser
|
||||
refresh={refresh}
|
||||
visible={showAddUser}
|
||||
handleClose={closeAddUser}
|
||||
></AddUser>
|
||||
<EditUser
|
||||
refresh={refresh}
|
||||
visible={showEditUser}
|
||||
handleClose={closeEditUser}
|
||||
editingUser={editingUser}
|
||||
></EditUser>
|
||||
|
||||
<Card
|
||||
className="!rounded-2xl"
|
||||
title={renderHeader()}
|
||||
shadows='always'
|
||||
bordered={false}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
formatPageText: (page) =>
|
||||
t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
start: page.currentStart,
|
||||
end: page.currentEnd,
|
||||
total: userCount,
|
||||
}),
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: userCount,
|
||||
pageSizeOpts: [10, 20, 50, 100],
|
||||
showSizeChanger: true,
|
||||
onPageSizeChange: (size) => {
|
||||
handlePageSizeChange(size);
|
||||
},
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
loading={loading}
|
||||
onRow={handleRow}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersTable;
|
||||
@@ -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,
|
||||
color: 'blue',
|
||||
label: '字节火山方舟、豆包、DeepSeek通用',
|
||||
label: '字节火山方舟、豆包通用',
|
||||
},
|
||||
{
|
||||
value: 48,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './toast.constants';
|
||||
export * from './user.constants';
|
||||
export * from './common.constant';
|
||||
export * from './channel.constants';
|
||||
export * from './user.constants';
|
||||
export * from './toast.constants';
|
||||
export * from './common.constant';
|
||||
export * from './playground.constants';
|
||||
|
||||
95
web/src/constants/playground.constants.js
Normal file
95
web/src/constants/playground.constants.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// ========== 消息相关常量 ==========
|
||||
export const MESSAGE_STATUS = {
|
||||
LOADING: 'loading',
|
||||
INCOMPLETE: 'incomplete',
|
||||
COMPLETE: 'complete',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
export const MESSAGE_ROLES = {
|
||||
USER: 'user',
|
||||
ASSISTANT: 'assistant',
|
||||
SYSTEM: 'system',
|
||||
};
|
||||
|
||||
// 默认消息示例
|
||||
export const DEFAULT_MESSAGES = [
|
||||
{
|
||||
role: MESSAGE_ROLES.USER,
|
||||
id: '2',
|
||||
createAt: 1715676751919,
|
||||
content: '你好',
|
||||
},
|
||||
{
|
||||
role: MESSAGE_ROLES.ASSISTANT,
|
||||
id: '3',
|
||||
createAt: 1715676751919,
|
||||
content: '你好,请问有什么可以帮助您的吗?',
|
||||
reasoningContent: '',
|
||||
isReasoningExpanded: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ========== UI 相关常量 ==========
|
||||
export const DEBUG_TABS = {
|
||||
PREVIEW: 'preview',
|
||||
REQUEST: 'request',
|
||||
RESPONSE: 'response',
|
||||
};
|
||||
|
||||
// ========== API 相关常量 ==========
|
||||
export const API_ENDPOINTS = {
|
||||
CHAT_COMPLETIONS: '/pg/chat/completions',
|
||||
USER_MODELS: '/api/user/models',
|
||||
USER_GROUPS: '/api/user/self/groups',
|
||||
};
|
||||
|
||||
// ========== 配置默认值 ==========
|
||||
export const DEFAULT_CONFIG = {
|
||||
inputs: {
|
||||
model: 'gpt-4o',
|
||||
group: '',
|
||||
temperature: 0.7,
|
||||
top_p: 1,
|
||||
max_tokens: 4096,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
seed: null,
|
||||
stream: true,
|
||||
imageEnabled: false,
|
||||
imageUrls: [''],
|
||||
},
|
||||
parameterEnabled: {
|
||||
temperature: true,
|
||||
top_p: true,
|
||||
max_tokens: false,
|
||||
frequency_penalty: true,
|
||||
presence_penalty: true,
|
||||
seed: false,
|
||||
},
|
||||
systemPrompt: '',
|
||||
showDebugPanel: false,
|
||||
customRequestMode: false,
|
||||
customRequestBody: '',
|
||||
};
|
||||
|
||||
// ========== 正则表达式 ==========
|
||||
export const THINK_TAG_REGEX = /<think>([\s\S]*?)<\/think>/g;
|
||||
|
||||
// ========== 错误消息 ==========
|
||||
export const ERROR_MESSAGES = {
|
||||
NO_TEXT_CONTENT: '此消息没有可复制的文本内容',
|
||||
INVALID_MESSAGE_TYPE: '无法复制此类型的消息内容',
|
||||
COPY_FAILED: '复制失败,请手动选择文本复制',
|
||||
COPY_HTTPS_REQUIRED: '复制功能需要 HTTPS 环境,请手动复制',
|
||||
BROWSER_NOT_SUPPORTED: '浏览器不支持复制功能,请手动复制',
|
||||
JSON_PARSE_ERROR: '自定义请求体格式错误,请检查JSON格式',
|
||||
API_REQUEST_ERROR: '请求发生错误',
|
||||
NETWORK_ERROR: '网络连接失败或服务器无响应',
|
||||
};
|
||||
|
||||
// ========== 存储键名 ==========
|
||||
export const STORAGE_KEYS = {
|
||||
CONFIG: 'playground_config',
|
||||
MESSAGES: 'playground_messages',
|
||||
};
|
||||
@@ -1,106 +1,227 @@
|
||||
// contexts/User/index.jsx
|
||||
// contexts/Style/index.js
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { isMobile } from '../../helpers/index.js';
|
||||
import React, { useReducer, useEffect, useMemo, createContext } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { isMobile as getIsMobile } from '../../helpers';
|
||||
|
||||
export const StyleContext = React.createContext({
|
||||
dispatch: () => null,
|
||||
});
|
||||
// Action Types
|
||||
const ACTION_TYPES = {
|
||||
TOGGLE_SIDER: 'TOGGLE_SIDER',
|
||||
SET_SIDER: 'SET_SIDER',
|
||||
SET_MOBILE: 'SET_MOBILE',
|
||||
SET_SIDER_COLLAPSED: 'SET_SIDER_COLLAPSED',
|
||||
BATCH_UPDATE: 'BATCH_UPDATE',
|
||||
};
|
||||
|
||||
export const StyleProvider = ({ children }) => {
|
||||
const [state, setState] = useState({
|
||||
isMobile: isMobile(),
|
||||
showSider: false,
|
||||
siderCollapsed: false,
|
||||
shouldInnerPadding: false,
|
||||
});
|
||||
// Constants
|
||||
const STORAGE_KEYS = {
|
||||
SIDEBAR_COLLAPSED: 'default_collapse_sidebar',
|
||||
};
|
||||
|
||||
const dispatch = (action) => {
|
||||
if ('type' in action) {
|
||||
switch (action.type) {
|
||||
case 'TOGGLE_SIDER':
|
||||
setState((prev) => ({ ...prev, showSider: !prev.showSider }));
|
||||
break;
|
||||
case 'SET_SIDER':
|
||||
setState((prev) => ({ ...prev, showSider: action.payload }));
|
||||
break;
|
||||
case 'SET_MOBILE':
|
||||
setState((prev) => ({ ...prev, isMobile: action.payload }));
|
||||
break;
|
||||
case 'SET_SIDER_COLLAPSED':
|
||||
setState((prev) => ({ ...prev, siderCollapsed: action.payload }));
|
||||
break;
|
||||
case 'SET_INNER_PADDING':
|
||||
setState((prev) => ({ ...prev, shouldInnerPadding: action.payload }));
|
||||
break;
|
||||
default:
|
||||
setState((prev) => ({ ...prev, ...action }));
|
||||
}
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, ...action }));
|
||||
}
|
||||
const ROUTE_PATTERNS = {
|
||||
CONSOLE: '/console',
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断路径是否为控制台路由
|
||||
* @param {string} pathname - 路由路径
|
||||
* @returns {boolean} 是否为控制台路由
|
||||
*/
|
||||
const isConsoleRoute = (pathname) => {
|
||||
return pathname === ROUTE_PATTERNS.CONSOLE ||
|
||||
pathname.startsWith(ROUTE_PATTERNS.CONSOLE + '/');
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取初始状态
|
||||
* @param {string} pathname - 当前路由路径
|
||||
* @returns {Object} 初始状态对象
|
||||
*/
|
||||
const getInitialState = (pathname) => {
|
||||
const isMobile = getIsMobile();
|
||||
const isConsole = isConsoleRoute(pathname);
|
||||
const isCollapsed = localStorage.getItem(STORAGE_KEYS.SIDEBAR_COLLAPSED) === 'true';
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
showSider: isConsole && !isMobile,
|
||||
siderCollapsed: isCollapsed,
|
||||
isManualSiderControl: false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Style reducer
|
||||
* @param {Object} state - 当前状态
|
||||
* @param {Object} action - action 对象
|
||||
* @returns {Object} 新状态
|
||||
*/
|
||||
const styleReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case ACTION_TYPES.TOGGLE_SIDER:
|
||||
return {
|
||||
...state,
|
||||
showSider: !state.showSider,
|
||||
isManualSiderControl: true,
|
||||
};
|
||||
|
||||
case ACTION_TYPES.SET_SIDER:
|
||||
return {
|
||||
...state,
|
||||
showSider: action.payload,
|
||||
isManualSiderControl: action.isManualControl ?? false,
|
||||
};
|
||||
|
||||
case ACTION_TYPES.SET_MOBILE:
|
||||
return {
|
||||
...state,
|
||||
isMobile: action.payload,
|
||||
};
|
||||
|
||||
case ACTION_TYPES.SET_SIDER_COLLAPSED:
|
||||
// 自动保存到 localStorage
|
||||
localStorage.setItem(STORAGE_KEYS.SIDEBAR_COLLAPSED, action.payload.toString());
|
||||
return {
|
||||
...state,
|
||||
siderCollapsed: action.payload,
|
||||
};
|
||||
|
||||
case ACTION_TYPES.BATCH_UPDATE:
|
||||
return {
|
||||
...state,
|
||||
...action.payload,
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
// Context (内部使用,不导出)
|
||||
const StyleContext = createContext(null);
|
||||
|
||||
/**
|
||||
* 自定义 Hook - 处理窗口大小变化
|
||||
* @param {Function} dispatch - dispatch 函数
|
||||
* @param {Object} state - 当前状态
|
||||
* @param {string} pathname - 当前路径
|
||||
*/
|
||||
const useWindowResize = (dispatch, state, pathname) => {
|
||||
useEffect(() => {
|
||||
const updateIsMobile = () => {
|
||||
const mobileDetected = isMobile();
|
||||
dispatch({ type: 'SET_MOBILE', payload: mobileDetected });
|
||||
|
||||
// If on mobile, we might want to auto-hide the sidebar
|
||||
if (mobileDetected && state.showSider) {
|
||||
dispatch({ type: 'SET_SIDER', payload: false });
|
||||
}
|
||||
};
|
||||
|
||||
updateIsMobile();
|
||||
|
||||
const updateShowSider = () => {
|
||||
// check pathname
|
||||
const pathname = window.location.pathname;
|
||||
if (
|
||||
pathname === '' ||
|
||||
pathname === '/' ||
|
||||
pathname.includes('/home') ||
|
||||
pathname.includes('/chat')
|
||||
) {
|
||||
dispatch({ type: 'SET_SIDER', payload: false });
|
||||
dispatch({ type: 'SET_INNER_PADDING', payload: false });
|
||||
} else if (pathname === '/setup') {
|
||||
dispatch({ type: 'SET_SIDER', payload: false });
|
||||
dispatch({ type: 'SET_INNER_PADDING', payload: false });
|
||||
} else {
|
||||
// Only show sidebar on non-mobile devices by default
|
||||
dispatch({ type: 'SET_SIDER', payload: !isMobile() });
|
||||
dispatch({ type: 'SET_INNER_PADDING', payload: true });
|
||||
}
|
||||
};
|
||||
|
||||
updateShowSider();
|
||||
|
||||
const updateSiderCollapsed = () => {
|
||||
const isCollapsed =
|
||||
localStorage.getItem('default_collapse_sidebar') === 'true';
|
||||
dispatch({ type: 'SET_SIDER_COLLAPSED', payload: isCollapsed });
|
||||
};
|
||||
|
||||
updateSiderCollapsed();
|
||||
|
||||
// Add event listeners to handle window resize
|
||||
const handleResize = () => {
|
||||
updateIsMobile();
|
||||
const isMobile = getIsMobile();
|
||||
dispatch({ type: ACTION_TYPES.SET_MOBILE, payload: isMobile });
|
||||
|
||||
// 只有在非手动控制的情况下,才根据屏幕大小自动调整侧边栏
|
||||
if (!state.isManualSiderControl && isConsoleRoute(pathname)) {
|
||||
dispatch({
|
||||
type: ACTION_TYPES.SET_SIDER,
|
||||
payload: !isMobile,
|
||||
isManualControl: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
let timeoutId;
|
||||
const debouncedResize = () => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(handleResize, 150);
|
||||
};
|
||||
|
||||
// Cleanup event listener on component unmount
|
||||
window.addEventListener('resize', debouncedResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('resize', debouncedResize);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, []);
|
||||
}, [dispatch, state.isManualSiderControl, pathname]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 自定义 Hook - 处理路由变化
|
||||
* @param {Function} dispatch - dispatch 函数
|
||||
* @param {string} pathname - 当前路径
|
||||
*/
|
||||
const useRouteChange = (dispatch, pathname) => {
|
||||
useEffect(() => {
|
||||
const isMobile = getIsMobile();
|
||||
const isConsole = isConsoleRoute(pathname);
|
||||
|
||||
dispatch({
|
||||
type: ACTION_TYPES.BATCH_UPDATE,
|
||||
payload: {
|
||||
showSider: isConsole && !isMobile,
|
||||
isManualSiderControl: false,
|
||||
},
|
||||
});
|
||||
}, [pathname, dispatch]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 自定义 Hook - 处理移动设备侧边栏自动收起
|
||||
* @param {Object} state - 当前状态
|
||||
* @param {Function} dispatch - dispatch 函数
|
||||
*/
|
||||
const useMobileSiderAutoHide = (state, dispatch) => {
|
||||
useEffect(() => {
|
||||
// 移动设备上,如果不是手动控制且侧边栏是打开的,则自动关闭
|
||||
if (state.isMobile && state.showSider && !state.isManualSiderControl) {
|
||||
dispatch({ type: ACTION_TYPES.SET_SIDER, payload: false });
|
||||
}
|
||||
}, [state.isMobile, state.showSider, state.isManualSiderControl, dispatch]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Style Provider 组件
|
||||
*/
|
||||
export const StyleProvider = ({ children }) => {
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
|
||||
const [state, dispatch] = useReducer(
|
||||
styleReducer,
|
||||
pathname,
|
||||
getInitialState
|
||||
);
|
||||
|
||||
useWindowResize(dispatch, state, pathname);
|
||||
useRouteChange(dispatch, pathname);
|
||||
useMobileSiderAutoHide(state, dispatch);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ state, dispatch }),
|
||||
[state]
|
||||
);
|
||||
|
||||
return (
|
||||
<StyleContext.Provider value={[state, dispatch]}>
|
||||
<StyleContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</StyleContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 自定义 Hook - 使用 StyleContext
|
||||
* @returns {{state: Object, dispatch: Function}} context value
|
||||
*/
|
||||
export const useStyle = () => {
|
||||
const context = React.useContext(StyleContext);
|
||||
if (!context) {
|
||||
throw new Error('useStyle must be used within StyleProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// 导出 action creators 以便外部使用
|
||||
export const styleActions = {
|
||||
toggleSider: () => ({ type: ACTION_TYPES.TOGGLE_SIDER }),
|
||||
setSider: (show, isManualControl = false) => ({
|
||||
type: ACTION_TYPES.SET_SIDER,
|
||||
payload: show,
|
||||
isManualControl
|
||||
}),
|
||||
setMobile: (isMobile) => ({ type: ACTION_TYPES.SET_MOBILE, payload: isMobile }),
|
||||
setSiderCollapsed: (collapsed) => ({
|
||||
type: ACTION_TYPES.SET_SIDER_COLLAPSED,
|
||||
payload: collapsed
|
||||
}),
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user