初始化导入 new-api 源码

This commit is contained in:
OpenClaw Task Bot
2026-04-09 22:31:14 +08:00
commit 75bda2e845
994 changed files with 250292 additions and 0 deletions

42
web/.eslintrc.cjs vendored Normal file
View File

@@ -0,0 +1,42 @@
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
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# 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
web/.prettierrc.mjs vendored Normal file
View File

@@ -0,0 +1 @@
module.exports = require('@so1ve/prettier-config');

86
web/i18next.config.js vendored Normal file
View File

@@ -0,0 +1,86 @@
/*
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,
},
});

29
web/index.html vendored Normal file
View File

@@ -0,0 +1,29 @@
<!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>New API</title>
<!--umami-->
<!--Google Analytics-->
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>

9
web/jsconfig.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"]
}

96
web/package.json vendored Normal file
View File

@@ -0,0 +1,96 @@
{
"name": "react-template",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": {
"@douyinfe/semi-icons": "^2.63.1",
"@douyinfe/semi-ui": "^2.69.1",
"@lobehub/icons": "^2.0.0",
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
"axios": "1.13.5",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"history": "^5.3.0",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^7.2.0",
"katex": "^0.16.22",
"lucide-react": "^0.511.0",
"marked": "^4.1.1",
"mermaid": "^11.6.0",
"qrcode.react": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-fireworks": "^1.0.4",
"react-i18next": "^13.0.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.3.0",
"react-telegram-login": "^1.1.2",
"react-toastify": "^9.0.8",
"react-turnstile": "^1.0.5",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sse.js": "^2.6.0",
"unist-util-visit": "^5.0.0",
"use-debounce": "^10.0.4"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "prettier . --check",
"lint:fix": "prettier . --write",
"eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
"eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache",
"preview": "vite 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": {
"@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6",
"@so1ve/prettier-config": "^3.1.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",
"code-inspector-plugin": "^1.3.3",
"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",
"prettier": "^3.0.0",
"tailwindcss": "^3",
"typescript": "4.4.2",
"vite": "^5.2.0"
},
"prettier": {
"singleQuote": true,
"jsxSingleQuote": true
},
"proxy": "http://localhost:3000"
}

25
web/postcss.config.js vendored Normal file
View File

@@ -0,0 +1,25 @@
/*
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: {},
},
};

BIN
web/public/azure_model_name.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

BIN
web/public/cover-4.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
web/public/favicon.ico vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
web/public/logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
web/public/pay-apple.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
web/public/pay-card.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
web/public/pay-google.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
web/public/ratio.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

3
web/public/robots.txt vendored Normal file
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

386
web/src/App.jsx vendored Normal file
View File

@@ -0,0 +1,386 @@
/*
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 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;

View File

@@ -0,0 +1,983 @@
/*
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 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='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;

View File

@@ -0,0 +1,107 @@
/*
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 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;

View File

@@ -0,0 +1,220 @@
/*
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 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='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;

View File

@@ -0,0 +1,193 @@
/*
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 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='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;

View File

@@ -0,0 +1,805 @@
/*
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 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='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;

View File

@@ -0,0 +1,244 @@
/*
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 { 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;

View File

@@ -0,0 +1,253 @@
/*
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 React, { useEffect, 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';
// 检查是否为 URL
const isUrl = (content) => {
try {
new URL(content.trim());
return true;
} catch {
return false;
}
};
// 检查是否为 HTML 内容
const isHtmlContent = (content) => {
if (!content || typeof content !== 'string') return false;
// 检查是否包含HTML标签
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
return htmlTagRegex.test(content);
};
// 安全地渲染HTML内容
const sanitizeHtml = (html) => {
// 创建一个临时元素来解析HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
// 提取样式
const styles = Array.from(tempDiv.querySelectorAll('style'))
.map((style) => style.innerHTML)
.join('\n');
// 提取body内容如果没有body标签则使用全部内容
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 [htmlStyles, setHtmlStyles] = useState('');
const [processedHtmlContent, setProcessedHtmlContent] = useState('');
const loadContent = async () => {
// 先从缓存中获取
const cachedContent = localStorage.getItem(cacheKey) || '';
if (cachedContent) {
setContent(cachedContent);
processContent(cachedContent);
setLoading(false);
}
try {
const res = await API.get(apiEndpoint);
const { success, message, data } = res.data;
if (success && data) {
setContent(data);
processContent(data);
localStorage.setItem(cacheKey, data);
} else {
if (!cachedContent) {
showError(message || emptyMessage);
setContent('');
}
}
} catch (error) {
if (!cachedContent) {
showError(emptyMessage);
setContent('');
}
} finally {
setLoading(false);
}
};
const processContent = (rawContent) => {
if (isHtmlContent(rawContent)) {
const { content: htmlContent, styles } = sanitizeHtml(rawContent);
setProcessedHtmlContent(htmlContent);
setHtmlStyles(styles);
} else {
setProcessedHtmlContent('');
setHtmlStyles('');
}
};
useEffect(() => {
loadContent();
}, []);
// 处理HTML样式注入
useEffect(() => {
const styleId = `document-renderer-styles-${cacheKey}`;
if (htmlStyles) {
let styleEl = document.getElementById(styleId);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = styleId;
styleEl.type = 'text/css';
document.head.appendChild(styleEl);
}
styleEl.innerHTML = htmlStyles;
} else {
const el = document.getElementById(styleId);
if (el) el.remove();
}
return () => {
const el = document.getElementById(styleId);
if (el) el.remove();
};
}, [htmlStyles, cacheKey]);
// 显示加载状态
if (loading) {
return (
<div className='flex justify-center items-center min-h-screen'>
<Spin size='large' />
</div>
);
}
// 如果没有内容,显示空状态
if (!content || content.trim() === '') {
return (
<div className='flex justify-center items-center min-h-screen 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='flex justify-center items-center min-h-screen 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)) {
const { content: htmlContent, styles } = sanitizeHtml(content);
// 设置样式(如果有的话)
useEffect(() => {
if (styles && styles !== htmlStyles) {
setHtmlStyles(styles);
}
}, [content, styles, htmlStyles]);
return (
<div className='min-h-screen 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: htmlContent }}
/>
</div>
</div>
</div>
);
}
// 其他内容统一使用 Markdown 渲染器
return (
<div className='min-h-screen 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;

View File

@@ -0,0 +1,56 @@
/*
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 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;

View File

@@ -0,0 +1,57 @@
/*
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 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;

View File

@@ -0,0 +1,55 @@
/*
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 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;

View File

@@ -0,0 +1,697 @@
/*
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 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;

View File

@@ -0,0 +1,449 @@
/* 基础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;
}

View File

@@ -0,0 +1,214 @@
/*
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 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 = '',
inputPlaceholder = '',
mismatchText = '',
cancelText = '',
confirmText = '',
onCancel,
onConfirm,
}) {
const isMobile = useIsMobile();
const [checkedItems, setCheckedItems] = useState([]);
const [typedText, setTypedText] = useState('');
useEffect(() => {
if (!visible) return;
setCheckedItems(Array(checklist.length).fill(false));
setTypedText('');
}, [visible, checklist.length]);
const allChecked = useMemo(() => {
if (checklist.length === 0) return true;
return checkedItems.length === checklist.length && checkedItems.every(Boolean);
}, [checkedItems, checklist.length]);
const typedMatched = useMemo(() => {
if (!requiredText) return true;
return typedText.trim() === requiredText.trim();
}, [typedText, requiredText]);
const detailText = useMemo(() => detailItems.join(', '), [detailItems]);
const canConfirm = allChecked && typedMatched;
const handleChecklistChange = useCallback((index, checked) => {
setCheckedItems((previous) => {
const next = [...previous];
next[index] = checked;
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}
{requiredText ? (
<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'>
{requiredText}
</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 && typedText ? (
<Text type='danger' size='small'>
{mismatchText}
</Text>
) : null}
</div>
) : null}
</div>
</Modal>
);
});
export default RiskAcknowledgementModal;

View File

@@ -0,0 +1,322 @@
/*
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 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;

View File

@@ -0,0 +1,200 @@
/*
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 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;

View File

@@ -0,0 +1,242 @@
/*
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 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;

View File

@@ -0,0 +1,280 @@
/*
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 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;

View File

@@ -0,0 +1,68 @@
/*
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 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;

View File

@@ -0,0 +1,718 @@
/*
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 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;

View File

@@ -0,0 +1,31 @@
/*
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 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;

View File

@@ -0,0 +1,60 @@
/*
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 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>
);
};

View File

@@ -0,0 +1,242 @@
/*
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 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;

View File

@@ -0,0 +1,295 @@
/*
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 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;

View File

@@ -0,0 +1,126 @@
/*
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 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;

View File

@@ -0,0 +1,119 @@
/*
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 React from 'react';
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
import { Server, Gauge, ExternalLink } 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='!text-semi-color-primary break-all cursor-pointer hover:underline mb-1'
onClick={() => handleCopyUrl(api.url)}
>
{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;

View File

@@ -0,0 +1,80 @@
/*
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 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,
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' />
</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} />
)}
</div>
</Card>
);
};
export default ChartsPanel;

View File

@@ -0,0 +1,61 @@
/*
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 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;

View File

@@ -0,0 +1,88 @@
/*
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 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;

View File

@@ -0,0 +1,116 @@
/*
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 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;

View File

@@ -0,0 +1,152 @@
/*
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 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;

View File

@@ -0,0 +1,273 @@
/*
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 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.cacheMetrics,
dashboardData.navigate,
dashboardData.t,
);
// ========== 数据处理 ==========
const initChart = async () => {
await dashboardData.loadQuotaData().then((data) => {
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
});
await dashboardData.loadUptimeData();
await dashboardData.loadCacheStats?.();
};
const handleRefresh = async () => {
const data = await dashboardData.refresh();
if (data && data.length > 0) {
dashboardCharts.updateChartData(data);
}
};
const handleSearchConfirm = async () => {
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
};
// ========== 数据准备 ==========
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}
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;

View File

@@ -0,0 +1,103 @@
/*
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 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;

View File

@@ -0,0 +1,248 @@
/*
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 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/neko-api-key-tool'
target='_blank'
rel='noopener noreferrer'
className='!text-semi-color-text-1'
>
neko-api-key-tool
</a>
</div>
</div>
<div className='text-left'>
<p className='!text-semi-color-text-0 font-semibold mb-5'>
{t('友情链接')}
</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 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>
),
[logo, systemName, t, currentYear, isDemoSiteMode],
);
useEffect(() => {
loadFooter();
}, []);
return (
<div className='w-full'>
{footer ? (
<div className='relative'>
<div
className='custom-footer'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
<div className='absolute bottom-2 right-4 text-xs !text-semi-color-text-2 opacity-70'>
<span>{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>
) : (
customFooter
)}
</div>
);
};
export default FooterBar;

View File

@@ -0,0 +1,255 @@
/*
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 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;

View File

@@ -0,0 +1,238 @@
/*
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 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 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);
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'
style={{
display: 'flex',
flexDirection: 'column',
overflow: isMobile ? 'visible' : 'hidden',
}}
>
<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: isMobile ? 'visible' : 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
{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',
}}
>
<Content
style={{
flex: '1 0 auto',
overflowY: isMobile ? 'visible' : 'hidden',
WebkitOverflowScrolling: 'touch',
padding: shouldInnerPadding ? (isMobile ? '5px' : '24px') : '0',
position: 'relative',
}}
>
<App />
</Content>
{!shouldHideFooter && (
<Layout.Footer
style={{
flex: '0 0 auto',
width: '100%',
}}
>
<FooterBar />
</Layout.Footer>
)}
</Layout>
</Layout>
<ToastContainer />
</Layout>
);
};
export default PageLayout;

View File

@@ -0,0 +1,40 @@
/*
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 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;

View File

@@ -0,0 +1,532 @@
/*
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 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')) {
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;

View File

@@ -0,0 +1,379 @@
/*
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 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;

View File

@@ -0,0 +1,74 @@
/*
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 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;

View File

@@ -0,0 +1,81 @@
/*
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 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;

View File

@@ -0,0 +1,86 @@
/*
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 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;

View File

@@ -0,0 +1,56 @@
/*
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 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;

View File

@@ -0,0 +1,88 @@
/*
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 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;

View File

@@ -0,0 +1,62 @@
/*
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 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;

View File

@@ -0,0 +1,46 @@
/*
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 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;

View File

@@ -0,0 +1,109 @@
/*
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 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>
}
>
<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'
/>
</Dropdown>
);
};
export default ThemeToggle;

View File

@@ -0,0 +1,200 @@
/*
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 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;

View File

@@ -0,0 +1,132 @@
/*
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 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;

View File

@@ -0,0 +1,412 @@
/*
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 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;

View File

@@ -0,0 +1,129 @@
/*
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 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;

View File

@@ -0,0 +1,401 @@
/*
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 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;

View File

@@ -0,0 +1,281 @@
/*
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 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;

View File

@@ -0,0 +1,155 @@
/*
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 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;

View File

@@ -0,0 +1,217 @@
/*
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 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;

View File

@@ -0,0 +1,224 @@
/*
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 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;

View File

@@ -0,0 +1,86 @@
/*
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 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;

View File

@@ -0,0 +1,142 @@
/*
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 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;

View File

@@ -0,0 +1,152 @@
/*
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 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;

View File

@@ -0,0 +1,373 @@
/*
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 React, { useRef, useEffect } from 'react';
import { Typography, TextArea, Button } from '@douyinfe/semi-ui';
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
import ThinkingContent from './ThinkingContent';
import { Loader2, Check, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const MessageContent = ({
message,
className,
styleState,
onToggleReasoningExpansion,
isEditing = false,
onEditSave,
onEditCancel,
editValue,
onEditValueChange,
}) => {
const { t } = useTranslation();
const previousContentLengthRef = useRef(0);
const lastContentRef = useRef('');
const isThinkingStatus =
message.status === 'loading' || message.status === 'incomplete';
useEffect(() => {
if (!isThinkingStatus) {
previousContentLengthRef.current = 0;
lastContentRef.current = '';
}
}, [isThinkingStatus]);
if (message.status === 'error') {
let errorText;
if (Array.isArray(message.content)) {
const textContent = message.content.find((item) => item.type === 'text');
errorText =
textContent && textContent.text && typeof textContent.text === 'string'
? textContent.text
: t('请求发生错误');
} else if (typeof message.content === 'string') {
errorText = message.content;
} else {
errorText = t('请求发生错误');
}
return (
<div className={`${className}`}>
<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;

View File

@@ -0,0 +1,97 @@
/*
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 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
);
},
);

View File

@@ -0,0 +1,297 @@
/*
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 React from 'react';
import { Input, 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>
<Input
placeholder='MaxTokens'
name='max_tokens'
required
autoComplete='new-password'
defaultValue={0}
value={inputs.max_tokens}
onChange={(value) => onInputChange('max_tokens', value)}
className='!rounded-lg'
disabled={!parameterEnabled.max_tokens || disabled}
/>
</div>
{/* Seed */}
<div
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.seed || disabled ? 'opacity-50' : ''}`}
>
<div className='flex items-center justify-between mb-2'>
<div className='flex items-center gap-2'>
<Shuffle size={16} className='text-gray-500' />
<Typography.Text strong className='text-sm'>
Seed
</Typography.Text>
<Typography.Text className='text-xs text-gray-400'>
({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;

View File

@@ -0,0 +1,314 @@
/*
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 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;

View File

@@ -0,0 +1,245 @@
/*
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 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;

View File

@@ -0,0 +1,180 @@
/*
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 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;

View File

@@ -0,0 +1,230 @@
/*
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 {
STORAGE_KEYS,
DEFAULT_CONFIG,
} from '../../constants/playground.constants';
const MESSAGES_STORAGE_KEY = 'playground_messages';
/**
* 保存配置到 localStorage
* @param {Object} config - 要保存的配置对象
*/
export const saveConfig = (config) => {
try {
const configToSave = {
...config,
timestamp: new Date().toISOString(),
};
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(configToSave));
} catch (error) {
console.error('保存配置失败:', error);
}
};
/**
* 保存消息到 localStorage
* @param {Array} messages - 要保存的消息数组
*/
export const saveMessages = (messages) => {
try {
const messagesToSave = {
messages,
timestamp: new Date().toISOString(),
};
localStorage.setItem(STORAGE_KEYS.MESSAGES, JSON.stringify(messagesToSave));
} catch (error) {
console.error('保存消息失败:', error);
}
};
/**
* 从 localStorage 加载配置
* @returns {Object} 配置对象,如果不存在则返回默认配置
*/
export const loadConfig = () => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
const mergedConfig = {
inputs: {
...DEFAULT_CONFIG.inputs,
...parsedConfig.inputs,
},
parameterEnabled: {
...DEFAULT_CONFIG.parameterEnabled,
...parsedConfig.parameterEnabled,
},
showDebugPanel:
parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
customRequestMode:
parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
customRequestBody:
parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
};
return mergedConfig;
}
} catch (error) {
console.error('加载配置失败:', error);
}
return DEFAULT_CONFIG;
};
/**
* 从 localStorage 加载消息
* @returns {Array} 消息数组,如果不存在则返回 null
*/
export const loadMessages = () => {
try {
const savedMessages = localStorage.getItem(STORAGE_KEYS.MESSAGES);
if (savedMessages) {
const parsedMessages = JSON.parse(savedMessages);
return parsedMessages.messages || null;
}
} catch (error) {
console.error('加载消息失败:', error);
}
return null;
};
/**
* 清除保存的配置
*/
export const clearConfig = () => {
try {
localStorage.removeItem(STORAGE_KEYS.CONFIG);
localStorage.removeItem(STORAGE_KEYS.MESSAGES); // 同时清除消息
} catch (error) {
console.error('清除配置失败:', error);
}
};
/**
* 清除保存的消息
*/
export const clearMessages = () => {
try {
localStorage.removeItem(STORAGE_KEYS.MESSAGES);
} catch (error) {
console.error('清除消息失败:', error);
}
};
/**
* 检查是否有保存的配置
* @returns {boolean} 是否存在保存的配置
*/
export const hasStoredConfig = () => {
try {
return localStorage.getItem(STORAGE_KEYS.CONFIG) !== null;
} catch (error) {
console.error('检查配置失败:', error);
return false;
}
};
/**
* 获取配置的最后保存时间
* @returns {string|null} 最后保存时间的 ISO 字符串
*/
export const getConfigTimestamp = () => {
try {
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
if (savedConfig) {
const parsedConfig = JSON.parse(savedConfig);
return parsedConfig.timestamp || null;
}
} catch (error) {
console.error('获取配置时间戳失败:', error);
}
return null;
};
/**
* 导出配置为 JSON 文件(包含消息)
* @param {Object} config - 要导出的配置
* @param {Array} messages - 要导出的消息
*/
export const exportConfig = (config, messages = null) => {
try {
const configToExport = {
...config,
messages: messages || loadMessages(), // 包含消息数据
exportTime: new Date().toISOString(),
version: '1.0',
};
const dataStr = JSON.stringify(configToExport, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(dataBlob);
link.download = `playground-config-${new Date().toISOString().split('T')[0]}.json`;
link.click();
URL.revokeObjectURL(link.href);
} catch (error) {
console.error('导出配置失败:', error);
}
};
/**
* 从文件导入配置(包含消息)
* @param {File} file - 包含配置的 JSON 文件
* @returns {Promise<Object>} 导入的配置对象
*/
export const importConfig = (file) => {
return new Promise((resolve, reject) => {
try {
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedConfig = JSON.parse(e.target.result);
if (importedConfig.inputs && importedConfig.parameterEnabled) {
// 如果导入的配置包含消息,也一起导入
if (
importedConfig.messages &&
Array.isArray(importedConfig.messages)
) {
saveMessages(importedConfig.messages);
}
resolve(importedConfig);
} else {
reject(new Error('配置文件格式无效'));
}
} catch (parseError) {
reject(new Error('解析配置文件失败: ' + parseError.message));
}
};
reader.onerror = () => reject(new Error('读取文件失败'));
reader.readAsText(file);
} catch (error) {
reject(new Error('导入配置失败: ' + error.message));
}
});
};

View File

@@ -0,0 +1,310 @@
/*
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 React, {
useState,
useEffect,
forwardRef,
useImperativeHandle,
} from 'react';
import { useIsMobile } from '../../hooks/common/useIsMobile';
import {
Modal,
Table,
Input,
Space,
Highlight,
Select,
Tag,
} from '@douyinfe/semi-ui';
import { IconSearch } from '@douyinfe/semi-icons';
const OFFICIAL_RATIO_PRESET_ID = -100;
const MODELS_DEV_PRESET_ID = -101;
const OFFICIAL_RATIO_PRESET_NAME = '官方倍率预设';
const MODELS_DEV_PRESET_NAME = 'models.dev 价格预设';
const OFFICIAL_RATIO_PRESET_BASE_URL = 'https://basellm.github.io';
const MODELS_DEV_PRESET_BASE_URL = 'https://models.dev';
const ChannelSelectorModal = forwardRef(
(
{
visible,
onCancel,
onOk,
allChannels,
selectedChannelIds,
setSelectedChannelIds,
channelEndpoints,
updateChannelEndpoint,
t,
},
ref,
) => {
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const isMobile = useIsMobile();
const [filteredData, setFilteredData] = useState([]);
useImperativeHandle(ref, () => ({
resetPagination: () => {
setCurrentPage(1);
setSearchText('');
},
}));
// 官方渠道识别
const isOfficialChannel = (record) => {
const id = record?.key ?? record?.value ?? record?._originalData?.id;
const base = record?._originalData?.base_url || '';
const name = record?.label || '';
return (
id === OFFICIAL_RATIO_PRESET_ID ||
id === MODELS_DEV_PRESET_ID ||
base === OFFICIAL_RATIO_PRESET_BASE_URL ||
base === MODELS_DEV_PRESET_BASE_URL ||
name === OFFICIAL_RATIO_PRESET_NAME ||
name === MODELS_DEV_PRESET_NAME
);
};
useEffect(() => {
if (!allChannels) return;
const searchLower = searchText.trim().toLowerCase();
const matched = searchLower
? allChannels.filter((item) => {
const name = (item.label || '').toLowerCase();
const baseUrl = (item._originalData?.base_url || '').toLowerCase();
return name.includes(searchLower) || baseUrl.includes(searchLower);
})
: allChannels;
const sorted = [...matched].sort((a, b) => {
const wa = isOfficialChannel(a) ? 0 : 1;
const wb = isOfficialChannel(b) ? 0 : 1;
return wa - wb;
});
setFilteredData(sorted);
}, [allChannels, searchText]);
const total = filteredData.length;
const paginatedData = filteredData.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const updateEndpoint = (channelId, endpoint) => {
if (typeof updateChannelEndpoint === 'function') {
updateChannelEndpoint(channelId, endpoint);
}
};
const renderEndpointCell = (text, record) => {
const channelId = record.key || record.value;
const currentEndpoint = channelEndpoints[channelId] || '';
const getEndpointType = (ep) => {
if (ep === '/api/ratio_config') return 'ratio_config';
if (ep === '/api/pricing') return 'pricing';
if (ep === 'openrouter') return 'openrouter';
return 'custom';
};
const currentType = getEndpointType(currentEndpoint);
const handleTypeChange = (val) => {
if (val === 'ratio_config') {
updateEndpoint(channelId, '/api/ratio_config');
} else if (val === 'pricing') {
updateEndpoint(channelId, '/api/pricing');
} else if (val === 'openrouter') {
updateEndpoint(channelId, 'openrouter');
} else {
if (currentType !== 'custom') {
updateEndpoint(channelId, '');
}
}
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Select
size='small'
value={currentType}
onChange={handleTypeChange}
style={{ width: 120 }}
optionList={[
{ label: 'ratio_config', value: 'ratio_config' },
{ label: 'pricing', value: 'pricing' },
{ label: 'OpenRouter', value: 'openrouter' },
{ label: 'custom', value: 'custom' },
]}
/>
{currentType === 'custom' && (
<Input
size='small'
value={currentEndpoint}
onChange={(val) => updateEndpoint(channelId, val)}
placeholder='/your/endpoint'
style={{ width: 160, fontSize: 12 }}
/>
)}
</div>
);
};
const renderStatusCell = (record) => {
const status = record?._originalData?.status || 0;
const official = isOfficialChannel(record);
let statusTag = null;
switch (status) {
case 1:
statusTag = (
<Tag color='green' shape='circle'>
{t('已启用')}
</Tag>
);
break;
case 2:
statusTag = (
<Tag color='red' shape='circle'>
{t('已禁用')}
</Tag>
);
break;
case 3:
statusTag = (
<Tag color='yellow' shape='circle'>
{t('自动禁用')}
</Tag>
);
break;
default:
statusTag = (
<Tag color='grey' shape='circle'>
{t('未知状态')}
</Tag>
);
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{statusTag}
{official && (
<Tag color='green' shape='circle' type='light'>
{t('官方')}
</Tag>
)}
</div>
);
};
const renderNameCell = (text) => (
<Highlight sourceString={text} searchWords={[searchText]} />
);
const renderBaseUrlCell = (text) => (
<Highlight sourceString={text} searchWords={[searchText]} />
);
const columns = [
{
title: t('名称'),
dataIndex: 'label',
render: renderNameCell,
},
{
title: t('源地址'),
dataIndex: '_originalData.base_url',
render: (_, record) =>
renderBaseUrlCell(record._originalData?.base_url || ''),
},
{
title: t('状态'),
dataIndex: '_originalData.status',
render: (_, record) => renderStatusCell(record),
},
{
title: t('同步接口'),
dataIndex: 'endpoint',
fixed: 'right',
render: renderEndpointCell,
},
];
const rowSelection = {
selectedRowKeys: selectedChannelIds,
onChange: (keys) => setSelectedChannelIds(keys),
};
return (
<Modal
visible={visible}
onCancel={onCancel}
onOk={onOk}
title={
<span className='text-lg font-semibold'>{t('选择同步渠道')}</span>
}
size={isMobile ? 'full-width' : 'large'}
keepDOM
lazyRender={false}
>
<Space vertical style={{ width: '100%' }}>
<Input
prefix={<IconSearch size={14} />}
placeholder={t('搜索渠道名称或地址')}
value={searchText}
onChange={setSearchText}
showClear
/>
<Table
columns={columns}
dataSource={paginatedData}
rowKey='key'
rowSelection={rowSelection}
pagination={{
currentPage: currentPage,
pageSize: pageSize,
total: total,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
},
onShowSizeChange: (curr, size) => {
setCurrentPage(1);
setPageSize(size);
},
}}
size='small'
/>
</Space>
</Modal>
);
},
);
export default ChannelSelectorModal;

View File

@@ -0,0 +1,82 @@
/*
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 React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsChats from '../../pages/Setting/Chat/SettingsChats';
import { API, showError, toBoolean } from '../../helpers';
const ChatsSetting = () => {
let [inputs, setInputs] = useState({
/* 聊天设置 */
Chats: '[]',
});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (
item.key.endsWith('Enabled') ||
['DefaultCollapseSidebar'].includes(item.key)
) {
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
async function onRefresh() {
try {
setLoading(true);
await getOptions();
} catch (error) {
showError('刷新失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
onRefresh();
}, []);
return (
<>
<Spin spinning={loading} size='large'>
{/* 聊天设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsChats options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default ChatsSetting;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
/*
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 React, { useEffect, useState, useMemo } from 'react';
import { Card, Spin, Button, Modal } from '@douyinfe/semi-ui';
import { API, showError, showSuccess, toBoolean } from '../../helpers';
import SettingsAPIInfo from '../../pages/Setting/Dashboard/SettingsAPIInfo';
import SettingsAnnouncements from '../../pages/Setting/Dashboard/SettingsAnnouncements';
import SettingsFAQ from '../../pages/Setting/Dashboard/SettingsFAQ';
import SettingsUptimeKuma from '../../pages/Setting/Dashboard/SettingsUptimeKuma';
import SettingsDataDashboard from '../../pages/Setting/Dashboard/SettingsDataDashboard';
const DashboardSetting = () => {
let [inputs, setInputs] = useState({
'console_setting.api_info': '',
'console_setting.announcements': '',
'console_setting.faq': '',
'console_setting.uptime_kuma_groups': '',
'console_setting.api_info_enabled': '',
'console_setting.announcements_enabled': '',
'console_setting.faq_enabled': '',
'console_setting.uptime_kuma_enabled': '',
// 用于迁移检测的旧键,下个版本会删除
ApiInfo: '',
Announcements: '',
FAQ: '',
UptimeKumaUrl: '',
UptimeKumaSlug: '',
/* 数据看板 */
DataExportEnabled: false,
DataExportDefaultTime: 'hour',
DataExportInterval: 5,
});
let [loading, setLoading] = useState(false);
const [showMigrateModal, setShowMigrateModal] = useState(false); // 下个版本会删除
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (item.key in inputs) {
newInputs[item.key] = item.value;
}
if (item.key.endsWith('Enabled') && item.key === 'DataExportEnabled') {
newInputs[item.key] = toBoolean(item.value);
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
async function onRefresh() {
try {
setLoading(true);
await getOptions();
} catch (error) {
showError('刷新失败');
console.error(error);
} finally {
setLoading(false);
}
}
useEffect(() => {
onRefresh();
}, []);
// 用于迁移检测的旧键,下个版本会删除
const hasLegacyData = useMemo(() => {
const legacyKeys = [
'ApiInfo',
'Announcements',
'FAQ',
'UptimeKumaUrl',
'UptimeKumaSlug',
];
return legacyKeys.some((k) => inputs[k]);
}, [inputs]);
useEffect(() => {
if (hasLegacyData) {
setShowMigrateModal(true);
}
}, [hasLegacyData]);
const handleMigrate = async () => {
try {
setLoading(true);
await API.post('/api/option/migrate_console_setting');
showSuccess('旧配置迁移完成');
await onRefresh();
setShowMigrateModal(false);
} catch (err) {
console.error(err);
showError('迁移失败: ' + (err.message || '未知错误'));
} finally {
setLoading(false);
}
};
return (
<>
<Spin spinning={loading} size='large'>
{/* 用于迁移检测的旧键模态框,下个版本会删除 */}
<Modal
title='配置迁移确认'
visible={showMigrateModal}
onOk={handleMigrate}
onCancel={() => setShowMigrateModal(false)}
confirmLoading={loading}
okText='确认迁移'
cancelText='取消'
>
<p>检测到旧版本的配置数据是否要迁移到新的配置格式</p>
<p style={{ color: '#f57c00', marginTop: '10px' }}>
<strong>注意</strong>
迁移过程中会自动处理数据格式转换迁移完成后旧配置将被清除请在迁移前在数据库中备份好旧配置
</p>
</Modal>
{/* 数据看板设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsDataDashboard options={inputs} refresh={onRefresh} />
</Card>
{/* 系统公告管理 */}
<Card style={{ marginTop: '10px' }}>
<SettingsAnnouncements options={inputs} refresh={onRefresh} />
</Card>
{/* API信息管理 */}
<Card style={{ marginTop: '10px' }}>
<SettingsAPIInfo options={inputs} refresh={onRefresh} />
</Card>
{/* 常见问答管理 */}
<Card style={{ marginTop: '10px' }}>
<SettingsFAQ options={inputs} refresh={onRefresh} />
</Card>
{/* Uptime Kuma 监控设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsUptimeKuma options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default DashboardSetting;

View File

@@ -0,0 +1,84 @@
/*
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 React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsDrawing from '../../pages/Setting/Drawing/SettingsDrawing';
import { API, showError, toBoolean } from '../../helpers';
const DrawingSetting = () => {
let [inputs, setInputs] = useState({
/* 绘图设置 */
DrawingEnabled: false,
MjNotifyEnabled: false,
MjAccountFilterEnabled: false,
MjForwardUrlEnabled: false,
MjModeClearEnabled: false,
MjActionCheckSuccessEnabled: false,
});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
async function onRefresh() {
try {
setLoading(true);
await getOptions();
} catch (error) {
showError('刷新失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
onRefresh();
}, []);
return (
<>
<Spin spinning={loading} size='large'>
{/* 绘图设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsDrawing options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default DrawingSetting;

View File

@@ -0,0 +1,70 @@
/*
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 React from 'react';
import { Form, Tag, Typography } from '@douyinfe/semi-ui';
export default function HttpStatusCodeRulesInput(props) {
const { Text } = Typography;
const {
label,
field,
placeholder,
extraText,
onChange,
parsed,
invalidText,
} = props;
return (
<>
<Form.Input
label={label}
placeholder={placeholder}
extraText={extraText}
field={field}
onChange={onChange}
/>
{parsed?.ok && parsed.tokens?.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
marginTop: 8,
}}
>
{parsed.tokens.map((token) => (
<Tag key={token} size='small'>
{token}
</Tag>
))}
</div>
)}
{!parsed?.ok && (
<Text type='danger' style={{ display: 'block', marginTop: 8 }}>
{invalidText}
{parsed?.invalidTokens && parsed.invalidTokens.length > 0
? `: ${parsed.invalidTokens.join(', ')}`
: ''}
</Text>
)}
</>
);
}

View File

@@ -0,0 +1,85 @@
/*
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 React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import { API, showError, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
import SettingModelDeployment from '../../pages/Setting/Model/SettingModelDeployment';
const ModelDeploymentSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false,
});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {
'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false,
};
data.forEach((item) => {
if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) {
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
async function onRefresh() {
try {
setLoading(true);
await getOptions();
} catch (error) {
showError('刷新失败');
console.error(error);
} finally {
setLoading(false);
}
}
useEffect(() => {
onRefresh();
}, []);
return (
<>
<Spin spinning={loading} size='large'>
<Card style={{ marginTop: '10px' }}>
<SettingModelDeployment options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default ModelDeploymentSetting;

View File

@@ -0,0 +1,137 @@
/*
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 React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { API, showError, showSuccess, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
import SettingGeminiModel from '../../pages/Setting/Model/SettingGeminiModel';
import SettingClaudeModel from '../../pages/Setting/Model/SettingClaudeModel';
import SettingGlobalModel from '../../pages/Setting/Model/SettingGlobalModel';
import SettingGrokModel from '../../pages/Setting/Model/SettingGrokModel';
import SettingsChannelAffinity from '../../pages/Setting/Operation/SettingsChannelAffinity';
const ModelSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
'gemini.safety_settings': '',
'gemini.version_settings': '',
'gemini.supported_imagine_models': '',
'gemini.remove_function_response_id_enabled': true,
'claude.model_headers_settings': '',
'claude.thinking_adapter_enabled': true,
'claude.default_max_tokens': '',
'claude.thinking_adapter_budget_tokens_percentage': 0.8,
'global.pass_through_request_enabled': false,
'global.thinking_model_blacklist': '[]',
'global.chat_completions_to_responses_policy': '{}',
'general_setting.ping_interval_enabled': false,
'general_setting.ping_interval_seconds': 60,
'gemini.thinking_adapter_enabled': false,
'gemini.thinking_adapter_budget_tokens_percentage': 0.6,
'grok.violation_deduction_enabled': true,
'grok.violation_deduction_amount': 0.05,
});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (
item.key === 'gemini.safety_settings' ||
item.key === 'gemini.version_settings' ||
item.key === 'claude.model_headers_settings' ||
item.key === 'claude.default_max_tokens' ||
item.key === 'gemini.supported_imagine_models' ||
item.key === 'global.thinking_model_blacklist' ||
item.key === 'global.chat_completions_to_responses_policy'
) {
if (item.value !== '') {
try {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
} catch (e) {
// Keep raw value so user can fix it, and avoid crashing the page.
console.error(`Invalid JSON for option ${item.key}:`, e);
}
}
}
// Keep boolean config keys ending with enabled/Enabled so UI parses correctly.
if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) {
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
async function onRefresh() {
try {
setLoading(true);
await getOptions();
// showSuccess('刷新成功');
} catch (error) {
showError('刷新失败');
console.error(error);
} finally {
setLoading(false);
}
}
useEffect(() => {
onRefresh();
}, []);
return (
<>
<Spin spinning={loading} size='large'>
{/* OpenAI */}
<Card style={{ marginTop: '10px' }}>
<SettingGlobalModel options={inputs} refresh={onRefresh} />
</Card>
{/* Channel affinity */}
<Card style={{ marginTop: '10px' }}>
<SettingsChannelAffinity options={inputs} refresh={onRefresh} />
</Card>
{/* Gemini */}
<Card style={{ marginTop: '10px' }}>
<SettingGeminiModel options={inputs} refresh={onRefresh} />
</Card>
{/* Claude */}
<Card style={{ marginTop: '10px' }}>
<SettingClaudeModel options={inputs} refresh={onRefresh} />
</Card>
{/* Grok */}
<Card style={{ marginTop: '10px' }}>
<SettingGrokModel options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default ModelSetting;

View File

@@ -0,0 +1,162 @@
/*
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 React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral';
import SettingsHeaderNavModules from '../../pages/Setting/Operation/SettingsHeaderNavModules';
import SettingsSidebarModulesAdmin from '../../pages/Setting/Operation/SettingsSidebarModulesAdmin';
import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords';
import SettingsLog from '../../pages/Setting/Operation/SettingsLog';
import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring';
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit';
import SettingsCheckin from '../../pages/Setting/Operation/SettingsCheckin';
import { API, showError, toBoolean } from '../../helpers';
const OperationSetting = () => {
let [inputs, setInputs] = useState({
/* 额度相关 */
QuotaForNewUser: 0,
PreConsumedQuota: 0,
QuotaForInviter: 0,
QuotaForInvitee: 0,
'quota_setting.enable_free_model_pre_consume': true,
/* 通用设置 */
TopUpLink: '',
'general_setting.docs_link': '',
QuotaPerUnit: 0,
USDExchangeRate: 0,
RetryTimes: 0,
'general_setting.quota_display_type': 'USD',
DisplayTokenStatEnabled: false,
DefaultCollapseSidebar: false,
DemoSiteEnabled: false,
SelfUseModeEnabled: false,
/* 顶栏模块管理 */
HeaderNavModules: '',
/* 左侧边栏模块管理(管理员) */
SidebarModulesAdmin: '',
/* 敏感词设置 */
CheckSensitiveEnabled: false,
CheckSensitiveOnPromptEnabled: false,
SensitiveWords: '',
/* 日志设置 */
LogConsumeEnabled: false,
/* 监控设置 */
ChannelDisableThreshold: 0,
QuotaRemindThreshold: 0,
AutomaticDisableChannelEnabled: false,
AutomaticEnableChannelEnabled: false,
AutomaticDisableKeywords: '',
AutomaticDisableStatusCodes: '401',
AutomaticRetryStatusCodes:
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
'monitor_setting.auto_test_channel_enabled': false,
'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */,
'checkin_setting.enabled': false,
'checkin_setting.min_quota': 1000,
'checkin_setting.max_quota': 10000,
/* 令牌设置 */
'token_setting.max_user_tokens': 1000,
});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (typeof inputs[item.key] === 'boolean') {
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
async function onRefresh() {
try {
setLoading(true);
await getOptions();
// showSuccess('刷新成功');
} catch (error) {
showError('刷新失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
onRefresh();
}, []);
return (
<>
<Spin spinning={loading} size='large'>
{/* 通用设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsGeneral options={inputs} refresh={onRefresh} />
</Card>
{/* 顶栏模块管理 */}
<div style={{ marginTop: '10px' }}>
<SettingsHeaderNavModules options={inputs} refresh={onRefresh} />
</div>
{/* 左侧边栏模块管理(管理员) */}
<div style={{ marginTop: '10px' }}>
<SettingsSidebarModulesAdmin options={inputs} refresh={onRefresh} />
</div>
{/* 屏蔽词过滤设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsSensitiveWords options={inputs} refresh={onRefresh} />
</Card>
{/* 日志设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsLog options={inputs} refresh={onRefresh} />
</Card>
{/* 监控设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsMonitoring options={inputs} refresh={onRefresh} />
</Card>
{/* 额度设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsCreditLimit options={inputs} refresh={onRefresh} />
</Card>
{/* 签到设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsCheckin options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default OperationSetting;

View File

@@ -0,0 +1,524 @@
/*
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 React, { useContext, useEffect, useRef, useState } from 'react';
import {
Banner,
Button,
Col,
Form,
Row,
Modal,
Space,
Card,
} from '@douyinfe/semi-ui';
import { API, showError, showSuccess, timestamp2string } from '../../helpers';
import { marked } from 'marked';
import { useTranslation } from 'react-i18next';
import { StatusContext } from '../../context/Status';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
const LEGAL_USER_AGREEMENT_KEY = 'legal.user_agreement';
const LEGAL_PRIVACY_POLICY_KEY = 'legal.privacy_policy';
const OtherSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
Notice: '',
[LEGAL_USER_AGREEMENT_KEY]: '',
[LEGAL_PRIVACY_POLICY_KEY]: '',
SystemName: '',
Logo: '',
Footer: '',
About: '',
HomePageContent: '',
});
let [loading, setLoading] = useState(false);
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [statusState, statusDispatch] = useContext(StatusContext);
const [updateData, setUpdateData] = useState({
tag_name: '',
content: '',
});
const updateOption = async (key, value) => {
setLoading(true);
const res = await API.put('/api/option/', {
key,
value,
});
const { success, message } = res.data;
if (success) {
setInputs((inputs) => ({ ...inputs, [key]: value }));
} else {
showError(message);
}
setLoading(false);
};
const [loadingInput, setLoadingInput] = useState({
Notice: false,
[LEGAL_USER_AGREEMENT_KEY]: false,
[LEGAL_PRIVACY_POLICY_KEY]: false,
SystemName: false,
Logo: false,
HomePageContent: false,
About: false,
Footer: false,
CheckUpdate: false,
});
const handleInputChange = async (value, e) => {
const name = e.target.id;
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
// 通用设置
const formAPISettingGeneral = useRef();
// 通用设置 - Notice
const submitNotice = async () => {
try {
setLoadingInput((loadingInput) => ({ ...loadingInput, Notice: true }));
await updateOption('Notice', inputs.Notice);
showSuccess(t('公告已更新'));
} catch (error) {
console.error(t('公告更新失败'), error);
showError(t('公告更新失败'));
} finally {
setLoadingInput((loadingInput) => ({ ...loadingInput, Notice: false }));
}
};
// 通用设置 - UserAgreement
const submitUserAgreement = async () => {
try {
setLoadingInput((loadingInput) => ({
...loadingInput,
[LEGAL_USER_AGREEMENT_KEY]: true,
}));
await updateOption(
LEGAL_USER_AGREEMENT_KEY,
inputs[LEGAL_USER_AGREEMENT_KEY],
);
showSuccess(t('用户协议已更新'));
} catch (error) {
console.error(t('用户协议更新失败'), error);
showError(t('用户协议更新失败'));
} finally {
setLoadingInput((loadingInput) => ({
...loadingInput,
[LEGAL_USER_AGREEMENT_KEY]: false,
}));
}
};
// 通用设置 - PrivacyPolicy
const submitPrivacyPolicy = async () => {
try {
setLoadingInput((loadingInput) => ({
...loadingInput,
[LEGAL_PRIVACY_POLICY_KEY]: true,
}));
await updateOption(
LEGAL_PRIVACY_POLICY_KEY,
inputs[LEGAL_PRIVACY_POLICY_KEY],
);
showSuccess(t('隐私政策已更新'));
} catch (error) {
console.error(t('隐私政策更新失败'), error);
showError(t('隐私政策更新失败'));
} finally {
setLoadingInput((loadingInput) => ({
...loadingInput,
[LEGAL_PRIVACY_POLICY_KEY]: false,
}));
}
};
// 个性化设置
const formAPIPersonalization = useRef();
// 个性化设置 - SystemName
const submitSystemName = async () => {
try {
setLoadingInput((loadingInput) => ({
...loadingInput,
SystemName: true,
}));
await updateOption('SystemName', inputs.SystemName);
showSuccess(t('系统名称已更新'));
} catch (error) {
console.error(t('系统名称更新失败'), error);
showError(t('系统名称更新失败'));
} finally {
setLoadingInput((loadingInput) => ({
...loadingInput,
SystemName: false,
}));
}
};
// 个性化设置 - Logo
const submitLogo = async () => {
try {
setLoadingInput((loadingInput) => ({ ...loadingInput, Logo: true }));
await updateOption('Logo', inputs.Logo);
showSuccess('Logo 已更新');
} catch (error) {
console.error('Logo 更新失败', error);
showError('Logo 更新失败');
} finally {
setLoadingInput((loadingInput) => ({ ...loadingInput, Logo: false }));
}
};
// 个性化设置 - 首页内容
const submitOption = async (key) => {
try {
setLoadingInput((loadingInput) => ({
...loadingInput,
HomePageContent: true,
}));
await updateOption(key, inputs[key]);
showSuccess('首页内容已更新');
} catch (error) {
console.error('首页内容更新失败', error);
showError('首页内容更新失败');
} finally {
setLoadingInput((loadingInput) => ({
...loadingInput,
HomePageContent: false,
}));
}
};
// 个性化设置 - 关于
const submitAbout = async () => {
try {
setLoadingInput((loadingInput) => ({ ...loadingInput, About: true }));
await updateOption('About', inputs.About);
showSuccess('关于内容已更新');
} catch (error) {
console.error('关于内容更新失败', error);
showError('关于内容更新失败');
} finally {
setLoadingInput((loadingInput) => ({ ...loadingInput, About: false }));
}
};
// 个性化设置 - 页脚
const submitFooter = async () => {
try {
setLoadingInput((loadingInput) => ({ ...loadingInput, Footer: true }));
await updateOption('Footer', inputs.Footer);
showSuccess('页脚内容已更新');
} catch (error) {
console.error('页脚内容更新失败', error);
showError('页脚内容更新失败');
} finally {
setLoadingInput((loadingInput) => ({ ...loadingInput, Footer: false }));
}
};
const checkUpdate = async () => {
try {
setLoadingInput((loadingInput) => ({
...loadingInput,
CheckUpdate: true,
}));
// Use a CORS proxy to avoid direct cross-origin requests to GitHub API
// Option 1: Use a public CORS proxy service
// const proxyUrl = 'https://cors-anywhere.herokuapp.com/';
// const res = await API.get(
// `${proxyUrl}https://api.github.com/repos/Calcium-Ion/new-api/releases/latest`,
// );
// Option 2: Use the JSON proxy approach which often works better with GitHub API
const res = await fetch(
'https://api.github.com/repos/Calcium-Ion/new-api/releases/latest',
{
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
// Adding User-Agent which is often required by GitHub API
'User-Agent': 'new-api-update-checker',
},
},
).then((response) => response.json());
// Option 3: Use a local proxy endpoint
// Create a cached version of the response to avoid frequent GitHub API calls
// const res = await API.get('/api/status/github-latest-release');
const { tag_name, body } = res;
if (tag_name === statusState?.status?.version) {
showSuccess(`已是最新版本:${tag_name}`);
} else {
setUpdateData({
tag_name: tag_name,
content: marked.parse(body),
});
setShowUpdateModal(true);
}
} catch (error) {
console.error('Failed to check for updates:', error);
showError('检查更新失败,请稍后再试');
} finally {
setLoadingInput((loadingInput) => ({
...loadingInput,
CheckUpdate: false,
}));
}
};
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (item.key in inputs) {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
formAPISettingGeneral.current.setValues(newInputs);
formAPIPersonalization.current.setValues(newInputs);
} else {
showError(message);
}
};
useEffect(() => {
getOptions();
}, []);
// Function to open GitHub release page
const openGitHubRelease = () => {
window.open(
`https://github.com/Calcium-Ion/new-api/releases/tag/${updateData.tag_name}`,
'_blank',
);
};
const getStartTimeString = () => {
const timestamp = statusState?.status?.start_time;
return statusState.status ? timestamp2string(timestamp) : '';
};
return (
<Row>
<Col
span={24}
style={{
marginTop: '10px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
}}
>
{/* 版本信息 */}
<Form>
<Card>
<Form.Section text={t('系统信息')}>
<Row>
<Col span={16}>
<Space>
<Text>
{t('当前版本')}
{statusState?.status?.version || t('未知')}
</Text>
<Button
type='primary'
onClick={checkUpdate}
loading={loadingInput['CheckUpdate']}
>
{t('检查更新')}
</Button>
</Space>
</Col>
</Row>
<Row>
<Col span={16}>
<Text>
{t('启动时间')}{getStartTimeString()}
</Text>
</Col>
</Row>
</Form.Section>
</Card>
</Form>
{/* 通用设置 */}
<Form
values={inputs}
getFormApi={(formAPI) => (formAPISettingGeneral.current = formAPI)}
>
<Card>
<Form.Section text={t('通用设置')}>
<Form.TextArea
label={t('公告')}
placeholder={t(
'在此输入新的公告内容,支持 Markdown & HTML 代码',
)}
field={'Notice'}
onChange={handleInputChange}
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
autosize={{ minRows: 6, maxRows: 12 }}
/>
<Button onClick={submitNotice} loading={loadingInput['Notice']}>
{t('设置公告')}
</Button>
<Form.TextArea
label={t('用户协议')}
placeholder={t(
'在此输入用户协议内容,支持 Markdown & HTML 代码',
)}
field={LEGAL_USER_AGREEMENT_KEY}
onChange={handleInputChange}
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
autosize={{ minRows: 6, maxRows: 12 }}
helpText={t(
'填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议',
)}
/>
<Button
onClick={submitUserAgreement}
loading={loadingInput[LEGAL_USER_AGREEMENT_KEY]}
>
{t('设置用户协议')}
</Button>
<Form.TextArea
label={t('隐私政策')}
placeholder={t(
'在此输入隐私政策内容,支持 Markdown & HTML 代码',
)}
field={LEGAL_PRIVACY_POLICY_KEY}
onChange={handleInputChange}
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
autosize={{ minRows: 6, maxRows: 12 }}
helpText={t(
'填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策',
)}
/>
<Button
onClick={submitPrivacyPolicy}
loading={loadingInput[LEGAL_PRIVACY_POLICY_KEY]}
>
{t('设置隐私政策')}
</Button>
</Form.Section>
</Card>
</Form>
{/* 个性化设置 */}
<Form
values={inputs}
getFormApi={(formAPI) => (formAPIPersonalization.current = formAPI)}
>
<Card>
<Form.Section text={t('个性化设置')}>
<Form.Input
label={t('系统名称')}
placeholder={t('在此输入系统名称')}
field={'SystemName'}
onChange={handleInputChange}
/>
<Button
onClick={submitSystemName}
loading={loadingInput['SystemName']}
>
{t('设置系统名称')}
</Button>
<Form.Input
label={t('Logo 图片地址')}
placeholder={t('在此输入 Logo 图片地址')}
field={'Logo'}
onChange={handleInputChange}
/>
<Button onClick={submitLogo} loading={loadingInput['Logo']}>
{t('设置 Logo')}
</Button>
<Form.TextArea
label={t('首页内容')}
placeholder={t(
'在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页',
)}
field={'HomePageContent'}
onChange={handleInputChange}
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
autosize={{ minRows: 6, maxRows: 12 }}
/>
<Button
onClick={() => submitOption('HomePageContent')}
loading={loadingInput['HomePageContent']}
>
{t('设置首页内容')}
</Button>
<Form.TextArea
label={t('关于')}
placeholder={t(
'在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面',
)}
field={'About'}
onChange={handleInputChange}
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
autosize={{ minRows: 6, maxRows: 12 }}
/>
<Button onClick={submitAbout} loading={loadingInput['About']}>
{t('设置关于')}
</Button>
{/* */}
<Banner
fullMode={false}
type='info'
description={t(
'移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目',
)}
closeIcon={null}
style={{ marginTop: 15 }}
/>
<Form.Input
label={t('页脚')}
placeholder={t(
'在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码',
)}
field={'Footer'}
onChange={handleInputChange}
/>
<Button onClick={submitFooter} loading={loadingInput['Footer']}>
{t('设置页脚')}
</Button>
</Form.Section>
</Card>
</Form>
</Col>
<Modal
title={t('新版本') + '' + updateData.tag_name}
visible={showUpdateModal}
onCancel={() => setShowUpdateModal(false)}
footer={[
<Button
key='details'
type='primary'
onClick={() => {
setShowUpdateModal(false);
openGitHubRelease();
}}
>
{t('详情')}
</Button>,
]}
>
<div dangerouslySetInnerHTML={{ __html: updateData.content }}></div>
</Modal>
</Row>
);
};
export default OtherSetting;

View File

@@ -0,0 +1,155 @@
/*
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 React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment';
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway';
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe';
import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem';
import SettingsPaymentGatewayWaffo from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffo';
import { API, showError, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
const PaymentSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
ServerAddress: '',
PayAddress: '',
EpayId: '',
EpayKey: '',
Price: 7.3,
MinTopUp: 1,
TopupGroupRatio: '',
CustomCallbackAddress: '',
PayMethods: '',
AmountOptions: '',
AmountDiscount: '',
StripeApiSecret: '',
StripeWebhookSecret: '',
StripePriceId: '',
StripeUnitPrice: 8.0,
StripeMinTopUp: 1,
StripePromotionCodesEnabled: false,
});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
switch (item.key) {
case 'TopupGroupRatio':
try {
newInputs[item.key] = JSON.stringify(
JSON.parse(item.value),
null,
2,
);
} catch (error) {
newInputs[item.key] = item.value;
}
break;
case 'payment_setting.amount_options':
try {
newInputs['AmountOptions'] = JSON.stringify(
JSON.parse(item.value),
null,
2,
);
} catch (error) {
newInputs['AmountOptions'] = item.value;
}
break;
case 'payment_setting.amount_discount':
try {
newInputs['AmountDiscount'] = JSON.stringify(
JSON.parse(item.value),
null,
2,
);
} catch (error) {
newInputs['AmountDiscount'] = item.value;
}
break;
case 'Price':
case 'MinTopUp':
case 'StripeUnitPrice':
case 'StripeMinTopUp':
newInputs[item.key] = parseFloat(item.value);
break;
default:
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}
break;
}
});
setInputs(newInputs);
} else {
showError(t(message));
}
};
async function onRefresh() {
try {
setLoading(true);
await getOptions();
} catch (error) {
showError(t('刷新失败'));
} finally {
setLoading(false);
}
}
useEffect(() => {
onRefresh();
}, []);
return (
<>
<Spin spinning={loading} size='large'>
<Card style={{ marginTop: '10px' }}>
<SettingsGeneralPayment options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGateway options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayStripe options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayCreem options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayWaffo options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default PaymentSetting;

View File

@@ -0,0 +1,80 @@
/*
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 React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsPerformance from '../../pages/Setting/Performance/SettingsPerformance';
import { API, showError, toBoolean } from '../../helpers';
const PerformanceSetting = () => {
let [inputs, setInputs] = useState({
'performance_setting.disk_cache_enabled': false,
'performance_setting.disk_cache_threshold_mb': 10,
'performance_setting.disk_cache_max_size_mb': 1024,
'performance_setting.disk_cache_path': '',
});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (typeof inputs[item.key] === 'boolean') {
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
async function onRefresh() {
try {
setLoading(true);
await getOptions();
} catch (error) {
showError('刷新失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
onRefresh();
}, []);
return (
<>
<Spin spinning={loading} size='large'>
{/* 性能设置 */}
<Card style={{ marginTop: '10px' }}>
<SettingsPerformance options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default PerformanceSetting;

View File

@@ -0,0 +1,562 @@
/*
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 React, { useContext, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
API,
copy,
showError,
showInfo,
showSuccess,
setStatusData,
prepareCredentialCreationOptions,
buildRegistrationResult,
isPasskeySupported,
setUserData,
} from '../../helpers';
import { UserContext } from '../../context/User';
import { Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
// 导入子组件
import UserInfoHeader from './personal/components/UserInfoHeader';
import AccountManagement from './personal/cards/AccountManagement';
import NotificationSettings from './personal/cards/NotificationSettings';
import PreferencesSettings from './personal/cards/PreferencesSettings';
import CheckinCalendar from './personal/cards/CheckinCalendar';
import EmailBindModal from './personal/modals/EmailBindModal';
import WeChatBindModal from './personal/modals/WeChatBindModal';
import AccountDeleteModal from './personal/modals/AccountDeleteModal';
import ChangePasswordModal from './personal/modals/ChangePasswordModal';
const PersonalSetting = () => {
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
const { t } = useTranslation();
const [inputs, setInputs] = useState({
wechat_verification_code: '',
email_verification_code: '',
email: '',
self_account_deletion_confirmation: '',
original_password: '',
set_new_password: '',
set_new_password_confirmation: '',
});
const [status, setStatus] = useState({});
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
const [showEmailBindModal, setShowEmailBindModal] = useState(false);
const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [loading, setLoading] = useState(false);
const [disableButton, setDisableButton] = useState(false);
const [countdown, setCountdown] = useState(30);
const [systemToken, setSystemToken] = useState('');
const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false });
const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false);
const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false);
const [passkeySupported, setPasskeySupported] = useState(false);
const [notificationSettings, setNotificationSettings] = useState({
warningType: 'email',
warningThreshold: 100000,
webhookUrl: '',
webhookSecret: '',
notificationEmail: '',
barkUrl: '',
gotifyUrl: '',
gotifyToken: '',
gotifyPriority: 5,
upstreamModelUpdateNotifyEnabled: false,
acceptUnsetModelRatioModel: false,
recordIpLog: false,
});
useEffect(() => {
let saved = localStorage.getItem('status');
if (saved) {
const parsed = JSON.parse(saved);
setStatus(parsed);
if (parsed.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(parsed.turnstile_site_key);
} else {
setTurnstileEnabled(false);
setTurnstileSiteKey('');
}
}
// Always refresh status from server to avoid stale flags (e.g., admin just enabled OAuth)
(async () => {
try {
const res = await API.get('/api/status');
const { success, data } = res.data;
if (success && data) {
setStatus(data);
setStatusData(data);
if (data.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(data.turnstile_site_key);
} else {
setTurnstileEnabled(false);
setTurnstileSiteKey('');
}
}
} catch (e) {
// ignore and keep local status
}
})();
getUserData();
isPasskeySupported()
.then(setPasskeySupported)
.catch(() => setPasskeySupported(false));
}, []);
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(() => {
if (userState?.user?.setting) {
const settings = JSON.parse(userState.user.setting);
setNotificationSettings({
warningType: settings.notify_type || 'email',
warningThreshold: settings.quota_warning_threshold || 500000,
webhookUrl: settings.webhook_url || '',
webhookSecret: settings.webhook_secret || '',
notificationEmail: settings.notification_email || '',
barkUrl: settings.bark_url || '',
gotifyUrl: settings.gotify_url || '',
gotifyToken: settings.gotify_token || '',
gotifyPriority:
settings.gotify_priority !== undefined ? settings.gotify_priority : 5,
upstreamModelUpdateNotifyEnabled:
settings.upstream_model_update_notify_enabled === true,
acceptUnsetModelRatioModel:
settings.accept_unset_model_ratio_model || false,
recordIpLog: settings.record_ip_log || false,
});
}
}, [userState?.user?.setting]);
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const generateAccessToken = async () => {
const res = await API.get('/api/user/token');
const { success, message, data } = res.data;
if (success) {
setSystemToken(data);
await copy(data);
showSuccess(t('令牌已重置并已复制到剪贴板'));
} else {
showError(message);
}
};
const loadPasskeyStatus = async () => {
try {
const res = await API.get('/api/user/passkey');
const { success, data, message } = res.data;
if (success) {
setPasskeyStatus({
enabled: data?.enabled || false,
last_used_at: data?.last_used_at || null,
backup_eligible: data?.backup_eligible || false,
backup_state: data?.backup_state || false,
});
} else {
showError(message);
}
} catch (error) {
// 忽略错误,保留默认状态
}
};
const handleRegisterPasskey = async () => {
if (!passkeySupported || !window.PublicKeyCredential) {
showInfo(t('当前设备不支持 Passkey'));
return;
}
setPasskeyRegisterLoading(true);
try {
const beginRes = await API.post('/api/user/passkey/register/begin');
const { success, message, data } = beginRes.data;
if (!success) {
showError(message || t('无法发起 Passkey 注册'));
return;
}
const publicKey = prepareCredentialCreationOptions(
data?.options || data?.publicKey || data,
);
const credential = await navigator.credentials.create({ publicKey });
const payload = buildRegistrationResult(credential);
if (!payload) {
showError(t('Passkey 注册失败,请重试'));
return;
}
const finishRes = await API.post(
'/api/user/passkey/register/finish',
payload,
);
if (finishRes.data.success) {
showSuccess(t('Passkey 注册成功'));
await loadPasskeyStatus();
} else {
showError(finishRes.data.message || t('Passkey 注册失败,请重试'));
}
} catch (error) {
if (error?.name === 'AbortError') {
showInfo(t('已取消 Passkey 注册'));
} else {
showError(t('Passkey 注册失败,请重试'));
}
} finally {
setPasskeyRegisterLoading(false);
}
};
const handleRemovePasskey = async () => {
setPasskeyDeleteLoading(true);
try {
const res = await API.delete('/api/user/passkey');
const { success, message } = res.data;
if (success) {
showSuccess(t('Passkey 已解绑'));
await loadPasskeyStatus();
} else {
showError(message || t('操作失败,请重试'));
}
} catch (error) {
showError(t('操作失败,请重试'));
} finally {
setPasskeyDeleteLoading(false);
}
};
const getUserData = async () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
setUserData(data);
await loadPasskeyStatus();
} else {
showError(message);
}
};
const handleSystemTokenClick = async (e) => {
e.target.select();
await copy(e.target.value);
showSuccess(t('系统令牌已复制到剪切板'));
};
const deleteAccount = async () => {
if (inputs.self_account_deletion_confirmation !== userState.user.username) {
showError(t('请输入你的账户名以确认删除!'));
return;
}
const res = await API.delete('/api/user/self');
const { success, message } = res.data;
if (success) {
showSuccess(t('账户已删除!'));
await API.get('/api/user/logout');
userDispatch({ type: 'logout' });
localStorage.removeItem('user');
navigate('/login');
} else {
showError(message);
}
};
const bindWeChat = async () => {
if (inputs.wechat_verification_code === '') return;
const res = await API.get(
`/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`,
);
const { success, message } = res.data;
if (success) {
showSuccess(t('微信账户绑定成功!'));
setShowWeChatBindModal(false);
} else {
showError(message);
}
};
const changePassword = async () => {
// if (inputs.original_password === '') {
// showError(t('请输入原密码!'));
// return;
// }
if (inputs.set_new_password === '') {
showError(t('请输入新密码!'));
return;
}
if (inputs.original_password === inputs.set_new_password) {
showError(t('新密码需要和原密码不一致!'));
return;
}
if (inputs.set_new_password !== inputs.set_new_password_confirmation) {
showError(t('两次输入的密码不一致!'));
return;
}
const res = await API.put(`/api/user/self`, {
original_password: inputs.original_password,
password: inputs.set_new_password,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('密码修改成功!'));
setShowWeChatBindModal(false);
} else {
showError(message);
}
setShowChangePasswordModal(false);
};
const sendVerificationCode = async () => {
if (inputs.email === '') {
showError(t('请输入邮箱!'));
return;
}
setDisableButton(true);
if (turnstileEnabled && turnstileToken === '') {
showInfo(t('请稍后几秒重试Turnstile 正在检查用户环境!'));
return;
}
setLoading(true);
const res = await API.get(
`/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
);
const { success, message } = res.data;
if (success) {
showSuccess(t('验证码发送成功,请检查邮箱!'));
} else {
showError(message);
}
setLoading(false);
};
const bindEmail = async () => {
if (inputs.email_verification_code === '') {
showError(t('请输入邮箱验证码!'));
return;
}
setLoading(true);
const res = await API.get(
`/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`,
);
const { success, message } = res.data;
if (success) {
showSuccess(t('邮箱账户绑定成功!'));
setShowEmailBindModal(false);
userState.user.email = inputs.email;
} else {
showError(message);
}
setLoading(false);
};
const copyText = async (text) => {
if (await copy(text)) {
showSuccess(t('已复制:') + text);
} else {
// setSearchKeyword(text);
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
}
};
const handleNotificationSettingChange = (type, value) => {
setNotificationSettings((prev) => ({
...prev,
[type]: value.target
? value.target.value !== undefined
? value.target.value
: value.target.checked
: value, // handle checkbox properly
}));
};
const saveNotificationSettings = async () => {
try {
const res = await API.put('/api/user/setting', {
notify_type: notificationSettings.warningType,
quota_warning_threshold: parseFloat(
notificationSettings.warningThreshold,
),
webhook_url: notificationSettings.webhookUrl,
webhook_secret: notificationSettings.webhookSecret,
notification_email: notificationSettings.notificationEmail,
bark_url: notificationSettings.barkUrl,
gotify_url: notificationSettings.gotifyUrl,
gotify_token: notificationSettings.gotifyToken,
gotify_priority: (() => {
const parsed = parseInt(notificationSettings.gotifyPriority);
return isNaN(parsed) ? 5 : parsed;
})(),
upstream_model_update_notify_enabled:
notificationSettings.upstreamModelUpdateNotifyEnabled === true,
accept_unset_model_ratio_model:
notificationSettings.acceptUnsetModelRatioModel,
record_ip_log: notificationSettings.recordIpLog,
});
if (res.data.success) {
showSuccess(t('设置保存成功'));
await getUserData();
} else {
showError(res.data.message);
}
} catch (error) {
showError(t('设置保存失败'));
}
};
return (
<div className='mt-[60px]'>
<div className='flex justify-center'>
<div className='w-full max-w-7xl mx-auto px-2'>
{/* 顶部用户信息区域 */}
<UserInfoHeader t={t} userState={userState} />
{/* 签到日历 - 仅在启用时显示 */}
{status?.checkin_enabled && (
<div className='mt-4 md:mt-6'>
<CheckinCalendar
t={t}
status={status}
turnstileEnabled={turnstileEnabled}
turnstileSiteKey={turnstileSiteKey}
/>
</div>
)}
{/* 账户管理和其他设置 */}
<div className='grid grid-cols-1 xl:grid-cols-2 items-start gap-4 md:gap-6 mt-4 md:mt-6'>
{/* 左侧:账户管理设置 */}
<div className='flex flex-col gap-4 md:gap-6'>
<AccountManagement
t={t}
userState={userState}
status={status}
systemToken={systemToken}
setShowEmailBindModal={setShowEmailBindModal}
setShowWeChatBindModal={setShowWeChatBindModal}
generateAccessToken={generateAccessToken}
handleSystemTokenClick={handleSystemTokenClick}
setShowChangePasswordModal={setShowChangePasswordModal}
setShowAccountDeleteModal={setShowAccountDeleteModal}
passkeyStatus={passkeyStatus}
passkeySupported={passkeySupported}
passkeyRegisterLoading={passkeyRegisterLoading}
passkeyDeleteLoading={passkeyDeleteLoading}
onPasskeyRegister={handleRegisterPasskey}
onPasskeyDelete={handleRemovePasskey}
/>
{/* 偏好设置(语言等) */}
<PreferencesSettings t={t} />
</div>
{/* 右侧:其他设置 */}
<NotificationSettings
t={t}
notificationSettings={notificationSettings}
handleNotificationSettingChange={handleNotificationSettingChange}
saveNotificationSettings={saveNotificationSettings}
/>
</div>
</div>
</div>
{/* 模态框组件 */}
<EmailBindModal
t={t}
showEmailBindModal={showEmailBindModal}
setShowEmailBindModal={setShowEmailBindModal}
inputs={inputs}
handleInputChange={handleInputChange}
sendVerificationCode={sendVerificationCode}
bindEmail={bindEmail}
disableButton={disableButton}
loading={loading}
countdown={countdown}
turnstileEnabled={turnstileEnabled}
turnstileSiteKey={turnstileSiteKey}
setTurnstileToken={setTurnstileToken}
/>
<WeChatBindModal
t={t}
showWeChatBindModal={showWeChatBindModal}
setShowWeChatBindModal={setShowWeChatBindModal}
inputs={inputs}
handleInputChange={handleInputChange}
bindWeChat={bindWeChat}
status={status}
/>
<AccountDeleteModal
t={t}
showAccountDeleteModal={showAccountDeleteModal}
setShowAccountDeleteModal={setShowAccountDeleteModal}
inputs={inputs}
handleInputChange={handleInputChange}
deleteAccount={deleteAccount}
userState={userState}
turnstileEnabled={turnstileEnabled}
turnstileSiteKey={turnstileSiteKey}
setTurnstileToken={setTurnstileToken}
/>
<ChangePasswordModal
t={t}
showChangePasswordModal={showChangePasswordModal}
setShowChangePasswordModal={setShowChangePasswordModal}
inputs={inputs}
handleInputChange={handleInputChange}
changePassword={changePassword}
turnstileEnabled={turnstileEnabled}
turnstileSiteKey={turnstileSiteKey}
setTurnstileToken={setTurnstileToken}
/>
</div>
);
};
export default PersonalSetting;

View File

@@ -0,0 +1,89 @@
/*
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 React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import { API, showError, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
import RequestRateLimit from '../../pages/Setting/RateLimit/SettingsRequestRateLimit';
const RateLimitSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
ModelRequestRateLimitEnabled: false,
ModelRequestRateLimitCount: 0,
ModelRequestRateLimitSuccessCount: 1000,
ModelRequestRateLimitDurationMinutes: 1,
ModelRequestRateLimitGroup: '',
});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (item.key === 'ModelRequestRateLimitGroup') {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
}
if (item.key.endsWith('Enabled')) {
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
async function onRefresh() {
try {
setLoading(true);
await getOptions();
// showSuccess('刷新成功');
} catch (error) {
showError('刷新失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
onRefresh();
}, []);
return (
<>
<Spin spinning={loading} size='large'>
{/* AI请求速率限制 */}
<Card style={{ marginTop: '10px' }}>
<RequestRateLimit options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);
};
export default RateLimitSetting;

View File

@@ -0,0 +1,123 @@
/*
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 React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings';
import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings';
import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor';
import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor';
import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync';
import { API, showError, toBoolean } from '../../helpers';
const RatioSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
ModelPrice: '',
ModelRatio: '',
CacheRatio: '',
CreateCacheRatio: '',
CompletionRatio: '',
GroupRatio: '',
GroupGroupRatio: '',
ImageRatio: '',
AudioRatio: '',
AudioCompletionRatio: '',
AutoGroups: '',
DefaultUseAutoGroup: false,
ExposeRatioEnabled: false,
UserUsableGroups: '',
SubscriptionGroups: '',
'group_ratio_setting.group_special_usable_group': '',
});
const [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (item.value.startsWith('{') || item.value.startsWith('[')) {
try {
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
} catch (e) {
// 如果后端返回的不是合法 JSON直接展示
}
}
if (['DefaultUseAutoGroup', 'ExposeRatioEnabled'].includes(item.key)) {
newInputs[item.key] = toBoolean(item.value);
} else {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
const onRefresh = async () => {
try {
setLoading(true);
await getOptions();
} catch (error) {
showError('刷新失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
onRefresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Spin spinning={loading} size='large'>
{/* 模型倍率设置以及价格编辑器 */}
<Card style={{ marginTop: '10px' }}>
<Tabs type='card' defaultActiveKey='visual'>
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
<ModelRatioSettings options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('分组相关设置')} itemKey='group'>
<GroupRatioSettings options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('价格设置')} itemKey='visual'>
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('未设置价格模型')} itemKey='unset_models'>
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('上游倍率同步')} itemKey='upstream_sync'>
<UpstreamRatioSync options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
</Tabs>
</Card>
</Spin>
);
};
export default RatioSetting;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,774 @@
/*
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 React from 'react';
import {
Button,
Card,
Input,
Space,
Typography,
Avatar,
Tabs,
TabPane,
Popover,
Modal,
} from '@douyinfe/semi-ui';
import {
IconMail,
IconShield,
IconGithubLogo,
IconKey,
IconLock,
IconDelete,
} from '@douyinfe/semi-icons';
import { SiTelegram, SiWechat, SiLinux, SiDiscord } from 'react-icons/si';
import { UserPlus, ShieldCheck } from 'lucide-react';
import TelegramLoginButton from 'react-telegram-login';
import {
API,
showError,
showSuccess,
onGitHubOAuthClicked,
onOIDCClicked,
onLinuxDOOAuthClicked,
onDiscordOAuthClicked,
onCustomOAuthClicked,
getOAuthProviderIcon,
} from '../../../../helpers';
import TwoFASetting from '../components/TwoFASetting';
const AccountManagement = ({
t,
userState,
status,
systemToken,
setShowEmailBindModal,
setShowWeChatBindModal,
generateAccessToken,
handleSystemTokenClick,
setShowChangePasswordModal,
setShowAccountDeleteModal,
passkeyStatus,
passkeySupported,
passkeyRegisterLoading,
passkeyDeleteLoading,
onPasskeyRegister,
onPasskeyDelete,
}) => {
const renderAccountInfo = (accountId, label) => {
if (!accountId || accountId === '') {
return <span className='text-gray-500'>{t('未绑定')}</span>;
}
const popContent = (
<div className='text-xs p-2'>
<Typography.Paragraph copyable={{ content: accountId }}>
{accountId}
</Typography.Paragraph>
{label ? (
<div className='mt-1 text-[11px] text-gray-500'>{label}</div>
) : null}
</div>
);
return (
<Popover content={popContent} position='top' trigger='hover'>
<span className='block max-w-full truncate text-gray-600 hover:text-blue-600 cursor-pointer'>
{accountId}
</span>
</Popover>
);
};
const isBound = (accountId) => Boolean(accountId);
const [showTelegramBindModal, setShowTelegramBindModal] =
React.useState(false);
const [customOAuthBindings, setCustomOAuthBindings] = React.useState([]);
const [customOAuthLoading, setCustomOAuthLoading] = React.useState({});
// Fetch custom OAuth bindings
const loadCustomOAuthBindings = async () => {
try {
const res = await API.get('/api/user/oauth/bindings');
if (res.data.success) {
setCustomOAuthBindings(res.data.data || []);
} else {
showError(res.data.message || t('获取绑定信息失败'));
}
} catch (error) {
showError(error.response?.data?.message || error.message || t('获取绑定信息失败'));
}
};
// Unbind custom OAuth provider
const handleUnbindCustomOAuth = async (providerId, providerName) => {
Modal.confirm({
title: t('确认解绑'),
content: t('确定要解绑 {{name}} 吗?', { name: providerName }),
okText: t('确认'),
cancelText: t('取消'),
onOk: async () => {
setCustomOAuthLoading((prev) => ({ ...prev, [providerId]: true }));
try {
const res = await API.delete(`/api/user/oauth/bindings/${providerId}`);
if (res.data.success) {
showSuccess(t('解绑成功'));
await loadCustomOAuthBindings();
} else {
showError(res.data.message);
}
} catch (error) {
showError(error.response?.data?.message || error.message || t('操作失败'));
} finally {
setCustomOAuthLoading((prev) => ({ ...prev, [providerId]: false }));
}
},
});
};
// Handle bind custom OAuth
const handleBindCustomOAuth = (provider) => {
onCustomOAuthClicked(provider);
};
// Check if custom OAuth provider is bound
const isCustomOAuthBound = (providerId) => {
const normalizedId = Number(providerId);
return customOAuthBindings.some((b) => Number(b.provider_id) === normalizedId);
};
// Get binding info for a provider
const getCustomOAuthBinding = (providerId) => {
const normalizedId = Number(providerId);
return customOAuthBindings.find((b) => Number(b.provider_id) === normalizedId);
};
React.useEffect(() => {
loadCustomOAuthBindings();
}, []);
const passkeyEnabled = passkeyStatus?.enabled;
const lastUsedLabel = passkeyStatus?.last_used_at
? new Date(passkeyStatus.last_used_at).toLocaleString()
: t('尚未使用');
return (
<Card className='!rounded-2xl'>
{/* 卡片头部 */}
<div className='flex items-center mb-4'>
<Avatar size='small' color='teal' className='mr-3 shadow-md'>
<UserPlus size={16} />
</Avatar>
<div>
<Typography.Text className='text-lg font-medium'>
{t('账户管理')}
</Typography.Text>
<div className='text-xs text-gray-600'>
{t('账户绑定、安全设置和身份验证')}
</div>
</div>
</div>
<Tabs type='card' defaultActiveKey='binding'>
{/* 账户绑定 Tab */}
<TabPane
tab={
<div className='flex items-center'>
<UserPlus size={16} className='mr-2' />
{t('账户绑定')}
</div>
}
itemKey='binding'
>
<div className='py-4'>
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
{/* 邮箱绑定 */}
<Card className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center flex-1 min-w-0'>
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
<IconMail
size='default'
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{t('邮箱')}
</div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.email,
t('邮箱地址'),
)}
</div>
</div>
</div>
<div className='flex-shrink-0'>
<Button
type='primary'
theme='outline'
size='small'
onClick={() => setShowEmailBindModal(true)}
>
{isBound(userState.user?.email)
? t('修改绑定')
: t('绑定')}
</Button>
</div>
</div>
</Card>
{/* 微信绑定 */}
<Card className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center flex-1 min-w-0'>
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
<SiWechat
size={20}
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{t('微信')}
</div>
<div className='text-sm text-gray-500 truncate'>
{!status.wechat_login
? t('未启用')
: isBound(userState.user?.wechat_id)
? t('已绑定')
: t('未绑定')}
</div>
</div>
</div>
<div className='flex-shrink-0'>
<Button
type='primary'
theme='outline'
size='small'
disabled={!status.wechat_login}
onClick={() => setShowWeChatBindModal(true)}
>
{isBound(userState.user?.wechat_id)
? t('修改绑定')
: status.wechat_login
? t('绑定')
: t('未启用')}
</Button>
</div>
</div>
</Card>
{/* GitHub绑定 */}
<Card className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center flex-1 min-w-0'>
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
<IconGithubLogo
size='default'
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{t('GitHub')}
</div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.github_id,
t('GitHub ID'),
)}
</div>
</div>
</div>
<div className='flex-shrink-0'>
<Button
type='primary'
theme='outline'
size='small'
onClick={() =>
onGitHubOAuthClicked(status.github_client_id)
}
disabled={
isBound(userState.user?.github_id) ||
!status.github_oauth
}
>
{status.github_oauth ? t('绑定') : t('未启用')}
</Button>
</div>
</div>
</Card>
{/* Discord绑定 */}
<Card className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center flex-1 min-w-0'>
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
<SiDiscord
size={20}
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{t('Discord')}
</div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.discord_id,
t('Discord ID'),
)}
</div>
</div>
</div>
<div className='flex-shrink-0'>
<Button
type='primary'
theme='outline'
size='small'
onClick={() =>
onDiscordOAuthClicked(status.discord_client_id)
}
disabled={
isBound(userState.user?.discord_id) ||
!status.discord_oauth
}
>
{status.discord_oauth ? t('绑定') : t('未启用')}
</Button>
</div>
</div>
</Card>
{/* OIDC绑定 */}
<Card className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center flex-1 min-w-0'>
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
<IconShield
size='default'
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{t('OIDC')}
</div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.oidc_id,
t('OIDC ID'),
)}
</div>
</div>
</div>
<div className='flex-shrink-0'>
<Button
type='primary'
theme='outline'
size='small'
onClick={() =>
onOIDCClicked(
status.oidc_authorization_endpoint,
status.oidc_client_id,
)
}
disabled={
isBound(userState.user?.oidc_id) || !status.oidc_enabled
}
>
{status.oidc_enabled ? t('绑定') : t('未启用')}
</Button>
</div>
</div>
</Card>
{/* Telegram绑定 */}
<Card className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center flex-1 min-w-0'>
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
<SiTelegram
size={20}
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{t('Telegram')}
</div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.telegram_id,
t('Telegram ID'),
)}
</div>
</div>
</div>
<div className='flex-shrink-0'>
{status.telegram_oauth ? (
isBound(userState.user?.telegram_id) ? (
<Button
disabled
size='small'
type='primary'
theme='outline'
>
{t('已绑定')}
</Button>
) : (
<Button
type='primary'
theme='outline'
size='small'
onClick={() => setShowTelegramBindModal(true)}
>
{t('绑定')}
</Button>
)
) : (
<Button
disabled
size='small'
type='primary'
theme='outline'
>
{t('未启用')}
</Button>
)}
</div>
</div>
</Card>
<Modal
title={t('绑定 Telegram')}
visible={showTelegramBindModal}
onCancel={() => setShowTelegramBindModal(false)}
footer={null}
>
<div className='my-3 text-sm text-gray-600'>
{t('点击下方按钮通过 Telegram 完成绑定')}
</div>
<div className='flex justify-center'>
<div className='scale-90'>
<TelegramLoginButton
dataAuthUrl='/api/oauth/telegram/bind'
botName={status.telegram_bot_name}
/>
</div>
</div>
</Modal>
{/* LinuxDO绑定 */}
<Card className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center flex-1 min-w-0'>
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
<SiLinux
size={20}
className='text-slate-600 dark:text-slate-300'
/>
</div>
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{t('LinuxDO')}
</div>
<div className='text-sm text-gray-500 truncate'>
{renderAccountInfo(
userState.user?.linux_do_id,
t('LinuxDO ID'),
)}
</div>
</div>
</div>
<div className='flex-shrink-0'>
<Button
type='primary'
theme='outline'
size='small'
onClick={() =>
onLinuxDOOAuthClicked(status.linuxdo_client_id)
}
disabled={
isBound(userState.user?.linux_do_id) ||
!status.linuxdo_oauth
}
>
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
</Button>
</div>
</div>
</Card>
{/* 自定义 OAuth 提供商绑定 */}
{status.custom_oauth_providers &&
status.custom_oauth_providers.map((provider) => {
const bound = isCustomOAuthBound(provider.id);
const binding = getCustomOAuthBinding(provider.id);
return (
<Card key={provider.slug} className='!rounded-xl'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center flex-1 min-w-0'>
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
{getOAuthProviderIcon(
provider.icon || binding?.provider_icon || '',
20,
)}
</div>
<div className='flex-1 min-w-0'>
<div className='font-medium text-gray-900'>
{provider.name}
</div>
<div className='text-sm text-gray-500 truncate'>
{bound
? renderAccountInfo(
binding?.provider_user_id,
t('{{name}} ID', { name: provider.name }),
)
: t('未绑定')}
</div>
</div>
</div>
<div className='flex-shrink-0'>
{bound ? (
<Button
type='danger'
theme='outline'
size='small'
loading={customOAuthLoading[provider.id]}
onClick={() =>
handleUnbindCustomOAuth(provider.id, provider.name)
}
>
{t('解绑')}
</Button>
) : (
<Button
type='primary'
theme='outline'
size='small'
onClick={() => handleBindCustomOAuth(provider)}
>
{t('绑定')}
</Button>
)}
</div>
</div>
</Card>
);
})}
</div>
</div>
</TabPane>
{/* 安全设置 Tab */}
<TabPane
tab={
<div className='flex items-center'>
<ShieldCheck size={16} className='mr-2' />
{t('安全设置')}
</div>
}
itemKey='security'
>
<div className='py-4'>
<div className='space-y-6'>
<Space vertical className='w-full'>
{/* 系统访问令牌 */}
<Card className='!rounded-xl w-full'>
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
<div className='flex items-start w-full sm:w-auto'>
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
<IconKey size='large' className='text-slate-600' />
</div>
<div className='flex-1'>
<Typography.Title heading={6} className='mb-1'>
{t('系统访问令牌')}
</Typography.Title>
<Typography.Text type='tertiary' className='text-sm'>
{t('用于API调用的身份验证令牌请妥善保管')}
</Typography.Text>
{systemToken && (
<div className='mt-3'>
<Input
readonly
value={systemToken}
onClick={handleSystemTokenClick}
size='large'
prefix={<IconKey />}
/>
</div>
)}
</div>
</div>
<Button
type='primary'
theme='solid'
onClick={generateAccessToken}
className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
icon={<IconKey />}
>
{systemToken ? t('重新生成') : t('生成令牌')}
</Button>
</div>
</Card>
{/* 密码管理 */}
<Card className='!rounded-xl w-full'>
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
<div className='flex items-start w-full sm:w-auto'>
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
<IconLock size='large' className='text-slate-600' />
</div>
<div>
<Typography.Title heading={6} className='mb-1'>
{t('密码管理')}
</Typography.Title>
<Typography.Text type='tertiary' className='text-sm'>
{t('定期更改密码可以提高账户安全性')}
</Typography.Text>
</div>
</div>
<Button
type='primary'
theme='solid'
onClick={() => setShowChangePasswordModal(true)}
className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
icon={<IconLock />}
>
{t('修改密码')}
</Button>
</div>
</Card>
{/* Passkey 设置 */}
<Card className='!rounded-xl w-full'>
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
<div className='flex items-start w-full sm:w-auto'>
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
<IconKey size='large' className='text-slate-600' />
</div>
<div>
<Typography.Title heading={6} className='mb-1'>
{t('Passkey 登录')}
</Typography.Title>
<Typography.Text type='tertiary' className='text-sm'>
{passkeyEnabled
? t('已启用 Passkey无需密码即可登录')
: t('使用 Passkey 实现免密且更安全的登录体验')}
</Typography.Text>
<div className='mt-2 text-xs text-gray-500 space-y-1'>
<div>
{t('最后使用时间')}{lastUsedLabel}
</div>
{/*{passkeyEnabled && (*/}
{/* <div>*/}
{/* {t('备份支持')}*/}
{/* {passkeyStatus?.backup_eligible*/}
{/* ? t('支持备份')*/}
{/* : t('不支持')}*/}
{/* {t('备份状态')}*/}
{/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/}
{/* </div>*/}
{/*)}*/}
{!passkeySupported && (
<div className='text-amber-600'>
{t('当前设备不支持 Passkey')}
</div>
)}
</div>
</div>
</div>
<Button
type={passkeyEnabled ? 'danger' : 'primary'}
theme={passkeyEnabled ? 'solid' : 'solid'}
onClick={
passkeyEnabled
? () => {
Modal.confirm({
title: t('确认解绑 Passkey'),
content: t(
'解绑后将无法使用 Passkey 登录,确定要继续吗?',
),
okText: t('确认解绑'),
cancelText: t('取消'),
okType: 'danger',
onOk: onPasskeyDelete,
});
}
: onPasskeyRegister
}
className={`w-full sm:w-auto ${passkeyEnabled ? '!bg-slate-500 hover:!bg-slate-600' : ''}`}
icon={<IconKey />}
disabled={!passkeySupported && !passkeyEnabled}
loading={
passkeyEnabled
? passkeyDeleteLoading
: passkeyRegisterLoading
}
>
{passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
</Button>
</div>
</Card>
{/* 两步验证设置 */}
<TwoFASetting t={t} />
{/* 危险区域 */}
<Card className='!rounded-xl w-full'>
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
<div className='flex items-start w-full sm:w-auto'>
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
<IconDelete size='large' className='text-slate-600' />
</div>
<div>
<Typography.Title
heading={6}
className='mb-1 text-slate-700'
>
{t('删除账户')}
</Typography.Title>
<Typography.Text type='tertiary' className='text-sm'>
{t('此操作不可逆,所有数据将被永久删除')}
</Typography.Text>
</div>
</div>
<Button
type='danger'
theme='solid'
onClick={() => setShowAccountDeleteModal(true)}
className='w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600'
icon={<IconDelete />}
>
{t('删除账户')}
</Button>
</div>
</Card>
</Space>
</div>
</div>
</TabPane>
</Tabs>
</Card>
);
};
export default AccountManagement;

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