Просмотр исходного кода

Merge branch 'alpha' of github.com:AAEE86/new-api into AAEE86-alpha

creamlike1024 4 месяцев назад
Родитель
Сommit
fcb03392d1

+ 79 - 0
controller/channel.go

@@ -380,6 +380,85 @@ func GetChannel(c *gin.Context) {
 	return
 	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 通用的渠道校验函数
 // validateChannel 通用的渠道校验函数
 func validateChannel(channel *model.Channel, isAdd bool) error {
 func validateChannel(channel *model.Channel, isAdd bool) error {
 	// 校验 channel settings
 	// 校验 channel settings

+ 1 - 0
router/api-router.go

@@ -114,6 +114,7 @@ func SetApiRouter(router *gin.Engine) {
 			channelRoute.GET("/models", controller.ChannelListModels)
 			channelRoute.GET("/models", controller.ChannelListModels)
 			channelRoute.GET("/models_enabled", controller.EnabledListModels)
 			channelRoute.GET("/models_enabled", controller.EnabledListModels)
 			channelRoute.GET("/:id", controller.GetChannel)
 			channelRoute.GET("/:id", controller.GetChannel)
+			channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), controller.GetChannelKey)
 			channelRoute.GET("/test", controller.TestAllChannels)
 			channelRoute.GET("/test", controller.TestAllChannels)
 			channelRoute.GET("/test/:id", controller.TestChannel)
 			channelRoute.GET("/test/:id", controller.TestChannel)
 			channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)
 			channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)

+ 129 - 0
web/src/components/common/modals/TwoFactorAuthModal.jsx

@@ -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 [email protected]
+*/
+
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Modal, Button, Input, Typography } from '@douyinfe/semi-ui';
+
+/**
+ * 可复用的两步验证模态框组件
+ * @param {Object} props
+ * @param {boolean} props.visible - 是否显示模态框
+ * @param {string} props.code - 验证码值
+ * @param {boolean} props.loading - 是否正在验证
+ * @param {Function} props.onCodeChange - 验证码变化回调
+ * @param {Function} props.onVerify - 验证回调
+ * @param {Function} props.onCancel - 取消回调
+ * @param {string} props.title - 模态框标题
+ * @param {string} props.description - 验证描述文本
+ * @param {string} props.placeholder - 输入框占位文本
+ */
+const TwoFactorAuthModal = ({
+  visible,
+  code,
+  loading,
+  onCodeChange,
+  onVerify,
+  onCancel,
+  title,
+  description,
+  placeholder
+}) => {
+  const { t } = useTranslation();
+
+  const handleKeyDown = (e) => {
+    if (e.key === 'Enter' && code && !loading) {
+      onVerify();
+    }
+  };
+
+  return (
+    <Modal
+      title={
+        <div className="flex items-center">
+          <div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3">
+            <svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
+              <path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
+            </svg>
+          </div>
+          {title || t('安全验证')}
+        </div>
+      }
+      visible={visible}
+      onCancel={onCancel}
+      footer={
+        <>
+          <Button onClick={onCancel}>
+            {t('取消')}
+          </Button>
+          <Button
+            type="primary"
+            loading={loading}
+            disabled={!code || loading}
+            onClick={onVerify}
+          >
+            {t('验证')}
+          </Button>
+        </>
+      }
+      width={500}
+      style={{ maxWidth: '90vw' }}
+    >
+      <div className="space-y-6">
+        {/* 安全提示 */}
+        <div className="bg-blue-50 dark:bg-blue-900 rounded-lg p-4">
+          <div className="flex items-start">
+            <svg className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
+              <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
+            </svg>
+            <div>
+              <Typography.Text strong className="text-blue-800 dark:text-blue-200">
+                {t('安全验证')}
+              </Typography.Text>
+              <Typography.Text className="block text-blue-700 dark:text-blue-300 text-sm mt-1">
+                {description || t('为了保护账户安全,请验证您的两步验证码。')}
+              </Typography.Text>
+            </div>
+          </div>
+        </div>
+
+        {/* 验证码输入 */}
+        <div>
+          <Typography.Text strong className="block mb-2">
+            {t('验证身份')}
+          </Typography.Text>
+          <Input
+            placeholder={placeholder || t('请输入认证器验证码或备用码')}
+            value={code}
+            onChange={onCodeChange}
+            size="large"
+            maxLength={8}
+            onKeyDown={handleKeyDown}
+            autoFocus
+          />
+          <Typography.Text type="tertiary" size="small" className="mt-2 block">
+            {t('支持6位TOTP验证码或8位备用码')}
+          </Typography.Text>
+        </div>
+      </div>
+    </Modal>
+  );
+};
+
+export default TwoFactorAuthModal;

+ 224 - 0
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 <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+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;

+ 182 - 18
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -45,10 +45,13 @@ import {
   Row,
   Row,
   Col,
   Col,
   Highlight,
   Highlight,
+  Input,
 } from '@douyinfe/semi-ui';
 } from '@douyinfe/semi-ui';
 import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers';
 import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers';
 import ModelSelectModal from './ModelSelectModal';
 import ModelSelectModal from './ModelSelectModal';
 import JSONEditor from '../../../common/ui/JSONEditor';
 import JSONEditor from '../../../common/ui/JSONEditor';
+import TwoFactorAuthModal from '../../../common/modals/TwoFactorAuthModal';
+import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
 import {
 import {
   IconSave,
   IconSave,
   IconClose,
   IconClose,
@@ -158,6 +161,44 @@ const EditChannelModal = (props) => {
   const [channelSearchValue, setChannelSearchValue] = useState('');
   const [channelSearchValue, setChannelSearchValue] = useState('');
   const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式
   const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式
   const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加)
   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({
   const [channelSettings, setChannelSettings] = useState({
     force_format: false,
     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(() => {
   useEffect(() => {
     const modelMap = new Map();
     const modelMap = new Map();
 
 
@@ -576,27 +653,37 @@ const EditChannelModal = (props) => {
       // 重置手动输入模式状态
       // 重置手动输入模式状态
       setUseManualInput(false);
       setUseManualInput(false);
     } else {
     } 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]);
   }, [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 }) => {
   const handleVertexUploadChange = ({ fileList }) => {
     vertexErroredNames.current.clear();
     vertexErroredNames.current.clear();
     (async () => {
     (async () => {
@@ -1080,6 +1167,16 @@ const EditChannelModal = (props) => {
                                 {t('追加模式:新密钥将添加到现有密钥列表的末尾')}
                                 {t('追加模式:新密钥将添加到现有密钥列表的末尾')}
                               </Text>
                               </Text>
                             )}
                             )}
+                            {isEdit && (
+                              <Button
+                                size="small"
+                                type="primary"
+                                theme="outline"
+                                onClick={handleShow2FAModal}
+                              >
+                                {t('查看密钥')}
+                              </Button>
+                            )}
                             {batchExtra}
                             {batchExtra}
                           </div>
                           </div>
                         }
                         }
@@ -1154,6 +1251,16 @@ const EditChannelModal = (props) => {
                                       {t('追加模式:新密钥将添加到现有密钥列表的末尾')}
                                       {t('追加模式:新密钥将添加到现有密钥列表的末尾')}
                                     </Text>
                                     </Text>
                                   )}
                                   )}
+                                  {isEdit && (
+                                    <Button
+                                      size="small"
+                                      type="primary"
+                                      theme="outline"
+                                      onClick={handleShow2FAModal}
+                                    >
+                                      {t('查看密钥')}
+                                    </Button>
+                                  )}
                                   {batchExtra}
                                   {batchExtra}
                                 </div>
                                 </div>
                               }
                               }
@@ -1194,6 +1301,16 @@ const EditChannelModal = (props) => {
                                   {t('追加模式:新密钥将添加到现有密钥列表的末尾')}
                                   {t('追加模式:新密钥将添加到现有密钥列表的末尾')}
                                 </Text>
                                 </Text>
                               )}
                               )}
+                              {isEdit && (
+                                <Button
+                                  size="small"
+                                  type="primary"
+                                  theme="outline"
+                                  onClick={handleShow2FAModal}
+                                >
+                                  {t('查看密钥')}
+                                </Button>
+                              )}
                               {batchExtra}
                               {batchExtra}
                             </div>
                             </div>
                           }
                           }
@@ -1846,6 +1963,53 @@ const EditChannelModal = (props) => {
           onVisibleChange={(visible) => setIsModalOpenurl(visible)}
           onVisibleChange={(visible) => setIsModalOpenurl(visible)}
         />
         />
       </SideSheet>
       </SideSheet>
+      {/* 使用TwoFactorAuthModal组件进行2FA验证 */}
+      <TwoFactorAuthModal
+        visible={show2FAVerifyModal}
+        code={verifyCode}
+        loading={verifyLoading}
+        onCodeChange={setVerifyCode}
+        onVerify={handleVerify2FA}
+        onCancel={reset2FAVerifyState}
+        title={t('查看渠道密钥')}
+        description={t('为了保护账户安全,请验证您的两步验证码。')}
+        placeholder={t('请输入验证码或备用码')}
+      />
+
+      {/* 使用ChannelKeyDisplay组件显示密钥 */}
+      <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="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
+              </svg>
+            </div>
+            {t('渠道密钥信息')}
+          </div>
+        }
+        visible={twoFAState.showModal && twoFAState.showKey}
+        onCancel={resetTwoFAState}
+        footer={
+          <Button
+            type="primary"
+            onClick={resetTwoFAState}
+          >
+            {t('完成')}
+          </Button>
+        }
+        width={700}
+        style={{ maxWidth: '90vw' }}
+      >
+        <ChannelKeyDisplay
+          keyData={twoFAState.keyData}
+          showSuccessIcon={true}
+          successText={t('密钥获取成功')}
+          showWarning={true}
+          warningText={t('请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。')}
+        />
+      </Modal>
+
       <ModelSelectModal
       <ModelSelectModal
         visible={modelModalVisible}
         visible={modelModalVisible}
         models={fetchedModels}
         models={fetchedModels}

+ 21 - 1
web/src/i18n/locales/en.json

@@ -1997,5 +1997,25 @@
   "深色": "Dark",
   "深色": "Dark",
   "浅色": "Light",
   "浅色": "Light",
   "点击复制模型名称": "Click to copy model name",
   "点击复制模型名称": "Click to copy model name",
-  "已复制:{{name}}": "Copied: {{name}}"
+  "已复制:{{name}}": "Copied: {{name}}",
+  "所有密钥已复制到剪贴板": "All keys have been copied to the clipboard",
+  "密钥已复制到剪贴板": "Key copied to clipboard",
+  "验证成功": "Verification successful",
+  "渠道密钥列表": "Channel key list",
+  "渠道密钥": "Channel key",
+  "共 {{count}} 个密钥": "{{count}} keys in total",
+  "复制全部": "Copy all",
+  "JSON格式密钥,请确保格式正确": "JSON format key, please ensure the format is correct",
+  "检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。": "Detected multiple keys, you can copy each key individually or click Copy All to get the complete content.",
+  "安全提醒": "Security reminder",
+  "请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。": "Keep key information secure, do not disclose to others. If there are security concerns, please change the key immediately.",
+  "安全验证": "Security verification",
+  "验证": "Verify",
+  "为了保护账户安全,请验证您的两步验证码。": "To protect account security, please verify your two-factor authentication code.",
+  "支持6位TOTP验证码或8位备用码": "Supports 6-digit TOTP verification code or 8-digit backup code",
+  "获取密钥失败": "Failed to get key",
+  "查看密钥": "View key",
+  "查看渠道密钥": "View channel key",
+  "渠道密钥信息": "Channel key information",
+  "密钥获取成功": "Key acquisition successful"
 }
 }