diff --git a/web/src/components/common/ui/ResponsiveModal.jsx b/web/src/components/common/ui/ResponsiveModal.jsx
new file mode 100644
index 000000000..dee39a9c1
--- /dev/null
+++ b/web/src/components/common/ui/ResponsiveModal.jsx
@@ -0,0 +1,135 @@
+/*
+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 .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Modal, Typography } from '@douyinfe/semi-ui';
+import PropTypes from 'prop-types';
+import { useIsMobile } from '../../../hooks/common/useIsMobile';
+
+const { Title } = Typography;
+
+/**
+ * ResponsiveModal 响应式模态框组件
+ *
+ * 特性:
+ * - 响应式布局:移动端和桌面端不同的宽度和布局
+ * - 自定义头部:标题左对齐,操作按钮右对齐,移动端自动换行
+ * - Tailwind CSS 样式支持
+ * - 保持原 Modal 组件的所有功能
+ */
+const ResponsiveModal = ({
+ visible,
+ onCancel,
+ title,
+ headerActions = [],
+ children,
+ width = { mobile: '95%', desktop: 600 },
+ className = '',
+ footer = null,
+ titleProps = {},
+ headerClassName = '',
+ actionsClassName = '',
+ ...props
+}) => {
+ const isMobile = useIsMobile();
+
+ // 自定义 Header 组件
+ const CustomHeader = () => {
+ if (!title && (!headerActions || headerActions.length === 0)) return null;
+
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+ {headerActions && headerActions.length > 0 && (
+
+ {headerActions.map((action, index) => (
+ {action}
+ ))}
+
+ )}
+
+ );
+ };
+
+ // 计算模态框宽度
+ const getModalWidth = () => {
+ if (typeof width === 'object') {
+ return isMobile ? width.mobile : width.desktop;
+ }
+ return width;
+ };
+
+ return (
+ }
+ onCancel={onCancel}
+ footer={footer}
+ width={getModalWidth()}
+ className={`!top-12 ${className}`}
+ {...props}
+ >
+ {children}
+
+ );
+};
+
+ResponsiveModal.propTypes = {
+ // Modal 基础属性
+ visible: PropTypes.bool.isRequired,
+ onCancel: PropTypes.func.isRequired,
+ children: PropTypes.node,
+
+ // 自定义头部
+ title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+ headerActions: PropTypes.arrayOf(PropTypes.node),
+
+ // 样式和布局
+ width: PropTypes.oneOfType([
+ PropTypes.number,
+ PropTypes.string,
+ PropTypes.shape({
+ mobile: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+ desktop: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+ }),
+ ]),
+ className: PropTypes.string,
+ footer: PropTypes.node,
+
+ // 标题自定义属性
+ titleProps: PropTypes.object,
+
+ // 自定义 CSS 类
+ headerClassName: PropTypes.string,
+ actionsClassName: PropTypes.string,
+};
+
+export default ResponsiveModal;
diff --git a/web/src/components/settings/oauth2/OAuth2ClientSettings.jsx b/web/src/components/settings/oauth2/OAuth2ClientSettings.jsx
index 7b9971bac..74f432801 100644
--- a/web/src/components/settings/oauth2/OAuth2ClientSettings.jsx
+++ b/web/src/components/settings/oauth2/OAuth2ClientSettings.jsx
@@ -343,13 +343,12 @@ export default function OAuth2ClientSettings() {
}
title={t('暂无OAuth2客户端')}
- description={t('还没有创建任何客户端,点击下方按钮创建第一个客户端')}
+ description={t(
+ '还没有创建任何客户端,点击下方按钮创建第一个客户端',
+ )}
style={{ padding: 30 }}
>
- setShowCreateModal(true)}
- >
+ setShowCreateModal(true)}>
{t('创建第一个客户端')}
diff --git a/web/src/components/settings/oauth2/OAuth2ServerSettings.jsx b/web/src/components/settings/oauth2/OAuth2ServerSettings.jsx
index eaa788d27..3b84dc98b 100644
--- a/web/src/components/settings/oauth2/OAuth2ServerSettings.jsx
+++ b/web/src/components/settings/oauth2/OAuth2ServerSettings.jsx
@@ -220,7 +220,7 @@ export default function OAuth2ServerSettings(props) {
>
-
{t('OAuth2 & SSO 管理')}
+
{t('OAuth2 服务端管理')}
{isEnabled ? (
serverInfo ? (
{
setLoading(true);
@@ -84,9 +92,32 @@ export default function JWKSManagerModal({ visible, onClose }) {
}
};
+ // Import PEM state
+ const [pem, setPem] = useState('');
+ const [customKid, setCustomKid] = useState('');
+
+ // Generate PEM file state
+ const [genPath, setGenPath] = useState('/etc/new-api/oauth2-private.pem');
+ const [genKid, setGenKid] = useState('');
+
+ // 重置表单数据
+ const resetForms = () => {
+ setPem('');
+ setCustomKid('');
+ setGenKid('');
+ };
+
useEffect(() => {
- if (visible) load();
+ if (visible) {
+ load();
+ // 重置到主视图
+ setActiveTab(OPERATION_MODES.VIEW);
+ } else {
+ // 模态框关闭时重置表单数据
+ resetForms();
+ }
}, [visible]);
+
useEffect(() => {
if (!visible) return;
(async () => {
@@ -98,10 +129,7 @@ export default function JWKSManagerModal({ visible, onClose }) {
})();
}, [visible]);
- // Import PEM state
- const [showImport, setShowImport] = useState(false);
- const [pem, setPem] = useState('');
- const [customKid, setCustomKid] = useState('');
+ // 导入 PEM 私钥
const importPem = async () => {
if (!pem.trim()) return Toast.warning(t('请粘贴 PEM 私钥'));
setLoading(true);
@@ -114,9 +142,8 @@ export default function JWKSManagerModal({ visible, onClose }) {
Toast.success(
t('已导入私钥并切换到 kid={{kid}}', { kid: res.data.kid }),
);
- setPem('');
- setCustomKid('');
- setShowImport(false);
+ resetForms();
+ setActiveTab(OPERATION_MODES.VIEW);
await load();
} else {
Toast.error(res?.data?.message || t('导入失败'));
@@ -128,10 +155,7 @@ export default function JWKSManagerModal({ visible, onClose }) {
}
};
- // Generate PEM file state
- const [showGenerate, setShowGenerate] = useState(false);
- const [genPath, setGenPath] = useState('/etc/new-api/oauth2-private.pem');
- const [genKid, setGenKid] = useState('');
+ // 生成 PEM 文件
const generatePemFile = async () => {
if (!genPath.trim()) return Toast.warning(t('请填写保存路径'));
setLoading(true);
@@ -142,6 +166,8 @@ export default function JWKSManagerModal({ visible, onClose }) {
});
if (res?.data?.success) {
Toast.success(t('已生成并生效:{{path}}', { path: res.data.path }));
+ resetForms();
+ setActiveTab(OPERATION_MODES.VIEW);
await load();
} else {
Toast.error(res?.data?.message || t('生成失败'));
@@ -172,7 +198,13 @@ export default function JWKSManagerModal({ visible, onClose }) {
title: t('状态'),
dataIndex: 'current',
render: (cur) =>
- cur ? {t('当前')} : {t('历史')} ,
+ cur ? (
+
+ {t('当前')}
+
+ ) : (
+ {t('历史')}
+ ),
},
{
title: t('操作'),
@@ -187,7 +219,7 @@ export default function JWKSManagerModal({ visible, onClose }) {
okText={t('删除')}
onConfirm={() => del(r.kid)}
>
-
+
{t('删除')}
@@ -197,109 +229,68 @@ export default function JWKSManagerModal({ visible, onClose }) {
},
];
- return (
-
-
-
+ // 头部操作按钮 - 根据当前标签页动态生成
+ const getHeaderActions = () => {
+ if (activeTab === OPERATION_MODES.VIEW) {
+ return [
+
{t('刷新')}
-
-
+ ,
+
{t('轮换密钥')}
-
- setShowImport(!showImport)}>
- {t('导入 PEM 私钥')}
-
- setShowGenerate(!showGenerate)}>
- {t('生成 PEM 文件')}
-
- {t('关闭')}
-
- {showGenerate && (
- ,
+ ];
+ }
+
+ if (activeTab === OPERATION_MODES.IMPORT) {
+ return [
+
-
-
-
-
-
- {t('生成并生效')}
-
-
-
-
- {t(
- '建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600),并妥善备份。',
- )}
-
-
- )}
- {showImport && (
- ,
+ ];
+ }
+
+ if (activeTab === OPERATION_MODES.GENERATE) {
+ return [
+
-
-
-
-
-
- {t('导入并生效')}
-
-
-
-
- {t(
- '建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。',
- )}
-
-
- )}
+ {t('生成并生效')}
+ ,
+ ];
+ }
+
+ return [];
+ };
+
+ // 渲染密钥列表视图
+ const renderKeysView = () => (
+
+ {t(
+ '提示:当前密钥用于签发 JWT 令牌。建议定期轮换密钥以提升安全性。只有历史密钥可以删除。',
+ )}
+
+ }
+ >
{t('暂无密钥')}}
/>
-
+
+ );
+
+ // 渲染导入 PEM 私钥视图
+ const renderImportView = () => (
+
+ {t(
+ '建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。请确保私钥来源可信。',
+ )}
+
+ }
+ >
+
+
+
+
+ );
+
+ // 渲染生成 PEM 文件视图
+ const renderGenerateView = () => (
+
+ {t(
+ '建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600),并妥善备份。',
+ )}
+
+ }
+ >
+
+
+
+
+ );
+
+ return (
+
+
+
+ {renderKeysView()}
+
+
+ {renderImportView()}
+
+
+ {renderGenerateView()}
+
+
+
);
}
diff --git a/web/src/components/settings/oauth2/modals/SecretDisplayModal.jsx b/web/src/components/settings/oauth2/modals/SecretDisplayModal.jsx
index 10a5b2160..3502881ab 100644
--- a/web/src/components/settings/oauth2/modals/SecretDisplayModal.jsx
+++ b/web/src/components/settings/oauth2/modals/SecretDisplayModal.jsx
@@ -45,9 +45,11 @@ const SecretDisplayModal = ({ visible, onClose, secret }) => {
)}
className='mb-5 !rounded-lg'
/>
-
- {secret}
-
+
+
+ {secret}
+
+
);
};
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index 1e95054a7..b74f30ea9 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -2137,7 +2137,7 @@
"客户端密钥已重新生成": "Client secret regenerated",
"我已复制保存": "I have copied and saved",
"新的客户端密钥如下,请立即复制保存。关闭此窗口后将无法再次查看。": "The new client secret is shown below. Copy and save it now. You will not be able to view it again after closing this window.",
- "OAuth2 & SSO 管理": "OAuth2 & SSO Management",
+ "OAuth2 服务端管理": "OAuth2 Server",
"运行正常": "Healthy",
"配置中": "Configuring",
"保存配置": "Save Configuration",
@@ -2177,5 +2177,34 @@
"客户端名称": "Client Name",
"暂无描述": "No description",
"OAuth2 服务器配置": "OAuth2 Server Configuration",
- "JWKS 密钥集": "JWKS Key Set"
+ "JWKS 密钥集": "JWKS Key Set",
+ "获取密钥列表失败": "Failed to fetch key list",
+ "签名密钥已轮换:{{kid}}": "Signing key rotated: {{kid}}",
+ "密钥轮换失败": "Key rotation failed",
+ "已删除:{{kid}}": "Deleted: {{kid}}",
+ "请粘贴 PEM 私钥": "Please paste PEM private key",
+ "已导入私钥并切换到 kid={{kid}}": "Private key imported and switched to kid={{kid}}",
+ "导入失败": "Import failed",
+ "请填写保存路径": "Please enter the save path",
+ "已生成并生效:{{path}}": "Generated and applied: {{path}}",
+ "生成失败": "Generation failed",
+ "当前": "Current",
+ "历史": "History",
+ "确定删除密钥 {{kid}} ?": "Confirm delete key {{kid}}?",
+ "删除后使用该 kid 签发的旧令牌仍可被验证(外部 JWKS 缓存可能仍保留)": "Tokens issued with this kid may still be verifiable (external JWKS caches may retain the key)",
+ "轮换密钥": "Rotate key",
+ "导入并生效": "Import and apply",
+ "生成并生效": "Generate and apply",
+ "提示:当前密钥用于签发 JWT 令牌。建议定期轮换密钥以提升安全性。只有历史密钥可以删除。": "Tip: The current key is used to sign JWT tokens. Rotate keys regularly for security. Only historical keys can be deleted.",
+ "暂无密钥": "No keys yet",
+ "建议:优先使用内存签名密钥与 JWKS 轮换;仅在有合规要求时导入外部私钥。请确保私钥来源可信。": "Recommendation: Prefer in-memory signing keys with JWKS rotation; import external private keys only when required for compliance. Ensure the private key source is trusted.",
+ "自定义 KID": "Custom KID",
+ "可留空自动生成": "Optional, auto-generate if empty",
+ "PEM 私钥": "PEM private key",
+ "建议:仅在合规要求下使用文件私钥。请确保目录权限安全(建议 0600),并妥善备份。": "Recommendation: Use file-based private keys only when required for compliance. Ensure directory permissions are secure (0600 recommended) and back up properly.",
+ "保存路径": "Save path",
+ "JWKS 管理": "JWKS Management",
+ "密钥列表": "Key list",
+ "导入 PEM 私钥": "Import PEM private key",
+ "生成 PEM 文件": "Generate PEM file"
}