mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-12 06:57:27 +00:00
Compare commits
281 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77d5dff0c6 | ||
|
|
02e43ee12e | ||
|
|
7bced6b236 | ||
|
|
a0844d5481 | ||
|
|
d79b9e266e | ||
|
|
6acfe31ee9 | ||
|
|
2c95a7c277 | ||
|
|
7010450f77 | ||
|
|
c9849ecc46 | ||
|
|
5b641a4ead | ||
|
|
b73af9e88f | ||
|
|
ed84f937e3 | ||
|
|
6bf8a72011 | ||
|
|
d3b93196cf | ||
|
|
fcc4d0074f | ||
|
|
cb83a06103 | ||
|
|
5018945c71 | ||
|
|
ce2fba7f8b | ||
|
|
2b898bc577 | ||
|
|
017fa70e1a | ||
|
|
5f52148e4e | ||
|
|
7e9bd35ac7 | ||
|
|
d124ec5b1a | ||
|
|
8202c115f0 | ||
|
|
6e7249cf06 | ||
|
|
33014e9399 | ||
|
|
387721e907 | ||
|
|
e0cc13094f | ||
|
|
5dc3543e41 | ||
|
|
f1f07cb31b | ||
|
|
a26dbf5358 | ||
|
|
49e77fb3df | ||
|
|
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 | ||
|
|
87540b4f7c | ||
|
|
e3d7b31a49 | ||
|
|
bf016543c3 | ||
|
|
eb94aa13e6 | ||
|
|
16b9cb6ff4 | ||
|
|
6e72dcd0ba | ||
|
|
96ab4177ca | ||
|
|
76824a0337 | ||
|
|
22af6af9c7 | ||
|
|
d542b529cb | ||
|
|
a7c79a9e34 | ||
|
|
e85f687c6b | ||
|
|
acdfd86286 | ||
|
|
1e57317322 | ||
|
|
16ad2d48d8 | ||
|
|
21077d4696 | ||
|
|
d3a6f1cc46 | ||
|
|
5b5f10cadc | ||
|
|
fa06ea19a6 | ||
|
|
3454d6c29e | ||
|
|
d96eb6fb1c | ||
|
|
693dfd18f9 | ||
|
|
3cd29a4963 | ||
|
|
41120b4d75 | ||
|
|
30d5a11f46 | ||
|
|
368fd75c86 | ||
|
|
ee07762611 | ||
|
|
a215538b4d | ||
|
|
873e3f3dc8 | ||
|
|
0298692852 | ||
|
|
91ff211ab1 | ||
|
|
156ad5c3fd | ||
|
|
d90e4bef63 | ||
|
|
39329fcd1c | ||
|
|
738a9a4558 | ||
|
|
96709dd9f3 | ||
|
|
072ac1b3c8 | ||
|
|
46a67e09f1 | ||
|
|
59de8e11ac | ||
|
|
dc5e53ec14 | ||
|
|
00c1ff05de | ||
|
|
33ae3479c4 | ||
|
|
18344ae580 | ||
|
|
de98d11d65 | ||
|
|
dadc2cf329 | ||
|
|
452853c1a4 | ||
|
|
c6ead4e5bd | ||
|
|
a044781423 | ||
|
|
e9af621d88 | ||
|
|
eadf9aad41 | ||
|
|
2cc2d4f652 | ||
|
|
eb69ada880 | ||
|
|
1660c47db5 | ||
|
|
eba661ad1e | ||
|
|
6d11fbee89 | ||
|
|
9a6c540013 | ||
|
|
0befa28e8e | ||
|
|
ce91049827 | ||
|
|
e911eb7988 | ||
|
|
d95583ce1d | ||
|
|
67a65213d8 | ||
|
|
0f3216564d | ||
|
|
e1190f98e9 | ||
|
|
e07163c568 | ||
|
|
a5bccd02dc | ||
|
|
64973e6cff | ||
|
|
c6d7cc7c25 | ||
|
|
0118364059 | ||
|
|
28d401ec01 | ||
|
|
881ad57a02 | ||
|
|
c75421e2c6 | ||
|
|
23cf1d268c | ||
|
|
cb281dfc11 | ||
|
|
4afe7a29b1 | ||
|
|
a726818c17 | ||
|
|
26c3da3548 | ||
|
|
4640d0a4aa | ||
|
|
bcd673de3a | ||
|
|
55a49baed7 | ||
|
|
bcbb9bb16a | ||
|
|
305d7528da | ||
|
|
1b0d7fbd56 | ||
|
|
85c40424d5 | ||
|
|
c04a816e59 | ||
|
|
9c12e02cb5 | ||
|
|
59b1e970fd | ||
|
|
7739219ca6 | ||
|
|
1242f35177 | ||
|
|
9247661849 | ||
|
|
16570909be | ||
|
|
1ea0dd8f06 | ||
|
|
a391ac29a0 | ||
|
|
350c29a054 | ||
|
|
1fa4ac69b2 | ||
|
|
19d1f7853f | ||
|
|
74572ab2ee |
@@ -1,14 +1,15 @@
|
||||
name: Publish Docker image (amd64)
|
||||
name: Publish Docker image (alpha)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
branches:
|
||||
- alpha
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
name:
|
||||
description: 'reason'
|
||||
description: "reason"
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
push_to_registries:
|
||||
name: Push Docker image to multiple registries
|
||||
@@ -22,7 +23,7 @@ jobs:
|
||||
|
||||
- name: Save version info
|
||||
run: |
|
||||
git describe --tags > VERSION
|
||||
echo "alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" > VERSION
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
@@ -37,6 +38,9 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -44,11 +48,15 @@ jobs:
|
||||
images: |
|
||||
calciumion/new-api
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=alpha
|
||||
type=raw,value=alpha-{{date 'YYYYMMDD'}}-{{sha}}
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
7
.github/workflows/docker-image-arm64.yml
vendored
7
.github/workflows/docker-image-arm64.yml
vendored
@@ -1,14 +1,9 @@
|
||||
name: Publish Docker image (arm64)
|
||||
name: Publish Docker image (Multi Registries)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
name:
|
||||
description: 'reason'
|
||||
required: false
|
||||
jobs:
|
||||
push_to_registries:
|
||||
name: Push Docker image to multiple registries
|
||||
|
||||
13
.github/workflows/linux-release.yml
vendored
13
.github/workflows/linux-release.yml
vendored
@@ -3,6 +3,11 @@ permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
name:
|
||||
description: 'reason'
|
||||
required: false
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
@@ -15,16 +20,16 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 18
|
||||
bun-version: latest
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web
|
||||
npm install
|
||||
REACT_APP_VERSION=$(git describe --tags) npm run build
|
||||
bun install
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
||||
cd ..
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
|
||||
13
.github/workflows/macos-release.yml
vendored
13
.github/workflows/macos-release.yml
vendored
@@ -3,6 +3,11 @@ permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
name:
|
||||
description: 'reason'
|
||||
required: false
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
@@ -15,16 +20,16 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 18
|
||||
bun-version: latest
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web
|
||||
npm install
|
||||
REACT_APP_VERSION=$(git describe --tags) npm run build
|
||||
bun install
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
||||
cd ..
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
|
||||
13
.github/workflows/windows-release.yml
vendored
13
.github/workflows/windows-release.yml
vendored
@@ -3,6 +3,11 @@ permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
name:
|
||||
description: 'reason'
|
||||
required: false
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
@@ -18,16 +23,16 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
node-version: 18
|
||||
bun-version: latest
|
||||
- name: Build Frontend
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web
|
||||
npm install
|
||||
REACT_APP_VERSION=$(git describe --tags) npm run build
|
||||
bun install
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
|
||||
cd ..
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
|
||||
@@ -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`
|
||||
|
||||
## 部署
|
||||
|
||||
|
||||
@@ -119,8 +119,11 @@ func FetchUpstreamModels(c *gin.Context) {
|
||||
baseURL = channel.GetBaseURL()
|
||||
}
|
||||
url := fmt.Sprintf("%s/v1/models", baseURL)
|
||||
if channel.Type == common.ChannelTypeGemini {
|
||||
switch channel.Type {
|
||||
case common.ChannelTypeGemini:
|
||||
url = fmt.Sprintf("%s/v1beta/openai/models", baseURL)
|
||||
case common.ChannelTypeAli:
|
||||
url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
|
||||
}
|
||||
body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
|
||||
if err != nil {
|
||||
|
||||
@@ -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,6 +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"`
|
||||
Tools []ToolCallRequest `json:"tools,omitempty"`
|
||||
ToolChoice any `json:"tool_choice,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
@@ -53,6 +54,16 @@ type GeneralOpenAIRequest struct {
|
||||
Audio any `json:"audio,omitempty"`
|
||||
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 {
|
||||
@@ -72,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
|
||||
}
|
||||
@@ -115,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 {
|
||||
@@ -371,6 +384,11 @@ func (m *Message) ParseContent() []MediaContent {
|
||||
return contentList
|
||||
}
|
||||
|
||||
type WebSearchOptions struct {
|
||||
SearchContextSize string `json:"search_context_size,omitempty"`
|
||||
UserLocation json.RawMessage `json:"user_location,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAIResponsesRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeEmbeddings:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/embeddings/text-embedding/text-embedding", info.BaseUrl)
|
||||
case constant.RelayModeRerank:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.BaseUrl)
|
||||
case constant.RelayModeImagesGenerations:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.BaseUrl)
|
||||
case constant.RelayModeCompletions:
|
||||
@@ -57,6 +59,12 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
if request == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
|
||||
// fix: ali parameter.enable_thinking must be set to false for non-streaming calls
|
||||
if !info.IsStream {
|
||||
request.EnableThinking = false
|
||||
}
|
||||
|
||||
switch info.RelayMode {
|
||||
default:
|
||||
aliReq := requestOpenAI2Ali(*request)
|
||||
@@ -70,7 +78,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
return ConvertRerankRequest(request), nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
|
||||
@@ -97,6 +105,8 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
err, usage = aliImageHandler(c, resp, info)
|
||||
case constant.RelayModeEmbeddings:
|
||||
err, usage = aliEmbeddingHandler(c, resp)
|
||||
case constant.RelayModeRerank:
|
||||
err, usage = RerankHandler(c, resp, info)
|
||||
default:
|
||||
if info.IsStream {
|
||||
err, usage = openai.OaiStreamHandler(c, resp, info)
|
||||
|
||||
@@ -8,6 +8,7 @@ var ModelList = []string{
|
||||
"qwq-32b",
|
||||
"qwen3-235b-a22b",
|
||||
"text-embedding-v1",
|
||||
"gte-rerank-v2",
|
||||
}
|
||||
|
||||
var ChannelName = "ali"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package ali
|
||||
|
||||
import "one-api/dto"
|
||||
|
||||
type AliMessage struct {
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
@@ -97,3 +99,28 @@ type AliImageRequest struct {
|
||||
} `json:"parameters,omitempty"`
|
||||
ResponseFormat string `json:"response_format,omitempty"`
|
||||
}
|
||||
|
||||
type AliRerankParameters struct {
|
||||
TopN *int `json:"top_n,omitempty"`
|
||||
ReturnDocuments *bool `json:"return_documents,omitempty"`
|
||||
}
|
||||
|
||||
type AliRerankInput struct {
|
||||
Query string `json:"query"`
|
||||
Documents []any `json:"documents"`
|
||||
}
|
||||
|
||||
type AliRerankRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input AliRerankInput `json:"input"`
|
||||
Parameters AliRerankParameters `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
type AliRerankResponse struct {
|
||||
Output struct {
|
||||
Results []dto.RerankResponseResult `json:"results"`
|
||||
} `json:"output"`
|
||||
Usage AliUsage `json:"usage"`
|
||||
RequestId string `json:"request_id"`
|
||||
AliError
|
||||
}
|
||||
|
||||
83
relay/channel/ali/rerank.go
Normal file
83
relay/channel/ali/rerank.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package ali
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/dto"
|
||||
relaycommon "one-api/relay/common"
|
||||
"one-api/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ConvertRerankRequest(request dto.RerankRequest) *AliRerankRequest {
|
||||
returnDocuments := request.ReturnDocuments
|
||||
if returnDocuments == nil {
|
||||
t := true
|
||||
returnDocuments = &t
|
||||
}
|
||||
return &AliRerankRequest{
|
||||
Model: request.Model,
|
||||
Input: AliRerankInput{
|
||||
Query: request.Query,
|
||||
Documents: request.Documents,
|
||||
},
|
||||
Parameters: AliRerankParameters{
|
||||
TopN: &request.TopN,
|
||||
ReturnDocuments: returnDocuments,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func RerankHandler(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
|
||||
}
|
||||
|
||||
var aliResponse AliRerankResponse
|
||||
err = json.Unmarshal(responseBody, &aliResponse)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError), nil
|
||||
}
|
||||
|
||||
if aliResponse.Code != "" {
|
||||
return &dto.OpenAIErrorWithStatusCode{
|
||||
Error: dto.OpenAIError{
|
||||
Message: aliResponse.Message,
|
||||
Type: aliResponse.Code,
|
||||
Param: aliResponse.RequestId,
|
||||
Code: aliResponse.Code,
|
||||
},
|
||||
StatusCode: resp.StatusCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
usage := dto.Usage{
|
||||
PromptTokens: aliResponse.Usage.TotalTokens,
|
||||
CompletionTokens: 0,
|
||||
TotalTokens: aliResponse.Usage.TotalTokens,
|
||||
}
|
||||
rerankResponse := dto.RerankResponse{
|
||||
Results: aliResponse.Output.Results,
|
||||
Usage: usage,
|
||||
}
|
||||
|
||||
jsonResponse, err := json.Marshal(rerankResponse)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
|
||||
}
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
_, err = c.Writer.Write(jsonResponse)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "write_response_body_failed", http.StatusInternalServerError), nil
|
||||
}
|
||||
|
||||
return nil, &usage
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package ali
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
@@ -11,6 +10,8 @@ import (
|
||||
"one-api/relay/helper"
|
||||
"one-api/service"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// https://help.aliyun.com/document_detail/613695.html?spm=a2c4g.2399480.0.0.1adb778fAdzP9w#341800c0f8w0r
|
||||
@@ -27,9 +28,6 @@ func requestOpenAI2Ali(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIReque
|
||||
}
|
||||
|
||||
func embeddingRequestOpenAI2Ali(request dto.EmbeddingRequest) *AliEmbeddingRequest {
|
||||
if request.Model == "" {
|
||||
request.Model = "text-embedding-v1"
|
||||
}
|
||||
return &AliEmbeddingRequest{
|
||||
Model: request.Model,
|
||||
Input: struct {
|
||||
@@ -64,7 +62,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW
|
||||
}, nil
|
||||
}
|
||||
|
||||
fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse)
|
||||
model := c.GetString("model")
|
||||
if model == "" {
|
||||
model = "text-embedding-v4"
|
||||
}
|
||||
fullTextResponse := embeddingResponseAli2OpenAI(&aliResponse, model)
|
||||
jsonResponse, err := json.Marshal(fullTextResponse)
|
||||
if err != nil {
|
||||
return service.OpenAIErrorWrapper(err, "marshal_response_body_failed", http.StatusInternalServerError), nil
|
||||
@@ -75,11 +77,11 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*dto.OpenAIErrorW
|
||||
return nil, &fullTextResponse.Usage
|
||||
}
|
||||
|
||||
func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse) *dto.OpenAIEmbeddingResponse {
|
||||
func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse, model string) *dto.OpenAIEmbeddingResponse {
|
||||
openAIEmbeddingResponse := dto.OpenAIEmbeddingResponse{
|
||||
Object: "list",
|
||||
Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(response.Output.Embeddings)),
|
||||
Model: "text-embedding-v1",
|
||||
Model: model,
|
||||
Usage: dto.Usage{TotalTokens: response.Usage.TotalTokens},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,13 +1,55 @@
|
||||
package mistral
|
||||
|
||||
import (
|
||||
"one-api/common"
|
||||
"one-api/dto"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var mistralToolCallIdRegexp = regexp.MustCompile("^[a-zA-Z0-9]{9}$")
|
||||
|
||||
func requestOpenAI2Mistral(request *dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest {
|
||||
messages := make([]dto.Message, 0, len(request.Messages))
|
||||
idMap := make(map[string]string)
|
||||
for _, message := range request.Messages {
|
||||
// 1. tool_calls.id
|
||||
toolCalls := message.ParseToolCalls()
|
||||
if toolCalls != nil {
|
||||
for i := range toolCalls {
|
||||
if !mistralToolCallIdRegexp.MatchString(toolCalls[i].ID) {
|
||||
if newId, ok := idMap[toolCalls[i].ID]; ok {
|
||||
toolCalls[i].ID = newId
|
||||
} else {
|
||||
newId, err := common.GenerateRandomCharsKey(9)
|
||||
if err == nil {
|
||||
idMap[toolCalls[i].ID] = newId
|
||||
toolCalls[i].ID = newId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
message.SetToolCalls(toolCalls)
|
||||
}
|
||||
|
||||
// 2. tool_call_id
|
||||
if message.ToolCallId != "" {
|
||||
if newId, ok := idMap[message.ToolCallId]; ok {
|
||||
message.ToolCallId = newId
|
||||
} else {
|
||||
if !mistralToolCallIdRegexp.MatchString(message.ToolCallId) {
|
||||
newId, err := common.GenerateRandomCharsKey(9)
|
||||
if err == nil {
|
||||
idMap[message.ToolCallId] = newId
|
||||
message.ToolCallId = newId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mediaMessages := message.ParseContent()
|
||||
if message.Role == "assistant" && message.ToolCalls != nil && string(message.Content) == "null" {
|
||||
mediaMessages = []dto.MediaContent{}
|
||||
}
|
||||
for j, mediaMessage := range mediaMessages {
|
||||
if mediaMessage.Type == dto.ContentTypeImageURL {
|
||||
imageUrl := mediaMessage.GetImageMedia()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -95,14 +96,23 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
} else {
|
||||
suffix = "generateContent"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
|
||||
region,
|
||||
adc.ProjectID,
|
||||
region,
|
||||
info.UpstreamModelName,
|
||||
suffix,
|
||||
), nil
|
||||
if region == "global" {
|
||||
return fmt.Sprintf(
|
||||
"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
|
||||
adc.ProjectID,
|
||||
info.UpstreamModelName,
|
||||
suffix,
|
||||
), nil
|
||||
} else {
|
||||
return fmt.Sprintf(
|
||||
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s",
|
||||
region,
|
||||
adc.ProjectID,
|
||||
region,
|
||||
info.UpstreamModelName,
|
||||
suffix,
|
||||
), nil
|
||||
}
|
||||
} else if a.RequestMode == RequestModeClaude {
|
||||
if info.IsStream {
|
||||
suffix = "streamRawPredict?alt=sse"
|
||||
@@ -192,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)
|
||||
}
|
||||
@@ -201,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)
|
||||
|
||||
@@ -47,6 +47,20 @@ func getAndValidateTextRequest(c *gin.Context, relayInfo *relaycommon.RelayInfo)
|
||||
if textRequest.Model == "" {
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
if textRequest.WebSearchOptions != nil {
|
||||
if textRequest.WebSearchOptions.SearchContextSize != "" {
|
||||
validSizes := map[string]bool{
|
||||
"high": true,
|
||||
"medium": true,
|
||||
"low": true,
|
||||
}
|
||||
if !validSizes[textRequest.WebSearchOptions.SearchContextSize] {
|
||||
return nil, errors.New("invalid search_context_size, must be one of: high, medium, low")
|
||||
}
|
||||
} else {
|
||||
textRequest.WebSearchOptions.SearchContextSize = "medium"
|
||||
}
|
||||
}
|
||||
switch relayInfo.RelayMode {
|
||||
case relayconstant.RelayModeCompletions:
|
||||
if textRequest.Prompt == "" {
|
||||
@@ -76,6 +90,10 @@ func TextHelper(c *gin.Context) (openaiErr *dto.OpenAIErrorWithStatusCode) {
|
||||
|
||||
// get & validate textRequest 获取并验证文本请求
|
||||
textRequest, err := getAndValidateTextRequest(c, relayInfo)
|
||||
if textRequest.WebSearchOptions != nil {
|
||||
c.Set("chat_completion_web_search_context_size", textRequest.WebSearchOptions.SearchContextSize)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
common.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error()))
|
||||
return service.OpenAIErrorWrapperLocal(err, "invalid_text_request", http.StatusBadRequest)
|
||||
@@ -334,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
|
||||
|
||||
@@ -349,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)
|
||||
@@ -370,9 +390,20 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
dWebSearchQuota = decimal.NewFromFloat(webSearchPrice).
|
||||
Mul(decimal.NewFromInt(int64(webSearchTool.CallCount))).
|
||||
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
|
||||
extraContent += fmt.Sprintf("Web Search 调用 %d 次,上下文大小 %s,调用花费 $%s",
|
||||
extraContent += fmt.Sprintf("Web Search 调用 %d 次,上下文大小 %s,调用花费 %s",
|
||||
webSearchTool.CallCount, webSearchTool.SearchContextSize, dWebSearchQuota.String())
|
||||
}
|
||||
} else if strings.HasSuffix(modelName, "search-preview") {
|
||||
// search-preview 模型不支持 response api
|
||||
searchContextSize := ctx.GetString("chat_completion_web_search_context_size")
|
||||
if searchContextSize == "" {
|
||||
searchContextSize = "medium"
|
||||
}
|
||||
webSearchPrice = operation_setting.GetWebSearchPricePerThousand(modelName, searchContextSize)
|
||||
dWebSearchQuota = decimal.NewFromFloat(webSearchPrice).
|
||||
Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit)
|
||||
extraContent += fmt.Sprintf("Web Search 调用 1 次,上下文大小 %s,调用花费 %s",
|
||||
searchContextSize, dWebSearchQuota.String())
|
||||
}
|
||||
// file search tool 计费
|
||||
var dFileSearchQuota decimal.Decimal
|
||||
@@ -383,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)
|
||||
@@ -413,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
|
||||
@@ -463,10 +516,16 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
|
||||
other["image_ratio"] = imageRatio
|
||||
other["image_output"] = imageTokens
|
||||
}
|
||||
if !dWebSearchQuota.IsZero() && relayInfo.ResponsesUsageInfo != nil {
|
||||
if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists {
|
||||
if !dWebSearchQuota.IsZero() {
|
||||
if relayInfo.ResponsesUsageInfo != nil {
|
||||
if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists {
|
||||
other["web_search"] = true
|
||||
other["web_search_call_count"] = webSearchTool.CallCount
|
||||
other["web_search_price"] = webSearchPrice
|
||||
}
|
||||
} else if strings.HasSuffix(modelName, "search-preview") {
|
||||
other["web_search"] = true
|
||||
other["web_search_call_count"] = webSearchTool.CallCount
|
||||
other["web_search_call_count"] = 1
|
||||
other["web_search_price"] = webSearchPrice
|
||||
}
|
||||
}
|
||||
@@ -477,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
|
||||
}
|
||||
|
||||
BIN
web/bun.lockb
BIN
web/bun.lockb
Binary file not shown.
@@ -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"
|
||||
"sse.js": "^2.6.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-debounce": "^10.0.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -54,9 +69,13 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6",
|
||||
"@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,16 +1,16 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Dimmer, Loader, Segment } from 'semantic-ui-react';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
import Loading from '../common/Loading';
|
||||
|
||||
const OAuth2Callback = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [prompt, setPrompt] = useState('处理中...');
|
||||
const [processing, setProcessing] = useState(true);
|
||||
const [prompt, setPrompt] = useState(t('处理中...'));
|
||||
|
||||
let navigate = useNavigate();
|
||||
|
||||
@@ -21,25 +21,25 @@ const OAuth2Callback = (props) => {
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (message === 'bind') {
|
||||
showSuccess('绑定成功!');
|
||||
navigate('/setting');
|
||||
showSuccess(t('绑定成功!'));
|
||||
navigate('/console/setting');
|
||||
} else {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
showSuccess('登录成功!');
|
||||
navigate('/token');
|
||||
showSuccess(t('登录成功!'));
|
||||
navigate('/console/token');
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
if (count === 0) {
|
||||
setPrompt(`操作失败,重定向至登录界面中...`);
|
||||
navigate('/setting'); // in case this is failed to bind GitHub
|
||||
setPrompt(t('操作失败,重定向至登录界面中...'));
|
||||
navigate('/console/setting'); // in case this is failed to bind GitHub
|
||||
return;
|
||||
}
|
||||
count++;
|
||||
setPrompt(`出现错误,第 ${count} 次重试中...`);
|
||||
setPrompt(t('出现错误,第 ${count} 次重试中...', { count }));
|
||||
await new Promise((resolve) => setTimeout(resolve, count * 2000));
|
||||
await sendCode(code, state, count);
|
||||
}
|
||||
@@ -51,13 +51,7 @@ const OAuth2Callback = (props) => {
|
||||
sendCode(code, state, 0).then();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Segment style={{ minHeight: '300px' }}>
|
||||
<Dimmer active inverted>
|
||||
<Loader size='large'>{prompt}</Loader>
|
||||
</Dimmer>
|
||||
</Segment>
|
||||
);
|
||||
return <Loading prompt={prompt} />;
|
||||
};
|
||||
|
||||
export default OAuth2Callback;
|
||||
184
web/src/components/auth/PasswordResetConfirm.js
Normal file
184
web/src/components/auth/PasswordResetConfirm.js
Normal file
@@ -0,0 +1,184 @@
|
||||
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, Banner } from '@douyinfe/semi-ui';
|
||||
import { IconMail, IconLock, IconCopy } 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 isValidResetLink = email && token;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [formApi, setFormApi] = useState(null);
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
|
||||
useEffect(() => {
|
||||
let token = searchParams.get('token');
|
||||
let email = searchParams.get('email');
|
||||
setInputs({
|
||||
token: token || '',
|
||||
email: email || '',
|
||||
});
|
||||
if (formApi) {
|
||||
formApi.setValues({
|
||||
email: email || '',
|
||||
newPassword: newPassword || ''
|
||||
});
|
||||
}
|
||||
}, [searchParams, newPassword, formApi]);
|
||||
|
||||
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) {
|
||||
showError(t('无效的重置链接,请重新发起密码重置请求'));
|
||||
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">
|
||||
{!isValidResetLink && (
|
||||
<Banner
|
||||
type="danger"
|
||||
description={t('无效的重置链接,请重新发起密码重置请求')}
|
||||
className="mb-4 !rounded-lg"
|
||||
closeIcon={null}
|
||||
/>
|
||||
)}
|
||||
<Form
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
initValues={{ email: email || '', newPassword: newPassword || '' }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Form.Input
|
||||
field="email"
|
||||
label={t('邮箱')}
|
||||
name="email"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
disabled={true}
|
||||
prefix={<IconMail />}
|
||||
placeholder={email ? '' : t('等待获取邮箱信息...')}
|
||||
/>
|
||||
|
||||
{newPassword && (
|
||||
<Form.Input
|
||||
field="newPassword"
|
||||
label={t('新密码')}
|
||||
name="newPassword"
|
||||
size="large"
|
||||
className="!rounded-md"
|
||||
disabled={true}
|
||||
prefix={<IconLock />}
|
||||
suffix={
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
onClick={async () => {
|
||||
await copy(newPassword);
|
||||
showNotice(`${t('密码已复制到剪贴板:')} ${newPassword}`);
|
||||
}}
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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 || !isValidResetLink}
|
||||
>
|
||||
{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;
|
||||
159
web/src/components/auth/PasswordResetForm.js
Normal file
159
web/src/components/auth/PasswordResetForm.js
Normal file
@@ -0,0 +1,159 @@
|
||||
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) {
|
||||
showError(t('请输入邮箱地址'));
|
||||
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 };
|
||||
}
|
||||
112
web/src/components/layout/Footer.js
Normal file
112
web/src/components/layout/Footer.js
Normal file
@@ -0,0 +1,112 @@
|
||||
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">{t('关于项目')}</a>
|
||||
<a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('联系我们')}</a>
|
||||
<a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{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">{t('快速开始')}</a>
|
||||
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('安装指南')}</a>
|
||||
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{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">One API</a>
|
||||
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">Midjourney-Proxy</a>
|
||||
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">chatnio</a>
|
||||
<a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">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">new-api-horizon</a>
|
||||
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">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>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
|
||||
<a href="https://github.com/QuantumNous/new-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">New API</a>
|
||||
<span className="!text-semi-color-text-1"> & </span>
|
||||
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">One API</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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user