refactor: remove mt theme, clean up New API branding from classic frontend
Docker Build / Build and Push Docker Image (push) Successful in 3m54s

This commit is contained in:
2026-06-14 11:06:47 +08:00
parent a8a96a7e60
commit 26ac8b5dc1
441 changed files with 16 additions and 140123 deletions
-12
View File
@@ -20,17 +20,6 @@ COPY ./web/classic ./classic
COPY ./VERSION /build/VERSION
RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-mt
WORKDIR /build/web
COPY web/package.json web/bun.lock ./
COPY web/default/package.json ./default/package.json
COPY web/classic/package.json ./classic/package.json
RUN bun install --frozen-lockfile
COPY ./web/mt ./classic
COPY ./VERSION /build/VERSION
RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
ENV GO111MODULE=on CGO_ENABLED=0
@@ -47,7 +36,6 @@ RUN go mod download
COPY . .
COPY --from=builder /build/web/default/dist ./web/default/dist
COPY --from=builder-classic /build/web/classic/dist ./web/classic/dist
COPY --from=builder-mt /build/web/classic/dist ./web/mt/dist
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
+2 -2
View File
@@ -30,9 +30,9 @@ func GetTheme() string {
}
// SetTheme updates the frontend theme atomically.
// Only "default", "classic", and "mt" are accepted; other values are silently ignored.
// Only "default" and "classic" are accepted; other values are silently ignored.
func SetTheme(t string) {
if t == "default" || t == "classic" || t == "mt" {
if t == "default" || t == "classic" {
themeValue.Store(t)
}
}
+2 -7
View File
@@ -48,15 +48,12 @@ func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
type themeAwareFileSystem struct {
defaultFS static.ServeFileSystem
classicFS static.ServeFileSystem
mtFS static.ServeFileSystem
}
func (t *themeAwareFileSystem) Exists(prefix string, path string) bool {
switch GetTheme() {
case "classic":
return t.classicFS.Exists(prefix, path)
case "mt":
return t.mtFS.Exists(prefix, path)
default:
return t.defaultFS.Exists(prefix, path)
}
@@ -66,13 +63,11 @@ func (t *themeAwareFileSystem) Open(name string) (http.File, error) {
switch GetTheme() {
case "classic":
return t.classicFS.Open(name)
case "mt":
return t.mtFS.Open(name)
default:
return t.defaultFS.Open(name)
}
}
func NewThemeAwareFS(defaultFS, classicFS, mtFS static.ServeFileSystem) static.ServeFileSystem {
return &themeAwareFileSystem{defaultFS: defaultFS, classicFS: classicFS, mtFS: mtFS}
func NewThemeAwareFS(defaultFS, classicFS static.ServeFileSystem) static.ServeFileSystem {
return &themeAwareFileSystem{defaultFS: defaultFS, classicFS: classicFS}
}
+2 -2
View File
@@ -204,10 +204,10 @@ func UpdateOption(c *gin.Context) {
return
}
case "theme.frontend":
if option.Value != "default" && option.Value != "classic" && option.Value != "mt" {
if option.Value != "default" && option.Value != "classic" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的主题值,可选值:default(新版前端)、classic(经典前端)、mtMT 前端)",
"message": "无效的主题值,可选值:default(新版前端)、classic(经典前端)",
})
return
}
-8
View File
@@ -47,12 +47,6 @@ var classicBuildFS embed.FS
//go:embed web/classic/dist/index.html
var classicIndexPage []byte
//go:embed web/mt/dist
var mtBuildFS embed.FS
//go:embed web/mt/dist/index.html
var mtIndexPage []byte
func main() {
startTime := time.Now()
@@ -201,8 +195,6 @@ func main() {
DefaultIndexPage: indexPage,
ClassicBuildFS: classicBuildFS,
ClassicIndexPage: classicIndexPage,
MtBuildFS: mtBuildFS,
MtIndexPage: mtIndexPage,
})
var port = os.Getenv("PORT")
if port == "" {
+1 -6
View File
@@ -19,15 +19,12 @@ type ThemeAssets struct {
DefaultIndexPage []byte
ClassicBuildFS embed.FS
ClassicIndexPage []byte
MtBuildFS embed.FS
MtIndexPage []byte
}
func SetWebRouter(router *gin.Engine, assets ThemeAssets) {
defaultFS := common.EmbedFolder(assets.DefaultBuildFS, "web/default/dist")
classicFS := common.EmbedFolder(assets.ClassicBuildFS, "web/classic/dist")
mtFS := common.EmbedFolder(assets.MtBuildFS, "web/mt/dist")
themeFS := common.NewThemeAwareFS(defaultFS, classicFS, mtFS)
themeFS := common.NewThemeAwareFS(defaultFS, classicFS)
router.Use(gzip.Gzip(gzip.DefaultCompression))
router.Use(middleware.GlobalWebRateLimit())
@@ -43,8 +40,6 @@ func SetWebRouter(router *gin.Engine, assets ThemeAssets) {
switch common.GetTheme() {
case "classic":
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.ClassicIndexPage)
case "mt":
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.MtIndexPage)
default:
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.DefaultIndexPage)
}
+5 -112
View File
@@ -56,14 +56,14 @@ const FooterBar = () => {
/>
</div>
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full'>
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 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/'
href='https://modelstoken.com'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
@@ -71,21 +71,13 @@ const FooterBar = () => {
{t('关于项目')}
</a>
<a
href='https://docs.newapi.pro/support/community-interaction/'
href='https://modelstoken.com'
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>
@@ -95,7 +87,7 @@ const FooterBar = () => {
</p>
<div className='flex flex-col gap-4'>
<a
href='https://docs.newapi.pro/getting-started/'
href='https://modelstoken.com'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
@@ -103,15 +95,7 @@ const FooterBar = () => {
{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/'
href='https://modelstoken.com'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
@@ -120,70 +104,6 @@ const FooterBar = () => {
</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/Calcium-Ion/new-api-key-tool'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
new-api-key-tool
</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/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/coaidev/coai'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
CoAI
</a>
<a
href='https://www.gpt-load.com/'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
GPT-Load
</a>
</div>
</div>
</div>
</div>
)}
@@ -194,20 +114,6 @@ const FooterBar = () => {
© {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>
</div>
</div>
</footer>
),
@@ -227,19 +133,6 @@ const FooterBar = () => {
className='custom-footer na-cb6feafeb3990c78 text-sm !text-semi-color-text-1'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
<div className='text-sm flex-shrink-0'>
<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>
</div>
</div>
</footer>
) : (
+1 -1
View File
@@ -48,7 +48,7 @@ export function isRoot() {
export function getSystemName() {
let system_name = localStorage.getItem('system_name');
if (!system_name) return 'New API';
if (!system_name) return 'ModelsToken';
return system_name;
}
+1 -1
View File
@@ -39,7 +39,7 @@ import en_GB from '@douyinfe/semi-ui/lib/es/locale/source/en_GB';
// Welcome message (Do not remove this without permission from the original developer)
if (typeof window !== 'undefined') {
console.log(
'%cWE ❤ NEWAPI%c Github: https://github.com/QuantumNous/new-api',
'%cWE ❤ ModelsToken%c https://modelstoken.com',
'color: #10b981; font-weight: bold; font-size: 24px;',
'color: inherit; font-size: 14px;',
);
+2 -54
View File
@@ -62,63 +62,11 @@ const About = () => {
const customDescription = (
<div style={{ textAlign: 'center' }}>
<p>{t('可在设置页面设置关于内容,支持 HTML & Markdown')}</p>
{t('New API项目仓库地址:')}
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-primary'
>
https://github.com/QuantumNous/new-api
</a>
<p>
<a
href='https://github.com/QuantumNous/new-api'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-primary'
>
NewAPI
</a>{' '}
{t('© {{currentYear}}', { currentYear })}{' '}
<a
href='https://github.com/QuantumNous'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-primary'
>
QuantumNous
</a>{' '}
{t('| 基于')}{' '}
<a
href='https://github.com/songquanpeng/one-api/releases/tag/v0.5.4'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-primary'
>
One API v0.5.4
</a>{' '}
© 2023{' '}
<a
href='https://github.com/songquanpeng'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-primary'
>
JustSong
</a>
ModelsToken © {currentYear} modelstoken.com
</p>
<p>
{t('本项目根据')}
<a
href='https://github.com/songquanpeng/one-api/blob/v0.5.4/LICENSE'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-primary'
>
{t('MIT许可证')}
</a>
{t('授权,需在遵守')}
<a
href='https://www.gnu.org/licenses/agpl-3.0.html'
target='_blank'
@@ -127,7 +75,7 @@ const About = () => {
>
{t('AGPL v3.0协议')}
</a>
{t('的前提下使用。')}
{t('授权使用。')}
</p>
</div>
);
-42
View File
@@ -1,42 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2021: true, node: true },
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
plugins: ['header', 'react-hooks'],
overrides: [
{
files: ['**/*.{js,jsx}'],
rules: {
'header/header': [
2,
'block',
[
'',
'Copyright (C) 2025 QuantumNous',
'',
'This program is free software: you can redistribute it and/or modify',
'it under the terms of the GNU Affero General Public License as',
'published by the Free Software Foundation, either version 3 of the',
'License, or (at your option) any later version.',
'',
'This program is distributed in the hope that it will be useful,',
'but WITHOUT ANY WARRANTY; without even the implied warranty of',
'MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the',
'GNU Affero General Public License for more details.',
'',
'You should have received a copy of the GNU Affero General Public License',
'along with this program. If not, see <https://www.gnu.org/licenses/>.',
'',
'For commercial licensing, please contact support@quantumnous.com',
'',
],
],
'no-multiple-empty-lines': ['error', { max: 1 }],
},
},
],
};
-26
View File
@@ -1,26 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.idea
package-lock.json
yarn.lock
-1
View File
@@ -1 +0,0 @@
module.exports = require('@so1ve/prettier-config');
-1
View File
@@ -1 +0,0 @@
<!DOCTYPE html><html><head><meta charset=utf-8><title>Classic Theme</title></head><body><div id=root></div></body></html>
-86
View File
@@ -1,86 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { defineConfig } from 'i18next-cli';
/** @type {import('i18next-cli').I18nextToolkitConfig} */
export default defineConfig({
locales: ['zh-CN', 'zh-TW', 'en', 'fr', 'ru', 'ja', 'vi'],
extract: {
input: ['src/**/*.{js,jsx,ts,tsx}'],
ignore: ['src/i18n/**/*'],
output: 'src/i18n/locales/{{language}}.json',
ignoredAttributes: [
'accept',
'align',
'aria-label',
'autoComplete',
'className',
'clipRule',
'color',
'crossOrigin',
'data-index',
'data-name',
'data-testid',
'data-type',
'defaultActiveKey',
'direction',
'editorType',
'field',
'fill',
'fillRule',
'height',
'hoverStyle',
'htmlType',
'id',
'itemKey',
'key',
'keyPrefix',
'layout',
'margin',
'maxHeight',
'mode',
'name',
'overflow',
'placement',
'position',
'rel',
'role',
'rowKey',
'searchPosition',
'selectedStyle',
'shape',
'size',
'style',
'theme',
'trigger',
'uploadTrigger',
'validateStatus',
'value',
'viewBox',
'width',
],
sort: true,
disablePlurals: false,
removeUnusedKeys: false,
nsSeparator: false,
keySeparator: false,
mergeNamespaces: true,
},
});
-28
View File
@@ -1,28 +0,0 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#ffffff" />
<meta
name="description"
lang="zh"
content="统一的 AI 模型聚合与分发网关,支持将各类大语言模型跨格式转换为 OpenAI、Claude、Gemini 兼容接口,为个人与企业提供集中式模型管理与网关服务。"
/>
<meta
name="description"
lang="en"
content="A unified AI model hub for aggregation & distribution. It supports cross-converting various LLMs into OpenAI-compatible, Claude-compatible, or Gemini-compatible formats. A centralized gateway for personal and enterprise model management."
/>
<meta name="generator" content="new-api" />
<title>ModelsToken</title>
<!--umami-->
<!--Google Analytics-->
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
-9
View File
@@ -1,9 +0,0 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"]
}
-97
View File
@@ -1,97 +0,0 @@
{
"name": "react-template",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": {
"@douyinfe/semi-illustrations": "^2.69.1",
"@douyinfe/semi-icons": "^2.63.1",
"@douyinfe/semi-ui": "^2.69.1",
"@lobehub/icons": "catalog:",
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
"axios": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"history": "^5.3.0",
"highlight.js": "^11.11.1",
"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",
"qrcode.react": "catalog:",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-dropzone": "^14.2.3",
"react-fireworks": "^1.0.4",
"react-i18next": "^13.0.0",
"react-icons": "catalog:",
"react-markdown": "catalog:",
"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": "catalog:",
"remark-math": "^6.0.0",
"sse.js": "catalog:",
"unist-util-visit": "^5.0.0",
"use-debounce": "^10.0.4"
},
"scripts": {
"dev": "rsbuild dev",
"build": "rsbuild build",
"lint": "prettier . --check",
"lint:fix": "prettier . --write",
"eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
"eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache",
"preview": "rsbuild preview",
"i18n:extract": "bunx i18next-cli extract",
"i18n:status": "bunx i18next-cli status",
"i18n:sync": "bunx i18next-cli sync",
"i18n:lint": "bunx i18next-cli lint"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@rsbuild/core": "^2.0.7",
"@rsbuild/plugin-react": "^2.0.0",
"@so1ve/prettier-config": "^3.1.0",
"autoprefixer": "^10.4.21",
"eslint": "8.57.0",
"eslint-plugin-header": "^3.1.1",
"eslint-plugin-react-hooks": "^5.2.0",
"i18next-cli": "^1.10.3",
"postcss": "^8.5.3",
"prop-types": "^15.8.1",
"prettier": "catalog:",
"tailwindcss": "^3",
"typescript": "4.4.2"
},
"prettier": {
"singleQuote": true,
"jsxSingleQuote": true
},
"proxy": "http://localhost:3000"
}
-25
View File
@@ -1,25 +0,0 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

-3
View File
@@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
-5
View File
@@ -1,5 +0,0 @@
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.8965 17.8787L11.2995 12.6132L10.1344 8.7762C9.97497 8.25193 9.4909 7.89355 8.94204 7.89355H4.41838C3.89937 7.89355 3.52838 8.39249 3.67767 8.88637L6.0675 16.7643C6.37941 17.7907 7.32849 18.4928 8.40398 18.4928H12.4398C12.7599 18.4922 12.9893 18.1845 12.8965 17.8787ZM7.47396 10.6301C7.11059 10.7302 6.71038 10.4345 6.58079 9.96909C6.4512 9.50371 6.64177 9.04403 7.00514 8.94399C7.36851 8.84395 7.76745 9.13964 7.89641 9.60502C8.026 10.0717 7.83733 10.5301 7.47396 10.6301Z" fill="white"/>
<path d="M13.0281 18.269C12.8777 18.4077 12.6794 18.4926 12.4646 18.4927H12.4382C12.7588 18.4926 12.9887 18.1847 12.8962 17.8784L11.2996 12.6128L11.3054 12.5923L13.0281 18.269ZM14.5144 13.771V13.7729L13.2615 17.9028C13.2401 17.973 13.2071 18.0369 13.1697 18.0972L11.4021 12.271L12.4626 8.77588C12.6221 8.25169 13.1063 7.89317 13.655 7.89307H16.2976L14.5144 13.771Z" fill="white"/>
<path d="M19.5133 18.4932H16.8707C16.3221 18.493 15.8378 18.135 15.6783 17.6104L14.61 14.0859L16.3883 8.19336L17.7311 12.6133L19.1617 7.89355H22.7291L19.5133 18.4932Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-5
View File
@@ -1,5 +0,0 @@
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.8967 17.8789L11.2996 12.6135L10.1346 8.77644C9.97511 8.25218 9.49104 7.8938 8.94218 7.8938H4.4185C3.8995 7.8938 3.5285 8.39274 3.67779 8.88662L6.06763 16.7646C6.37954 17.7909 7.32862 18.4931 8.40411 18.4931H12.4399C12.7601 18.4925 12.9894 18.1848 12.8967 17.8789ZM7.47409 10.6304C7.11073 10.7304 6.71051 10.4347 6.58092 9.96934C6.45133 9.50396 6.64191 9.04428 7.00527 8.94423C7.36864 8.84419 7.76758 9.13989 7.89654 9.60527C8.02613 10.0719 7.83746 10.5303 7.47409 10.6304Z" fill="black"/>
<path d="M13.0278 18.2703C12.8774 18.4086 12.679 18.4929 12.4643 18.4929H12.4379C12.7587 18.4929 12.9886 18.1851 12.8959 17.8787L11.2993 12.613L11.3051 12.5925L13.0278 18.2703ZM16.2973 7.89331L14.5151 13.7712V13.7732L13.2612 17.9031C13.2397 17.9736 13.207 18.0379 13.1694 18.0984L11.4018 12.2712L12.4633 8.77612C12.6228 8.25186 13.1068 7.89331 13.6557 7.89331H16.2973Z" fill="black"/>
<path d="M22.7283 7.89478L19.5134 18.4934H16.8718C16.323 18.4934 15.8389 18.1355 15.6794 17.6106L14.6101 14.0862L16.3884 8.19458L17.7312 12.6145L19.1619 7.89478H22.7283Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-106
View File
@@ -1,106 +0,0 @@
import path from 'path'
import { createRequire } from 'module'
import { fileURLToPath } from 'url'
import { defineConfig, loadEnv } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const semiUiDir = path.resolve(
path.dirname(require.resolve('@douyinfe/semi-ui')),
'../..',
)
export default defineConfig(({ envMode }) => {
const env = loadEnv({ mode: envMode, prefixes: ['VITE_'] })
const clientServerUrl =
process.env.VITE_REACT_APP_SERVER_URL ||
env.rawPublicVars.VITE_REACT_APP_SERVER_URL ||
''
const proxyServerUrl =
clientServerUrl ||
'http://localhost:3000'
const isProd = envMode === 'production'
const devProxy = Object.fromEntries(
(['/api', '/mj', '/pg'] as const).map((key) => [
key,
{ target: proxyServerUrl, changeOrigin: true },
]),
) as Record<string, { target: string; changeOrigin: boolean }>
return {
plugins: [pluginReact()],
source: {
entry: {
index: './src/index.jsx',
},
define: {
'import.meta.env.VITE_REACT_APP_SERVER_URL': JSON.stringify(
clientServerUrl,
),
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@douyinfe/semi-ui/dist/css/semi.css': path.resolve(
semiUiDir,
'dist/css/semi.css',
),
},
},
html: {
template: './index.html',
},
server: {
host: '0.0.0.0',
strictPort: true,
proxy: devProxy,
},
output: {
minify: isProd,
target: 'web',
distPath: {
root: 'dist',
},
},
performance: {
removeConsole: isProd ? ['log'] : false,
buildCache: {
cacheDigest: [process.env.VITE_REACT_APP_VERSION],
},
},
tools: {
rspack: {
module: {
rules: [
{
test: /src[\\/].*\.js$/,
type: 'javascript/auto',
use: [
{
loader: 'builtin:swc-loader',
options: {
jsc: {
parser: {
syntax: 'ecmascript',
jsx: true,
},
transform: {
react: {
runtime: 'automatic',
development: !isProd,
refresh: !isProd,
},
},
},
},
},
],
},
],
},
},
},
}
})
-386
View File
@@ -1,386 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { lazy, Suspense, useContext, useMemo } from 'react';
import { Route, Routes, useLocation, useParams } from 'react-router-dom';
import Loading from './components/common/ui/Loading';
import User from './pages/User';
import { AuthRedirect, PrivateRoute, AdminRoute } from './helpers';
import RegisterForm from './components/auth/RegisterForm';
import LoginForm from './components/auth/LoginForm';
import NotFound from './pages/NotFound';
import Forbidden from './pages/Forbidden';
import Setting from './pages/Setting';
import { StatusContext } from './context/Status';
import PasswordResetForm from './components/auth/PasswordResetForm';
import PasswordResetConfirm from './components/auth/PasswordResetConfirm';
import Channel from './pages/Channel';
import Token from './pages/Token';
import Redemption from './pages/Redemption';
import TopUp from './pages/TopUp';
import Log from './pages/Log';
import Chat from './pages/Chat';
import Chat2Link from './pages/Chat2Link';
import Midjourney from './pages/Midjourney';
import Pricing from './pages/Pricing';
import Task from './pages/Task';
import ModelPage from './pages/Model';
import ModelDeploymentPage from './pages/ModelDeployment';
import Playground from './pages/Playground';
import Subscription from './pages/Subscription';
import OAuth2Callback from './components/auth/OAuth2Callback';
import PersonalSetting from './components/settings/PersonalSetting';
import Setup from './pages/Setup';
import SetupCheck from './components/layout/SetupCheck';
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const About = lazy(() => import('./pages/About'));
const UserAgreement = lazy(() => import('./pages/UserAgreement'));
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy'));
function DynamicOAuth2Callback() {
const { provider } = useParams();
return <OAuth2Callback type={provider} />;
}
function App() {
const location = useLocation();
const [statusState] = useContext(StatusContext);
// 获取模型广场权限配置
const pricingRequireAuth = useMemo(() => {
const headerNavModulesConfig = statusState?.status?.HeaderNavModules;
if (headerNavModulesConfig) {
try {
const modules = JSON.parse(headerNavModulesConfig);
// 处理向后兼容性:如果pricing是boolean,默认不需要登录
if (typeof modules.pricing === 'boolean') {
return false; // 默认不需要登录鉴权
}
// 如果是对象格式,使用requireAuth配置
return modules.pricing?.requireAuth === true;
} catch (error) {
console.error('解析顶栏模块配置失败:', error);
return false; // 默认不需要登录
}
}
return false; // 默认不需要登录
}, [statusState?.status?.HeaderNavModules]);
return (
<SetupCheck>
<Routes>
<Route
path='/'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Home />
</Suspense>
}
/>
<Route
path='/setup'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Setup />
</Suspense>
}
/>
<Route path='/forbidden' element={<Forbidden />} />
<Route
path='/console/models'
element={
<AdminRoute>
<ModelPage />
</AdminRoute>
}
/>
<Route
path='/console/deployment'
element={
<AdminRoute>
<ModelDeploymentPage />
</AdminRoute>
}
/>
<Route
path='/console/subscription'
element={
<AdminRoute>
<Subscription />
</AdminRoute>
}
/>
<Route
path='/console/channel'
element={
<AdminRoute>
<Channel />
</AdminRoute>
}
/>
<Route
path='/console/token'
element={
<PrivateRoute>
<Token />
</PrivateRoute>
}
/>
<Route
path='/console/playground'
element={
<PrivateRoute>
<Playground />
</PrivateRoute>
}
/>
<Route
path='/console/redemption'
element={
<AdminRoute>
<Redemption />
</AdminRoute>
}
/>
<Route
path='/console/user'
element={
<AdminRoute>
<User />
</AdminRoute>
}
/>
<Route
path='/user/reset'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<PasswordResetConfirm />
</Suspense>
}
/>
<Route
path='/login'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<AuthRedirect>
<LoginForm />
</AuthRedirect>
</Suspense>
}
/>
<Route
path='/register'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<AuthRedirect>
<RegisterForm />
</AuthRedirect>
</Suspense>
}
/>
<Route
path='/reset'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<PasswordResetForm />
</Suspense>
}
/>
<Route
path='/oauth/github'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<OAuth2Callback type='github'></OAuth2Callback>
</Suspense>
}
/>
<Route
path='/oauth/discord'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<OAuth2Callback type='discord'></OAuth2Callback>
</Suspense>
}
/>
<Route
path='/oauth/oidc'
element={
<Suspense fallback={<Loading></Loading>}>
<OAuth2Callback type='oidc'></OAuth2Callback>
</Suspense>
}
/>
<Route
path='/oauth/linuxdo'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<OAuth2Callback type='linuxdo'></OAuth2Callback>
</Suspense>
}
/>
<Route
path='/oauth/:provider'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<DynamicOAuth2Callback />
</Suspense>
}
/>
<Route
path='/console/setting'
element={
<AdminRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Setting />
</Suspense>
</AdminRoute>
}
/>
<Route
path='/console/personal'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<PersonalSetting />
</Suspense>
</PrivateRoute>
}
/>
<Route
path='/console/topup'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<TopUp />
</Suspense>
</PrivateRoute>
}
/>
<Route
path='/console/log'
element={
<PrivateRoute>
<Log />
</PrivateRoute>
}
/>
<Route
path='/console'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Dashboard />
</Suspense>
</PrivateRoute>
}
/>
<Route
path='/console/midjourney'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Midjourney />
</Suspense>
</PrivateRoute>
}
/>
<Route
path='/console/task'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Task />
</Suspense>
</PrivateRoute>
}
/>
<Route
path='/pricing'
element={
pricingRequireAuth ? (
<PrivateRoute>
<Suspense
fallback={<Loading></Loading>}
key={location.pathname}
>
<Pricing />
</Suspense>
</PrivateRoute>
) : (
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Pricing />
</Suspense>
)
}
/>
<Route
path='/about'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<About />
</Suspense>
}
/>
<Route
path='/user-agreement'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<UserAgreement />
</Suspense>
}
/>
<Route
path='/privacy-policy'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<PrivacyPolicy />
</Suspense>
}
/>
<Route
path='/console/chat/:id?'
element={
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Chat />
</Suspense>
}
/>
{/* 方便使用chat2link直接跳转聊天... */}
<Route
path='/chat2link'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
<Chat2Link />
</Suspense>
</PrivateRoute>
}
/>
<Route path='*' element={<NotFound />} />
</Routes>
</SetupCheck>
);
}
export default App;
-983
View File
@@ -1,983 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { UserContext } from '../../context/User';
import { StatusContext } from '../../context/Status';
import {
API,
getLogo,
showError,
showInfo,
showSuccess,
updateAPI,
getSystemName,
getOAuthProviderIcon,
setUserData,
onGitHubOAuthClicked,
onDiscordOAuthClicked,
onOIDCClicked,
onLinuxDOOAuthClicked,
onCustomOAuthClicked,
prepareCredentialRequestOptions,
buildAssertionResult,
isPasskeySupported,
} from '../../helpers';
import Turnstile from 'react-turnstile';
import {
Button,
Card,
Checkbox,
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,
IconKey,
} from '@douyinfe/semi-icons';
import OIDCIcon from '../common/logo/OIDCIcon';
import WeChatIcon from '../common/logo/WeChatIcon';
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
import TwoFAVerification from './TwoFAVerification';
import { useTranslation } from 'react-i18next';
import { SiDiscord } from 'react-icons/si';
const LoginForm = () => {
let navigate = useNavigate();
const { t } = useTranslation();
const githubButtonTextKeyByState = {
idle: '使用 GitHub 继续',
redirecting: '正在跳转 GitHub...',
timeout: '请求超时,请刷新页面后重新发起 GitHub 登录',
};
const [inputs, setInputs] = useState({
username: '',
password: '',
wechat_verification_code: '',
});
const { username, password } = inputs;
const [searchParams, setSearchParams] = useSearchParams();
const [submitted, setSubmitted] = useState(false);
const [userState, userDispatch] = useContext(UserContext);
const [statusState] = useContext(StatusContext);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
const [showEmailLogin, setShowEmailLogin] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false);
const [githubLoading, setGithubLoading] = useState(false);
const [discordLoading, setDiscordLoading] = 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 [showTwoFA, setShowTwoFA] = useState(false);
const [passkeySupported, setPasskeySupported] = useState(false);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [hasUserAgreement, setHasUserAgreement] = useState(false);
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
const [githubButtonState, setGithubButtonState] = useState('idle');
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false);
const githubTimeoutRef = useRef(null);
const githubButtonText = t(githubButtonTextKeyByState[githubButtonState]);
const [customOAuthLoading, setCustomOAuthLoading] = useState({});
const logo = getLogo();
const systemName = getSystemName();
let affCode = new URLSearchParams(window.location.search).get('aff');
if (affCode) {
localStorage.setItem('aff', affCode);
}
const status = useMemo(() => {
if (statusState?.status) return statusState.status;
const savedStatus = localStorage.getItem('status');
if (!savedStatus) return {};
try {
return JSON.parse(savedStatus) || {};
} catch (err) {
return {};
}
}, [statusState?.status]);
const hasCustomOAuthProviders =
(status.custom_oauth_providers || []).length > 0;
const hasOAuthLoginOptions = Boolean(
status.github_oauth ||
status.discord_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.linuxdo_oauth ||
status.telegram_oauth ||
hasCustomOAuthProviders,
);
useEffect(() => {
if (status?.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
// 从 status 获取用户协议和隐私政策的启用状态
setHasUserAgreement(status?.user_agreement_enabled || false);
setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
}, [status]);
useEffect(() => {
isPasskeySupported()
.then(setPasskeySupported)
.catch(() => setPasskeySupported(false));
return () => {
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current);
}
};
}, []);
useEffect(() => {
if (searchParams.get('expired')) {
showError(t('未登录或登录已过期,请重新登录'));
}
}, []);
const onWeChatLoginClicked = () => {
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
showInfo(t('请先阅读并同意用户协议和隐私政策'));
return;
}
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 ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
showInfo(t('请先阅读并同意用户协议和隐私政策'));
return;
}
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) {
// 检查是否需要2FA验证
if (data && data.require_2fa) {
setShowTwoFA(true);
setLoginLoading(false);
return;
}
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) => {
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
showInfo(t('请先阅读并同意用户协议和隐私政策'));
return;
}
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 = () => {
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
showInfo(t('请先阅读并同意用户协议和隐私政策'));
return;
}
if (githubButtonDisabled) {
return;
}
setGithubLoading(true);
setGithubButtonDisabled(true);
setGithubButtonState('redirecting');
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current);
}
githubTimeoutRef.current = setTimeout(() => {
setGithubLoading(false);
setGithubButtonState('timeout');
setGithubButtonDisabled(true);
}, 20000);
try {
onGitHubOAuthClicked(status.github_client_id, { shouldLogout: true });
} finally {
// 由于重定向,这里不会执行到,但为了完整性添加
setTimeout(() => setGithubLoading(false), 3000);
}
};
// 包装的Discord登录点击处理
const handleDiscordClick = () => {
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
showInfo(t('请先阅读并同意用户协议和隐私政策'));
return;
}
setDiscordLoading(true);
try {
onDiscordOAuthClicked(status.discord_client_id, { shouldLogout: true });
} finally {
// 由于重定向,这里不会执行到,但为了完整性添加
setTimeout(() => setDiscordLoading(false), 3000);
}
};
// 包装的OIDC登录点击处理
const handleOIDCClick = () => {
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
showInfo(t('请先阅读并同意用户协议和隐私政策'));
return;
}
setOidcLoading(true);
try {
onOIDCClicked(
status.oidc_authorization_endpoint,
status.oidc_client_id,
false,
{ shouldLogout: true },
);
} finally {
// 由于重定向,这里不会执行到,但为了完整性添加
setTimeout(() => setOidcLoading(false), 3000);
}
};
// 包装的LinuxDO登录点击处理
const handleLinuxDOClick = () => {
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
showInfo(t('请先阅读并同意用户协议和隐私政策'));
return;
}
setLinuxdoLoading(true);
try {
onLinuxDOOAuthClicked(status.linuxdo_client_id, { shouldLogout: true });
} finally {
// 由于重定向,这里不会执行到,但为了完整性添加
setTimeout(() => setLinuxdoLoading(false), 3000);
}
};
// 包装的自定义OAuth登录点击处理
const handleCustomOAuthClick = (provider) => {
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
showInfo(t('请先阅读并同意用户协议和隐私政策'));
return;
}
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: true }));
try {
onCustomOAuthClicked(provider, { shouldLogout: true });
} finally {
// 由于重定向,这里不会执行到,但为了完整性添加
setTimeout(() => {
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: false }));
}, 3000);
}
};
// 包装的邮箱登录选项点击处理
const handleEmailLoginClick = () => {
setEmailLoginLoading(true);
setShowEmailLogin(true);
setEmailLoginLoading(false);
};
const handlePasskeyLogin = async () => {
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
showInfo(t('请先阅读并同意用户协议和隐私政策'));
return;
}
if (!passkeySupported) {
showInfo('当前环境无法使用 Passkey 登录');
return;
}
if (!window.PublicKeyCredential) {
showInfo('当前浏览器不支持 Passkey');
return;
}
setPasskeyLoading(true);
try {
const beginRes = await API.post('/api/user/passkey/login/begin');
const { success, message, data } = beginRes.data;
if (!success) {
showError(message || '无法发起 Passkey 登录');
return;
}
const publicKeyOptions = prepareCredentialRequestOptions(
data?.options || data?.publicKey || data,
);
const assertion = await navigator.credentials.get({
publicKey: publicKeyOptions,
});
const payload = buildAssertionResult(assertion);
if (!payload) {
showError('Passkey 验证失败,请重试');
return;
}
const finishRes = await API.post(
'/api/user/passkey/login/finish',
payload,
);
const finish = finishRes.data;
if (finish.success) {
userDispatch({ type: 'login', payload: finish.data });
setUserData(finish.data);
updateAPI();
showSuccess('登录成功!');
navigate('/console');
} else {
showError(finish.message || 'Passkey 登录失败,请重试');
}
} catch (error) {
if (error?.name === 'AbortError') {
showInfo('已取消 Passkey 登录');
} else {
showError('Passkey 登录失败,请重试');
}
} finally {
setPasskeyLoading(false);
}
};
// 包装的重置密码点击处理
const handleResetPasswordClick = () => {
setResetPasswordLoading(true);
navigate('/reset');
setResetPasswordLoading(false);
};
// 包装的其他登录选项点击处理
const handleOtherLoginOptionsClick = () => {
setOtherLoginOptionsLoading(true);
setShowEmailLogin(false);
setOtherLoginOptionsLoading(false);
};
// 2FA验证成功处理
const handle2FASuccess = (data) => {
userDispatch({ type: 'login', payload: data });
setUserData(data);
updateAPI();
showSuccess('登录成功!');
navigate('/console');
};
// 返回登录页面
const handleBackToLogin = () => {
setShowTwoFA(false);
setInputs({ username: '', password: '', wechat_verification_code: '' });
};
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-gray-800'>
{systemName}
</Title>
</div>
<Card className='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' }} />
}
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' />}
onClick={handleGitHubClick}
loading={githubLoading}
disabled={githubButtonDisabled}
>
<span className='ml-3'>{githubButtonText}</span>
</Button>
)}
{status.discord_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={
<SiDiscord
style={{
color: '#5865F2',
width: '20px',
height: '20px',
}}
/>
}
onClick={handleDiscordClick}
loading={discordLoading}
>
<span className='ml-3'>{t('使用 Discord 继续')}</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' }} />}
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',
}}
/>
}
onClick={handleLinuxDOClick}
loading={linuxdoLoading}
>
<span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
</Button>
)}
{status.custom_oauth_providers &&
status.custom_oauth_providers.map((provider) => (
<Button
key={provider.slug}
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={getOAuthProviderIcon(provider.icon || '', 20)}
onClick={() => handleCustomOAuthClick(provider)}
loading={customOAuthLoading[provider.slug]}
>
<span className='ml-3'>
{t('使用 {{name}} 继续', { name: provider.name })}
</span>
</Button>
))}
{status.telegram_oauth && (
<div className='flex justify-center my-2'>
<TelegramLoginButton
dataOnauth={onTelegramLoginClicked}
botName={status.telegram_bot_name}
/>
</div>
)}
{status.passkey_login && passkeySupported && (
<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={<IconKey size='large' />}
onClick={handlePasskeyLogin}
loading={passkeyLoading}
>
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
</Button>
)}
<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' />}
onClick={handleEmailLoginClick}
loading={emailLoginLoading}
>
<span className='ml-3'>{t('使用 邮箱或用户名 登录')}</span>
</Button>
</div>
{(hasUserAgreement || hasPrivacyPolicy) && (
<div className='mt-6'>
<Checkbox
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.target.checked)}
>
<Text size='small' className='text-gray-600'>
{t('我已阅读并同意')}
{hasUserAgreement && (
<>
<a
href='/user-agreement'
target='_blank'
rel='noopener noreferrer'
className='text-blue-600 hover:text-blue-800 mx-1'
>
{t('用户协议')}
</a>
</>
)}
{hasUserAgreement && hasPrivacyPolicy && t('和')}
{hasPrivacyPolicy && (
<>
<a
href='/privacy-policy'
target='_blank'
rel='noopener noreferrer'
className='text-blue-600 hover:text-blue-800 mx-1'
>
{t('隐私政策')}
</a>
</>
)}
</Text>
</Checkbox>
</div>
)}
{!status.self_use_mode_enabled && (
<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='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'>
{status.passkey_login && passkeySupported && (
<Button
theme='outline'
type='tertiary'
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors mb-4'
icon={<IconKey size='large' />}
onClick={handlePasskeyLogin}
loading={passkeyLoading}
>
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
</Button>
)}
<Form className='space-y-3'>
<Form.Input
field='username'
label={t('用户名或邮箱')}
placeholder={t('请输入您的用户名或邮箱地址')}
name='username'
onChange={(value) => handleChange('username', value)}
prefix={<IconMail />}
/>
<Form.Input
field='password'
label={t('密码')}
placeholder={t('请输入您的密码')}
name='password'
mode='password'
onChange={(value) => handleChange('password', value)}
prefix={<IconLock />}
/>
{(hasUserAgreement || hasPrivacyPolicy) && (
<div className='pt-4'>
<Checkbox
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.target.checked)}
>
<Text size='small' className='text-gray-600'>
{t('我已阅读并同意')}
{hasUserAgreement && (
<>
<a
href='/user-agreement'
target='_blank'
rel='noopener noreferrer'
className='text-blue-600 hover:text-blue-800 mx-1'
>
{t('用户协议')}
</a>
</>
)}
{hasUserAgreement && hasPrivacyPolicy && t('和')}
{hasPrivacyPolicy && (
<>
<a
href='/privacy-policy'
target='_blank'
rel='noopener noreferrer'
className='text-blue-600 hover:text-blue-800 mx-1'
>
{t('隐私政策')}
</a>
</>
)}
</Text>
</Checkbox>
</div>
)}
<div className='space-y-2 pt-2'>
<Button
theme='solid'
className='w-full !rounded-full'
type='primary'
htmlType='submit'
onClick={handleSubmit}
loading={loginLoading}
disabled={
(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms
}
>
{t('继续')}
</Button>
<Button
theme='borderless'
type='tertiary'
className='w-full !rounded-full'
onClick={handleResetPasswordClick}
loading={resetPasswordLoading}
>
{t('忘记密码?')}
</Button>
</div>
</Form>
{hasOAuthLoginOptions && (
<>
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<div className='mt-4 text-center'>
<Button
theme='outline'
type='tertiary'
className='w-full !rounded-full'
onClick={handleOtherLoginOptionsClick}
loading={otherLoginOptionsLoading}
>
{t('其他登录选项')}
</Button>
</div>
</>
)}
{!status.self_use_mode_enabled && (
<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('登录')}
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>
<Form.Input
field='wechat_verification_code'
placeholder={t('验证码')}
label={t('验证码')}
value={inputs.wechat_verification_code}
onChange={(value) =>
handleChange('wechat_verification_code', value)
}
/>
</Form>
</Modal>
);
};
// 2FA验证弹窗
const render2FAModal = () => {
return (
<Modal
title={
<div className='flex items-center'>
<div className='w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3'>
<svg
className='w-4 h-4 text-green-600 dark:text-green-400'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M6 8a2 2 0 11-4 0 2 2 0 014 0zM8 7a1 1 0 100 2h8a1 1 0 100-2H8zM6 14a2 2 0 11-4 0 2 2 0 014 0zM8 13a1 1 0 100 2h8a1 1 0 100-2H8z'
clipRule='evenodd'
/>
</svg>
</div>
两步验证
</div>
}
visible={showTwoFA}
onCancel={handleBackToLogin}
footer={null}
width={450}
centered
>
<TwoFAVerification
onSuccess={handle2FASuccess}
onBack={handleBackToLogin}
isModal={true}
/>
</Modal>
);
};
return (
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div
className='blur-ball blur-ball-indigo'
style={{ top: '-80px', right: '-80px', transform: 'none' }}
/>
<div
className='blur-ball blur-ball-teal'
style={{ top: '50%', left: '-120px' }}
/>
<div className='w-full max-w-sm mt-[60px]'>
{showEmailLogin ||
!hasOAuthLoginOptions
? renderEmailLoginForm()
: renderOAuthOptions()}
{renderWeChatLoginModal()}
{render2FAModal()}
{turnstileEnabled && (
<div className='flex justify-center mt-6'>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
</div>
</div>
);
};
export default LoginForm;
-107
View File
@@ -1,107 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useContext, useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
API,
showError,
showSuccess,
updateAPI,
setUserData,
} from '../../helpers';
import { UserContext } from '../../context/User';
import Loading from '../common/ui/Loading';
const OAuth2Callback = (props) => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const [, userDispatch] = useContext(UserContext);
const navigate = useNavigate();
// 防止 React 18 Strict Mode 下重复执行
const hasExecuted = useRef(false);
// 最大重试次数
const MAX_RETRIES = 3;
const sendCode = async (code, state, retry = 0) => {
try {
const { data: resData } = await API.get(
`/api/oauth/${props.type}?code=${code}&state=${state}`,
);
const { success, message, data } = resData;
if (!success) {
// 业务错误不重试,直接显示错误
showError(message || t('授权失败'));
return;
}
if (data?.action === 'bind') {
showSuccess(t('绑定成功!'));
navigate('/console/personal');
} else {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
setUserData(data);
updateAPI();
showSuccess(t('登录成功!'));
navigate('/console/token');
}
} catch (error) {
// 网络错误等可重试
if (retry < MAX_RETRIES) {
// 递增的退避等待
await new Promise((resolve) => setTimeout(resolve, (retry + 1) * 2000));
return sendCode(code, state, retry + 1);
}
// 重试次数耗尽,提示错误并返回设置页面
showError(error.message || t('授权失败'));
navigate('/console/personal');
}
};
useEffect(() => {
// 防止 React 18 Strict Mode 下重复执行
if (hasExecuted.current) {
return;
}
hasExecuted.current = true;
const code = searchParams.get('code');
const state = searchParams.get('state');
// 参数缺失直接返回
if (!code) {
showError(t('未获取到授权码'));
navigate('/console/personal');
return;
}
sendCode(code, state);
}, []);
return <Loading />;
};
export default OAuth2Callback;
-220
View File
@@ -1,220 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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';
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='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div
className='blur-ball blur-ball-indigo'
style={{ top: '-80px', right: '-80px', transform: 'none' }}
/>
<div
className='blur-ball blur-ball-teal'
style={{ top: '50%', left: '-120px' }}
/>
<div className='w-full max-w-sm mt-[60px]'>
<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-gray-800'>
{systemName}
</Title>
</div>
<Card className='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'
disabled={true}
prefix={<IconMail />}
placeholder={email ? '' : t('等待获取邮箱信息...')}
/>
{newPassword && (
<Form.Input
field='newPassword'
label={t('新密码')}
name='newPassword'
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'
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;
-193
View File
@@ -1,193 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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';
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='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div
className='blur-ball blur-ball-indigo'
style={{ top: '-80px', right: '-80px', transform: 'none' }}
/>
<div
className='blur-ball blur-ball-teal'
style={{ top: '50%', left: '-120px' }}
/>
<div className='w-full max-w-sm mt-[60px]'>
<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-gray-800'>
{systemName}
</Title>
</div>
<Card className='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'
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'
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;
-805
View File
@@ -1,805 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
API,
getLogo,
showError,
showInfo,
showSuccess,
updateAPI,
getSystemName,
getOAuthProviderIcon,
setUserData,
onDiscordOAuthClicked,
onCustomOAuthClicked,
} from '../../helpers';
import Turnstile from 'react-turnstile';
import {
Button,
Card,
Checkbox,
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';
import OIDCIcon from '../common/logo/OIDCIcon';
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
import WeChatIcon from '../common/logo/WeChatIcon';
import TelegramLoginButton from 'react-telegram-login/src';
import { UserContext } from '../../context/User';
import { StatusContext } from '../../context/Status';
import { useTranslation } from 'react-i18next';
import { SiDiscord } from 'react-icons/si';
const RegisterForm = () => {
let navigate = useNavigate();
const { t } = useTranslation();
const githubButtonTextKeyByState = {
idle: '使用 GitHub 继续',
redirecting: '正在跳转 GitHub...',
timeout: '请求超时,请刷新页面后重新发起 GitHub 登录',
};
const [inputs, setInputs] = useState({
username: '',
password: '',
password2: '',
email: '',
verification_code: '',
wechat_verification_code: '',
});
const { username, password, password2 } = inputs;
const [userState, userDispatch] = useContext(UserContext);
const [statusState] = useContext(StatusContext);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
const [showEmailRegister, setShowEmailRegister] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false);
const [githubLoading, setGithubLoading] = useState(false);
const [discordLoading, setDiscordLoading] = 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);
const [customOAuthLoading, setCustomOAuthLoading] = useState({});
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [hasUserAgreement, setHasUserAgreement] = useState(false);
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
const [githubButtonState, setGithubButtonState] = useState('idle');
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false);
const githubTimeoutRef = useRef(null);
const githubButtonText = t(githubButtonTextKeyByState[githubButtonState]);
const logo = getLogo();
const systemName = getSystemName();
let affCode = new URLSearchParams(window.location.search).get('aff');
if (affCode) {
localStorage.setItem('aff', affCode);
}
const status = useMemo(() => {
if (statusState?.status) return statusState.status;
const savedStatus = localStorage.getItem('status');
if (!savedStatus) return {};
try {
return JSON.parse(savedStatus) || {};
} catch (err) {
return {};
}
}, [statusState?.status]);
const hasCustomOAuthProviders =
(status.custom_oauth_providers || []).length > 0;
const hasOAuthRegisterOptions = Boolean(
status.github_oauth ||
status.discord_oauth ||
status.oidc_enabled ||
status.wechat_login ||
status.linuxdo_oauth ||
status.telegram_oauth ||
hasCustomOAuthProviders,
);
const [showEmailVerification, setShowEmailVerification] = useState(false);
useEffect(() => {
setShowEmailVerification(!!status?.email_verification);
if (status?.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
// 从 status 获取用户协议和隐私政策的启用状态
setHasUserAgreement(status?.user_agreement_enabled || false);
setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
}, [status]);
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); // Clean up on unmount
}, [disableButton, countdown]);
useEffect(() => {
return () => {
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current);
}
};
}, []);
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=${encodeURIComponent(inputs.email)}&turnstile=${turnstileToken}`,
);
const { success, message } = res.data;
if (success) {
showSuccess('验证码发送成功,请检查你的邮箱!');
setDisableButton(true); // 发送成功后禁用按钮,开始倒计时
} else {
showError(message);
}
} catch (error) {
showError('发送验证码失败,请重试');
} finally {
setVerificationCodeLoading(false);
}
};
const handleGitHubClick = () => {
if (githubButtonDisabled) {
return;
}
setGithubLoading(true);
setGithubButtonDisabled(true);
setGithubButtonState('redirecting');
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current);
}
githubTimeoutRef.current = setTimeout(() => {
setGithubLoading(false);
setGithubButtonState('timeout');
setGithubButtonDisabled(true);
}, 20000);
try {
onGitHubOAuthClicked(status.github_client_id, { shouldLogout: true });
} finally {
setTimeout(() => setGithubLoading(false), 3000);
}
};
const handleDiscordClick = () => {
setDiscordLoading(true);
try {
onDiscordOAuthClicked(status.discord_client_id, { shouldLogout: true });
} finally {
setTimeout(() => setDiscordLoading(false), 3000);
}
};
const handleOIDCClick = () => {
setOidcLoading(true);
try {
onOIDCClicked(
status.oidc_authorization_endpoint,
status.oidc_client_id,
false,
{ shouldLogout: true },
);
} finally {
setTimeout(() => setOidcLoading(false), 3000);
}
};
const handleLinuxDOClick = () => {
setLinuxdoLoading(true);
try {
onLinuxDOOAuthClicked(status.linuxdo_client_id, { shouldLogout: true });
} finally {
setTimeout(() => setLinuxdoLoading(false), 3000);
}
};
const handleCustomOAuthClick = (provider) => {
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: true }));
try {
onCustomOAuthClicked(provider, { shouldLogout: true });
} finally {
setTimeout(() => {
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: 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-gray-800'>
{systemName}
</Title>
</div>
<Card className='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' }} />
}
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' />}
onClick={handleGitHubClick}
loading={githubLoading}
disabled={githubButtonDisabled}
>
<span className='ml-3'>{githubButtonText}</span>
</Button>
)}
{status.discord_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={
<SiDiscord
style={{
color: '#5865F2',
width: '20px',
height: '20px',
}}
/>
}
onClick={handleDiscordClick}
loading={discordLoading}
>
<span className='ml-3'>{t('使用 Discord 继续')}</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' }} />}
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',
}}
/>
}
onClick={handleLinuxDOClick}
loading={linuxdoLoading}
>
<span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
</Button>
)}
{status.custom_oauth_providers &&
status.custom_oauth_providers.map((provider) => (
<Button
key={provider.slug}
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={getOAuthProviderIcon(provider.icon || '', 20)}
onClick={() => handleCustomOAuthClick(provider)}
loading={customOAuthLoading[provider.slug]}
>
<span className='ml-3'>
{t('使用 {{name}} 继续', { name: provider.name })}
</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' />}
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-gray-800'>
{systemName}
</Title>
</div>
<Card className='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'
onChange={(value) => handleChange('username', value)}
prefix={<IconUser />}
/>
<Form.Input
field='password'
label={t('密码')}
placeholder={t('输入密码,最短 8 位,最长 20 位')}
name='password'
mode='password'
onChange={(value) => handleChange('password', value)}
prefix={<IconLock />}
/>
<Form.Input
field='password2'
label={t('确认密码')}
placeholder={t('确认密码')}
name='password2'
mode='password'
onChange={(value) => handleChange('password2', value)}
prefix={<IconLock />}
/>
{showEmailVerification && (
<>
<Form.Input
field='email'
label={t('邮箱')}
placeholder={t('输入邮箱地址')}
name='email'
type='email'
onChange={(value) => handleChange('email', value)}
prefix={<IconMail />}
suffix={
<Button
onClick={sendVerificationCode}
loading={verificationCodeLoading}
disabled={disableButton || verificationCodeLoading}
>
{disableButton
? `${t('重新发送')} (${countdown})`
: t('获取验证码')}
</Button>
}
/>
<Form.Input
field='verification_code'
label={t('验证码')}
placeholder={t('输入验证码')}
name='verification_code'
onChange={(value) =>
handleChange('verification_code', value)
}
prefix={<IconKey />}
/>
</>
)}
{(hasUserAgreement || hasPrivacyPolicy) && (
<div className='pt-4'>
<Checkbox
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.target.checked)}
>
<Text size='small' className='text-gray-600'>
{t('我已阅读并同意')}
{hasUserAgreement && (
<>
<a
href='/user-agreement'
target='_blank'
rel='noopener noreferrer'
className='text-blue-600 hover:text-blue-800 mx-1'
>
{t('用户协议')}
</a>
</>
)}
{hasUserAgreement && hasPrivacyPolicy && t('和')}
{hasPrivacyPolicy && (
<>
<a
href='/privacy-policy'
target='_blank'
rel='noopener noreferrer'
className='text-blue-600 hover:text-blue-800 mx-1'
>
{t('隐私政策')}
</a>
</>
)}
</Text>
</Checkbox>
</div>
)}
<div className='space-y-2 pt-2'>
<Button
theme='solid'
className='w-full !rounded-full'
type='primary'
htmlType='submit'
onClick={handleSubmit}
loading={registerLoading}
disabled={
(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms
}
>
{t('注册')}
</Button>
</div>
</Form>
{hasOAuthRegisterOptions && (
<>
<Divider margin='12px' align='center'>
{t('或')}
</Divider>
<div className='mt-4 text-center'>
<Button
theme='outline'
type='tertiary'
className='w-full !rounded-full'
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('登录')}
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>
<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='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
{/* 背景模糊晕染球 */}
<div
className='blur-ball blur-ball-indigo'
style={{ top: '-80px', right: '-80px', transform: 'none' }}
/>
<div
className='blur-ball blur-ball-teal'
style={{ top: '50%', left: '-120px' }}
/>
<div className='w-full max-w-sm mt-[60px]'>
{showEmailRegister ||
!hasOAuthRegisterOptions
? renderEmailRegisterForm()
: renderOAuthOptions()}
{renderWeChatLoginModal()}
{turnstileEnabled && (
<div className='flex justify-center mt-6'>
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
</div>
)}
</div>
</div>
);
};
export default RegisterForm;
-244
View File
@@ -1,244 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import { API, showError, showSuccess } from '../../helpers';
import {
Button,
Card,
Divider,
Form,
Input,
Typography,
} from '@douyinfe/semi-ui';
import React, { useState } from 'react';
const { Title, Text, Paragraph } = Typography;
const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
const [loading, setLoading] = useState(false);
const [useBackupCode, setUseBackupCode] = useState(false);
const [verificationCode, setVerificationCode] = useState('');
const handleSubmit = async () => {
if (!verificationCode) {
showError('请输入验证码');
return;
}
// Validate code format
if (useBackupCode && verificationCode.length !== 8) {
showError('备用码必须是8位');
return;
} else if (!useBackupCode && !/^\d{6}$/.test(verificationCode)) {
showError('验证码必须是6位数字');
return;
}
setLoading(true);
try {
const res = await API.post('/api/user/login/2fa', {
code: verificationCode,
});
if (res.data.success) {
showSuccess('登录成功');
// 保存用户信息到本地存储
localStorage.setItem('user', JSON.stringify(res.data.data));
if (onSuccess) {
onSuccess(res.data.data);
}
} else {
showError(res.data.message);
}
} catch (error) {
showError('验证失败,请重试');
} finally {
setLoading(false);
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
handleSubmit();
}
};
if (isModal) {
return (
<div className='space-y-4'>
<Paragraph className='text-gray-600 dark:text-gray-300'>
请输入认证器应用显示的验证码完成登录
</Paragraph>
<Form onSubmit={handleSubmit}>
<Form.Input
field='code'
label={useBackupCode ? '备用码' : '验证码'}
placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
value={verificationCode}
onChange={setVerificationCode}
onKeyPress={handleKeyPress}
size='large'
style={{ marginBottom: 16 }}
autoFocus
/>
<Button
htmlType='submit'
type='primary'
loading={loading}
block
size='large'
style={{ marginBottom: 16 }}
>
验证并登录
</Button>
</Form>
<Divider />
<div style={{ textAlign: 'center' }}>
<Button
theme='borderless'
type='tertiary'
onClick={() => {
setUseBackupCode(!useBackupCode);
setVerificationCode('');
}}
style={{ marginRight: 16, color: '#1890ff', padding: 0 }}
>
{useBackupCode ? '使用认证器验证码' : '使用备用码'}
</Button>
{onBack && (
<Button
theme='borderless'
type='tertiary'
onClick={onBack}
style={{ color: '#1890ff', padding: 0 }}
>
返回登录
</Button>
)}
</div>
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-3'>
<Text size='small' type='secondary'>
<strong>提示</strong>
<br />
验证码每30秒更新一次
<br />
如果无法获取验证码请使用备用码
<br /> 每个备用码只能使用一次
</Text>
</div>
</div>
);
}
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '60vh',
}}
>
<Card style={{ width: 400, padding: 24 }}>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Title heading={3}>两步验证</Title>
<Paragraph type='secondary'>
请输入认证器应用显示的验证码完成登录
</Paragraph>
</div>
<Form onSubmit={handleSubmit}>
<Form.Input
field='code'
label={useBackupCode ? '备用码' : '验证码'}
placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
value={verificationCode}
onChange={setVerificationCode}
onKeyPress={handleKeyPress}
size='large'
style={{ marginBottom: 16 }}
autoFocus
/>
<Button
htmlType='submit'
type='primary'
loading={loading}
block
size='large'
style={{ marginBottom: 16 }}
>
验证并登录
</Button>
</Form>
<Divider />
<div style={{ textAlign: 'center' }}>
<Button
theme='borderless'
type='tertiary'
onClick={() => {
setUseBackupCode(!useBackupCode);
setVerificationCode('');
}}
style={{ marginRight: 16, color: '#1890ff', padding: 0 }}
>
{useBackupCode ? '使用认证器验证码' : '使用备用码'}
</Button>
{onBack && (
<Button
theme='borderless'
type='tertiary'
onClick={onBack}
style={{ color: '#1890ff', padding: 0 }}
>
返回登录
</Button>
)}
</div>
<div
style={{
marginTop: 24,
padding: 16,
background: '#f6f8fa',
borderRadius: 6,
}}
>
<Text size='small' type='secondary'>
<strong>提示</strong>
<br />
验证码每30秒更新一次
<br />
如果无法获取验证码请使用备用码
<br /> 每个备用码只能使用一次
</Text>
</div>
</Card>
</div>
);
};
export default TwoFAVerification;
-232
View File
@@ -1,232 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useEffect, useMemo, useState } from 'react';
import { API, showError } from '../../../helpers';
import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui';
const { Title } = Typography;
import {
IllustrationConstruction,
IllustrationConstructionDark,
} from '@douyinfe/semi-illustrations';
import { useTranslation } from 'react-i18next';
import MarkdownRenderer from '../markdown/MarkdownRenderer';
// Check whether content is a URL.
const isUrl = (content) => {
try {
new URL(content.trim());
return true;
} catch {
return false;
}
};
// Check whether content contains HTML.
const isHtmlContent = (content) => {
if (!content || typeof content !== 'string') return false;
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
return htmlTagRegex.test(content);
};
// Parse HTML content and extract inline styles.
const sanitizeHtml = (html) => {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
const styles = Array.from(tempDiv.querySelectorAll('style'))
.map((style) => style.innerHTML)
.join('\n');
const bodyContent = tempDiv.querySelector('body');
const content = bodyContent ? bodyContent.innerHTML : html;
return { content, styles };
};
/**
* 通用文档渲染组件
* @param {string} apiEndpoint - API 接口地址
* @param {string} title - 文档标题
* @param {string} cacheKey - 本地存储缓存键
* @param {string} emptyMessage - 空内容时的提示消息
*/
const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const { t } = useTranslation();
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const loadContent = async () => {
const cachedContent = localStorage.getItem(cacheKey) || '';
if (cachedContent) {
setContent(cachedContent);
setLoading(false);
}
try {
const res = await API.get(apiEndpoint);
const { success, message, data } = res.data;
if (success && data) {
setContent(data);
localStorage.setItem(cacheKey, data);
} else {
if (!cachedContent) {
showError(message || emptyMessage);
setContent('');
}
}
} catch (error) {
if (!cachedContent) {
showError(emptyMessage);
setContent('');
}
} finally {
setLoading(false);
}
};
const htmlPayload = useMemo(() => {
if (!isHtmlContent(content)) {
return { content: '', styles: '' };
}
return sanitizeHtml(content);
}, [content]);
useEffect(() => {
loadContent();
}, []);
// 处理HTML样式注入
useEffect(() => {
const styleId = `document-renderer-styles-${cacheKey}`;
const { styles } = htmlPayload;
if (styles) {
let styleEl = document.getElementById(styleId);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = styleId;
styleEl.type = 'text/css';
document.head.appendChild(styleEl);
}
styleEl.innerHTML = styles;
} else {
const el = document.getElementById(styleId);
if (el) el.remove();
}
return () => {
const el = document.getElementById(styleId);
if (el) el.remove();
};
}, [cacheKey, htmlPayload]);
// 显示加载状态
if (loading) {
return (
<div className='classic-page-fill flex justify-center items-center'>
<Spin size='large' />
</div>
);
}
// 如果没有内容,显示空状态
if (!content || content.trim() === '') {
return (
<div className='classic-page-fill flex justify-center items-center bg-gray-50'>
<Empty
title={t('管理员未设置' + title + '内容')}
image={
<IllustrationConstruction style={{ width: 150, height: 150 }} />
}
darkModeImage={
<IllustrationConstructionDark style={{ width: 150, height: 150 }} />
}
className='p-8'
/>
</div>
);
}
// 如果是 URL,显示链接卡片
if (isUrl(content)) {
return (
<div className='classic-page-fill flex justify-center items-center bg-gray-50 p-4'>
<Card className='max-w-md w-full'>
<div className='text-center'>
<Title heading={4} className='mb-4'>
{title}
</Title>
<p className='text-gray-600 mb-4'>
{t('管理员设置了外部链接,点击下方按钮访问')}
</p>
<a
href={content.trim()}
target='_blank'
rel='noopener noreferrer'
title={content.trim()}
aria-label={`${t('访问' + title)}: ${content.trim()}`}
className='inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors'
>
{t('访问' + title)}
</a>
</div>
</Card>
</div>
);
}
// 如果是 HTML 内容,直接渲染
if (isHtmlContent(content)) {
return (
<div className='classic-page-fill bg-gray-50'>
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
<div className='bg-white rounded-lg shadow-sm p-8'>
<Title heading={2} className='text-center mb-8'>
{title}
</Title>
<div
className='prose prose-lg max-w-none'
dangerouslySetInnerHTML={{ __html: htmlPayload.content }}
/>
</div>
</div>
</div>
);
}
// 其他内容统一使用 Markdown 渲染器
return (
<div className='classic-page-fill bg-gray-50'>
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
<div className='bg-white rounded-lg shadow-sm p-8'>
<Title heading={2} className='text-center mb-8'>
{title}
</Title>
<div className='prose prose-lg max-w-none'>
<MarkdownRenderer content={content} />
</div>
</div>
</div>
</div>
);
};
export default DocumentRenderer;
-52
View File
@@ -1,52 +0,0 @@
import React from 'react';
import { Empty, Button } from '@douyinfe/semi-ui';
import {
IllustrationFailure,
IllustrationFailureDark,
} from '@douyinfe/semi-illustrations';
import { withTranslation } from 'react-i18next';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error('[ErrorBoundary]', error, errorInfo);
}
render() {
if (this.state.hasError) {
const { t } = this.props;
return (
<div className='flex flex-col justify-center items-center h-screen p-8'>
<Empty
image={
<IllustrationFailure style={{ width: 250, height: 250 }} />
}
darkModeImage={
<IllustrationFailureDark style={{ width: 250, height: 250 }} />
}
description={t('页面渲染出错,请刷新页面重试')}
/>
<Button
theme='solid'
type='primary'
style={{ marginTop: 16 }}
onClick={() => window.location.reload()}
>
{t('刷新页面')}
</Button>
</div>
);
}
return this.props.children;
}
}
export default withTranslation()(ErrorBoundary);
@@ -1,113 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Modal } from '@douyinfe/semi-ui';
import { useSecureVerification } from '../../../hooks/common/useSecureVerification';
import { createApiCalls } from '../../../services/secureVerification';
import SecureVerificationModal from '../modals/SecureVerificationModal';
import ChannelKeyDisplay from '../ui/ChannelKeyDisplay';
/**
* 渠道密钥查看组件使用示例
* 展示如何使用通用安全验证系统
*/
const ChannelKeyViewExample = ({ channelId }) => {
const { t } = useTranslation();
const [keyData, setKeyData] = useState('');
const [showKeyModal, setShowKeyModal] = useState(false);
// 使用通用安全验证 Hook
const {
isModalVisible,
verificationMethods,
verificationState,
startVerification,
executeVerification,
cancelVerification,
setVerificationCode,
switchVerificationMethod,
} = useSecureVerification({
onSuccess: (result) => {
// 验证成功后处理结果
if (result.success && result.data?.key) {
setKeyData(result.data.key);
setShowKeyModal(true);
}
},
successMessage: t('密钥获取成功'),
});
// 开始查看密钥流程
const handleViewKey = async () => {
const apiCall = createApiCalls.viewChannelKey(channelId);
await startVerification(apiCall, {
title: t('查看渠道密钥'),
description: t('为了保护账户安全,请验证您的身份。'),
preferredMethod: 'passkey', // 可以指定首选验证方式
});
};
return (
<>
{/* 查看密钥按钮 */}
<Button type='primary' theme='outline' onClick={handleViewKey}>
{t('查看密钥')}
</Button>
{/* 安全验证模态框 */}
<SecureVerificationModal
visible={isModalVisible}
verificationMethods={verificationMethods}
verificationState={verificationState}
onVerify={executeVerification}
onCancel={cancelVerification}
onCodeChange={setVerificationCode}
onMethodSwitch={switchVerificationMethod}
title={verificationState.title}
description={verificationState.description}
/>
{/* 密钥显示模态框 */}
<Modal
title={t('渠道密钥信息')}
visible={showKeyModal}
onCancel={() => setShowKeyModal(false)}
footer={
<Button type='primary' onClick={() => setShowKeyModal(false)}>
{t('完成')}
</Button>
}
width={700}
style={{ maxWidth: '90vw' }}
>
<ChannelKeyDisplay
keyData={keyData}
showSuccessIcon={true}
successText={t('密钥获取成功')}
showWarning={true}
/>
</Modal>
</>
);
};
export default ChannelKeyViewExample;
-56
View File
@@ -1,56 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Icon } from '@douyinfe/semi-ui';
const LinuxDoIcon = (props) => {
function CustomIcon() {
return (
<svg
className='icon'
viewBox='0 0 16 16'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
width='1em'
height='1em'
{...props}
>
<g id='linuxdo_icon' data-name='linuxdo_icon'>
<path
d='m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z'
fill='#EFEFEF'
/>
<path
d='m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z'
fill='#FEB005'
/>
<path
d='m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z'
fill='#1D1D1F'
/>
</g>
</svg>
);
}
return <Icon svg={<CustomIcon />} />;
};
export default LinuxDoIcon;
-57
View File
@@ -1,57 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Icon } from '@douyinfe/semi-ui';
const OIDCIcon = (props) => {
function CustomIcon() {
return (
<svg
t='1723135116886'
className='icon'
viewBox='0 0 1024 1024'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
p-id='10969'
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'
p-id='10970'
fill='#2c2c2c'
stroke='#2c2c2c'
stroke-width='60'
></path>
<path
d='M197.7 512c0-78.3 31.6-98.8 87.2-98.8 56.2 0 87.2 20.5 87.2 98.8s-31 98.8-87.2 98.8c-55.7 0-87.2-20.5-87.2-98.8z m130.4 0c0-46.8-7.8-64.5-43.2-64.5-35.2 0-42.9 17.7-42.9 64.5 0 47.1 7.8 63.7 42.9 63.7 35.4 0 43.2-16.6 43.2-63.7zM409.7 415.9h42.1V608h-42.1V415.9zM653.9 512c0 74.2-37.1 96.1-93.6 96.1h-65.9V415.9h65.9c56.5 0 93.6 16.1 93.6 96.1z m-43.5 0c0-49.3-17.7-60.6-52.3-60.6h-21.6v120.7h21.6c35.4 0 52.3-13.3 52.3-60.1zM686.5 512c0-74.2 36.3-98.8 92.7-98.8 18.3 0 33.2 2.2 44.8 6.4v36.3c-11.9-4.2-26-6.6-42.1-6.6-34.6 0-49.8 15.5-49.8 62.6 0 50.1 15.2 62.6 49.3 62.6 15.8 0 30.2-2.2 44.8-7.5v36c-11.3 4.7-28.5 8-46.8 8-56.1-0.2-92.9-18.7-92.9-99z'
p-id='10971'
fill='#2c2c2c'
stroke='#2c2c2c'
stroke-width='20'
></path>
</svg>
);
}
return <Icon svg={<CustomIcon />} />;
};
export default OIDCIcon;
-55
View File
@@ -1,55 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Icon } from '@douyinfe/semi-ui';
const WeChatIcon = () => {
function CustomIcon() {
return (
<svg
t='1709714447384'
className='icon'
viewBox='0 0 1024 1024'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
p-id='5091'
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'
p-id='5092'
></path>
<path
d='M866.7 792.7c56.9-41.2 93.2-102 93.2-169.7 0-124-120.8-224.5-269.9-224.5-149 0-269.9 100.5-269.9 224.5S540.9 847.5 690 847.5c30.8 0 60.6-4.4 88.1-12.3 2.6-0.8 5.2-1.2 7.9-1.2 5.2 0 9.9 1.6 14.3 4.1l59.1 34c1.7 1 3.3 1.7 5.2 1.7 2.4 0 4.7-0.9 6.4-2.6 1.7-1.7 2.6-4 2.6-6.4 0-2.2-0.9-4.4-1.4-6.6-0.3-1.2-7.6-28.3-12.2-45.3-0.5-1.9-0.9-3.8-0.9-5.7 0.1-5.9 3.1-11.2 7.6-14.5zM600.2 587.2c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c0 19.8-16.2 35.9-36 35.9z m179.9 0c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c-0.1 19.8-16.2 35.9-36 35.9z'
p-id='5093'
></path>
</svg>
);
}
return (
<div>
<Icon svg={<CustomIcon />} />
</div>
);
};
export default WeChatIcon;
@@ -1,697 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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>
);
}
function SandboxedHtmlPreview({ code }) {
const iframeRef = useRef(null);
const [iframeHeight, setIframeHeight] = useState(150);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const handleLoad = () => {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc) {
const height =
doc.documentElement.scrollHeight || doc.body.scrollHeight;
setIframeHeight(Math.min(Math.max(height + 16, 60), 600));
}
} catch {
// sandbox restrictions may prevent access, that's fine
}
};
iframe.addEventListener('load', handleLoad);
return () => iframe.removeEventListener('load', handleLoad);
}, [code]);
return (
<iframe
ref={iframeRef}
sandbox='allow-same-origin'
srcDoc={code}
title='HTML Preview'
style={{
width: '100%',
height: `${iframeHeight}px`,
border: 'none',
overflow: 'auto',
backgroundColor: '#fff',
borderRadius: '4px',
}}
/>
);
}
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 codeElement = ref.current.querySelector('code');
const code = codeElement?.textContent ?? '';
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>
<SandboxedHtmlPreview code={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;
-449
View File
@@ -1,449 +0,0 @@
/* 基础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,298 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Modal,
Button,
Typography,
Checkbox,
Input,
Space,
} from '@douyinfe/semi-ui';
import { IconAlertTriangle } from '@douyinfe/semi-icons';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import MarkdownRenderer from '../markdown/MarkdownRenderer';
const { Text } = Typography;
const RiskMarkdownBlock = React.memo(function RiskMarkdownBlock({
markdownContent,
}) {
if (!markdownContent) {
return null;
}
return (
<div
className='rounded-lg'
style={{
border: '1px solid var(--semi-color-warning-light-hover)',
padding: '12px',
contentVisibility: 'auto',
}}
>
<MarkdownRenderer content={markdownContent} />
</div>
);
});
const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
visible,
title,
markdownContent = '',
detailTitle = '',
detailItems = [],
checklist = [],
inputPrompt = '',
requiredText = '',
requiredTextParts = [],
inputPlaceholder = '',
mismatchText = '',
cancelText = '',
confirmText = '',
onCancel,
onConfirm,
}) {
const isMobile = useIsMobile();
const [checkedItems, setCheckedItems] = useState([]);
const [typedText, setTypedText] = useState('');
const [typedTextParts, setTypedTextParts] = useState([]);
const normalizedRequiredTextParts = useMemo(() => {
let inputIndex = 0;
return requiredTextParts.map((part) => {
if (part.type === 'input') {
const normalizedPart = { ...part, inputIndex };
inputIndex += 1;
return normalizedPart;
}
return part;
});
}, [requiredTextParts]);
const requiredTextInputCount = useMemo(
() =>
normalizedRequiredTextParts.filter((part) => part.type === 'input')
.length,
[normalizedRequiredTextParts],
);
const hasSegmentedRequiredText = requiredTextInputCount > 0;
const requiredTextToDisplay = hasSegmentedRequiredText
? normalizedRequiredTextParts.map((part) => part.text).join('')
: requiredText;
useEffect(() => {
if (!visible) return;
setCheckedItems(Array(checklist.length).fill(false));
setTypedText('');
setTypedTextParts(Array(requiredTextInputCount).fill(''));
}, [visible, checklist.length, requiredTextInputCount]);
const allChecked = useMemo(() => {
if (checklist.length === 0) return true;
return (
checkedItems.length === checklist.length && checkedItems.every(Boolean)
);
}, [checkedItems, checklist.length]);
const typedMatched = useMemo(() => {
if (hasSegmentedRequiredText) {
return normalizedRequiredTextParts.every((part) => {
if (part.type === 'static') return true;
return (
typedTextParts[part.inputIndex ?? 0]?.trim() === part.text.trim()
);
});
}
if (!requiredText) return true;
return typedText.trim() === requiredText.trim();
}, [
hasSegmentedRequiredText,
normalizedRequiredTextParts,
requiredText,
typedText,
typedTextParts,
]);
const detailText = useMemo(() => detailItems.join(', '), [detailItems]);
const hasTypedRequiredText = hasSegmentedRequiredText
? typedTextParts.some((part) => part.trim() !== '')
: typedText.length > 0;
const canConfirm = allChecked && typedMatched;
const handleChecklistChange = useCallback((index, checked) => {
setCheckedItems((previous) => {
const next = [...previous];
next[index] = checked;
return next;
});
}, []);
const handleTextPartChange = useCallback((index, value) => {
setTypedTextParts((previous) => {
const next = [...previous];
next[index] = value;
return next;
});
}, []);
return (
<Modal
visible={visible}
title={
<Space align='center'>
<IconAlertTriangle style={{ color: 'var(--semi-color-warning)' }} />
<span>{title}</span>
</Space>
}
width={isMobile ? '100%' : 860}
centered
maskClosable={false}
closeOnEsc={false}
onCancel={onCancel}
bodyStyle={{
maxHeight: isMobile ? '70vh' : '72vh',
overflowY: 'auto',
padding: isMobile ? '12px 16px' : '18px 22px',
}}
footer={
<Space>
<Button onClick={onCancel}>{cancelText}</Button>
<Button
theme='solid'
type='danger'
disabled={!canConfirm}
onClick={onConfirm}
>
{confirmText}
</Button>
</Space>
}
>
<div className='flex flex-col gap-4'>
<RiskMarkdownBlock markdownContent={markdownContent} />
{detailItems.length > 0 ? (
<div
className='flex flex-col gap-2 rounded-lg'
style={{
border: '1px solid var(--semi-color-warning-light-hover)',
background: 'var(--semi-color-fill-0)',
padding: isMobile ? '10px 12px' : '12px 14px',
}}
>
{detailTitle ? <Text strong>{detailTitle}</Text> : null}
<div className='font-mono text-xs break-all bg-orange-50 border border-orange-200 rounded-md p-2'>
{detailText}
</div>
</div>
) : null}
{checklist.length > 0 ? (
<div
className='flex flex-col gap-2 rounded-lg'
style={{
border: '1px solid var(--semi-color-border)',
background: 'var(--semi-color-fill-0)',
padding: isMobile ? '10px 12px' : '12px 14px',
}}
>
{checklist.map((item, index) => (
<Checkbox
key={`risk-check-${index}`}
checked={!!checkedItems[index]}
onChange={(event) => {
handleChecklistChange(index, event.target.checked);
}}
>
{item}
</Checkbox>
))}
</div>
) : null}
{requiredTextToDisplay ? (
<div
className='flex flex-col gap-2 rounded-lg'
style={{
border: '1px solid var(--semi-color-danger-light-hover)',
background: 'var(--semi-color-danger-light-default)',
padding: isMobile ? '10px 12px' : '12px 14px',
}}
>
{inputPrompt ? <Text strong>{inputPrompt}</Text> : null}
<div className='font-mono text-xs break-all rounded-md p-2 bg-gray-50 border border-gray-200'>
{requiredTextToDisplay}
</div>
{hasSegmentedRequiredText ? (
<div className='flex flex-wrap items-center gap-2'>
{normalizedRequiredTextParts.map((part, index) =>
part.type === 'static' ? (
<span
key={`static-${index}`}
className='select-none rounded-md border border-gray-200 bg-white px-2 py-1 font-mono text-sm text-gray-500'
>
{part.text}
</span>
) : (
<Input
key={`input-${index}`}
value={typedTextParts[part.inputIndex ?? 0] ?? ''}
onChange={(value) =>
handleTextPartChange(part.inputIndex ?? 0, value)
}
placeholder={
part.placeholder || part.text || inputPlaceholder
}
autoFocus={visible && part.inputIndex === 0}
onCopy={(event) => event.preventDefault()}
onCut={(event) => event.preventDefault()}
onPaste={(event) => event.preventDefault()}
onDrop={(event) => event.preventDefault()}
style={{ width: isMobile ? '100%' : 260 }}
/>
),
)}
</div>
) : (
<Input
value={typedText}
onChange={setTypedText}
placeholder={inputPlaceholder}
autoFocus={visible}
onCopy={(event) => event.preventDefault()}
onCut={(event) => event.preventDefault()}
onPaste={(event) => event.preventDefault()}
onDrop={(event) => event.preventDefault()}
/>
)}
{!typedMatched && hasTypedRequiredText ? (
<Text type='danger' size='small'>
{mismatchText}
</Text>
) : null}
</div>
) : null}
</div>
</Modal>
);
});
export default RiskAcknowledgementModal;
@@ -1,322 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
Button,
Input,
Typography,
Tabs,
TabPane,
Space,
Spin,
} from '@douyinfe/semi-ui';
/**
* 通用安全验证模态框组件
* 配合 useSecureVerification Hook 使用
* @param {Object} props
* @param {boolean} props.visible - 是否显示模态框
* @param {Object} props.verificationMethods - 可用的验证方式
* @param {Object} props.verificationState - 当前验证状态
* @param {Function} props.onVerify - 验证回调
* @param {Function} props.onCancel - 取消回调
* @param {Function} props.onCodeChange - 验证码变化回调
* @param {Function} props.onMethodSwitch - 验证方式切换回调
* @param {string} props.title - 模态框标题
* @param {string} props.description - 验证描述文本
*/
const SecureVerificationModal = ({
visible,
verificationMethods,
verificationState,
onVerify,
onCancel,
onCodeChange,
onMethodSwitch,
title,
description,
}) => {
const { t } = useTranslation();
const [isAnimating, setIsAnimating] = useState(false);
const [verifySuccess, setVerifySuccess] = useState(false);
const { has2FA, hasPasskey, passkeySupported } = verificationMethods;
const { method, loading, code } = verificationState;
useEffect(() => {
if (visible) {
setIsAnimating(true);
setVerifySuccess(false);
} else {
setIsAnimating(false);
}
}, [visible]);
const handleKeyDown = (e) => {
if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') {
onVerify(method, code);
}
if (e.key === 'Escape' && !loading) {
onCancel();
}
};
// 如果用户没有启用任何验证方式
if (visible && !has2FA && !hasPasskey) {
return (
<Modal
title={title || t('安全验证')}
visible={visible}
onCancel={onCancel}
footer={<Button onClick={onCancel}>{t('确定')}</Button>}
width={500}
style={{ maxWidth: '90vw' }}
>
<div className='text-center py-6'>
<div className='mb-4'>
<svg
className='w-16 h-16 text-yellow-500 mx-auto mb-4'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
clipRule='evenodd'
/>
</svg>
</div>
<Typography.Title heading={4} className='mb-2'>
{t('需要安全验证')}
</Typography.Title>
<Typography.Text type='tertiary'>
{t('您需要先启用两步验证或 Passkey 才能查看敏感信息。')}
</Typography.Text>
<br />
<Typography.Text type='tertiary'>
{t('请前往个人设置 → 安全设置进行配置。')}
</Typography.Text>
</div>
</Modal>
);
}
return (
<Modal
title={title || t('安全验证')}
visible={visible}
onCancel={loading ? undefined : onCancel}
closeOnEsc={!loading}
footer={null}
width={460}
centered
style={{
maxWidth: 'calc(100vw - 32px)',
}}
bodyStyle={{
padding: '20px 24px',
}}
>
<div style={{ width: '100%' }}>
{/* 描述信息 */}
{description && (
<Typography.Paragraph
type='tertiary'
style={{
margin: '0 0 20px 0',
fontSize: '14px',
lineHeight: '1.6',
}}
>
{description}
</Typography.Paragraph>
)}
{/* 验证方式选择 */}
<Tabs
activeKey={method}
onChange={onMethodSwitch}
type='line'
size='default'
style={{ margin: 0 }}
>
{has2FA && (
<TabPane tab={t('两步验证')} itemKey='2fa'>
<div style={{ paddingTop: '20px' }}>
<div style={{ marginBottom: '12px' }}>
<Input
placeholder={t('请输入6位验证码或8位备用码')}
value={code}
onChange={onCodeChange}
size='large'
maxLength={8}
onKeyDown={handleKeyDown}
autoFocus={method === '2fa'}
disabled={loading}
prefix={
<svg
style={{
width: 16,
height: 16,
marginRight: 8,
flexShrink: 0,
}}
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
clipRule='evenodd'
/>
</svg>
}
style={{ width: '100%' }}
/>
</div>
<Typography.Text
type='tertiary'
size='small'
style={{
display: 'block',
marginBottom: '20px',
fontSize: '13px',
lineHeight: '1.5',
}}
>
{t('从认证器应用中获取验证码,或使用备用码')}
</Typography.Text>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '8px',
flexWrap: 'wrap',
}}
>
<Button onClick={onCancel} disabled={loading}>
{t('取消')}
</Button>
<Button
theme='solid'
type='primary'
loading={loading}
disabled={!code.trim() || loading}
onClick={() => onVerify(method, code)}
>
{t('验证')}
</Button>
</div>
</div>
</TabPane>
)}
{hasPasskey && passkeySupported && (
<TabPane tab={t('Passkey')} itemKey='passkey'>
<div style={{ paddingTop: '20px' }}>
<div
style={{
textAlign: 'center',
padding: '24px 16px',
marginBottom: '20px',
}}
>
<div
style={{
width: 56,
height: 56,
margin: '0 auto 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
background: 'var(--semi-color-primary-light-default)',
}}
>
<svg
style={{
width: 28,
height: 28,
color: 'var(--semi-color-primary)',
}}
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
clipRule='evenodd'
/>
</svg>
</div>
<Typography.Title
heading={5}
style={{ margin: '0 0 8px', fontSize: '16px' }}
>
{t('使用 Passkey 验证')}
</Typography.Title>
<Typography.Text
type='tertiary'
style={{
display: 'block',
margin: 0,
fontSize: '13px',
lineHeight: '1.5',
}}
>
{t('点击验证按钮,使用您的生物特征或安全密钥')}
</Typography.Text>
</div>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '8px',
flexWrap: 'wrap',
}}
>
<Button onClick={onCancel} disabled={loading}>
{t('取消')}
</Button>
<Button
theme='solid'
type='primary'
loading={loading}
disabled={loading}
onClick={() => onVerify(method)}
>
{t('验证 Passkey')}
</Button>
</div>
</div>
</TabPane>
)}
</Tabs>
</div>
</Modal>
);
};
export default SecureVerificationModal;
@@ -1,148 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, Button, Input, Typography } from '@douyinfe/semi-ui';
/**
* 可复用的两步验证模态框组件
* @param {Object} props
* @param {boolean} props.visible - 是否显示模态框
* @param {string} props.code - 验证码值
* @param {boolean} props.loading - 是否正在验证
* @param {Function} props.onCodeChange - 验证码变化回调
* @param {Function} props.onVerify - 验证回调
* @param {Function} props.onCancel - 取消回调
* @param {string} props.title - 模态框标题
* @param {string} props.description - 验证描述文本
* @param {string} props.placeholder - 输入框占位文本
*/
const TwoFactorAuthModal = ({
visible,
code,
loading,
onCodeChange,
onVerify,
onCancel,
title,
description,
placeholder,
}) => {
const { t } = useTranslation();
const handleKeyDown = (e) => {
if (e.key === 'Enter' && code && !loading) {
onVerify();
}
};
return (
<Modal
title={
<div className='flex items-center'>
<div className='w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3'>
<svg
className='w-4 h-4 text-blue-600 dark:text-blue-400'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z'
clipRule='evenodd'
/>
</svg>
</div>
{title || t('安全验证')}
</div>
}
visible={visible}
onCancel={onCancel}
footer={
<>
<Button onClick={onCancel}>{t('取消')}</Button>
<Button
type='primary'
loading={loading}
disabled={!code || loading}
onClick={onVerify}
>
{t('验证')}
</Button>
</>
}
width={500}
style={{ maxWidth: '90vw' }}
>
<div className='space-y-6'>
{/* 安全提示 */}
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'>
<div className='flex items-start'>
<svg
className='w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
clipRule='evenodd'
/>
</svg>
<div>
<Typography.Text
strong
className='text-blue-800 dark:text-blue-200'
>
{t('安全验证')}
</Typography.Text>
<Typography.Text className='block text-blue-700 dark:text-blue-300 text-sm mt-1'>
{description || t('为了保护账户安全,请验证您的两步验证码。')}
</Typography.Text>
</div>
</div>
</div>
{/* 验证码输入 */}
<div>
<Typography.Text strong className='block mb-2'>
{t('验证身份')}
</Typography.Text>
<Input
placeholder={placeholder || t('请输入认证器验证码或备用码')}
value={code}
onChange={onCodeChange}
size='large'
maxLength={8}
onKeyDown={handleKeyDown}
autoFocus
/>
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
{t(
'支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。',
)}
</Typography.Text>
</div>
</div>
</Modal>
);
};
export default TwoFactorAuthModal;
-200
View File
@@ -1,200 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useState } from 'react';
import { Card, Divider, Typography, Button } from '@douyinfe/semi-ui';
import PropTypes from 'prop-types';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { IconEyeOpened, IconEyeClosed } from '@douyinfe/semi-icons';
const { Text } = Typography;
/**
* CardPro 高级卡片组件
*
* 布局分为6个区域:
* 1. 统计信息区域 (statsArea)
* 2. 描述信息区域 (descriptionArea)
* 3. 类型切换/标签区域 (tabsArea)
* 4. 操作按钮区域 (actionsArea)
* 5. 搜索表单区域 (searchArea)
* 6. 分页区域 (paginationArea) - 固定在卡片底部
*
* 支持三种布局类型:
* - type1: 操作型 (如TokensTable) - 描述信息 + 操作按钮 + 搜索表单
* - type2: 查询型 (如LogsTable) - 统计信息 + 搜索表单
* - type3: 复杂型 (如ChannelsTable) - 描述信息 + 类型切换 + 操作按钮 + 搜索表单
*/
const CardPro = ({
type = 'type1',
className = '',
children,
// 各个区域的内容
statsArea,
descriptionArea,
tabsArea,
actionsArea,
searchArea,
paginationArea, // 新增分页区域
// 卡片属性
shadows = '',
bordered = true,
// 自定义样式
style,
// 国际化函数
t = (key) => key,
...props
}) => {
const isMobile = useIsMobile();
const [showMobileActions, setShowMobileActions] = useState(false);
const toggleMobileActions = () => {
setShowMobileActions(!showMobileActions);
};
const hasMobileHideableContent = actionsArea || searchArea;
const renderHeader = () => {
const hasContent =
statsArea || descriptionArea || tabsArea || actionsArea || searchArea;
if (!hasContent) return null;
return (
<div className='flex flex-col w-full'>
{/* 统计信息区域 - 用于type2 */}
{type === 'type2' && statsArea && <>{statsArea}</>}
{/* 描述信息区域 - 用于type1和type3 */}
{(type === 'type1' || type === 'type3') && descriptionArea && (
<>{descriptionArea}</>
)}
{/* 第一个分隔线 - 在描述信息或统计信息后面 */}
{((type === 'type1' || type === 'type3') && descriptionArea) ||
(type === 'type2' && statsArea) ? (
<Divider margin='12px' />
) : null}
{/* 类型切换/标签区域 - 主要用于type3 */}
{type === 'type3' && tabsArea && <>{tabsArea}</>}
{/* 移动端操作切换按钮 */}
{isMobile && hasMobileHideableContent && (
<>
<div className='w-full mb-2'>
<Button
onClick={toggleMobileActions}
icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />}
type='tertiary'
size='small'
theme='outline'
block
>
{showMobileActions ? t('隐藏操作项') : t('显示操作项')}
</Button>
</div>
</>
)}
{/* 操作按钮和搜索表单的容器 */}
<div
className={`flex flex-col gap-2 ${isMobile && !showMobileActions ? 'hidden' : ''}`}
>
{/* 操作按钮区域 - 用于type1和type3 */}
{(type === 'type1' || type === 'type3') &&
actionsArea &&
(Array.isArray(actionsArea) ? (
actionsArea.map((area, idx) => (
<React.Fragment key={idx}>
{idx !== 0 && <Divider />}
<div className='w-full'>{area}</div>
</React.Fragment>
))
) : (
<div className='w-full'>{actionsArea}</div>
))}
{/* 当同时存在操作区和搜索区时,插入分隔线 */}
{actionsArea && searchArea && <Divider />}
{/* 搜索表单区域 - 所有类型都可能有 */}
{searchArea && <div className='w-full'>{searchArea}</div>}
</div>
</div>
);
};
const headerContent = renderHeader();
// 渲染分页区域
const renderFooter = () => {
if (!paginationArea) return null;
return (
<div
className={`flex w-full pt-4 border-t ${isMobile ? 'justify-center' : 'justify-between items-center'}`}
style={{ borderColor: 'var(--semi-color-border)' }}
>
{paginationArea}
</div>
);
};
const footerContent = renderFooter();
return (
<Card
className={`table-scroll-card !rounded-2xl ${className}`}
title={headerContent}
footer={footerContent}
shadows={shadows}
bordered={bordered}
style={style}
{...props}
>
{children}
</Card>
);
};
CardPro.propTypes = {
// 布局类型
type: PropTypes.oneOf(['type1', 'type2', 'type3']),
// 样式相关
className: PropTypes.string,
style: PropTypes.object,
shadows: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
bordered: PropTypes.bool,
// 内容区域
statsArea: PropTypes.node,
descriptionArea: PropTypes.node,
tabsArea: PropTypes.node,
actionsArea: PropTypes.oneOfType([
PropTypes.node,
PropTypes.arrayOf(PropTypes.node),
]),
searchArea: PropTypes.node,
paginationArea: PropTypes.node,
// 表格内容
children: PropTypes.node,
// 国际化函数
t: PropTypes.func,
};
export default CardPro;
-242
View File
@@ -1,242 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
Table,
Card,
Skeleton,
Pagination,
Empty,
Button,
Collapsible,
} from '@douyinfe/semi-ui';
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
import PropTypes from 'prop-types';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
/**
* CardTable 响应式表格组件
*
* 在桌面端渲染 Semi-UI 的 Table 组件,在移动端则将每一行数据渲染成 Card 形式。
* 该组件与 Table 组件的大部分 API 保持一致,只需将原 Table 换成 CardTable 即可。
*/
const CardTable = ({
columns = [],
dataSource = [],
loading = false,
rowKey = 'key',
hidePagination = false,
...tableProps
}) => {
const isMobile = useIsMobile();
const { t } = useTranslation();
const showSkeleton = useMinimumLoadingTime(loading);
const getRowKey = (record, index) => {
if (typeof rowKey === 'function') return rowKey(record);
return record[rowKey] !== undefined ? record[rowKey] : index;
};
if (!isMobile) {
const finalTableProps = hidePagination
? { ...tableProps, pagination: false }
: tableProps;
return (
<Table
columns={columns}
dataSource={dataSource}
loading={loading}
rowKey={rowKey}
{...finalTableProps}
/>
);
}
if (showSkeleton) {
const visibleCols = columns.filter((col) => {
if (tableProps?.visibleColumns && col.key) {
return tableProps.visibleColumns[col.key];
}
return true;
});
const renderSkeletonCard = (key) => {
const placeholder = (
<div className='p-2'>
{visibleCols.map((col, idx) => {
if (!col.title) {
return (
<div key={idx} className='mt-2 flex justify-end'>
<Skeleton.Title active style={{ width: 100, height: 24 }} />
</div>
);
}
return (
<div
key={idx}
className='flex justify-between items-center py-1 border-b last:border-b-0 border-dashed'
style={{ borderColor: 'var(--semi-color-border)' }}
>
<Skeleton.Title active style={{ width: 80, height: 14 }} />
<Skeleton.Title
active
style={{
width: `${50 + (idx % 3) * 10}%`,
maxWidth: 180,
height: 14,
}}
/>
</div>
);
})}
</div>
);
return (
<Card key={key} className='!rounded-2xl shadow-sm'>
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
</Card>
);
};
return (
<div className='flex flex-col gap-2'>
{[1, 2, 3].map((i) => renderSkeletonCard(i))}
</div>
);
}
const isEmpty = !showSkeleton && (!dataSource || dataSource.length === 0);
const MobileRowCard = ({ record, index }) => {
const [showDetails, setShowDetails] = useState(false);
const rowKeyVal = getRowKey(record, index);
const hasDetails =
tableProps.expandedRowRender &&
(!tableProps.rowExpandable || tableProps.rowExpandable(record));
return (
<Card key={rowKeyVal} className='!rounded-2xl shadow-sm'>
{columns.map((col, colIdx) => {
if (
tableProps?.visibleColumns &&
!tableProps.visibleColumns[col.key]
) {
return null;
}
const title = col.title;
const cellContent = col.render
? col.render(record[col.dataIndex], record, index)
: record[col.dataIndex];
if (!title) {
return (
<div key={col.key || colIdx} className='mt-2 flex justify-end'>
{cellContent}
</div>
);
}
return (
<div
key={col.key || colIdx}
className='flex justify-between items-start py-1 border-b last:border-b-0 border-dashed'
style={{ borderColor: 'var(--semi-color-border)' }}
>
<span className='font-medium text-gray-600 mr-2 whitespace-nowrap select-none'>
{title}
</span>
<div className='flex-1 break-all flex justify-end items-center gap-1'>
{cellContent !== undefined && cellContent !== null
? cellContent
: '-'}
</div>
</div>
);
})}
{hasDetails && (
<>
<Button
theme='borderless'
size='small'
className='w-full flex justify-center mt-2'
icon={showDetails ? <IconChevronUp /> : <IconChevronDown />}
onClick={(e) => {
e.stopPropagation();
setShowDetails(!showDetails);
}}
>
{showDetails ? t('收起') : t('详情')}
</Button>
<Collapsible isOpen={showDetails} keepDOM>
<div className='pt-2'>
{tableProps.expandedRowRender(record, index)}
</div>
</Collapsible>
</>
)}
</Card>
);
};
if (isEmpty) {
if (tableProps.empty) return tableProps.empty;
return (
<div className='flex justify-center p-4'>
<Empty description='No Data' />
</div>
);
}
return (
<div className='flex flex-col gap-2'>
{dataSource.map((record, index) => (
<MobileRowCard
key={getRowKey(record, index)}
record={record}
index={index}
/>
))}
{!hidePagination && tableProps.pagination && dataSource.length > 0 && (
<div className='mt-2 flex justify-center'>
<Pagination {...tableProps.pagination} />
</div>
)}
</div>
);
};
CardTable.propTypes = {
columns: PropTypes.array.isRequired,
dataSource: PropTypes.array,
loading: PropTypes.bool,
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
hidePagination: PropTypes.bool,
};
export default CardTable;
-280
View File
@@ -1,280 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Card, Button, Typography, Tag } from '@douyinfe/semi-ui';
import { copy, showSuccess } from '../../../helpers';
/**
* 解析密钥数据,支持多种格式
* @param {string} keyData - 密钥数据
* @param {Function} t - 翻译函数
* @returns {Array} 解析后的密钥数组
*/
const parseChannelKeys = (keyData, t) => {
if (!keyData) return [];
const trimmed = keyData.trim();
// 检查是否是JSON数组格式(如Vertex AI
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.map((item, index) => ({
id: index,
content:
typeof item === 'string' ? item : JSON.stringify(item, null, 2),
type: typeof item === 'string' ? 'text' : 'json',
label: `${t('密钥')} ${index + 1}`,
}));
}
} catch (e) {
// 如果解析失败,按普通文本处理
console.warn('Failed to parse JSON keys:', e);
}
}
// 检查是否是多行密钥(按换行符分割)
const lines = trimmed.split('\n').filter((line) => line.trim());
if (lines.length > 1) {
return lines.map((line, index) => ({
id: index,
content: line.trim(),
type: 'text',
label: `${t('密钥')} ${index + 1}`,
}));
}
// 单个密钥
return [
{
id: 0,
content: trimmed,
type: trimmed.startsWith('{') ? 'json' : 'text',
label: t('密钥'),
},
];
};
/**
* 可复用的密钥显示组件
* @param {Object} props
* @param {string} props.keyData - 密钥数据
* @param {boolean} props.showSuccessIcon - 是否显示成功图标
* @param {string} props.successText - 成功文本
* @param {boolean} props.showWarning - 是否显示安全警告
* @param {string} props.warningText - 警告文本
*/
const ChannelKeyDisplay = ({
keyData,
showSuccessIcon = true,
successText,
showWarning = true,
warningText,
}) => {
const { t } = useTranslation();
const parsedKeys = parseChannelKeys(keyData, t);
const isMultipleKeys = parsedKeys.length > 1;
const handleCopyAll = () => {
copy(keyData);
showSuccess(t('所有密钥已复制到剪贴板'));
};
const handleCopyKey = (content) => {
copy(content);
showSuccess(t('密钥已复制到剪贴板'));
};
return (
<div className='space-y-4'>
{/* 成功状态 */}
{showSuccessIcon && (
<div className='flex items-center gap-2'>
<svg
className='w-5 h-5 text-green-600'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
clipRule='evenodd'
/>
</svg>
<Typography.Text strong className='text-green-700'>
{successText || t('验证成功')}
</Typography.Text>
</div>
)}
{/* 密钥内容 */}
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<Typography.Text strong>
{isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')}
</Typography.Text>
{isMultipleKeys && (
<div className='flex items-center gap-2'>
<Typography.Text type='tertiary' size='small'>
{t('共 {{count}} 个密钥', { count: parsedKeys.length })}
</Typography.Text>
<Button
size='small'
type='primary'
theme='outline'
onClick={handleCopyAll}
>
{t('复制全部')}
</Button>
</div>
)}
</div>
<div className='space-y-3 max-h-80 overflow-auto'>
{parsedKeys.map((keyItem) => (
<Card
key={keyItem.id}
className='!rounded-lg !border !border-gray-200 dark:!border-gray-700'
>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Typography.Text
strong
size='small'
className='text-gray-700 dark:text-gray-300'
>
{keyItem.label}
</Typography.Text>
<div className='flex items-center gap-2'>
{keyItem.type === 'json' && (
<Tag size='small' color='blue'>
{t('JSON')}
</Tag>
)}
<Button
size='small'
type='primary'
theme='outline'
icon={
<svg
className='w-3 h-3'
fill='currentColor'
viewBox='0 0 20 20'
>
<path d='M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z' />
<path d='M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z' />
</svg>
}
onClick={() => handleCopyKey(keyItem.content)}
>
{t('复制')}
</Button>
</div>
</div>
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-3 max-h-40 overflow-auto'>
<Typography.Text
code
className='text-xs font-mono break-all whitespace-pre-wrap text-gray-800 dark:text-gray-200'
>
{keyItem.content}
</Typography.Text>
</div>
{keyItem.type === 'json' && (
<Typography.Text
type='tertiary'
size='small'
className='block'
>
{t('JSON格式密钥,请确保格式正确')}
</Typography.Text>
)}
</div>
</Card>
))}
</div>
{isMultipleKeys && (
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-3'>
<Typography.Text
type='tertiary'
size='small'
className='text-blue-700 dark:text-blue-300'
>
<svg
className='w-4 h-4 inline mr-1'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
clipRule='evenodd'
/>
</svg>
{t(
'检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。',
)}
</Typography.Text>
</div>
)}
</div>
{/* 安全警告 */}
{showWarning && (
<div className='bg-yellow-50 dark:bg-yellow-900 rounded-lg p-4'>
<div className='flex items-start'>
<svg
className='w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 mr-3 flex-shrink-0'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
clipRule='evenodd'
/>
</svg>
<div>
<Typography.Text
strong
className='text-yellow-800 dark:text-yellow-200'
>
{t('安全提醒')}
</Typography.Text>
<Typography.Text className='block text-yellow-700 dark:text-yellow-300 text-sm mt-1'>
{warningText ||
t(
'请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。',
)}
</Typography.Text>
</div>
</div>
</div>
)}
</div>
);
};
export default ChannelKeyDisplay;
-68
View File
@@ -1,68 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import PropTypes from 'prop-types';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
/**
* 紧凑模式切换按钮组件
* 用于在自适应列表和紧凑列表之间切换
* 在移动端时自动隐藏,因为移动端使用"显示操作项"按钮来控制内容显示
*/
const CompactModeToggle = ({
compactMode,
setCompactMode,
t,
size = 'small',
type = 'tertiary',
className = '',
...props
}) => {
const isMobile = useIsMobile();
// 在移动端隐藏紧凑列表切换按钮
if (isMobile) {
return null;
}
return (
<Button
type={type}
size={size}
className={`w-full md:w-auto ${className}`}
onClick={() => setCompactMode(!compactMode)}
{...props}
>
{compactMode ? t('自适应列表') : t('紧凑列表')}
</Button>
);
};
CompactModeToggle.propTypes = {
compactMode: PropTypes.bool.isRequired,
setCompactMode: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
size: PropTypes.string,
type: PropTypes.string,
className: PropTypes.string,
};
export default CompactModeToggle;
-718
View File
@@ -1,718 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Form,
Typography,
Banner,
Tabs,
TabPane,
Card,
Input,
InputNumber,
Switch,
TextArea,
Row,
Col,
Divider,
Tooltip,
} from '@douyinfe/semi-ui';
import { IconPlus, IconDelete, IconAlertTriangle } from '@douyinfe/semi-icons';
const { Text } = Typography;
// 唯一 ID 生成器,确保在组件生命周期内稳定且递增
const generateUniqueId = (() => {
let counter = 0;
return () => `kv_${counter++}`;
})();
const JSONEditor = ({
value = '',
onChange,
field,
label,
placeholder,
extraText,
extraFooter,
showClear = true,
template,
templateLabel,
editorType = 'keyValue',
rules = [],
formApi = null,
renderStringValueSuffix,
...props
}) => {
const { t } = useTranslation();
// 将对象转换为键值对数组(包含唯一ID)
const objectToKeyValueArray = useCallback((obj, prevPairs = []) => {
if (!obj || typeof obj !== 'object') return [];
const entries = Object.entries(obj);
return entries.map(([key, value], index) => {
// 如果上一次转换后同位置的键一致,则沿用其 id,保持 React key 稳定
const prev = prevPairs[index];
const shouldReuseId = prev && prev.key === key;
return {
id: shouldReuseId ? prev.id : generateUniqueId(),
key,
value,
};
});
}, []);
// 将键值对数组转换为对象(重复键时后面的会覆盖前面的)
const keyValueArrayToObject = useCallback((arr) => {
const result = {};
arr.forEach((item) => {
if (item.key) {
result[item.key] = item.value;
}
});
return result;
}, []);
// 初始化键值对数组
const [keyValuePairs, setKeyValuePairs] = useState(() => {
if (typeof value === 'string' && value.trim()) {
try {
const parsed = JSON.parse(value);
return objectToKeyValueArray(parsed);
} catch (error) {
return [];
}
}
if (typeof value === 'object' && value !== null) {
return objectToKeyValueArray(value);
}
return [];
});
// 手动模式下的本地文本缓冲
const [manualText, setManualText] = useState(() => {
if (typeof value === 'string') return value;
if (value && typeof value === 'object')
return JSON.stringify(value, null, 2);
return '';
});
// 根据键数量决定默认编辑模式
const [editMode, setEditMode] = useState(() => {
if (typeof value === 'string' && value.trim()) {
try {
const parsed = JSON.parse(value);
const keyCount = Object.keys(parsed).length;
return keyCount > 10 ? 'manual' : 'visual';
} catch (error) {
return 'manual';
}
}
return 'visual';
});
const [jsonError, setJsonError] = useState('');
// 计算重复的键
const duplicateKeys = useMemo(() => {
const keyCount = {};
const duplicates = new Set();
keyValuePairs.forEach((pair) => {
if (pair.key) {
keyCount[pair.key] = (keyCount[pair.key] || 0) + 1;
if (keyCount[pair.key] > 1) {
duplicates.add(pair.key);
}
}
});
return duplicates;
}, [keyValuePairs]);
// 数据同步 - 当value变化时更新键值对数组
useEffect(() => {
try {
let parsed = {};
if (typeof value === 'string' && value.trim()) {
parsed = JSON.parse(value);
} else if (typeof value === 'object' && value !== null) {
parsed = value;
}
// 只在外部值真正改变时更新,避免循环更新
const currentObj = keyValueArrayToObject(keyValuePairs);
if (JSON.stringify(parsed) !== JSON.stringify(currentObj)) {
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
}
setJsonError('');
} catch (error) {
console.log('JSON解析失败:', error.message);
setJsonError(error.message);
}
}, [value]);
// 外部 value 变化时,若不在手动模式,则同步手动文本
useEffect(() => {
if (editMode !== 'manual') {
if (typeof value === 'string') setManualText(value);
else if (value && typeof value === 'object')
setManualText(JSON.stringify(value, null, 2));
else setManualText('');
}
}, [value, editMode]);
// 处理可视化编辑的数据变化
const handleVisualChange = useCallback(
(newPairs) => {
setKeyValuePairs(newPairs);
const jsonObject = keyValueArrayToObject(newPairs);
const jsonString =
Object.keys(jsonObject).length === 0
? ''
: JSON.stringify(jsonObject, null, 2);
setJsonError('');
// 通过formApi设置值
if (formApi && field) {
formApi.setValue(field, jsonString);
}
onChange?.(jsonString);
},
[onChange, formApi, field, keyValueArrayToObject],
);
// 处理手动编辑的数据变化
const handleManualChange = useCallback(
(newValue) => {
setManualText(newValue);
if (newValue && newValue.trim()) {
try {
const parsed = JSON.parse(newValue);
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
setJsonError('');
onChange?.(newValue);
} catch (error) {
setJsonError(error.message);
}
} else {
setKeyValuePairs([]);
setJsonError('');
onChange?.('');
}
},
[onChange, objectToKeyValueArray, keyValuePairs],
);
// 切换编辑模式
const toggleEditMode = useCallback(() => {
if (editMode === 'visual') {
const jsonObject = keyValueArrayToObject(keyValuePairs);
setManualText(
Object.keys(jsonObject).length === 0
? ''
: JSON.stringify(jsonObject, null, 2),
);
setEditMode('manual');
} else {
try {
let parsed = {};
if (manualText && manualText.trim()) {
parsed = JSON.parse(manualText);
} else if (typeof value === 'string' && value.trim()) {
parsed = JSON.parse(value);
} else if (typeof value === 'object' && value !== null) {
parsed = value;
}
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
setJsonError('');
setEditMode('visual');
} catch (error) {
setJsonError(error.message);
return;
}
}
}, [
editMode,
value,
manualText,
keyValuePairs,
keyValueArrayToObject,
objectToKeyValueArray,
]);
// 添加键值对
const addKeyValue = useCallback(() => {
const newPairs = [...keyValuePairs];
const existingKeys = newPairs.map((p) => p.key);
let counter = 1;
let newKey = `field_${counter}`;
while (existingKeys.includes(newKey)) {
counter += 1;
newKey = `field_${counter}`;
}
newPairs.push({
id: generateUniqueId(),
key: newKey,
value: '',
});
handleVisualChange(newPairs);
}, [keyValuePairs, handleVisualChange]);
// 删除键值对
const removeKeyValue = useCallback(
(id) => {
const newPairs = keyValuePairs.filter((pair) => pair.id !== id);
handleVisualChange(newPairs);
},
[keyValuePairs, handleVisualChange],
);
// 更新键名
const updateKey = useCallback(
(id, newKey) => {
const newPairs = keyValuePairs.map((pair) =>
pair.id === id ? { ...pair, key: newKey } : pair,
);
handleVisualChange(newPairs);
},
[keyValuePairs, handleVisualChange],
);
// 更新值
const updateValue = useCallback(
(id, newValue) => {
const newPairs = keyValuePairs.map((pair) =>
pair.id === id ? { ...pair, value: newValue } : pair,
);
handleVisualChange(newPairs);
},
[keyValuePairs, handleVisualChange],
);
// 填入模板
const fillTemplate = useCallback(() => {
if (template) {
const templateString = JSON.stringify(template, null, 2);
if (formApi && field) {
formApi.setValue(field, templateString);
}
setManualText(templateString);
setKeyValuePairs(objectToKeyValueArray(template, keyValuePairs));
onChange?.(templateString);
setJsonError('');
}
}, [
template,
onChange,
formApi,
field,
objectToKeyValueArray,
keyValuePairs,
]);
// 渲染值输入控件(支持嵌套)
const renderValueInput = (pairId, pairKey, value) => {
const valueType = typeof value;
if (valueType === 'boolean') {
return (
<div className='flex items-center'>
<Switch
checked={value}
onChange={(newValue) => updateValue(pairId, newValue)}
/>
<Text type='tertiary' className='ml-2'>
{value ? t('true') : t('false')}
</Text>
</div>
);
}
if (valueType === 'number') {
return (
<InputNumber
value={value}
onChange={(newValue) => updateValue(pairId, newValue)}
style={{ width: '100%' }}
placeholder={t('输入数字')}
/>
);
}
if (valueType === 'object' && value !== null) {
// 简化嵌套对象的处理,使用TextArea
return (
<TextArea
rows={2}
value={JSON.stringify(value, null, 2)}
onChange={(txt) => {
try {
const obj = txt.trim() ? JSON.parse(txt) : {};
updateValue(pairId, obj);
} catch {
// 忽略解析错误
}
}}
placeholder={t('输入JSON对象')}
/>
);
}
// 字符串或其他原始类型
return (
<Input
placeholder={t('参数值')}
value={String(value)}
suffix={renderStringValueSuffix?.({ pairId, pairKey, value })}
onChange={(newValue) => {
let convertedValue = newValue;
if (newValue === 'true') convertedValue = true;
else if (newValue === 'false') convertedValue = false;
else if (!isNaN(newValue) && newValue !== '') {
const num = Number(newValue);
// 检查是否为整数
if (Number.isInteger(num)) {
convertedValue = num;
}
}
updateValue(pairId, convertedValue);
}}
/>
);
};
// 渲染键值对编辑器
const renderKeyValueEditor = () => {
return (
<div className='space-y-1'>
{/* 重复键警告 */}
{duplicateKeys.size > 0 && (
<Banner
type='warning'
icon={<IconAlertTriangle />}
description={
<div>
<Text strong>{t('存在重复的键名:')}</Text>
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
<br />
<Text type='tertiary' size='small'>
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
</Text>
</div>
}
className='mb-3'
/>
)}
{keyValuePairs.length === 0 && (
<div className='text-center py-6 px-4'>
<Text type='tertiary' className='text-gray-500 text-sm'>
{t('暂无数据,点击下方按钮添加键值对')}
</Text>
</div>
)}
{keyValuePairs.map((pair, index) => {
const isDuplicate = duplicateKeys.has(pair.key);
const isLastDuplicate =
isDuplicate &&
keyValuePairs.slice(index + 1).every((p) => p.key !== pair.key);
return (
<Row key={pair.id} gutter={8} align='middle'>
<Col span={10}>
<div className='relative'>
<Input
placeholder={t('键名')}
value={pair.key}
onChange={(newKey) => updateKey(pair.id, newKey)}
status={isDuplicate ? 'warning' : undefined}
/>
{isDuplicate && (
<Tooltip
content={
isLastDuplicate
? t('这是重复键中的最后一个,其值将被使用')
: t('重复的键名,此值将被后面的同名键覆盖')
}
>
<IconAlertTriangle
className='absolute right-2 top-1/2 transform -translate-y-1/2'
style={{
color: isLastDuplicate ? '#ff7d00' : '#faad14',
fontSize: '14px',
}}
/>
</Tooltip>
)}
</div>
</Col>
<Col span={12}>
{renderValueInput(pair.id, pair.key, pair.value)}
</Col>
<Col span={2}>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
onClick={() => removeKeyValue(pair.id)}
style={{ width: '100%' }}
/>
</Col>
</Row>
);
})}
<div className='mt-2 flex justify-center'>
<Button
icon={<IconPlus />}
type='primary'
theme='outline'
onClick={addKeyValue}
>
{t('添加键值对')}
</Button>
</div>
</div>
);
};
// 渲染区域编辑器(特殊格式)- 也需要改造以支持重复键
const renderRegionEditor = () => {
const defaultPair = keyValuePairs.find((pair) => pair.key === 'default');
const modelPairs = keyValuePairs.filter((pair) => pair.key !== 'default');
return (
<div className='space-y-2'>
{/* 重复键警告 */}
{duplicateKeys.size > 0 && (
<Banner
type='warning'
icon={<IconAlertTriangle />}
description={
<div>
<Text strong>{t('存在重复的键名:')}</Text>
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
<br />
<Text type='tertiary' size='small'>
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
</Text>
</div>
}
className='mb-3'
/>
)}
{/* 默认区域 */}
<Form.Slot label={t('默认区域')}>
<Input
placeholder={t('默认区域,如: us-central1')}
value={defaultPair ? defaultPair.value : ''}
onChange={(value) => {
if (defaultPair) {
updateValue(defaultPair.id, value);
} else {
const newPairs = [
...keyValuePairs,
{
id: generateUniqueId(),
key: 'default',
value: value,
},
];
handleVisualChange(newPairs);
}
}}
/>
</Form.Slot>
{/* 模型专用区域 */}
<Form.Slot label={t('模型专用区域')}>
<div>
{modelPairs.map((pair) => {
const isDuplicate = duplicateKeys.has(pair.key);
return (
<Row key={pair.id} gutter={8} align='middle' className='mb-2'>
<Col span={10}>
<div className='relative'>
<Input
placeholder={t('模型名称')}
value={pair.key}
onChange={(newKey) => updateKey(pair.id, newKey)}
status={isDuplicate ? 'warning' : undefined}
/>
{isDuplicate && (
<Tooltip content={t('重复的键名')}>
<IconAlertTriangle
className='absolute right-2 top-1/2 transform -translate-y-1/2'
style={{ color: '#faad14', fontSize: '14px' }}
/>
</Tooltip>
)}
</div>
</Col>
<Col span={12}>
<Input
placeholder={t('区域')}
value={pair.value}
onChange={(newValue) => updateValue(pair.id, newValue)}
/>
</Col>
<Col span={2}>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
onClick={() => removeKeyValue(pair.id)}
style={{ width: '100%' }}
/>
</Col>
</Row>
);
})}
<div className='mt-2 flex justify-center'>
<Button
icon={<IconPlus />}
onClick={addKeyValue}
type='primary'
theme='outline'
>
{t('添加模型区域')}
</Button>
</div>
</div>
</Form.Slot>
</div>
);
};
// 渲染可视化编辑器
const renderVisualEditor = () => {
switch (editorType) {
case 'region':
return renderRegionEditor();
case 'object':
case 'keyValue':
default:
return renderKeyValueEditor();
}
};
const hasJsonError = jsonError && jsonError.trim() !== '';
return (
<Form.Slot label={label}>
<Card
header={
<div className='flex justify-between items-center'>
<Tabs
type='slash'
activeKey={editMode}
onChange={(key) => {
if (key === 'manual' && editMode === 'visual') {
setEditMode('manual');
} else if (key === 'visual' && editMode === 'manual') {
toggleEditMode();
}
}}
>
<TabPane tab={t('可视化')} itemKey='visual' />
<TabPane tab={t('手动编辑')} itemKey='manual' />
</Tabs>
{template && templateLabel && (
<Button type='tertiary' onClick={fillTemplate} size='small'>
{templateLabel}
</Button>
)}
</div>
}
headerStyle={{ padding: '12px 16px' }}
bodyStyle={{ padding: '16px' }}
className='!rounded-2xl'
>
{/* JSON错误提示 */}
{hasJsonError && (
<Banner
type='danger'
description={`JSON 格式错误: ${jsonError}`}
className='mb-3'
/>
)}
{/* 编辑器内容 */}
{editMode === 'visual' ? (
<div>
{renderVisualEditor()}
{/* 隐藏的Form字段用于验证和数据绑定 */}
<Form.Input
field={field}
value={value}
rules={rules}
style={{ display: 'none' }}
noLabel={true}
{...props}
/>
</div>
) : (
<div>
<TextArea
placeholder={placeholder}
value={manualText}
onChange={handleManualChange}
showClear={showClear}
rows={Math.max(8, manualText ? manualText.split('\n').length : 8)}
/>
{/* 隐藏的Form字段用于验证和数据绑定 */}
<Form.Input
field={field}
value={value}
rules={rules}
style={{ display: 'none' }}
noLabel={true}
{...props}
/>
</div>
)}
{/* 额外文本显示在卡片底部 */}
{extraText && (
<Divider margin='12px' align='center'>
<Text type='tertiary' size='small'>
{extraText}
</Text>
</Divider>
)}
{extraFooter && <div className='mt-1'>{extraFooter}</div>}
</Card>
</Form.Slot>
);
};
export default JSONEditor;
-31
View File
@@ -1,31 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Spin } from '@douyinfe/semi-ui';
const Loading = ({ size = 'small' }) => {
return (
<div className='fixed inset-0 w-screen h-screen flex items-center justify-center'>
<Spin size={size} spinning={true} />
</div>
);
};
export default Loading;
-60
View File
@@ -1,60 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Space, Tag, Typography, Popover } from '@douyinfe/semi-ui';
const { Text } = Typography;
// 通用渲染函数:限制项目数量显示,支持popover展开
export function renderLimitedItems({ items, renderItem, maxDisplay = 3 }) {
if (!items || items.length === 0) return '-';
const displayItems = items.slice(0, maxDisplay);
const remainingItems = items.slice(maxDisplay);
return (
<Space spacing={1} wrap>
{displayItems.map((item, idx) => renderItem(item, idx))}
{remainingItems.length > 0 && (
<Popover
content={
<div className='p-2'>
<Space spacing={1} wrap>
{remainingItems.map((item, idx) => renderItem(item, idx))}
</Space>
</div>
}
position='top'
>
<Tag size='small' shape='circle' color='grey'>
+{remainingItems.length}
</Tag>
</Popover>
)}
</Space>
);
}
// 渲染描述字段,长文本支持tooltip
export const renderDescription = (text, maxWidth = 200) => {
return (
<Text ellipsis={{ showTooltip: true }} style={{ maxWidth }}>
{text || '-'}
</Text>
);
};
-242
View File
@@ -1,242 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, {
useRef,
useState,
useEffect,
useCallback,
useMemo,
useImperativeHandle,
forwardRef,
} from 'react';
/**
* ScrollableContainer 可滚动容器组件
*
* 提供自动检测滚动状态和显示渐变指示器的功能
* 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器
*
*/
const ScrollableContainer = forwardRef(
(
{
children,
maxHeight = '24rem',
className = '',
contentClassName = '',
fadeIndicatorClassName = '',
checkInterval = 100,
scrollThreshold = 5,
debounceDelay = 16, // ~60fps
onScroll,
onScrollStateChange,
...props
},
ref,
) => {
const scrollRef = useRef(null);
const containerRef = useRef(null);
const debounceTimerRef = useRef(null);
const resizeObserverRef = useRef(null);
const onScrollStateChangeRef = useRef(onScrollStateChange);
const onScrollRef = useRef(onScroll);
const [showScrollHint, setShowScrollHint] = useState(false);
useEffect(() => {
onScrollStateChangeRef.current = onScrollStateChange;
}, [onScrollStateChange]);
useEffect(() => {
onScrollRef.current = onScroll;
}, [onScroll]);
const debounce = useCallback((func, delay) => {
return (...args) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => func(...args), delay);
};
}, []);
const checkScrollable = useCallback(() => {
if (!scrollRef.current) return;
const element = scrollRef.current;
const isScrollable = element.scrollHeight > element.clientHeight;
const isAtBottom =
element.scrollTop + element.clientHeight >=
element.scrollHeight - scrollThreshold;
const shouldShowHint = isScrollable && !isAtBottom;
setShowScrollHint(shouldShowHint);
if (onScrollStateChangeRef.current) {
onScrollStateChangeRef.current({
isScrollable,
isAtBottom,
showScrollHint: shouldShowHint,
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
});
}
}, [scrollThreshold]);
const debouncedCheckScrollable = useMemo(
() => debounce(checkScrollable, debounceDelay),
[debounce, checkScrollable, debounceDelay],
);
const handleScroll = useCallback(
(e) => {
debouncedCheckScrollable();
if (onScrollRef.current) {
onScrollRef.current(e);
}
},
[debouncedCheckScrollable],
);
useImperativeHandle(
ref,
() => ({
checkScrollable: () => {
checkScrollable();
},
scrollToTop: () => {
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
},
scrollToBottom: () => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
},
getScrollInfo: () => {
if (!scrollRef.current) return null;
const element = scrollRef.current;
return {
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
isScrollable: element.scrollHeight > element.clientHeight,
isAtBottom:
element.scrollTop + element.clientHeight >=
element.scrollHeight - scrollThreshold,
};
},
}),
[checkScrollable, scrollThreshold],
);
useEffect(() => {
const timer = setTimeout(() => {
checkScrollable();
}, checkInterval);
return () => clearTimeout(timer);
}, [checkScrollable, checkInterval]);
useEffect(() => {
if (!scrollRef.current) return;
if (typeof ResizeObserver === 'undefined') {
if (typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(() => {
debouncedCheckScrollable();
});
observer.observe(scrollRef.current, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
});
return () => observer.disconnect();
}
return;
}
resizeObserverRef.current = new ResizeObserver((entries) => {
for (const entry of entries) {
debouncedCheckScrollable();
}
});
resizeObserverRef.current.observe(scrollRef.current);
return () => {
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
}
};
}, [debouncedCheckScrollable]);
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
const containerStyle = useMemo(
() => ({
maxHeight,
}),
[maxHeight],
);
const fadeIndicatorStyle = useMemo(
() => ({
opacity: showScrollHint ? 1 : 0,
}),
[showScrollHint],
);
return (
<div
ref={containerRef}
className={`card-content-container ${className}`}
{...props}
>
<div
ref={scrollRef}
className={`overflow-y-auto card-content-scroll ${contentClassName}`}
style={containerStyle}
onScroll={handleScroll}
>
{children}
</div>
<div
className={`card-content-fade-indicator ${fadeIndicatorClassName}`}
style={fadeIndicatorStyle}
/>
</div>
);
},
);
ScrollableContainer.displayName = 'ScrollableContainer';
export default ScrollableContainer;
@@ -1,295 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useState, useRef, useEffect } from 'react';
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
import { useContainerWidth } from '../../../hooks/common/useContainerWidth';
import {
Divider,
Button,
Row,
Col,
Collapsible,
Checkbox,
Skeleton,
Tooltip,
} from '@douyinfe/semi-ui';
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
/**
* 通用可选择按钮组组件
*
* @param {string} title 标题
* @param {Array<{value:any,label:string,icon?:React.ReactNode,tagCount?:number}>} items 按钮项
* @param {*|Array} activeValue 当前激活的值可以是单个值或数组多选
* @param {(value:any)=>void} onChange 选择改变回调
* @param {function} t i18n
* @param {object} style 额外样式
* @param {boolean} collapsible 是否支持折叠默认true
* @param {number} collapseHeight 折叠时的高度默认200
* @param {boolean} withCheckbox 是否启用前缀 Checkbox 来控制激活状态
* @param {boolean} loading 是否处于加载状态
* @param {string} variant 颜色变体: 'violet' | 'teal' | 'amber' | 'rose' | 'green'不传则使用默认蓝色
*/
const SelectableButtonGroup = ({
title,
items = [],
activeValue,
onChange,
t = (v) => v,
style = {},
collapsible = true,
collapseHeight = 200,
withCheckbox = false,
loading = false,
variant,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [skeletonCount] = useState(12);
const [containerRef, containerWidth] = useContainerWidth();
const ConditionalTooltipText = ({ text }) => {
const textRef = useRef(null);
const [isOverflowing, setIsOverflowing] = useState(false);
useEffect(() => {
const el = textRef.current;
if (!el) return;
setIsOverflowing(el.scrollWidth > el.clientWidth);
}, [text, containerWidth]);
const textElement = (
<span ref={textRef} className='sbg-ellipsis'>
{text}
</span>
);
return isOverflowing ? (
<Tooltip content={text}>{textElement}</Tooltip>
) : (
textElement
);
};
//
const getResponsiveConfig = () => {
if (containerWidth <= 280) return { columns: 1, showTags: true }; // 1+
if (containerWidth <= 380) return { columns: 2, showTags: true }; // 2+
if (containerWidth <= 460) return { columns: 3, showTags: false }; // 3
return { columns: 3, showTags: true }; // 3+
};
const { columns: perRow, showTags: shouldShowTags } = getResponsiveConfig();
const maxVisibleRows = Math.max(1, Math.floor(collapseHeight / 32)); // Approx row height 32
const needCollapse = collapsible && items.length > perRow * maxVisibleRows;
const showSkeleton = useMinimumLoadingTime(loading);
// 使
const gutterSize = [4, 4];
// Semi UI Col span
const getColSpan = () => {
return Math.floor(24 / perRow);
};
const maskStyle = isOpen
? {}
: {
WebkitMaskImage:
'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
};
const toggle = () => {
setIsOpen(!isOpen);
};
const linkStyle = {
position: 'absolute',
left: 0,
right: 0,
textAlign: 'center',
bottom: -10,
fontWeight: 400,
cursor: 'pointer',
fontSize: '12px',
color: 'var(--semi-color-text-2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
};
const renderSkeletonButtons = () => {
const placeholder = (
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
{Array.from({ length: skeletonCount }).map((_, index) => (
<Col span={getColSpan()} key={index}>
<div
style={{
width: '100%',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
border: '1px solid var(--semi-color-border)',
borderRadius: 'var(--semi-border-radius-medium)',
padding: '0 12px',
gap: '6px',
}}
>
{withCheckbox && (
<Skeleton.Title active style={{ width: 14, height: 14 }} />
)}
<Skeleton.Title
active
style={{
width: `${60 + (index % 3) * 20}px`,
height: 14,
}}
/>
</div>
</Col>
))}
</Row>
);
return (
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
);
};
const contentElement = showSkeleton ? (
renderSkeletonButtons()
) : (
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
{items.map((item) => {
const isActive = Array.isArray(activeValue)
? activeValue.includes(item.value)
: activeValue === item.value;
if (withCheckbox) {
return (
<Col span={getColSpan()} key={item.value}>
<Button
onClick={() => {
/* disabled */
}}
theme={isActive ? 'light' : 'outline'}
type={isActive ? 'primary' : 'tertiary'}
className='sbg-button'
icon={
<Checkbox
checked={isActive}
onChange={() => onChange(item.value)}
style={{ pointerEvents: 'auto' }}
/>
}
style={{ width: '100%', cursor: 'default' }}
>
<div className='sbg-content'>
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
<ConditionalTooltipText text={item.label} />
{item.tagCount !== undefined && shouldShowTags && (
<span className={`sbg-badge ${isActive ? 'sbg-badge-active' : ''}`}>
{item.tagCount}
</span>
)}
</div>
</Button>
</Col>
);
}
return (
<Col span={getColSpan()} key={item.value}>
<Button
onClick={() => onChange(item.value)}
theme={isActive ? 'light' : 'outline'}
type={isActive ? 'primary' : 'tertiary'}
className='sbg-button'
style={{ width: '100%' }}
>
<div className='sbg-content'>
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
<ConditionalTooltipText text={item.label} />
{item.tagCount !== undefined && shouldShowTags && item.tagCount !== '' && (
<span className={`sbg-badge ${isActive ? 'sbg-badge-active' : ''}`}>
{item.tagCount}
</span>
)}
</div>
</Button>
</Col>
);
})}
</Row>
);
return (
<div
className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}${variant ? ` sbg-variant-${variant}` : ''}`}
ref={containerRef}
>
{title && (
<Divider margin='12px' align='left'>
{showSkeleton ? (
<Skeleton.Title active style={{ width: 80, height: 14 }} />
) : (
title
)}
</Divider>
)}
{needCollapse && !showSkeleton ? (
<div style={{ position: 'relative' }}>
<Collapsible
isOpen={isOpen}
collapseHeight={collapseHeight}
style={{ ...maskStyle }}
>
{contentElement}
</Collapsible>
{isOpen ? null : (
<div onClick={toggle} style={{ ...linkStyle }}>
<IconChevronDown size='small' />
<span>{t('展开更多')}</span>
</div>
)}
{isOpen && (
<div
onClick={toggle}
style={{
...linkStyle,
position: 'static',
marginTop: 8,
bottom: 'auto',
}}
>
<IconChevronUp size='small' />
<span>{t('收起')}</span>
</div>
)}
</div>
) : (
contentElement
)}
</div>
);
};
export default SelectableButtonGroup;
-126
View File
@@ -1,126 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Card, Tag, Timeline, Empty } from '@douyinfe/semi-ui';
import { Bell } from 'lucide-react';
import { marked } from 'marked';
import {
IllustrationConstruction,
IllustrationConstructionDark,
} from '@douyinfe/semi-illustrations';
import ScrollableContainer from '../common/ui/ScrollableContainer';
const AnnouncementsPanel = ({
announcementData,
announcementLegendData,
CARD_PROPS,
ILLUSTRATION_SIZE,
t,
}) => {
return (
<Card
{...CARD_PROPS}
className='shadow-sm !rounded-2xl lg:col-span-2'
title={
<div className='flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full'>
<div className='flex items-center gap-2'>
<Bell size={16} />
{t('系统公告')}
<Tag color='white' shape='circle'>
{t('显示最新20条')}
</Tag>
</div>
{/* 图例 */}
<div className='flex flex-wrap gap-3 text-xs'>
{announcementLegendData.map((legend, index) => (
<div key={index} className='flex items-center gap-1'>
<div
className='w-2 h-2 rounded-full'
style={{
backgroundColor:
legend.color === 'grey'
? '#8b9aa7'
: legend.color === 'blue'
? '#3b82f6'
: legend.color === 'green'
? '#10b981'
: legend.color === 'orange'
? '#f59e0b'
: legend.color === 'red'
? '#ef4444'
: '#8b9aa7',
}}
/>
<span className='text-gray-600'>{legend.label}</span>
</div>
))}
</div>
</div>
}
bodyStyle={{ padding: 0 }}
>
<ScrollableContainer maxHeight='24rem'>
{announcementData.length > 0 ? (
<Timeline mode='left'>
{announcementData.map((item, idx) => {
const htmlExtra = item.extra ? marked.parse(item.extra) : '';
return (
<Timeline.Item
key={idx}
type={item.type || 'default'}
time={`${item.relative ? item.relative + ' ' : ''}${item.time}`}
extra={
item.extra ? (
<div
className='text-xs text-gray-500'
dangerouslySetInnerHTML={{ __html: htmlExtra }}
/>
) : null
}
>
<div>
<div
dangerouslySetInnerHTML={{
__html: marked.parse(item.content || ''),
}}
/>
</div>
</Timeline.Item>
);
})}
</Timeline>
) : (
<div className='flex justify-center items-center py-8'>
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
}
title={t('暂无系统公告')}
description={t('请联系管理员在系统设置中配置公告信息')}
/>
</div>
)}
</ScrollableContainer>
</Card>
);
};
export default AnnouncementsPanel;
-126
View File
@@ -1,126 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
import { Server, Gauge, ExternalLink, Copy } from 'lucide-react';
import {
IllustrationConstruction,
IllustrationConstructionDark,
} from '@douyinfe/semi-illustrations';
import ScrollableContainer from '../common/ui/ScrollableContainer';
const ApiInfoPanel = ({
apiInfoData,
handleCopyUrl,
handleSpeedTest,
CARD_PROPS,
FLEX_CENTER_GAP2,
ILLUSTRATION_SIZE,
t,
}) => {
return (
<Card
{...CARD_PROPS}
className='bg-gray-50 border-0 !rounded-2xl'
title={
<div className={FLEX_CENTER_GAP2}>
<Server size={16} />
{t('API信息')}
</div>
}
bodyStyle={{ padding: 0 }}
>
<ScrollableContainer maxHeight='24rem'>
{apiInfoData.length > 0 ? (
apiInfoData.map((api) => (
<React.Fragment key={api.id}>
<div className='flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer'>
<div className='flex-shrink-0 mr-3'>
<Avatar size='extra-small' color={api.color}>
{api.route.substring(0, 2)}
</Avatar>
</div>
<div className='flex-1'>
<div className='flex flex-wrap items-center justify-between mb-1 w-full gap-2'>
<span className='text-sm font-medium text-gray-900 !font-bold break-all'>
{api.route}
</span>
<div className='flex items-center gap-1 mt-1 lg:mt-0'>
<Tag
prefixIcon={<Gauge size={12} />}
size='small'
color='white'
shape='circle'
onClick={() => handleSpeedTest(api.url)}
className='cursor-pointer hover:opacity-80 text-xs'
>
{t('测速')}
</Tag>
<Tag
prefixIcon={<ExternalLink size={12} />}
size='small'
color='white'
shape='circle'
onClick={() =>
window.open(api.url, '_blank', 'noopener,noreferrer')
}
className='cursor-pointer hover:opacity-80 text-xs'
>
{t('跳转')}
</Tag>
</div>
</div>
<div className='flex items-center gap-1 mb-1'>
<span
className='!text-semi-color-primary break-all cursor-pointer hover:underline'
onClick={() => handleCopyUrl(api.url)}
>
{api.url}
</span>
<Copy
size={14}
className='flex-shrink-0 text-gray-400 hover:text-semi-color-primary cursor-pointer transition-colors'
onClick={() => handleCopyUrl(api.url)}
/>
</div>
<div className='text-gray-500'>{api.description}</div>
</div>
</div>
<Divider />
</React.Fragment>
))
) : (
<div className='flex justify-center items-center min-h-[20rem] w-full'>
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
}
title={t('暂无API信息')}
description={t('请联系管理员在系统设置中配置API信息')}
/>
</div>
)}
</ScrollableContainer>
</Card>
);
};
export default ApiInfoPanel;
-95
View File
@@ -1,95 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Card, Tabs, TabPane } from '@douyinfe/semi-ui';
import { PieChart } from 'lucide-react';
import { VChart } from '@visactor/react-vchart';
const ChartsPanel = ({
activeChartTab,
setActiveChartTab,
spec_line,
spec_model_line,
spec_pie,
spec_rank_bar,
spec_user_rank,
spec_user_trend,
isAdminUser,
CARD_PROPS,
CHART_CONFIG,
FLEX_CENTER_GAP2,
hasApiInfoPanel,
t,
}) => {
return (
<Card
{...CARD_PROPS}
className={`!rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`}
title={
<div className='flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3'>
<div className={FLEX_CENTER_GAP2}>
<PieChart size={16} />
{t('模型数据分析')}
</div>
<Tabs
type='slash'
activeKey={activeChartTab}
onChange={setActiveChartTab}
>
<TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' />
<TabPane tab={<span>{t('调用趋势')}</span>} itemKey='2' />
<TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
<TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
{isAdminUser && (
<TabPane tab={<span>{t('用户消耗排行')}</span>} itemKey='5' />
)}
{isAdminUser && (
<TabPane tab={<span>{t('用户消耗趋势')}</span>} itemKey='6' />
)}
</Tabs>
</div>
}
bodyStyle={{ padding: 0 }}
>
<div className='h-96 p-2'>
{activeChartTab === '1' && (
<VChart spec={spec_line} option={CHART_CONFIG} />
)}
{activeChartTab === '2' && (
<VChart spec={spec_model_line} option={CHART_CONFIG} />
)}
{activeChartTab === '3' && (
<VChart spec={spec_pie} option={CHART_CONFIG} />
)}
{activeChartTab === '4' && (
<VChart spec={spec_rank_bar} option={CHART_CONFIG} />
)}
{activeChartTab === '5' && isAdminUser && (
<VChart spec={spec_user_rank} option={CHART_CONFIG} />
)}
{activeChartTab === '6' && isAdminUser && (
<VChart spec={spec_user_trend} option={CHART_CONFIG} />
)}
</div>
</Card>
);
};
export default ChartsPanel;
-61
View File
@@ -1,61 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import { RefreshCw, Search } from 'lucide-react';
const DashboardHeader = ({
getGreeting,
greetingVisible,
showSearchModal,
refresh,
loading,
t,
}) => {
const ICON_BUTTON_CLASS = 'text-white hover:bg-opacity-80 !rounded-full';
return (
<div className='flex items-center justify-between mb-4'>
<h2
className='text-2xl font-semibold text-gray-800 transition-opacity duration-1000 ease-in-out'
style={{ opacity: greetingVisible ? 1 : 0 }}
>
{getGreeting}
</h2>
<div className='flex gap-3'>
<Button
type='tertiary'
icon={<Search size={16} />}
onClick={showSearchModal}
className={`bg-green-500 hover:bg-green-600 ${ICON_BUTTON_CLASS}`}
/>
<Button
type='tertiary'
icon={<RefreshCw size={16} />}
onClick={refresh}
loading={loading}
className={`bg-blue-500 hover:bg-blue-600 ${ICON_BUTTON_CLASS}`}
/>
</div>
</div>
);
};
export default DashboardHeader;
-88
View File
@@ -1,88 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Card, Collapse, Empty } from '@douyinfe/semi-ui';
import { HelpCircle } from 'lucide-react';
import { IconPlus, IconMinus } from '@douyinfe/semi-icons';
import { marked } from 'marked';
import {
IllustrationConstruction,
IllustrationConstructionDark,
} from '@douyinfe/semi-illustrations';
import ScrollableContainer from '../common/ui/ScrollableContainer';
const FaqPanel = ({
faqData,
CARD_PROPS,
FLEX_CENTER_GAP2,
ILLUSTRATION_SIZE,
t,
}) => {
return (
<Card
{...CARD_PROPS}
className='shadow-sm !rounded-2xl lg:col-span-1'
title={
<div className={FLEX_CENTER_GAP2}>
<HelpCircle size={16} />
{t('常见问答')}
</div>
}
bodyStyle={{ padding: 0 }}
>
<ScrollableContainer maxHeight='24rem'>
{faqData.length > 0 ? (
<Collapse
accordion
expandIcon={<IconPlus />}
collapseIcon={<IconMinus />}
>
{faqData.map((item, index) => (
<Collapse.Panel
key={index}
header={item.question}
itemKey={index.toString()}
>
<div
dangerouslySetInnerHTML={{
__html: marked.parse(item.answer || ''),
}}
/>
</Collapse.Panel>
))}
</Collapse>
) : (
<div className='flex justify-center items-center py-8'>
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
}
title={t('暂无常见问答')}
description={t('请联系管理员在系统设置中配置常见问答')}
/>
</div>
)}
</ScrollableContainer>
</Card>
);
};
export default FaqPanel;
-116
View File
@@ -1,116 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Card, Avatar, Skeleton, Tag } from '@douyinfe/semi-ui';
import { VChart } from '@visactor/react-vchart';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
const StatsCards = ({
groupedStatsData,
loading,
getTrendSpec,
CARD_PROPS,
CHART_CONFIG,
}) => {
const navigate = useNavigate();
const { t } = useTranslation();
return (
<div className='mb-4'>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
{groupedStatsData.map((group, idx) => (
<Card
key={idx}
{...CARD_PROPS}
className={`${group.color} border-0 !rounded-2xl w-full`}
title={group.title}
>
<div className='space-y-4'>
{group.items.map((item, itemIdx) => (
<div
key={itemIdx}
className='flex items-center justify-between cursor-pointer'
onClick={item.onClick}
>
<div className='flex items-center'>
<Avatar
className='mr-3'
size='small'
color={item.avatarColor}
>
{item.icon}
</Avatar>
<div>
<div className='text-xs text-gray-500'>{item.title}</div>
<div className='text-lg font-semibold'>
<Skeleton
loading={loading}
active
placeholder={
<Skeleton.Paragraph
active
rows={1}
style={{
width: '65px',
height: '24px',
marginTop: '4px',
}}
/>
}
>
{item.value}
</Skeleton>
</div>
</div>
</div>
{item.title === t('当前余额') ? (
<Tag
color='white'
shape='circle'
size='large'
onClick={(e) => {
e.stopPropagation();
navigate('/console/topup');
}}
>
{t('充值')}
</Tag>
) : (
(loading ||
(item.trendData && item.trendData.length > 0)) && (
<div className='w-24 h-10'>
<VChart
spec={getTrendSpec(item.trendData, item.trendColor)}
option={CHART_CONFIG}
/>
</div>
)
)}
</div>
))}
</div>
</Card>
))}
</div>
</div>
);
};
export default StatsCards;
-152
View File
@@ -1,152 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import {
Card,
Button,
Spin,
Tabs,
TabPane,
Tag,
Empty,
} from '@douyinfe/semi-ui';
import { Gauge, RefreshCw } from 'lucide-react';
import {
IllustrationConstruction,
IllustrationConstructionDark,
} from '@douyinfe/semi-illustrations';
import ScrollableContainer from '../common/ui/ScrollableContainer';
const UptimePanel = ({
uptimeData,
uptimeLoading,
activeUptimeTab,
setActiveUptimeTab,
loadUptimeData,
uptimeLegendData,
renderMonitorList,
CARD_PROPS,
ILLUSTRATION_SIZE,
t,
}) => {
return (
<Card
{...CARD_PROPS}
className='shadow-sm !rounded-2xl lg:col-span-1'
title={
<div className='flex items-center justify-between w-full gap-2'>
<div className='flex items-center gap-2'>
<Gauge size={16} />
{t('服务可用性')}
</div>
<Button
icon={<RefreshCw size={14} />}
onClick={loadUptimeData}
loading={uptimeLoading}
size='small'
theme='borderless'
type='tertiary'
className='text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full'
/>
</div>
}
bodyStyle={{ padding: 0 }}
>
{/* 内容区域 */}
<div className='relative'>
<Spin spinning={uptimeLoading}>
{uptimeData.length > 0 ? (
uptimeData.length === 1 ? (
<ScrollableContainer maxHeight='24rem'>
{renderMonitorList(uptimeData[0].monitors)}
</ScrollableContainer>
) : (
<Tabs
type='card'
collapsible
activeKey={activeUptimeTab}
onChange={setActiveUptimeTab}
size='small'
>
{uptimeData.map((group, groupIdx) => (
<TabPane
tab={
<span className='flex items-center gap-2'>
<Gauge size={14} />
{group.categoryName}
<Tag
color={
activeUptimeTab === group.categoryName
? 'red'
: 'grey'
}
size='small'
shape='circle'
>
{group.monitors ? group.monitors.length : 0}
</Tag>
</span>
}
itemKey={group.categoryName}
key={groupIdx}
>
<ScrollableContainer maxHeight='21.5rem'>
{renderMonitorList(group.monitors)}
</ScrollableContainer>
</TabPane>
))}
</Tabs>
)
) : (
<div className='flex justify-center items-center py-8'>
<Empty
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
darkModeImage={
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
}
title={t('暂无监控数据')}
description={t('请联系管理员在系统设置中配置Uptime')}
/>
</div>
)}
</Spin>
</div>
{/* 图例 */}
{uptimeData.length > 0 && (
<div className='p-3 bg-gray-50 rounded-b-2xl'>
<div className='flex flex-wrap gap-3 text-xs justify-center'>
{uptimeLegendData.map((legend, index) => (
<div key={index} className='flex items-center gap-1'>
<div
className='w-2 h-2 rounded-full'
style={{ backgroundColor: legend.color }}
/>
<span className='text-gray-600'>{legend.label}</span>
</div>
))}
</div>
</div>
)}
</Card>
);
};
export default UptimePanel;
-286
View File
@@ -1,286 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useContext, useEffect } from 'react';
import { getRelativeTime } from '../../helpers';
import { UserContext } from '../../context/User';
import { StatusContext } from '../../context/Status';
import DashboardHeader from './DashboardHeader';
import StatsCards from './StatsCards';
import ChartsPanel from './ChartsPanel';
import ApiInfoPanel from './ApiInfoPanel';
import AnnouncementsPanel from './AnnouncementsPanel';
import FaqPanel from './FaqPanel';
import UptimePanel from './UptimePanel';
import SearchModal from './modals/SearchModal';
import { useDashboardData } from '../../hooks/dashboard/useDashboardData';
import { useDashboardStats } from '../../hooks/dashboard/useDashboardStats';
import { useDashboardCharts } from '../../hooks/dashboard/useDashboardCharts';
import {
CHART_CONFIG,
CARD_PROPS,
FLEX_CENTER_GAP2,
ILLUSTRATION_SIZE,
ANNOUNCEMENT_LEGEND_DATA,
UPTIME_STATUS_MAP,
} from '../../constants/dashboard.constants';
import {
getTrendSpec,
handleCopyUrl,
handleSpeedTest,
getUptimeStatusColor,
getUptimeStatusText,
renderMonitorList,
} from '../../helpers/dashboard';
const Dashboard = () => {
// ========== Context ==========
const [userState, userDispatch] = useContext(UserContext);
const [statusState, statusDispatch] = useContext(StatusContext);
// ========== ==========
const dashboardData = useDashboardData(userState, userDispatch, statusState);
// ========== ==========
const dashboardCharts = useDashboardCharts(
dashboardData.dataExportDefaultTime,
dashboardData.setTrendData,
dashboardData.setConsumeQuota,
dashboardData.setTimes,
dashboardData.setConsumeTokens,
dashboardData.setPieData,
dashboardData.setLineData,
dashboardData.setModelColors,
dashboardData.t,
);
// ========== ==========
const { groupedStatsData } = useDashboardStats(
userState,
dashboardData.consumeQuota,
dashboardData.consumeTokens,
dashboardData.times,
dashboardData.trendData,
dashboardData.performanceMetrics,
dashboardData.navigate,
dashboardData.t,
);
// ========== ==========
const loadUserData = async () => {
if (dashboardData.isAdminUser) {
const userData = await dashboardData.loadUserQuotaData();
if (userData && userData.length > 0) {
dashboardCharts.updateUserChartData(userData);
}
}
};
const initChart = async () => {
await dashboardData.loadQuotaData().then((data) => {
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
});
await loadUserData();
await dashboardData.loadUptimeData();
};
const handleRefresh = async () => {
const data = await dashboardData.refresh();
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
await loadUserData();
};
const handleSearchConfirm = async () => {
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
await loadUserData();
};
// ========== ==========
const apiInfoData = statusState?.status?.api_info || [];
const announcementData = (statusState?.status?.announcements || []).map(
(item) => {
const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
const absoluteTime =
pubDate && !isNaN(pubDate.getTime())
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
: item?.publishDate || '';
const relativeTime = getRelativeTime(item.publishDate);
return {
...item,
time: absoluteTime,
relative: relativeTime,
};
},
);
const faqData = statusState?.status?.faq || [];
const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map(
([status, info]) => ({
status: Number(status),
color: info.color,
label: dashboardData.t(info.label),
}),
);
// ========== Effects ==========
useEffect(() => {
initChart();
}, []);
return (
<div className='h-full'>
<DashboardHeader
getGreeting={dashboardData.getGreeting}
greetingVisible={dashboardData.greetingVisible}
showSearchModal={dashboardData.showSearchModal}
refresh={handleRefresh}
loading={dashboardData.loading}
t={dashboardData.t}
/>
<SearchModal
searchModalVisible={dashboardData.searchModalVisible}
handleSearchConfirm={handleSearchConfirm}
handleCloseModal={dashboardData.handleCloseModal}
isMobile={dashboardData.isMobile}
isAdminUser={dashboardData.isAdminUser}
inputs={dashboardData.inputs}
dataExportDefaultTime={dashboardData.dataExportDefaultTime}
timeOptions={dashboardData.timeOptions}
handleInputChange={dashboardData.handleInputChange}
t={dashboardData.t}
/>
<StatsCards
groupedStatsData={groupedStatsData}
loading={dashboardData.loading}
getTrendSpec={getTrendSpec}
CARD_PROPS={CARD_PROPS}
CHART_CONFIG={CHART_CONFIG}
/>
{/* API信息和图表面板 */}
<div className='mb-4'>
<div
className={`grid grid-cols-1 gap-4 ${dashboardData.hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}
>
<ChartsPanel
activeChartTab={dashboardData.activeChartTab}
setActiveChartTab={dashboardData.setActiveChartTab}
spec_line={dashboardCharts.spec_line}
spec_model_line={dashboardCharts.spec_model_line}
spec_pie={dashboardCharts.spec_pie}
spec_rank_bar={dashboardCharts.spec_rank_bar}
spec_user_rank={dashboardCharts.spec_user_rank}
spec_user_trend={dashboardCharts.spec_user_trend}
isAdminUser={dashboardData.isAdminUser}
CARD_PROPS={CARD_PROPS}
CHART_CONFIG={CHART_CONFIG}
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
hasApiInfoPanel={dashboardData.hasApiInfoPanel}
t={dashboardData.t}
/>
{dashboardData.hasApiInfoPanel && (
<ApiInfoPanel
apiInfoData={apiInfoData}
handleCopyUrl={(url) => handleCopyUrl(url, dashboardData.t)}
handleSpeedTest={handleSpeedTest}
CARD_PROPS={CARD_PROPS}
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
t={dashboardData.t}
/>
)}
</div>
</div>
{/* 系统公告和常见问答卡片 */}
{dashboardData.hasInfoPanels && (
<div className='mb-4'>
<div className='grid grid-cols-1 lg:grid-cols-4 gap-4'>
{/* 公告卡片 */}
{dashboardData.announcementsEnabled && (
<AnnouncementsPanel
announcementData={announcementData}
announcementLegendData={ANNOUNCEMENT_LEGEND_DATA.map(
(item) => ({
...item,
label: dashboardData.t(item.label),
}),
)}
CARD_PROPS={CARD_PROPS}
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
t={dashboardData.t}
/>
)}
{/* 常见问答卡片 */}
{dashboardData.faqEnabled && (
<FaqPanel
faqData={faqData}
CARD_PROPS={CARD_PROPS}
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
t={dashboardData.t}
/>
)}
{/* 服务可用性卡片 */}
{dashboardData.uptimeEnabled && (
<UptimePanel
uptimeData={dashboardData.uptimeData}
uptimeLoading={dashboardData.uptimeLoading}
activeUptimeTab={dashboardData.activeUptimeTab}
setActiveUptimeTab={dashboardData.setActiveUptimeTab}
loadUptimeData={dashboardData.loadUptimeData}
uptimeLegendData={uptimeLegendData}
renderMonitorList={(monitors) =>
renderMonitorList(
monitors,
(status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP),
(status) =>
getUptimeStatusText(
status,
UPTIME_STATUS_MAP,
dashboardData.t,
),
dashboardData.t,
)
}
CARD_PROPS={CARD_PROPS}
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
t={dashboardData.t}
/>
)}
</div>
</div>
)}
</div>
);
};
export default Dashboard;
-103
View File
@@ -1,103 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useRef } from 'react';
import { Modal, Form } from '@douyinfe/semi-ui';
const SearchModal = ({
searchModalVisible,
handleSearchConfirm,
handleCloseModal,
isMobile,
isAdminUser,
inputs,
dataExportDefaultTime,
timeOptions,
handleInputChange,
t,
}) => {
const formRef = useRef();
const FORM_FIELD_PROPS = {
className: 'w-full mb-2 !rounded-lg',
};
const createFormField = (Component, props) => (
<Component {...FORM_FIELD_PROPS} {...props} />
);
const { start_timestamp, end_timestamp, username } = inputs;
return (
<Modal
title={t('搜索条件')}
visible={searchModalVisible}
onOk={handleSearchConfirm}
onCancel={handleCloseModal}
closeOnEsc={true}
size={isMobile ? 'full-width' : 'small'}
centered
>
<Form ref={formRef} layout='vertical' className='w-full'>
{createFormField(Form.DatePicker, {
field: 'start_timestamp',
label: t('起始时间'),
initValue: start_timestamp,
value: start_timestamp,
type: 'dateTime',
name: 'start_timestamp',
onChange: (value) => handleInputChange(value, 'start_timestamp'),
})}
{createFormField(Form.DatePicker, {
field: 'end_timestamp',
label: t('结束时间'),
initValue: end_timestamp,
value: end_timestamp,
type: 'dateTime',
name: 'end_timestamp',
onChange: (value) => handleInputChange(value, 'end_timestamp'),
})}
{createFormField(Form.Select, {
field: 'data_export_default_time',
label: t('时间粒度'),
initValue: dataExportDefaultTime,
placeholder: t('时间粒度'),
name: 'data_export_default_time',
optionList: timeOptions,
onChange: (value) =>
handleInputChange(value, 'data_export_default_time'),
})}
{isAdminUser &&
createFormField(Form.Input, {
field: 'username',
label: t('用户名称'),
value: username,
placeholder: t('可选值'),
name: 'username',
onChange: (value) => handleInputChange(value, 'username'),
})}
</Form>
</Modal>
);
};
export default SearchModal;
-225
View File
@@ -1,225 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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 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/Calcium-Ion/new-api-key-tool'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
new-api-key-tool
</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/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/coaidev/coai'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
CoAI
</a>
<a
href='https://www.gpt-load.com/'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
GPT-Load
</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>
</footer>
),
[logo, systemName, t, currentYear, isDemoSiteMode],
);
useEffect(() => {
loadFooter();
}, []);
return (
<div className='w-full'>
{footer ? (
<footer className='relative h-auto py-4 px-6 md:px-24 w-full flex items-center justify-center overflow-hidden'>
<div className='flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-4'>
<div
className='custom-footer na-cb6feafeb3990c78 text-sm !text-semi-color-text-1'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
</div>
</footer>
) : (
customFooter
)}
</div>
);
};
export default FooterBar;
-255
View File
@@ -1,255 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useEffect, useState, useContext, useMemo } from 'react';
import {
Button,
Modal,
Empty,
Tabs,
TabPane,
Timeline,
} from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { API, showError, getRelativeTime } from '../../helpers';
import { marked } from 'marked';
import {
IllustrationNoContent,
IllustrationNoContentDark,
} from '@douyinfe/semi-illustrations';
import { StatusContext } from '../../context/Status';
import { Bell, Megaphone } from 'lucide-react';
const NoticeModal = ({
visible,
onClose,
isMobile,
defaultTab = 'inApp',
unreadKeys = [],
}) => {
const { t } = useTranslation();
const [noticeContent, setNoticeContent] = useState('');
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState(defaultTab);
const [statusState] = useContext(StatusContext);
const announcements = statusState?.status?.announcements || [];
const unreadSet = useMemo(() => new Set(unreadKeys), [unreadKeys]);
const getKeyForItem = (item) =>
`${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`;
const processedAnnouncements = useMemo(() => {
return (announcements || []).slice(0, 20).map((item) => {
const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
const absoluteTime =
pubDate && !isNaN(pubDate.getTime())
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
: item?.publishDate || '';
return {
key: getKeyForItem(item),
type: item.type || 'default',
time: absoluteTime,
content: item.content,
extra: item.extra,
relative: getRelativeTime(item.publishDate),
isUnread: unreadSet.has(getKeyForItem(item)),
};
});
}, [announcements, unreadSet]);
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]);
useEffect(() => {
if (visible) {
setActiveTab(defaultTab);
}
}, [defaultTab, visible]);
const renderMarkdownNotice = () => {
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='notice-content-scroll max-h-[55vh] overflow-y-auto pr-2'
/>
);
};
const renderAnnouncementTimeline = () => {
if (processedAnnouncements.length === 0) {
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 className='max-h-[55vh] overflow-y-auto pr-2 card-content-scroll'>
<Timeline mode='left'>
{processedAnnouncements.map((item, idx) => {
const htmlContent = marked.parse(item.content || '');
const htmlExtra = item.extra ? marked.parse(item.extra) : '';
return (
<Timeline.Item
key={idx}
type={item.type}
time={`${item.relative ? item.relative + ' ' : ''}${item.time}`}
extra={
item.extra ? (
<div
className='text-xs text-gray-500'
dangerouslySetInnerHTML={{ __html: htmlExtra }}
/>
) : null
}
className={item.isUnread ? '' : ''}
>
<div>
<div
className={item.isUnread ? 'shine-text' : ''}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</div>
</Timeline.Item>
);
})}
</Timeline>
</div>
);
};
const renderBody = () => {
if (activeTab === 'inApp') {
return renderMarkdownNotice();
}
return renderAnnouncementTimeline();
};
return (
<Modal
title={
<div className='flex items-center justify-between w-full'>
<span>{t('系统公告')}</span>
<Tabs activeKey={activeTab} onChange={setActiveTab} type='button'>
<TabPane
tab={
<span className='flex items-center gap-1'>
<Bell size={14} /> {t('通知')}
</span>
}
itemKey='inApp'
/>
<TabPane
tab={
<span className='flex items-center gap-1'>
<Megaphone size={14} /> {t('系统公告')}
</span>
}
itemKey='system'
/>
</Tabs>
</div>
}
visible={visible}
onCancel={onClose}
footer={
<div className='flex justify-end'>
<Button type='secondary' onClick={handleCloseTodayNotice}>
{t('今日关闭')}
</Button>
<Button type='primary' onClick={onClose}>
{t('关闭公告')}
</Button>
</div>
}
size={isMobile ? 'full-width' : 'large'}
>
{renderBody()}
</Modal>
);
};
export default NoticeModal;
-246
View File
@@ -1,246 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import HeaderBar from './headerbar';
import { Layout } from '@douyinfe/semi-ui';
import SiderBar from './SiderBar';
import App from '../../App';
import FooterBar from './Footer';
import { ToastContainer } from 'react-toastify';
import ErrorBoundary from '../common/ErrorBoundary';
import React, { useContext, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/common/useIsMobile';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
import { useTranslation } from 'react-i18next';
import {
API,
getLogo,
getSystemName,
showError,
setStatusData,
} from '../../helpers';
import { UserContext } from '../../context/User';
import { StatusContext } from '../../context/Status';
import { useLocation } from 'react-router-dom';
import { normalizeLanguage } from '../../i18n/language';
const { Sider, Content, Header } = Layout;
const PageLayout = () => {
const [userState, userDispatch] = useContext(UserContext);
const [, statusDispatch] = useContext(StatusContext);
const isMobile = useIsMobile();
const [collapsed, , setCollapsed] = useSidebarCollapsed();
const [drawerOpen, setDrawerOpen] = useState(false);
const { i18n } = useTranslation();
const location = useLocation();
const cardProPages = [
'/console/channel',
'/console/log',
'/console/redemption',
'/console/user',
'/console/token',
'/console/midjourney',
'/console/task',
'/console/models',
'/pricing',
];
const shouldHideFooter = cardProPages.includes(location.pathname);
const shouldInnerPadding =
location.pathname.includes('/console') &&
!location.pathname.startsWith('/console/chat') &&
location.pathname !== '/console/playground';
const isConsoleRoute = location.pathname.startsWith('/console');
const showSider = isConsoleRoute && (!isMobile || drawerOpen);
const isFixedLayout = isConsoleRoute || location.pathname === '/pricing';
useEffect(() => {
if (isMobile && drawerOpen && collapsed) {
setCollapsed(false);
}
}, [isMobile, drawerOpen, collapsed, setCollapsed]);
const loadUser = () => {
let user = localStorage.getItem('user');
if (user) {
let data = JSON.parse(user);
userDispatch({ type: 'login', payload: data });
}
};
const loadStatus = async () => {
try {
const res = await API.get('/api/status');
const { success, data } = res.data;
if (success) {
statusDispatch({ type: 'set', payload: data });
setStatusData(data);
} else {
showError('Unable to connect to server');
}
} catch (error) {
showError('Failed to load status');
}
};
useEffect(() => {
loadUser();
loadStatus().catch(console.error);
let systemName = getSystemName();
if (systemName) {
document.title = systemName;
}
let logo = getLogo();
if (logo) {
let linkElement = document.querySelector("link[rel~='icon']");
if (linkElement) {
linkElement.href = logo;
}
}
}, []);
useEffect(() => {
let preferredLang;
if (userState?.user?.setting) {
try {
const settings = JSON.parse(userState.user.setting);
preferredLang = normalizeLanguage(settings.language);
} catch (e) {
// Ignore parse errors
}
}
if (!preferredLang) {
const savedLang = localStorage.getItem('i18nextLng');
if (savedLang) {
preferredLang = normalizeLanguage(savedLang);
}
}
if (preferredLang) {
localStorage.setItem('i18nextLng', preferredLang);
if (preferredLang !== i18n.language) {
i18n.changeLanguage(preferredLang);
}
}
}, [i18n, userState?.user?.setting]);
return (
<Layout
className={`app-layout${isFixedLayout ? ' app-layout-fixed' : ''}`}
style={{
display: 'flex',
flexDirection: 'column',
overflow: isFixedLayout && !isMobile ? 'hidden' : 'visible',
}}
>
<Header
style={{
padding: 0,
height: 'auto',
lineHeight: 'normal',
position: 'fixed',
width: '100%',
top: 0,
zIndex: 100,
}}
>
<HeaderBar
onMobileMenuToggle={() => setDrawerOpen((prev) => !prev)}
drawerOpen={drawerOpen}
/>
</Header>
<Layout
style={{
overflow: isFixedLayout && !isMobile ? 'auto' : 'visible',
display: 'flex',
flexDirection: 'column',
flex: '1 1 auto',
}}
>
{showSider && (
<Sider
className='app-sider'
style={{
position: 'fixed',
left: 0,
top: '64px',
zIndex: 99,
border: 'none',
paddingRight: '0',
width: 'var(--sidebar-current-width)',
}}
>
<SiderBar
onNavigate={() => {
if (isMobile) setDrawerOpen(false);
}}
/>
</Sider>
)}
<Layout
style={{
marginLeft: isMobile
? '0'
: showSider
? 'var(--sidebar-current-width)'
: '0',
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
<Content
className={isFixedLayout ? undefined : 'public-page-content'}
style={{
flex: isFixedLayout ? '1 0 auto' : '1 1 auto',
overflowY: isFixedLayout && !isMobile ? 'hidden' : 'visible',
WebkitOverflowScrolling: 'touch',
padding: shouldInnerPadding ? (isMobile ? '5px' : '24px') : '0',
position: 'relative',
minHeight: 0,
}}
>
<ErrorBoundary>
<App />
</ErrorBoundary>
</Content>
{!shouldHideFooter && (
<Layout.Footer
style={{
flex: '0 0 auto',
width: '100%',
}}
>
<FooterBar />
</Layout.Footer>
)}
</Layout>
</Layout>
<ToastContainer />
</Layout>
);
};
export default PageLayout;
-40
View File
@@ -1,40 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useContext, useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { StatusContext } from '../../context/Status';
const SetupCheck = ({ children }) => {
const [statusState] = useContext(StatusContext);
const location = useLocation();
useEffect(() => {
if (
statusState?.status?.setup === false &&
location.pathname !== '/setup'
) {
window.location.href = '/setup';
}
}, [statusState?.status?.setup, location.pathname]);
return children;
};
export default SetupCheck;
-536
View File
@@ -1,536 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useEffect, useMemo, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { getLucideIcon } from '../../helpers/render';
import { ChevronLeft } from 'lucide-react';
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
import { useSidebar } from '../../hooks/common/useSidebar';
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
import { isAdmin, isRoot, showError } from '../../helpers';
import SkeletonWrapper from './components/SkeletonWrapper';
import { Nav, Divider, Button } from '@douyinfe/semi-ui';
const routerMap = {
home: '/',
channel: '/console/channel',
token: '/console/token',
redemption: '/console/redemption',
topup: '/console/topup',
user: '/console/user',
subscription: '/console/subscription',
log: '/console/log',
midjourney: '/console/midjourney',
setting: '/console/setting',
about: '/about',
detail: '/console',
pricing: '/pricing',
task: '/console/task',
models: '/console/models',
deployment: '/console/deployment',
playground: '/console/playground',
personal: '/console/personal',
};
const SiderBar = ({ onNavigate = () => {} }) => {
const { t } = useTranslation();
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
const {
isModuleVisible,
hasSectionVisibleModules,
loading: sidebarLoading,
} = useSidebar();
const showSkeleton = useMinimumLoadingTime(sidebarLoading, 200);
const [selectedKeys, setSelectedKeys] = useState(['home']);
const [chatItems, setChatItems] = useState([]);
const [openedKeys, setOpenedKeys] = useState([]);
const location = useLocation();
const [routerMapState, setRouterMapState] = useState(routerMap);
const workspaceItems = useMemo(() => {
const items = [
{
text: t('数据看板'),
itemKey: 'detail',
to: '/detail',
className:
localStorage.getItem('enable_data_export') === 'true'
? ''
: 'tableHiddle',
},
{
text: t('令牌管理'),
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',
},
];
//
const filteredItems = items.filter((item) => {
const configVisible = isModuleVisible('console', item.itemKey);
return configVisible;
});
return filteredItems;
}, [
localStorage.getItem('enable_data_export'),
localStorage.getItem('enable_drawing'),
localStorage.getItem('enable_task'),
t,
isModuleVisible,
]);
const financeItems = useMemo(() => {
const items = [
{
text: t('钱包管理'),
itemKey: 'topup',
to: '/topup',
},
{
text: t('个人设置'),
itemKey: 'personal',
to: '/personal',
},
];
//
const filteredItems = items.filter((item) => {
const configVisible = isModuleVisible('personal', item.itemKey);
return configVisible;
});
return filteredItems;
}, [t, isModuleVisible]);
const adminItems = useMemo(() => {
const items = [
{
text: t('渠道管理'),
itemKey: 'channel',
to: '/channel',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('订阅管理'),
itemKey: 'subscription',
to: '/subscription',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('模型管理'),
itemKey: 'models',
to: '/console/models',
className: isAdmin() ? '' : 'tableHiddle',
},
{
text: t('模型部署'),
itemKey: 'deployment',
to: '/deployment',
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',
},
];
//
const filteredItems = items.filter((item) => {
const configVisible = isModuleVisible('admin', item.itemKey);
return configVisible;
});
return filteredItems;
}, [isAdmin(), isRoot(), t, isModuleVisible]);
const chatMenuItems = useMemo(() => {
const items = [
{
text: t('操练场'),
itemKey: 'playground',
to: '/playground',
},
{
text: t('聊天'),
itemKey: 'chat',
items: chatItems,
},
];
//
const filteredItems = items.filter((item) => {
const configVisible = isModuleVisible('chat', item.itemKey);
return configVisible;
});
return filteredItems;
}, [chatItems, t, isModuleVisible]);
//
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 shouldSkip = false;
let chat = {};
for (let key in chats[i]) {
let link = chats[i][key];
if (typeof link !== 'string') continue; //
if (
link.startsWith('fluent') ||
link.startsWith('ccswitch') ||
link.startsWith('deepchat')
) {
shouldSkip = true;
break;
}
chat.text = key;
chat.itemKey = 'chat' + i;
chat.to = '/console/chat/' + i;
}
if (shouldSkip || !chat.text) continue; //
chatItems.push(chat);
}
setChatItems(chatItems);
updateRouterMapWithChats(chats);
}
} catch (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]);
// body class
useEffect(() => {
if (collapsed) {
document.body.classList.add('sidebar-collapsed');
} else {
document.body.classList.remove('sidebar-collapsed');
}
}, [collapsed]);
//
const SELECTED_COLOR = 'var(--semi-color-primary)';
//
const renderNavItem = (item) => {
//
if (item.className === 'tableHiddle') return null;
const isSelected = selectedKeys.includes(item.itemKey);
const textColor = isSelected ? SELECTED_COLOR : 'inherit';
return (
<Nav.Item
key={item.itemKey}
itemKey={item.itemKey}
text={
<span
className='truncate font-medium text-sm'
style={{ color: textColor }}
>
{item.text}
</span>
}
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 ? SELECTED_COLOR : 'inherit';
return (
<Nav.Sub
key={item.itemKey}
itemKey={item.itemKey}
text={
<span
className='truncate font-medium text-sm'
style={{ color: textColor }}
>
{item.text}
</span>
}
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 ? SELECTED_COLOR : '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: 'var(--sidebar-current-width)',
}}
>
<SkeletonWrapper
loading={showSkeleton}
type='sidebar'
className=''
collapsed={collapsed}
showAdmin={isAdmin()}
>
<Nav
className='sidebar-nav'
defaultIsCollapsed={collapsed}
isCollapsed={collapsed}
onCollapseChange={toggleCollapsed}
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}
onClick={onNavigate}
>
{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);
}}
>
{/* 聊天区域 */}
{hasSectionVisibleModules('chat') && (
<div className='sidebar-section'>
{!collapsed && (
<div className='sidebar-group-label'>{t('聊天')}</div>
)}
{chatMenuItems.map((item) => renderSubItem(item))}
</div>
)}
{/* 控制台区域 */}
{hasSectionVisibleModules('console') && (
<>
<Divider className='sidebar-divider' />
<div>
{!collapsed && (
<div className='sidebar-group-label'>{t('控制台')}</div>
)}
{workspaceItems.map((item) => renderNavItem(item))}
</div>
</>
)}
{/* 个人中心区域 */}
{hasSectionVisibleModules('personal') && (
<>
<Divider className='sidebar-divider' />
<div>
{!collapsed && (
<div className='sidebar-group-label'>{t('个人中心')}</div>
)}
{financeItems.map((item) => renderNavItem(item))}
</div>
</>
)}
{/* 管理员区域 - 只在管理员时显示且配置允许时显示 */}
{isAdmin() && hasSectionVisibleModules('admin') && (
<>
<Divider className='sidebar-divider' />
<div>
{!collapsed && (
<div className='sidebar-group-label'>{t('管理员')}</div>
)}
{adminItems.map((item) => renderNavItem(item))}
</div>
</>
)}
</Nav>
</SkeletonWrapper>
{/* 底部折叠按钮 */}
<div className='sidebar-collapse-button'>
<SkeletonWrapper
loading={showSkeleton}
type='button'
width={collapsed ? 36 : 156}
height={24}
className='w-full'
>
<Button
theme='outline'
type='tertiary'
size='small'
icon={
<ChevronLeft
size={16}
strokeWidth={2.5}
color='var(--semi-color-text-2)'
style={{
transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
}
onClick={toggleCollapsed}
icononly={collapsed}
style={
collapsed
? { width: 36, height: 24, padding: 0 }
: { padding: '4px 12px', width: '100%' }
}
>
{!collapsed ? t('收起侧边栏') : null}
</Button>
</SkeletonWrapper>
</div>
</div>
);
};
export default SiderBar;
@@ -1,379 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Skeleton } from '@douyinfe/semi-ui';
const SkeletonWrapper = ({
loading = false,
type = 'text',
count = 1,
width = 60,
height = 16,
isMobile = false,
className = '',
collapsed = false,
showAdmin = true,
children,
...props
}) => {
if (!loading) {
return children;
}
//
const renderNavigationSkeleton = () => {
const skeletonLinkClasses = isMobile
? 'flex items-center gap-1 p-1 w-full rounded-md'
: 'flex items-center gap-1 p-2 rounded-md';
return Array(count)
.fill(null)
.map((_, index) => (
<div key={index} className={skeletonLinkClasses}>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
style={{ width: isMobile ? 40 : width, height }}
/>
}
/>
</div>
));
};
// ( + )
const renderUserAreaSkeleton = () => {
return (
<div
className={`flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 ${className}`}
>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Avatar size='extra-small' className='shadow-sm' />
}
/>
<div className='ml-1.5 mr-1'>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
style={{ width: isMobile ? 15 : width, height: 12 }}
/>
}
/>
</div>
</div>
);
};
// Logo
const renderImageSkeleton = () => {
return (
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Image
className={`absolute inset-0 !rounded-full ${className}`}
style={{ width: '100%', height: '100%' }}
/>
}
/>
);
};
//
const renderTitleSkeleton = () => {
return (
<Skeleton
loading={true}
active
placeholder={<Skeleton.Title style={{ width, height: 24 }} />}
/>
);
};
//
const renderTextSkeleton = () => {
return (
<div className={className}>
<Skeleton
loading={true}
active
placeholder={<Skeleton.Title style={{ width, height }} />}
/>
</div>
);
};
//
const renderButtonSkeleton = () => {
return (
<div className={className}>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title style={{ width, height, borderRadius: 9999 }} />
}
/>
</div>
);
};
// ( + )
const renderSidebarNavItemSkeleton = () => {
return Array(count)
.fill(null)
.map((_, index) => (
<div
key={index}
className={`flex items-center p-2 mb-1 rounded-md ${className}`}
>
{/* 图标骨架屏 */}
<div className='sidebar-icon-container flex-shrink-0'>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Avatar size='extra-small' shape='square' />
}
/>
</div>
{/* 文本骨架屏 */}
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
style={{ width: width || 80, height: height || 14 }}
/>
}
/>
</div>
));
};
//
const renderSidebarGroupTitleSkeleton = () => {
return (
<div className={`mb-2 ${className}`}>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
style={{ width: width || 60, height: height || 12 }}
/>
}
/>
</div>
);
};
// - 1:1
const renderSidebarSkeleton = () => {
const NAV_WIDTH = 164;
const NAV_HEIGHT = 30;
const COLLAPSED_WIDTH = 44;
const COLLAPSED_HEIGHT = 44;
const ICON_SIZE = 16;
const TITLE_HEIGHT = 12;
const TEXT_HEIGHT = 16;
const renderIcon = () => (
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Avatar
shape='square'
style={{ width: ICON_SIZE, height: ICON_SIZE }}
/>
}
/>
);
const renderLabel = (labelWidth) => (
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title style={{ width: labelWidth, height: TEXT_HEIGHT }} />
}
/>
);
const NavRow = ({ labelWidth }) => (
<div
className='flex items-center p-2 mb-1 rounded-md'
style={{
width: `${NAV_WIDTH}px`,
height: `${NAV_HEIGHT}px`,
margin: '3px 8px',
}}
>
<div className='sidebar-icon-container flex-shrink-0'>
{renderIcon()}
</div>
{renderLabel(labelWidth)}
</div>
);
const CollapsedRow = ({ keyPrefix, index }) => (
<div
key={`${keyPrefix}-${index}`}
className='flex items-center justify-center'
style={{
width: `${COLLAPSED_WIDTH}px`,
height: `${COLLAPSED_HEIGHT}px`,
margin: '0 8px 4px 8px',
}}
>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Avatar
shape='square'
style={{ width: ICON_SIZE, height: ICON_SIZE }}
/>
}
/>
</div>
);
if (collapsed) {
return (
<div className={`w-full ${className}`} style={{ paddingTop: '12px' }}>
{Array(2)
.fill(null)
.map((_, i) => (
<CollapsedRow keyPrefix='c-chat' index={i} />
))}
{Array(5)
.fill(null)
.map((_, i) => (
<CollapsedRow keyPrefix='c-console' index={i} />
))}
{Array(2)
.fill(null)
.map((_, i) => (
<CollapsedRow keyPrefix='c-personal' index={i} />
))}
{Array(5)
.fill(null)
.map((_, i) => (
<CollapsedRow keyPrefix='c-admin' index={i} />
))}
</div>
);
}
const sections = [
{ key: 'chat', titleWidth: 32, itemWidths: [54, 32], wrapper: 'section' },
{ key: 'console', titleWidth: 48, itemWidths: [64, 64, 64, 64, 64] },
{ key: 'personal', titleWidth: 64, itemWidths: [64, 64] },
...(showAdmin
? [{ key: 'admin', titleWidth: 48, itemWidths: [64, 64, 80, 64, 64] }]
: []),
];
return (
<div className={`w-full ${className}`} style={{ paddingTop: '12px' }}>
{sections.map((sec, idx) => (
<React.Fragment key={sec.key}>
{sec.wrapper === 'section' ? (
<div className='sidebar-section'>
<div
className='sidebar-group-label'
style={{ padding: '4px 15px 8px' }}
>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
/>
}
/>
</div>
{sec.itemWidths.map((w, i) => (
<NavRow key={`${sec.key}-${i}`} labelWidth={w} />
))}
</div>
) : (
<div>
<div
className='sidebar-group-label'
style={{ padding: '4px 15px 8px' }}
>
<Skeleton
loading={true}
active
placeholder={
<Skeleton.Title
style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
/>
}
/>
</div>
{sec.itemWidths.map((w, i) => (
<NavRow key={`${sec.key}-${i}`} labelWidth={w} />
))}
</div>
)}
</React.Fragment>
))}
</div>
);
};
//
switch (type) {
case 'navigation':
return renderNavigationSkeleton();
case 'userArea':
return renderUserAreaSkeleton();
case 'image':
return renderImageSkeleton();
case 'title':
return renderTitleSkeleton();
case 'sidebarNavItem':
return renderSidebarNavItemSkeleton();
case 'sidebarGroupTitle':
return renderSidebarGroupTitleSkeleton();
case 'sidebar':
return renderSidebarSkeleton();
case 'button':
return renderButtonSkeleton();
case 'text':
default:
return renderTextSkeleton();
}
};
export default SkeletonWrapper;
@@ -1,74 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import NewYearButton from './NewYearButton';
import NotificationButton from './NotificationButton';
import ThemeToggle from './ThemeToggle';
import LanguageSelector from './LanguageSelector';
import UserArea from './UserArea';
const ActionButtons = ({
isNewYear,
unreadCount,
onNoticeOpen,
theme,
onThemeToggle,
currentLang,
onLanguageChange,
userState,
isLoading,
isMobile,
isSelfUseMode,
logout,
navigate,
t,
}) => {
return (
<div className='flex items-center gap-2 md:gap-3'>
<NewYearButton isNewYear={isNewYear} />
<NotificationButton
unreadCount={unreadCount}
onNoticeOpen={onNoticeOpen}
t={t}
/>
<ThemeToggle theme={theme} onThemeToggle={onThemeToggle} t={t} />
<LanguageSelector
currentLang={currentLang}
onLanguageChange={onLanguageChange}
t={t}
/>
<UserArea
userState={userState}
isLoading={isLoading}
isMobile={isMobile}
isSelfUseMode={isSelfUseMode}
logout={logout}
navigate={navigate}
t={t}
/>
</div>
);
};
export default ActionButtons;
-81
View File
@@ -1,81 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Link } from 'react-router-dom';
import { Typography, Tag } from '@douyinfe/semi-ui';
import SkeletonWrapper from '../components/SkeletonWrapper';
const HeaderLogo = ({
isMobile,
isConsoleRoute,
logo,
logoLoaded,
isLoading,
systemName,
isSelfUseMode,
isDemoSiteMode,
t,
}) => {
if (isMobile && isConsoleRoute) {
return null;
}
return (
<Link to='/' className='group flex items-center gap-2'>
<div className='relative w-8 h-8 md:w-8 md:h-8'>
<SkeletonWrapper loading={isLoading || !logoLoaded} type='image' />
<img
src={logo}
alt='logo'
className={`absolute inset-0 w-full h-full transition-all duration-200 group-hover:scale-110 rounded-full ${!isLoading && logoLoaded ? 'opacity-100' : 'opacity-0'}`}
/>
</div>
<div className='hidden md:flex items-center gap-2'>
<div className='flex items-center gap-2'>
<SkeletonWrapper
loading={isLoading}
type='title'
width={120}
height={24}
>
<Typography.Title
heading={4}
className='!text-lg !font-semibold !mb-0'
>
{systemName}
</Typography.Title>
</SkeletonWrapper>
{(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>
);
};
export default HeaderLogo;
@@ -1,86 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Button, Dropdown } from '@douyinfe/semi-ui';
import { Languages } from 'lucide-react';
const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
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'>
{/* Language sorting: Order by English name (Chinese, English, French, Japanese, Russian) */}
<Dropdown.Item
onClick={() => onLanguageChange('zh-CN')}
className={`!px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh-CN' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
>
简体中文
</Dropdown.Item>
<Dropdown.Item
onClick={() => onLanguageChange('zh-TW')}
className={`!px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh-TW' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
>
繁體中文
</Dropdown.Item> <Dropdown.Item
onClick={() => onLanguageChange('en')}
className={`!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'}`}
>
English
</Dropdown.Item>
<Dropdown.Item
onClick={() => onLanguageChange('fr')}
className={`!px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'fr' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
>
Français
</Dropdown.Item>
<Dropdown.Item
onClick={() => onLanguageChange('ja')}
className={`!px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'ja' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
>
日本語
</Dropdown.Item>
<Dropdown.Item
onClick={() => onLanguageChange('ru')}
className={`!px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'ru' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
>
Русский
</Dropdown.Item>
<Dropdown.Item
onClick={() => onLanguageChange('vi')}
className={`!px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'vi' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
>
Tiếng Việt
</Dropdown.Item>
</Dropdown.Menu>
}
>
<Button
icon={<Languages size={18} />}
aria-label={t('common.changeLanguage')}
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>
);
};
export default LanguageSelector;
@@ -1,56 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import { IconClose, IconMenu } from '@douyinfe/semi-icons';
const MobileMenuButton = ({
isConsoleRoute,
isMobile,
drawerOpen,
collapsed,
onToggle,
t,
}) => {
if (!isConsoleRoute || !isMobile) {
return null;
}
return (
<Button
icon={
(isMobile ? drawerOpen : collapsed) ? (
<IconClose className='text-lg' />
) : (
<IconMenu className='text-lg' />
)
}
aria-label={
(isMobile ? drawerOpen : collapsed) ? t('关闭侧边栏') : t('打开侧边栏')
}
onClick={onToggle}
theme='borderless'
type='tertiary'
className='!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700'
/>
);
};
export default MobileMenuButton;
-88
View File
@@ -1,88 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Link } from 'react-router-dom';
import SkeletonWrapper from '../components/SkeletonWrapper';
const Navigation = ({
mainNavLinks,
isMobile,
isLoading,
userState,
pricingRequireAuth,
}) => {
const renderNavLinks = () => {
const baseClasses =
'flex-shrink-0 flex items-center gap-1 font-semibold rounded-md transition-all duration-200 ease-in-out';
const hoverClasses = 'hover:text-semi-color-primary';
const spacingClasses = isMobile ? 'p-1' : 'p-2';
const commonLinkClasses = `${baseClasses} ${spacingClasses} ${hoverClasses}`;
return mainNavLinks.map((link) => {
const linkContent = <span>{link.text}</span>;
if (link.isExternal) {
return (
<a
key={link.itemKey}
href={link.externalLink}
target='_blank'
rel='noopener noreferrer'
className={commonLinkClasses}
>
{linkContent}
</a>
);
}
let targetPath = link.to;
if (link.itemKey === 'console' && !userState.user) {
targetPath = '/login';
}
if (link.itemKey === 'pricing' && pricingRequireAuth && !userState.user) {
targetPath = '/login';
}
return (
<Link key={link.itemKey} to={targetPath} className={commonLinkClasses}>
{linkContent}
</Link>
);
});
};
return (
<nav className='flex flex-1 items-center gap-1 lg:gap-2 mx-2 md:mx-4 overflow-x-auto whitespace-nowrap scrollbar-hide'>
<SkeletonWrapper
loading={isLoading}
type='navigation'
count={4}
width={60}
height={16}
isMobile={isMobile}
>
{renderNavLinks()}
</SkeletonWrapper>
</nav>
);
};
export default Navigation;
@@ -1,62 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Button, Dropdown } from '@douyinfe/semi-ui';
import fireworks from 'react-fireworks';
const NewYearButton = ({ isNewYear }) => {
if (!isNewYear) {
return null;
}
const handleNewYearClick = () => {
fireworks.init('root', {});
fireworks.start();
setTimeout(() => {
fireworks.stop();
}, 3000);
};
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={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>
);
};
export default NewYearButton;
@@ -1,46 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Button, Badge } from '@douyinfe/semi-ui';
import { Bell } from 'lucide-react';
const NotificationButton = ({ unreadCount, onNoticeOpen, t }) => {
const buttonProps = {
icon: <Bell size={18} />,
'aria-label': t('系统公告'),
onClick: onNoticeOpen,
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',
};
if (unreadCount > 0) {
return (
<Badge count={unreadCount} type='danger' overflowCount={99}>
<Button {...buttonProps} />
</Badge>
);
}
return <Button {...buttonProps} />;
};
export default NotificationButton;
-111
View File
@@ -1,111 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useMemo } from 'react';
import { Button, Dropdown } from '@douyinfe/semi-ui';
import { Sun, Moon, Monitor } from 'lucide-react';
import { useActualTheme } from '../../../context/Theme';
const ThemeToggle = ({ theme, onThemeToggle, t }) => {
const actualTheme = useActualTheme();
const themeOptions = useMemo(
() => [
{
key: 'light',
icon: <Sun size={18} />,
buttonIcon: <Sun size={18} />,
label: t('浅色模式'),
description: t('始终使用浅色主题'),
},
{
key: 'dark',
icon: <Moon size={18} />,
buttonIcon: <Moon size={18} />,
label: t('深色模式'),
description: t('始终使用深色主题'),
},
{
key: 'auto',
icon: <Monitor size={18} />,
buttonIcon: <Monitor size={18} />,
label: t('自动模式'),
description: t('跟随系统主题设置'),
},
],
[t],
);
const getItemClassName = (isSelected) =>
isSelected
? '!bg-semi-color-primary-light-default !font-semibold'
: 'hover:!bg-semi-color-fill-1';
const currentButtonIcon = useMemo(() => {
const currentOption = themeOptions.find((option) => option.key === theme);
return currentOption?.buttonIcon || themeOptions[2].buttonIcon;
}, [theme, themeOptions]);
return (
<Dropdown
position='bottomRight'
render={
<Dropdown.Menu>
{themeOptions.map((option) => (
<Dropdown.Item
key={option.key}
icon={option.icon}
onClick={() => onThemeToggle(option.key)}
className={getItemClassName(theme === option.key)}
>
<div className='flex flex-col'>
<span>{option.label}</span>
<span className='text-xs text-semi-color-text-2'>
{option.description}
</span>
</div>
</Dropdown.Item>
))}
{theme === 'auto' && (
<>
<Dropdown.Divider />
<div className='px-3 py-2 text-xs text-semi-color-text-2'>
{t('当前跟随系统')}
{actualTheme === 'dark' ? t('深色') : t('浅色')}
</div>
</>
)}
</Dropdown.Menu>
}
>
<span className='inline-flex'>
<Button
icon={currentButtonIcon}
aria-label={t('切换主题')}
theme='borderless'
type='tertiary'
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1'
/>
</span>
</Dropdown>
);
};
export default ThemeToggle;
-200
View File
@@ -1,200 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useRef } from 'react';
import { Link } from 'react-router-dom';
import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
import { ChevronDown } from 'lucide-react';
import {
IconExit,
IconUserSetting,
IconCreditCard,
IconKey,
} from '@douyinfe/semi-icons';
import { stringToColor } from '../../../helpers';
import SkeletonWrapper from '../components/SkeletonWrapper';
const UserArea = ({
userState,
isLoading,
isMobile,
isSelfUseMode,
logout,
navigate,
t,
}) => {
const dropdownRef = useRef(null);
if (isLoading) {
return (
<SkeletonWrapper
loading={true}
type='userArea'
width={50}
isMobile={isMobile}
/>
);
}
if (userState.user) {
return (
<div className='relative' ref={dropdownRef}>
<Dropdown
position='bottomRight'
getPopupContainer={() => dropdownRef.current}
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');
}}
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');
}}
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('令牌管理')}</span>
</div>
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
navigate('/console/topup');
}}
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>
<ChevronDown
size={14}
className='text-xs text-semi-color-text-2 dark:text-gray-400'
/>
</Button>
</Dropdown>
</div>
);
} 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 (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' 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' className='flex -ml-px'>
<Button
theme='solid'
type='primary'
className={registerButtonClasses}
>
<span className={registerButtonTextSpanClass}>{t('注册')}</span>
</Button>
</Link>
</div>
)}
</div>
);
}
};
export default UserArea;
-132
View File
@@ -1,132 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { useHeaderBar } from '../../../hooks/common/useHeaderBar';
import { useNotifications } from '../../../hooks/common/useNotifications';
import { useNavigation } from '../../../hooks/common/useNavigation';
import NoticeModal from '../NoticeModal';
import MobileMenuButton from './MobileMenuButton';
import HeaderLogo from './HeaderLogo';
import Navigation from './Navigation';
import ActionButtons from './ActionButtons';
const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
const {
userState,
statusState,
isMobile,
collapsed,
logoLoaded,
currentLang,
isLoading,
systemName,
logo,
isNewYear,
isSelfUseMode,
docsLink,
isDemoSiteMode,
isConsoleRoute,
theme,
headerNavModules,
pricingRequireAuth,
logout,
handleLanguageChange,
handleThemeToggle,
handleMobileMenuToggle,
navigate,
t,
} = useHeaderBar({ onMobileMenuToggle, drawerOpen });
const {
noticeVisible,
unreadCount,
handleNoticeOpen,
handleNoticeClose,
getUnreadKeys,
} = useNotifications(statusState);
const { mainNavLinks } = useNavigation(t, docsLink, headerNavModules);
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={handleNoticeClose}
isMobile={isMobile}
defaultTab={unreadCount > 0 ? 'system' : 'inApp'}
unreadKeys={getUnreadKeys()}
/>
<div className='w-full px-2'>
<div className='flex items-center justify-between h-16'>
<div className='flex items-center'>
<MobileMenuButton
isConsoleRoute={isConsoleRoute}
isMobile={isMobile}
drawerOpen={drawerOpen}
collapsed={collapsed}
onToggle={handleMobileMenuToggle}
t={t}
/>
<HeaderLogo
isMobile={isMobile}
isConsoleRoute={isConsoleRoute}
logo={logo}
logoLoaded={logoLoaded}
isLoading={isLoading}
systemName={systemName}
isSelfUseMode={isSelfUseMode}
isDemoSiteMode={isDemoSiteMode}
t={t}
/>
</div>
<Navigation
mainNavLinks={mainNavLinks}
isMobile={isMobile}
isLoading={isLoading}
userState={userState}
pricingRequireAuth={pricingRequireAuth}
/>
<ActionButtons
isNewYear={isNewYear}
unreadCount={unreadCount}
onNoticeOpen={handleNoticeOpen}
theme={theme}
onThemeToggle={handleThemeToggle}
currentLang={currentLang}
onLanguageChange={handleLanguageChange}
userState={userState}
isLoading={isLoading}
isMobile={isMobile}
isSelfUseMode={isSelfUseMode}
logout={logout}
navigate={navigate}
t={t}
/>
</div>
</div>
</header>
);
};
export default HeaderBar;
@@ -1,412 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import { Card, Button, Typography } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Settings, Server, AlertCircle, WifiOff } from 'lucide-react';
const { Title, Text } = Typography;
const DeploymentAccessGuard = ({
children,
loading,
isEnabled,
connectionLoading,
connectionOk,
connectionError,
onRetry,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
const handleGoToSettings = () => {
navigate('/console/setting?tab=model-deployment');
};
if (loading) {
return (
<div className='mt-[60px] px-2'>
<Card loading={true} style={{ minHeight: '400px' }}>
<div style={{ textAlign: 'center', padding: '50px 0' }}>
<Text type='secondary'>{t('加载设置中...')}</Text>
</div>
</Card>
</div>
);
}
if (!isEnabled) {
return (
<div
className='mt-[60px] px-4'
style={{
minHeight: 'calc(100vh - 60px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
maxWidth: '600px',
width: '100%',
textAlign: 'center',
padding: '0 20px',
}}
>
<Card
style={{
padding: '60px 40px',
borderRadius: '16px',
border: '1px solid var(--semi-color-border)',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
background:
'linear-gradient(135deg, var(--semi-color-bg-0) 0%, var(--semi-color-fill-0) 100%)',
}}
>
{/* 图标区域 */}
<div style={{ marginBottom: '32px' }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '120px',
height: '120px',
borderRadius: '50%',
background:
'linear-gradient(135deg, rgba(var(--semi-orange-4), 0.15) 0%, rgba(var(--semi-orange-5), 0.1) 100%)',
border: '3px solid rgba(var(--semi-orange-4), 0.3)',
marginBottom: '24px',
}}
>
<AlertCircle size={56} color='var(--semi-color-warning)' />
</div>
</div>
{/* 标题区域 */}
<div style={{ marginBottom: '24px' }}>
<Title
heading={2}
style={{
color: 'var(--semi-color-text-0)',
margin: '0 0 12px 0',
fontSize: '28px',
fontWeight: '700',
}}
>
{t('模型部署服务未启用')}
</Title>
<Text
style={{
fontSize: '18px',
lineHeight: '1.6',
color: 'var(--semi-color-text-1)',
display: 'block',
}}
>
{t('访问模型部署功能需要先启用 io.net 部署服务')}
</Text>
</div>
{/* 配置要求区域 */}
<div
style={{
backgroundColor: 'var(--semi-color-bg-1)',
padding: '24px',
borderRadius: '12px',
border: '1px solid var(--semi-color-border)',
margin: '32px 0',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
marginBottom: '16px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
borderRadius: '8px',
backgroundColor: 'rgba(var(--semi-blue-4), 0.15)',
}}
>
<Server size={20} color='var(--semi-color-primary)' />
</div>
<Text
strong
style={{
fontSize: '16px',
color: 'var(--semi-color-text-0)',
}}
>
{t('需要配置的项目')}
</Text>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '12px',
alignItems: 'flex-start',
textAlign: 'left',
maxWidth: '320px',
margin: '0 auto',
}}
>
<div
style={{ display: 'flex', alignItems: 'center', gap: '12px' }}
>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: 'var(--semi-color-primary)',
flexShrink: 0,
}}
></div>
<Text
style={{
fontSize: '15px',
color: 'var(--semi-color-text-1)',
}}
>
{t('启用 io.net 部署开关')}
</Text>
</div>
<div
style={{ display: 'flex', alignItems: 'center', gap: '12px' }}
>
<div
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: 'var(--semi-color-primary)',
flexShrink: 0,
}}
></div>
<Text
style={{
fontSize: '15px',
color: 'var(--semi-color-text-1)',
}}
>
{t('配置有效的 io.net API Key')}
</Text>
</div>
</div>
</div>
{/* 操作链接区域 */}
<div style={{ marginBottom: '20px' }}>
<div
onClick={handleGoToSettings}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer',
padding: '12px 24px',
borderRadius: '8px',
fontSize: '16px',
fontWeight: '500',
color: 'var(--semi-color-primary)',
background: 'var(--semi-color-fill-0)',
border: '1px solid var(--semi-color-border)',
transition: 'all 0.2s ease',
textDecoration: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--semi-color-fill-1)';
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow =
'0 2px 8px rgba(0, 0, 0, 0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--semi-color-fill-0)';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<Settings size={18} />
{t('前往设置页面')}
</div>
</div>
{/* 底部提示 */}
<Text
type='tertiary'
style={{
fontSize: '14px',
color: 'var(--semi-color-text-2)',
lineHeight: '1.5',
}}
>
{t('配置完成后刷新页面即可使用模型部署功能')}
</Text>
</Card>
</div>
</div>
);
}
if (connectionLoading || (connectionOk === null && !connectionError)) {
return (
<div className='mt-[60px] px-2'>
<Card loading={true} style={{ minHeight: '400px' }}>
<div style={{ textAlign: 'center', padding: '50px 0' }}>
<Text type='secondary'>{t('正在检查 io.net 连接...')}</Text>
</div>
</Card>
</div>
);
}
if (connectionOk === false) {
const isExpired = connectionError?.type === 'expired';
const title = isExpired ? t('接口密钥已过期') : t('无法连接 io.net');
const description = isExpired
? t('当前 API 密钥已过期,请在设置中更新。')
: t('当前配置无法连接到 io.net。');
const detail = connectionError?.message || '';
return (
<div
className='mt-[60px] px-4'
style={{
minHeight: 'calc(100vh - 60px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
maxWidth: '600px',
width: '100%',
textAlign: 'center',
padding: '0 20px',
}}
>
<Card
style={{
padding: '60px 40px',
borderRadius: '16px',
border: '1px solid var(--semi-color-border)',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
background:
'linear-gradient(135deg, var(--semi-color-bg-0) 0%, var(--semi-color-fill-0) 100%)',
}}
>
<div style={{ marginBottom: '32px' }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '120px',
height: '120px',
borderRadius: '50%',
background:
'linear-gradient(135deg, rgba(var(--semi-red-4), 0.15) 0%, rgba(var(--semi-red-5), 0.1) 100%)',
border: '3px solid rgba(var(--semi-red-4), 0.3)',
marginBottom: '24px',
}}
>
<WifiOff size={56} color='var(--semi-color-danger)' />
</div>
</div>
<div style={{ marginBottom: '24px' }}>
<Title
heading={2}
style={{
color: 'var(--semi-color-text-0)',
margin: '0 0 12px 0',
fontSize: '28px',
fontWeight: '700',
}}
>
{title}
</Title>
<Text
style={{
fontSize: '18px',
lineHeight: '1.6',
color: 'var(--semi-color-text-1)',
display: 'block',
}}
>
{description}
</Text>
{detail ? (
<Text
type='tertiary'
style={{
fontSize: '14px',
lineHeight: '1.5',
display: 'block',
marginTop: '8px',
}}
>
{detail}
</Text>
) : null}
</div>
<div
style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}
>
<Button
type='primary'
icon={<Settings size={18} />}
onClick={handleGoToSettings}
>
{t('前往设置')}
</Button>
{onRetry ? (
<Button type='tertiary' onClick={onRetry}>
{t('重试连接')}
</Button>
) : null}
</div>
</Card>
</div>
</div>
);
}
return children;
};
export default DeploymentAccessGuard;
-129
View File
@@ -1,129 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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;
-401
View File
@@ -1,401 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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 escapeHtml = (str) => {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
const highlightJson = (str) => {
const tokenRegex =
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g;
let result = '';
let lastIndex = 0;
let match;
while ((match = tokenRegex.exec(str)) !== null) {
// Escape non-token text (structural chars like {, }, [, ], :, comma, whitespace)
result += escapeHtml(str.slice(lastIndex, match.index));
const token = match[0];
let color = '#b5cea8';
if (/^"/.test(token)) {
color = /:$/.test(token) ? '#9cdcfe' : '#ce9178';
} else if (/true|false|null/.test(token)) {
color = '#569cd6';
}
// Escape token content before wrapping in span
result += `<span style="color: ${color}">${escapeHtml(token)}</span>`;
lastIndex = tokenRegex.lastIndex;
}
// Escape remaining text
result += escapeHtml(str.slice(lastIndex));
return result;
};
const linkRegex = /(https?:\/\/(?:[^\s<"'\]),;&}]|&amp;)+)/g;
const linkifyHtml = (html) => {
const parts = html.split(/(<[^>]+>)/g);
return parts
.map((part) => {
if (part.startsWith('<')) return part;
return part.replace(
linkRegex,
(url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
);
})
.join('');
};
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 escapeHtml(displayContent);
}
if (isJsonLike(displayContent, language)) {
return highlightJson(displayContent);
}
return escapeHtml(displayContent);
}, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]);
const renderedContent = useMemo(() => {
return linkifyHtml(highlightedContent);
}, [highlightedContent]);
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,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
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: renderedContent }} />
)}
</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;
-281
View File
@@ -1,281 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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;
-155
View File
@@ -1,155 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useRef, useEffect, useCallback } from 'react';
import { Toast } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { usePlayground } from '../../contexts/PlaygroundContext';
const CustomInputRender = (props) => {
const { t } = useTranslation();
const { onPasteImage, imageEnabled } = usePlayground();
const { detailProps } = props;
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } =
detailProps;
const containerRef = useRef(null);
const handlePaste = useCallback(
async (e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.indexOf('image') !== -1) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
try {
if (!imageEnabled) {
Toast.warning({
content: t('请先在设置中启用图片功能'),
duration: 3,
});
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const base64 = event.target.result;
if (onPasteImage) {
onPasteImage(base64);
Toast.success({
content: t('图片已添加'),
duration: 2,
});
} else {
Toast.error({
content: t('无法添加图片'),
duration: 2,
});
}
};
reader.onerror = () => {
console.error('Failed to read image file:', reader.error);
Toast.error({
content: t('粘贴图片失败'),
duration: 2,
});
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Failed to paste image:', error);
Toast.error({
content: t('粘贴图片失败'),
duration: 2,
});
}
}
break;
}
}
},
[onPasteImage, imageEnabled, t],
);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener('paste', handlePaste);
return () => {
container.removeEventListener('paste', handlePaste);
};
}, [handlePaste]);
//
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' ref={containerRef}>
<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}
title={t('支持 Ctrl+V 粘贴图片')}
>
{/* 清空对话按钮 - 左边 */}
{styledClearNode}
<div className='flex-1'>{inputNode}</div>
{/* 发送按钮 - 右边 */}
{styledSendNode}
</div>
</div>
);
};
export default CustomInputRender;
-217
View File
@@ -1,217 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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(`${t('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'>
{t('自定义请求体模式')}
</Typography.Text>
</div>
<Switch
checked={customRequestMode}
onChange={handleModeToggle}
checkedText={t('开')}
uncheckedText={t('关')}
size='small'
/>
</div>
{customRequestMode && (
<>
{/* 提示信息 */}
<Banner
type='warning'
description={t(
'启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。',
)}
icon={<AlertTriangle size={16} />}
className='!rounded-lg'
closeIcon={null}
/>
{/* JSON编辑器 */}
<div>
<div className='flex items-center justify-between mb-2'>
<Typography.Text strong className='text-sm'>
{t('请求体 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'>
{t('格式正确')}
</Typography.Text>
</div>
) : (
<div className='flex items-center gap-1 text-red-600'>
<X size={14} />
<Typography.Text className='text-xs'>
{t('格式错误')}
</Typography.Text>
</div>
)}
<Button
theme='borderless'
type='tertiary'
size='small'
icon={<Edit size={14} />}
onClick={formatJson}
disabled={!isValid}
className='!rounded-lg'
>
{t('格式化')}
</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'>
{t(
'请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。',
)}
</Typography.Text>
</div>
</>
)}
</div>
);
};
export default CustomRequestEditor;
-224
View File
@@ -1,224 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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';
import SSEViewer from './SSEViewer';
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('响应')}
{debugData.sseMessages && debugData.sseMessages.length > 0 && (
<span className='px-1.5 py-0.5 text-xs bg-blue-100 text-blue-600 rounded-full'>
SSE ({debugData.sseMessages.length})
</span>
)}
</div>
}
itemKey='response'
>
{debugData.sseMessages && debugData.sseMessages.length > 0 ? (
<SSEViewer sseData={debugData.sseMessages} title='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;
-86
View File
@@ -1,86 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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'
/>
)}
</>
);
};
export default FloatingButtons;
-142
View File
@@ -1,142 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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';
import { useTranslation } from 'react-i18next';
const ImageUrlInput = ({
imageUrls,
imageEnabled,
onImageUrlsChange,
onImageEnabledChange,
disabled = false,
}) => {
const { t } = useTranslation();
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'>
{t('图片地址')}
</Typography.Text>
{disabled && (
<Typography.Text className='text-xs text-orange-600'>
({t('已在自定义模式中忽略')})
</Typography.Text>
)}
</div>
<div className='flex items-center gap-2'>
<Switch
checked={imageEnabled}
onChange={onImageEnabledChange}
checkedText={t('启用')}
uncheckedText={t('停用')}
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
? t('图片功能在自定义请求体模式下不可用')
: t('启用后可添加图片URL进行多模态对话')}
</Typography.Text>
) : imageUrls.length === 0 ? (
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
{disabled
? t('图片功能在自定义请求体模式下不可用')
: t('点击 + 按钮添加图片URL进行多模态对话')}
</Typography.Text>
) : (
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
{t('已添加')} {imageUrls.length} {t('张图片')}
{disabled ? ` (${t('自定义模式下不可用')})` : ''}
</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;
-152
View File
@@ -1,152 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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;
-412
View File
@@ -1,412 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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, Settings, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { isAdmin } from '../../helpers/utils';
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('请求发生错误');
}
if (message.errorCode === 'model_price_error') {
return (
<div className={`${className}`}>
<div
className='rounded-lg p-3 space-y-2'
style={{
background: 'var(--semi-color-bg-0)',
border: '1px solid var(--semi-color-border)',
}}
>
<div className='flex items-center gap-2'>
<AlertTriangle size={16} className='text-orange-500 shrink-0' />
<Typography.Text strong className='!text-[var(--semi-color-text-0)]'>
{t('模型价格未配置')}
</Typography.Text>
</div>
<Typography.Paragraph
className='!text-[var(--semi-color-text-1)] !text-sm !mb-0'
style={{ wordBreak: 'break-word' }}
>
{errorText}
</Typography.Paragraph>
{isAdmin() && (
<Button
size='small'
theme='light'
type='warning'
icon={<Settings size={14} />}
onClick={() => window.open('/console/setting?tab=ratio', '_blank')}
>
{t('前往设置')}
</Button>
)}
</div>
</div>
);
}
return (
<div className={`${className}`}>
<Typography.Text className='text-white'>{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;
-97
View File
@@ -1,97 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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
);
},
);
-303
View File
@@ -1,303 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React from 'react';
import {
Input,
InputNumber,
Slider,
Typography,
Button,
Tag,
} from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import {
Hash,
Thermometer,
Target,
Repeat,
Ban,
Shuffle,
Check,
X,
} from 'lucide-react';
const ParameterControl = ({
inputs,
parameterEnabled,
onInputChange,
onParameterToggle,
disabled = false,
}) => {
const { t } = useTranslation();
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' shape='circle'>
{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'>
{t('控制输出的随机性和创造性')}
</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' shape='circle'>
{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'>
{t('核采样,控制词汇选择的多样性')}
</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' shape='circle'>
{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'>
{t('频率惩罚,减少重复词汇的出现')}
</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' shape='circle'>
{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'>
{t('存在惩罚,鼓励讨论新话题')}
</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>
<InputNumber
placeholder='MaxTokens'
name='max_tokens'
value={inputs.max_tokens}
onNumberChange={(value) => onInputChange('max_tokens', value)}
min={0}
precision={0}
style={{ width: '100%' }}
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'>
({t('可选,用于复现结果')})
</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={t('随机种子 (留空为随机)')}
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;
-314
View File
@@ -1,314 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import React, { useState, useMemo, useCallback } from 'react';
import {
Button,
Tooltip,
Toast,
Collapse,
Badge,
Typography,
} from '@douyinfe/semi-ui';
import {
Copy,
ChevronDown,
ChevronUp,
Zap,
CheckCircle,
XCircle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { copy } from '../../helpers';
/**
* SSEViewer component for displaying Server-Sent Events in an interactive format
* @param {Object} props - Component props
* @param {Array} props.sseData - Array of SSE messages to display
* @returns {JSX.Element} Rendered SSE viewer component
*/
const SSEViewer = ({ sseData }) => {
const { t } = useTranslation();
const [expandedKeys, setExpandedKeys] = useState([]);
const [copied, setCopied] = useState(false);
const parsedSSEData = useMemo(() => {
if (!sseData || !Array.isArray(sseData)) {
return [];
}
return sseData.map((item, index) => {
let parsed = null;
let error = null;
let isDone = false;
if (item === '[DONE]') {
isDone = true;
} else {
try {
parsed = typeof item === 'string' ? JSON.parse(item) : item;
} catch (e) {
error = e.message;
}
}
return {
index,
raw: item,
parsed,
error,
isDone,
key: `sse-${index}`,
};
});
}, [sseData]);
const stats = useMemo(() => {
const total = parsedSSEData.length;
const errors = parsedSSEData.filter((item) => item.error).length;
const done = parsedSSEData.filter((item) => item.isDone).length;
const valid = total - errors - done;
return { total, errors, done, valid };
}, [parsedSSEData]);
const handleToggleAll = useCallback(() => {
setExpandedKeys((prev) => {
if (prev.length === parsedSSEData.length) {
return [];
} else {
return parsedSSEData.map((item) => item.key);
}
});
}, [parsedSSEData]);
const handleCopyAll = useCallback(async () => {
try {
const allData = parsedSSEData
.map((item) =>
item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw,
)
.join('\n\n');
await copy(allData);
setCopied(true);
Toast.success(t('已复制全部数据'));
setTimeout(() => setCopied(false), 2000);
} catch (err) {
Toast.error(t('复制失败'));
console.error('Copy failed:', err);
}
}, [parsedSSEData, t]);
const handleCopySingle = useCallback(
async (item) => {
try {
const textToCopy = item.parsed
? JSON.stringify(item.parsed, null, 2)
: item.raw;
await copy(textToCopy);
Toast.success(t('已复制'));
} catch (err) {
Toast.error(t('复制失败'));
}
},
[t],
);
const renderSSEItem = (item) => {
if (item.isDone) {
return (
<div className='flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg'>
<CheckCircle size={16} className='text-green-600' />
<Typography.Text className='text-green-600 font-medium'>
{t('流式响应完成')} [DONE]
</Typography.Text>
</div>
);
}
if (item.error) {
return (
<div className='space-y-2'>
<div className='flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg'>
<XCircle size={16} className='text-red-600' />
<Typography.Text className='text-red-600'>
{t('解析错误')}: {item.error}
</Typography.Text>
</div>
<div className='p-3 bg-gray-100 dark:bg-gray-800 rounded-lg font-mono text-xs overflow-auto'>
<pre>{item.raw}</pre>
</div>
</div>
);
}
return (
<div className='space-y-2'>
{/* JSON 格式化显示 */}
<div className='relative'>
<pre className='p-4 bg-gray-900 text-gray-100 rounded-lg overflow-auto text-xs font-mono leading-relaxed'>
{JSON.stringify(item.parsed, null, 2)}
</pre>
<Button
icon={<Copy size={12} />}
size='small'
theme='borderless'
onClick={() => handleCopySingle(item)}
className='absolute top-2 right-2 !bg-gray-800/80 !text-gray-300 hover:!bg-gray-700'
/>
</div>
{/* 关键信息摘要 */}
{item.parsed?.choices?.[0] && (
<div className='flex flex-wrap gap-2 text-xs'>
{item.parsed.choices[0].delta?.content && (
<Badge
count={`${t('内容')}: "${String(item.parsed.choices[0].delta.content).substring(0, 20)}..."`}
type='primary'
/>
)}
{item.parsed.choices[0].delta?.reasoning_content && (
<Badge count={t('有 Reasoning')} type='warning' />
)}
{item.parsed.choices[0].finish_reason && (
<Badge
count={`${t('完成')}: ${item.parsed.choices[0].finish_reason}`}
type='success'
/>
)}
{item.parsed.usage && (
<Badge
count={`${t('令牌')}: ${item.parsed.usage.prompt_tokens || 0}/${item.parsed.usage.completion_tokens || 0}`}
type='tertiary'
/>
)}
</div>
)}
</div>
);
};
if (!parsedSSEData || parsedSSEData.length === 0) {
return (
<div className='flex items-center justify-center h-full min-h-[200px] text-gray-500'>
<span>{t('暂无SSE响应数据')}</span>
</div>
);
}
return (
<div className='h-full flex flex-col bg-gray-50 dark:bg-gray-900/50 rounded-lg'>
{/* 头部工具栏 */}
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0'>
<div className='flex items-center gap-3'>
<Zap size={16} className='text-blue-500' />
<Typography.Text strong>{t('SSE数据流')}</Typography.Text>
<Badge count={stats.total} type='primary' />
{stats.errors > 0 && (
<Badge count={`${stats.errors} ${t('错误')}`} type='danger' />
)}
</div>
<div className='flex items-center gap-2'>
<Tooltip content={t('复制全部')}>
<Button
icon={<Copy size={14} />}
size='small'
onClick={handleCopyAll}
theme='borderless'
>
{copied ? t('已复制') : t('复制全部')}
</Button>
</Tooltip>
<Tooltip
content={
expandedKeys.length === parsedSSEData.length
? t('全部收起')
: t('全部展开')
}
>
<Button
icon={
expandedKeys.length === parsedSSEData.length ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)
}
size='small'
onClick={handleToggleAll}
theme='borderless'
>
{expandedKeys.length === parsedSSEData.length
? t('收起')
: t('展开')}
</Button>
</Tooltip>
</div>
</div>
{/* SSE 数据列表 */}
<div className='flex-1 overflow-auto p-4'>
<Collapse
activeKey={expandedKeys}
onChange={setExpandedKeys}
accordion={false}
className='bg-white dark:bg-gray-800 rounded-lg'
>
{parsedSSEData.map((item) => (
<Collapse.Panel
key={item.key}
header={
<div className='flex items-center gap-2'>
<Badge count={`#${item.index + 1}`} type='tertiary' />
{item.isDone ? (
<span className='text-green-600 font-medium'>[DONE]</span>
) : item.error ? (
<span className='text-red-600'>{t('解析错误')}</span>
) : (
<>
<span className='text-gray-600'>
{item.parsed?.id ||
item.parsed?.object ||
t('SSE 事件')}
</span>
{item.parsed?.choices?.[0]?.delta && (
<span className='text-xs text-gray-400'>
{' '}
{Object.keys(item.parsed.choices[0].delta)
.filter((k) => item.parsed.choices[0].delta[k])
.join(', ')}
</span>
)}
</>
)}
</div>
}
>
{renderSSEItem(item)}
</Collapse.Panel>
))}
</Collapse>
</div>
</div>
);
};
export default SSEViewer;
-245
View File
@@ -1,245 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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, selectFilter } 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'>
({t('已在自定义模式中忽略')})
</Typography.Text>
)}
</div>
<Select
placeholder={t('请选择分组')}
name='group'
required
selection
filter={selectFilter}
autoClearSearchValue={false}
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'>
({t('已在自定义模式中忽略')})
</Typography.Text>
)}
</div>
<Select
placeholder={t('请选择模型')}
name='model'
required
selection
filter={selectFilter}
autoClearSearchValue={false}
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'>
{t('流式输出')}
</Typography.Text>
{customRequestMode && (
<Typography.Text className='text-xs text-orange-600'>
({t('已在自定义模式中忽略')})
</Typography.Text>
)}
</div>
<Switch
checked={inputs.stream}
onChange={(checked) => onInputChange('stream', checked)}
checkedText={t('开')}
uncheckedText={t('关')}
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;
-180
View File
@@ -1,180 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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;
-234
View File
@@ -1,234 +0,0 @@
/*
Copyright (C) 2025 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
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 parsedMaxTokens = parseInt(parsedConfig?.inputs?.max_tokens, 10);
const mergedConfig = {
inputs: {
...DEFAULT_CONFIG.inputs,
...parsedConfig.inputs,
max_tokens: Number.isNaN(parsedMaxTokens)
? parsedConfig?.inputs?.max_tokens
: parsedMaxTokens,
},
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));
}
});
};

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