diff --git a/controller/channel.go b/controller/channel.go
index 020a3327..70be91d4 100644
--- a/controller/channel.go
+++ b/controller/channel.go
@@ -380,6 +380,85 @@ func GetChannel(c *gin.Context) {
return
}
+// GetChannelKey 验证2FA后获取渠道密钥
+func GetChannelKey(c *gin.Context) {
+ type GetChannelKeyRequest struct {
+ Code string `json:"code" binding:"required"`
+ }
+
+ var req GetChannelKeyRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ common.ApiError(c, fmt.Errorf("参数错误: %v", err))
+ return
+ }
+
+ userId := c.GetInt("id")
+ channelId, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err))
+ return
+ }
+
+ // 获取2FA记录并验证
+ twoFA, err := model.GetTwoFAByUserId(userId)
+ if err != nil {
+ common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err))
+ return
+ }
+
+ if twoFA == nil || !twoFA.IsEnabled {
+ common.ApiError(c, fmt.Errorf("用户未启用2FA,无法查看密钥"))
+ return
+ }
+
+ // 统一的2FA验证逻辑
+ if !validateTwoFactorAuth(twoFA, req.Code) {
+ common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
+ return
+ }
+
+ // 获取渠道信息(包含密钥)
+ channel, err := model.GetChannelById(channelId, true)
+ if err != nil {
+ common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err))
+ return
+ }
+
+ if channel == nil {
+ common.ApiError(c, fmt.Errorf("渠道不存在"))
+ return
+ }
+
+ // 记录操作日志
+ model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
+
+ // 统一的成功响应格式
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "验证成功",
+ "data": map[string]interface{}{
+ "key": channel.Key,
+ },
+ })
+}
+
+// validateTwoFactorAuth 统一的2FA验证函数
+func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool {
+ // 尝试验证TOTP
+ if cleanCode, err := common.ValidateNumericCode(code); err == nil {
+ if isValid, _ := twoFA.ValidateTOTPAndUpdateUsage(cleanCode); isValid {
+ return true
+ }
+ }
+
+ // 尝试验证备用码
+ if isValid, err := twoFA.ValidateBackupCodeAndUpdateUsage(code); err == nil && isValid {
+ return true
+ }
+
+ return false
+}
+
// validateChannel 通用的渠道校验函数
func validateChannel(channel *model.Channel, isAdd bool) error {
// 校验 channel settings
diff --git a/router/api-router.go b/router/api-router.go
index be721b05..b3d4fe08 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -114,6 +114,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.GET("/models", controller.ChannelListModels)
channelRoute.GET("/models_enabled", controller.EnabledListModels)
channelRoute.GET("/:id", controller.GetChannel)
+ channelRoute.POST("/:id/key", controller.GetChannelKey)
channelRoute.GET("/test", controller.TestAllChannels)
channelRoute.GET("/test/:id", controller.TestChannel)
channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)
diff --git a/web/src/components/common/modals/TwoFactorAuthModal.jsx b/web/src/components/common/modals/TwoFactorAuthModal.jsx
new file mode 100644
index 00000000..a3884d98
--- /dev/null
+++ b/web/src/components/common/modals/TwoFactorAuthModal.jsx
@@ -0,0 +1,128 @@
+/*
+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 { useTranslation } from 'react-i18next';
+import { Modal, Button, Input, Typography } from '@douyinfe/semi-ui';
+
+/**
+ * 可复用的两步验证模态框组件
+ * @param {Object} props
+ * @param {boolean} props.visible - 是否显示模态框
+ * @param {string} props.code - 验证码值
+ * @param {boolean} props.loading - 是否正在验证
+ * @param {Function} props.onCodeChange - 验证码变化回调
+ * @param {Function} props.onVerify - 验证回调
+ * @param {Function} props.onCancel - 取消回调
+ * @param {string} props.title - 模态框标题
+ * @param {string} props.description - 验证描述文本
+ * @param {string} props.placeholder - 输入框占位文本
+ */
+const TwoFactorAuthModal = ({
+ visible,
+ code,
+ loading,
+ onCodeChange,
+ onVerify,
+ onCancel,
+ title,
+ description,
+ placeholder
+}) => {
+ const { t } = useTranslation();
+
+ const handleKeyPress = (e) => {
+ if (e.key === 'Enter' && code) {
+ onVerify();
+ }
+ };
+
+ return (
+
+
+ {title || t('安全验证')}
+
+ }
+ visible={visible}
+ onCancel={onCancel}
+ footer={
+ <>
+
+
+ >
+ }
+ width={500}
+ style={{ maxWidth: '90vw' }}
+ >
+
+ {/* 安全提示 */}
+
+
+
+
+
+ {t('安全验证')}
+
+
+ {description || t('为了保护账户安全,请验证您的两步验证码。')}
+
+
+
+
+
+ {/* 验证码输入 */}
+
+
+ {t('验证身份')}
+
+
+
+ {t('支持6位TOTP验证码或8位备用码')}
+
+
+
+
+ );
+};
+
+export default TwoFactorAuthModal;
\ No newline at end of file
diff --git a/web/src/components/common/ui/ChannelKeyDisplay.jsx b/web/src/components/common/ui/ChannelKeyDisplay.jsx
new file mode 100644
index 00000000..8c6e79ef
--- /dev/null
+++ b/web/src/components/common/ui/ChannelKeyDisplay.jsx
@@ -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 .
+
+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 (
+
+ {/* 成功状态 */}
+ {showSuccessIcon && (
+
+
+
+ {successText || t('验证成功')}
+
+
+ )}
+
+ {/* 密钥内容 */}
+
+
+
+ {isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')}
+
+ {isMultipleKeys && (
+
+
+ {t('共 {{count}} 个密钥', { count: parsedKeys.length })}
+
+
+
+ )}
+
+
+
+ {parsedKeys.map((keyItem) => (
+
+
+
+
+ {keyItem.label}
+
+
+ {keyItem.type === 'json' && (
+
{t('JSON')}
+ )}
+
+
+
+
+
+
+ {keyItem.content}
+
+
+
+ {keyItem.type === 'json' && (
+
+ {t('JSON格式密钥,请确保格式正确')}
+
+ )}
+
+
+ ))}
+
+
+ {isMultipleKeys && (
+
+
+
+ {t('检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。')}
+
+
+ )}
+
+
+ {/* 安全警告 */}
+ {showWarning && (
+
+
+
+
+
+ {t('安全提醒')}
+
+
+ {warningText || t('请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。')}
+
+
+
+
+ )}
+
+ );
+};
+
+export default ChannelKeyDisplay;
\ No newline at end of file
diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx
index de84da1e..78c8197f 100644
--- a/web/src/components/table/channels/modals/EditChannelModal.jsx
+++ b/web/src/components/table/channels/modals/EditChannelModal.jsx
@@ -45,10 +45,13 @@ import {
Row,
Col,
Highlight,
+ Input,
} from '@douyinfe/semi-ui';
import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers';
import ModelSelectModal from './ModelSelectModal';
import JSONEditor from '../../../common/ui/JSONEditor';
+import TwoFactorAuthModal from '../../../common/modals/TwoFactorAuthModal';
+import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
import {
IconSave,
IconClose,
@@ -158,6 +161,44 @@ const EditChannelModal = (props) => {
const [channelSearchValue, setChannelSearchValue] = useState('');
const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式
const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加)
+
+ // 2FA验证查看密钥相关状态
+ const [twoFAState, setTwoFAState] = useState({
+ showModal: false,
+ code: '',
+ loading: false,
+ showKey: false,
+ keyData: ''
+ });
+
+ // 专门的2FA验证状态(用于TwoFactorAuthModal)
+ const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false);
+ const [verifyCode, setVerifyCode] = useState('');
+ const [verifyLoading, setVerifyLoading] = useState(false);
+
+ // 2FA状态更新辅助函数
+ const updateTwoFAState = (updates) => {
+ setTwoFAState(prev => ({ ...prev, ...updates }));
+ };
+
+ // 重置2FA状态
+ const resetTwoFAState = () => {
+ setTwoFAState({
+ showModal: false,
+ code: '',
+ loading: false,
+ showKey: false,
+ keyData: ''
+ });
+ };
+
+ // 重置2FA验证状态
+ const reset2FAVerifyState = () => {
+ setShow2FAVerifyModal(false);
+ setVerifyCode('');
+ setVerifyLoading(false);
+ };
+
// 渠道额外设置状态
const [channelSettings, setChannelSettings] = useState({
force_format: false,
@@ -500,6 +541,42 @@ const EditChannelModal = (props) => {
}
};
+ // 使用TwoFactorAuthModal的验证函数
+ const handleVerify2FA = async () => {
+ if (!verifyCode) {
+ showError(t('请输入验证码或备用码'));
+ return;
+ }
+
+ setVerifyLoading(true);
+ try {
+ const res = await API.post(`/api/channel/${channelId}/key`, {
+ code: verifyCode
+ });
+ if (res.data.success) {
+ // 验证成功,显示密钥
+ updateTwoFAState({
+ showModal: true,
+ showKey: true,
+ keyData: res.data.data.key
+ });
+ reset2FAVerifyState();
+ showSuccess(t('验证成功'));
+ } else {
+ showError(res.data.message);
+ }
+ } catch (error) {
+ showError(t('获取密钥失败'));
+ } finally {
+ setVerifyLoading(false);
+ }
+ };
+
+ // 显示2FA验证模态框 - 使用TwoFactorAuthModal
+ const handleShow2FAModal = () => {
+ setShow2FAVerifyModal(true);
+ };
+
useEffect(() => {
const modelMap = new Map();
@@ -576,27 +653,37 @@ const EditChannelModal = (props) => {
// 重置手动输入模式状态
setUseManualInput(false);
} else {
- formApiRef.current?.reset();
- // 重置渠道设置状态
- setChannelSettings({
- force_format: false,
- thinking_to_content: false,
- proxy: '',
- pass_through_body_enabled: false,
- system_prompt: '',
- system_prompt_override: false,
- });
- // 重置密钥模式状态
- setKeyMode('append');
- // 清空表单中的key_mode字段
- if (formApiRef.current) {
- formApiRef.current.setValue('key_mode', undefined);
- }
- // 重置本地输入,避免下次打开残留上一次的 JSON 字段值
- setInputs(getInitValues());
+ // 统一的模态框关闭重置逻辑
+ resetModalState();
}
}, [props.visible, channelId]);
+ // 统一的模态框重置函数
+ const resetModalState = () => {
+ formApiRef.current?.reset();
+ // 重置渠道设置状态
+ setChannelSettings({
+ force_format: false,
+ thinking_to_content: false,
+ proxy: '',
+ pass_through_body_enabled: false,
+ system_prompt: '',
+ system_prompt_override: false,
+ });
+ // 重置密钥模式状态
+ setKeyMode('append');
+ // 清空表单中的key_mode字段
+ if (formApiRef.current) {
+ formApiRef.current.setValue('key_mode', undefined);
+ }
+ // 重置本地输入,避免下次打开残留上一次的 JSON 字段值
+ setInputs(getInitValues());
+ // 重置2FA状态
+ resetTwoFAState();
+ // 重置2FA验证状态
+ reset2FAVerifyState();
+ };
+
const handleVertexUploadChange = ({ fileList }) => {
vertexErroredNames.current.clear();
(async () => {
@@ -1080,6 +1167,16 @@ const EditChannelModal = (props) => {
{t('追加模式:新密钥将添加到现有密钥列表的末尾')}
)}
+ {isEdit && (
+
+ )}
{batchExtra}
}
@@ -1154,6 +1251,16 @@ const EditChannelModal = (props) => {
{t('追加模式:新密钥将添加到现有密钥列表的末尾')}
)}
+ {isEdit && (
+
+ )}
{batchExtra}
}
@@ -1194,6 +1301,16 @@ const EditChannelModal = (props) => {
{t('追加模式:新密钥将添加到现有密钥列表的末尾')}
)}
+ {isEdit && (
+
+ )}
{batchExtra}
}
@@ -1846,6 +1963,53 @@ const EditChannelModal = (props) => {
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/>
+ {/* 使用TwoFactorAuthModal组件进行2FA验证 */}
+
+
+ {/* 使用ChannelKeyDisplay组件显示密钥 */}
+
+
+ {t('渠道密钥信息')}
+
+ }
+ visible={twoFAState.showModal && twoFAState.showKey}
+ onCancel={resetTwoFAState}
+ footer={
+
+ }
+ width={700}
+ style={{ maxWidth: '90vw' }}
+ >
+
+
+