Explorar o código

Merge branch 'main' into traduction

# Conflicts:
#	web/src/i18n/locales/zh.json
comeback01 hai 5 meses
pai
achega
e647878031

+ 3 - 0
controller/channel.go

@@ -8,6 +8,7 @@ import (
 	"one-api/constant"
 	"one-api/dto"
 	"one-api/model"
+	"one-api/service"
 	"strconv"
 	"strings"
 
@@ -633,6 +634,7 @@ func AddChannel(c *gin.Context) {
 		common.ApiError(c, err)
 		return
 	}
+	service.ResetProxyClientCache()
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
@@ -894,6 +896,7 @@ func UpdateChannel(c *gin.Context) {
 		return
 	}
 	model.InitChannelCache()
+	service.ResetProxyClientCache()
 	channel.Key = ""
 	clearChannelInfo(&channel.Channel)
 	c.JSON(http.StatusOK, gin.H{

+ 2 - 1
controller/topup_stripe.go

@@ -225,7 +225,8 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
 				Quantity: stripe.Int64(amount),
 			},
 		},
-		Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
+		Mode:                stripe.String(string(stripe.CheckoutSessionModePayment)),
+		AllowPromotionCodes: stripe.Bool(setting.StripePromotionCodesEnabled),
 	}
 
 	if "" == customerId {

+ 1 - 0
dto/gemini.go

@@ -251,6 +251,7 @@ type GeminiChatTool struct {
 	GoogleSearchRetrieval any `json:"googleSearchRetrieval,omitempty"`
 	CodeExecution         any `json:"codeExecution,omitempty"`
 	FunctionDeclarations  any `json:"functionDeclarations,omitempty"`
+	URLContext            any `json:"urlContext,omitempty"`
 }
 
 type GeminiChatGenerationConfig struct {

+ 18 - 0
main.go

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"bytes"
 	"embed"
 	"fmt"
 	"log"
@@ -16,6 +17,7 @@ import (
 	"one-api/setting/ratio_setting"
 	"os"
 	"strconv"
+	"strings"
 	"time"
 
 	"github.com/bytedance/gopkg/util/gopool"
@@ -147,6 +149,22 @@ func main() {
 	})
 	server.Use(sessions.Sessions("session", store))
 
+	analyticsInjectBuilder := &strings.Builder{}
+	if os.Getenv("UMAMI_WEBSITE_ID") != "" {
+		umamiSiteID := os.Getenv("UMAMI_WEBSITE_ID")
+		umamiScriptURL := os.Getenv("UMAMI_SCRIPT_URL")
+		if umamiScriptURL == "" {
+			umamiScriptURL = "https://analytics.umami.is/script.js"
+		}
+		analyticsInjectBuilder.WriteString("<script defer src=\"")
+		analyticsInjectBuilder.WriteString(umamiScriptURL)
+		analyticsInjectBuilder.WriteString("\" data-website-id=\"")
+		analyticsInjectBuilder.WriteString(umamiSiteID)
+		analyticsInjectBuilder.WriteString("\"></script>")
+	}
+	analyticsInject := analyticsInjectBuilder.String()
+	indexPage = bytes.ReplaceAll(indexPage, []byte("<analytics></analytics>\n"), []byte(analyticsInject))
+
 	router.SetRouter(server, buildFS, indexPage)
 	var port = os.Getenv("PORT")
 	if port == "" {

+ 3 - 0
model/option.go

@@ -82,6 +82,7 @@ func InitOptionMap() {
 	common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret
 	common.OptionMap["StripePriceId"] = setting.StripePriceId
 	common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64)
+	common.OptionMap["StripePromotionCodesEnabled"] = strconv.FormatBool(setting.StripePromotionCodesEnabled)
 	common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
 	common.OptionMap["Chats"] = setting.Chats2JsonString()
 	common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
@@ -330,6 +331,8 @@ func updateOptionMap(key string, value string) (err error) {
 		setting.StripeUnitPrice, _ = strconv.ParseFloat(value, 64)
 	case "StripeMinTopUp":
 		setting.StripeMinTopUp, _ = strconv.Atoi(value)
+	case "StripePromotionCodesEnabled":
+		setting.StripePromotionCodesEnabled = value == "true"
 	case "TopupGroupRatio":
 		err = common.UpdateTopupGroupRatioByJSONString(value)
 	case "GitHubClientId":

+ 10 - 0
relay/channel/gemini/relay-gemini.go

@@ -245,6 +245,7 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
 		functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools))
 		googleSearch := false
 		codeExecution := false
+		urlContext := false
 		for _, tool := range textRequest.Tools {
 			if tool.Function.Name == "googleSearch" {
 				googleSearch = true
@@ -254,6 +255,10 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
 				codeExecution = true
 				continue
 			}
+			if tool.Function.Name == "urlContext" {
+				urlContext = true
+				continue
+			}
 			if tool.Function.Parameters != nil {
 
 				params, ok := tool.Function.Parameters.(map[string]interface{})
@@ -281,6 +286,11 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
 				GoogleSearch: make(map[string]string),
 			})
 		}
+		if urlContext {
+			geminiTools = append(geminiTools, dto.GeminiChatTool{
+				URLContext: make(map[string]string),
+			})
+		}
 		if len(functions) > 0 {
 			geminiTools = append(geminiTools, dto.GeminiChatTool{
 				FunctionDeclarations: functions,

+ 39 - 5
service/http_client.go

@@ -7,12 +7,17 @@ import (
 	"net/http"
 	"net/url"
 	"one-api/common"
+	"sync"
 	"time"
 
 	"golang.org/x/net/proxy"
 )
 
-var httpClient *http.Client
+var (
+	httpClient      *http.Client
+	proxyClientLock sync.Mutex
+	proxyClients    = make(map[string]*http.Client)
+)
 
 func InitHttpClient() {
 	if common.RelayTimeout == 0 {
@@ -28,12 +33,31 @@ func GetHttpClient() *http.Client {
 	return httpClient
 }
 
+// ResetProxyClientCache 清空代理客户端缓存,确保下次使用时重新初始化
+func ResetProxyClientCache() {
+	proxyClientLock.Lock()
+	defer proxyClientLock.Unlock()
+	for _, client := range proxyClients {
+		if transport, ok := client.Transport.(*http.Transport); ok && transport != nil {
+			transport.CloseIdleConnections()
+		}
+	}
+	proxyClients = make(map[string]*http.Client)
+}
+
 // NewProxyHttpClient 创建支持代理的 HTTP 客户端
 func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
 	if proxyURL == "" {
 		return http.DefaultClient, nil
 	}
 
+	proxyClientLock.Lock()
+	if client, ok := proxyClients[proxyURL]; ok {
+		proxyClientLock.Unlock()
+		return client, nil
+	}
+	proxyClientLock.Unlock()
+
 	parsedURL, err := url.Parse(proxyURL)
 	if err != nil {
 		return nil, err
@@ -41,11 +65,16 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
 
 	switch parsedURL.Scheme {
 	case "http", "https":
-		return &http.Client{
+		client := &http.Client{
 			Transport: &http.Transport{
 				Proxy: http.ProxyURL(parsedURL),
 			},
-		}, nil
+		}
+		client.Timeout = time.Duration(common.RelayTimeout) * time.Second
+		proxyClientLock.Lock()
+		proxyClients[proxyURL] = client
+		proxyClientLock.Unlock()
+		return client, nil
 
 	case "socks5", "socks5h":
 		// 获取认证信息
@@ -67,13 +96,18 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
 			return nil, err
 		}
 
-		return &http.Client{
+		client := &http.Client{
 			Transport: &http.Transport{
 				DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
 					return dialer.Dial(network, addr)
 				},
 			},
-		}, nil
+		}
+		client.Timeout = time.Duration(common.RelayTimeout) * time.Second
+		proxyClientLock.Lock()
+		proxyClients[proxyURL] = client
+		proxyClientLock.Unlock()
+		return client, nil
 
 	default:
 		return nil, fmt.Errorf("unsupported proxy scheme: %s", parsedURL.Scheme)

+ 1 - 0
setting/payment_stripe.go

@@ -5,3 +5,4 @@ var StripeWebhookSecret = ""
 var StripePriceId = ""
 var StripeUnitPrice = 8.0
 var StripeMinTopUp = 1
+var StripePromotionCodesEnabled = false

+ 1 - 0
web/index.html

@@ -10,6 +10,7 @@
       content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
     />
     <title>New API</title>
+<analytics></analytics>
   </head>
 
   <body>

+ 1 - 0
web/src/components/settings/PaymentSetting.jsx

@@ -45,6 +45,7 @@ const PaymentSetting = () => {
     StripePriceId: '',
     StripeUnitPrice: 8.0,
     StripeMinTopUp: 1,
+    StripePromotionCodesEnabled: false,
   });
 
   let [loading, setLoading] = useState(false);

+ 39 - 10
web/src/components/settings/PersonalSetting.jsx

@@ -19,7 +19,14 @@ 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 } from '../../helpers';
+import {
+  API,
+  copy,
+  showError,
+  showInfo,
+  showSuccess,
+  setStatusData,
+} from '../../helpers';
 import { UserContext } from '../../context/User';
 import { Modal } from '@douyinfe/semi-ui';
 import { useTranslation } from 'react-i18next';
@@ -71,18 +78,40 @@ const PersonalSetting = () => {
   });
 
   useEffect(() => {
-    let status = localStorage.getItem('status');
-    if (status) {
-      status = JSON.parse(status);
-      setStatus(status);
-      if (status.turnstile_check) {
+    let saved = localStorage.getItem('status');
+    if (saved) {
+      const parsed = JSON.parse(saved);
+      setStatus(parsed);
+      if (parsed.turnstile_check) {
         setTurnstileEnabled(true);
-        setTurnstileSiteKey(status.turnstile_site_key);
+        setTurnstileSiteKey(parsed.turnstile_site_key);
+      } else {
+        setTurnstileEnabled(false);
+        setTurnstileSiteKey('');
       }
     }
-    getUserData().then((res) => {
-      console.log(userState);
-    });
+    // 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();
   }, []);
 
   useEffect(() => {

+ 53 - 20
web/src/components/settings/personal/cards/AccountManagement.jsx

@@ -28,6 +28,7 @@ import {
   Tabs,
   TabPane,
   Popover,
+  Modal,
 } from '@douyinfe/semi-ui';
 import {
   IconMail,
@@ -83,6 +84,9 @@ const AccountManagement = ({
       </Popover>
     );
   };
+  const isBound = (accountId) => Boolean(accountId);
+  const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
+
   return (
     <Card className='!rounded-2xl'>
       {/* 卡片头部 */}
@@ -142,7 +146,7 @@ const AccountManagement = ({
                       size='small'
                       onClick={() => setShowEmailBindModal(true)}
                     >
-                      {userState.user && userState.user.email !== ''
+                      {isBound(userState.user?.email)
                         ? t('修改绑定')
                         : t('绑定')}
                     </Button>
@@ -165,9 +169,11 @@ const AccountManagement = ({
                         {t('微信')}
                       </div>
                       <div className='text-sm text-gray-500 truncate'>
-                        {userState.user && userState.user.wechat_id !== ''
-                          ? t('已绑定')
-                          : t('未绑定')}
+                        {!status.wechat_login
+                          ? t('未启用')
+                          : isBound(userState.user?.wechat_id)
+                            ? t('已绑定')
+                            : t('未绑定')}
                       </div>
                     </div>
                   </div>
@@ -179,7 +185,7 @@ const AccountManagement = ({
                       disabled={!status.wechat_login}
                       onClick={() => setShowWeChatBindModal(true)}
                     >
-                      {userState.user && userState.user.wechat_id !== ''
+                      {isBound(userState.user?.wechat_id)
                         ? t('修改绑定')
                         : status.wechat_login
                           ? t('绑定')
@@ -220,8 +226,7 @@ const AccountManagement = ({
                         onGitHubOAuthClicked(status.github_client_id)
                       }
                       disabled={
-                        (userState.user && userState.user.github_id !== '') ||
-                        !status.github_oauth
+                        isBound(userState.user?.github_id) || !status.github_oauth
                       }
                     >
                       {status.github_oauth ? t('绑定') : t('未启用')}
@@ -264,8 +269,7 @@ const AccountManagement = ({
                         )
                       }
                       disabled={
-                        (userState.user && userState.user.oidc_id !== '') ||
-                        !status.oidc_enabled
+                        isBound(userState.user?.oidc_id) || !status.oidc_enabled
                       }
                     >
                       {status.oidc_enabled ? t('绑定') : t('未启用')}
@@ -298,26 +302,56 @@ const AccountManagement = ({
                   </div>
                   <div className='flex-shrink-0'>
                     {status.telegram_oauth ? (
-                      userState.user.telegram_id !== '' ? (
-                        <Button disabled={true} size='small'>
+                      isBound(userState.user?.telegram_id) ? (
+                        <Button
+                          disabled
+                          size='small'
+                          type='primary'
+                          theme='outline'
+                        >
                           {t('已绑定')}
                         </Button>
                       ) : (
-                        <div className='scale-75'>
-                          <TelegramLoginButton
-                            dataAuthUrl='/api/oauth/telegram/bind'
-                            botName={status.telegram_bot_name}
-                          />
-                        </div>
+                        <Button
+                          type='primary'
+                          theme='outline'
+                          size='small'
+                          onClick={() => setShowTelegramBindModal(true)}
+                        >
+                          {t('绑定')}
+                        </Button>
                       )
                     ) : (
-                      <Button disabled={true} size='small'>
+                      <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'>
@@ -350,8 +384,7 @@ const AccountManagement = ({
                         onLinuxDOOAuthClicked(status.linuxdo_client_id)
                       }
                       disabled={
-                        (userState.user && userState.user.linux_do_id !== '') ||
-                        !status.linuxdo_oauth
+                        isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth
                       }
                     >
                       {status.linuxdo_oauth ? t('绑定') : t('未启用')}

+ 105 - 23
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -455,6 +455,14 @@ const EditChannelModal = (props) => {
         data.is_enterprise_account = false;
       }
 
+      if (
+        data.type === 45 &&
+        (!data.base_url ||
+          (typeof data.base_url === 'string' && data.base_url.trim() === ''))
+      ) {
+        data.base_url = 'https://ark.cn-beijing.volces.com';
+      }
+
       setInputs(data);
       if (formApiRef.current) {
         formApiRef.current.setValues(data);
@@ -837,7 +845,9 @@ const EditChannelModal = (props) => {
               delete localInputs.key;
             }
           } else {
-            localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]);
+            localInputs.key = batch
+              ? JSON.stringify(keys)
+              : JSON.stringify(keys[0]);
           }
         }
       }
@@ -954,6 +964,56 @@ const EditChannelModal = (props) => {
     }
   };
 
+  // 密钥去重函数
+  const deduplicateKeys = () => {
+    const currentKey = formApiRef.current?.getValue('key') || inputs.key || '';
+
+    if (!currentKey.trim()) {
+      showInfo(t('请先输入密钥'));
+      return;
+    }
+
+    // 按行分割密钥
+    const keyLines = currentKey.split('\n');
+    const beforeCount = keyLines.length;
+
+    // 使用哈希表去重,保持原有顺序
+    const keySet = new Set();
+    const deduplicatedKeys = [];
+
+    keyLines.forEach((line) => {
+      const trimmedLine = line.trim();
+      if (trimmedLine && !keySet.has(trimmedLine)) {
+        keySet.add(trimmedLine);
+        deduplicatedKeys.push(trimmedLine);
+      }
+    });
+
+    const afterCount = deduplicatedKeys.length;
+    const deduplicatedKeyText = deduplicatedKeys.join('\n');
+
+    // 更新表单和状态
+    if (formApiRef.current) {
+      formApiRef.current.setValue('key', deduplicatedKeyText);
+    }
+    handleInputChange('key', deduplicatedKeyText);
+
+    // 显示去重结果
+    const message = t(
+      '去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥',
+      {
+        before: beforeCount,
+        after: afterCount,
+      },
+    );
+
+    if (beforeCount === afterCount) {
+      showInfo(t('未发现重复密钥'));
+    } else {
+      showSuccess(message);
+    }
+  };
+
   const addCustomModels = () => {
     if (customModel.trim() === '') return;
     const modelArray = customModel.split(',').map((model) => model.trim());
@@ -1049,24 +1109,41 @@ const EditChannelModal = (props) => {
         </Checkbox>
       )}
       {batch && (
-        <Checkbox
-          disabled={isEdit}
-          checked={multiToSingle}
-          onChange={() => {
-            setMultiToSingle((prev) => !prev);
-            setInputs((prev) => {
-              const newInputs = { ...prev };
-              if (!multiToSingle) {
-                newInputs.multi_key_mode = multiKeyMode;
-              } else {
-                delete newInputs.multi_key_mode;
-              }
-              return newInputs;
-            });
-          }}
-        >
-          {t('密钥聚合模式')}
-        </Checkbox>
+        <>
+          <Checkbox
+            disabled={isEdit}
+            checked={multiToSingle}
+            onChange={() => {
+              setMultiToSingle((prev) => {
+                const nextValue = !prev;
+                setInputs((prevInputs) => {
+                  const newInputs = { ...prevInputs };
+                  if (nextValue) {
+                    newInputs.multi_key_mode = multiKeyMode;
+                  } else {
+                    delete newInputs.multi_key_mode;
+                  }
+                  return newInputs;
+                });
+                return nextValue;
+              });
+            }}
+          >
+            {t('密钥聚合模式')}
+          </Checkbox>
+
+          {inputs.type !== 41 && (
+            <Button
+              size='small'
+              type='tertiary'
+              theme='outline'
+              onClick={deduplicateKeys}
+              style={{ textDecoration: 'underline' }}
+            >
+              {t('密钥去重')}
+            </Button>
+          )}
+        </>
       )}
     </Space>
   ) : null;
@@ -1268,7 +1345,10 @@ const EditChannelModal = (props) => {
                       value={inputs.vertex_key_type || 'json'}
                       onChange={(value) => {
                         // 更新设置中的 vertex_key_type
-                        handleChannelOtherSettingsChange('vertex_key_type', value);
+                        handleChannelOtherSettingsChange(
+                          'vertex_key_type',
+                          value,
+                        );
                         // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件
                         if (value === 'api_key') {
                           setBatch(false);
@@ -1288,7 +1368,8 @@ const EditChannelModal = (props) => {
                     />
                   )}
                   {batch ? (
-                    inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
+                    inputs.type === 41 &&
+                    (inputs.vertex_key_type || 'json') === 'json' ? (
                       <Form.Upload
                         field='vertex_files'
                         label={t('密钥文件 (.json)')}
@@ -1324,7 +1405,7 @@ const EditChannelModal = (props) => {
                         autoComplete='new-password'
                         onChange={(value) => handleInputChange('key', value)}
                         extraText={
-                          <div className='flex items-center gap-2'>
+                          <div className='flex items-center gap-2 flex-wrap'>
                             {isEdit &&
                               isMultiKeyChannel &&
                               keyMode === 'append' && (
@@ -1352,7 +1433,8 @@ const EditChannelModal = (props) => {
                     )
                   ) : (
                     <>
-                      {inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
+                      {inputs.type === 41 &&
+                      (inputs.vertex_key_type || 'json') === 'json' ? (
                         <>
                           {!batch && (
                             <div className='flex items-center justify-between mb-3'>

+ 110 - 4
web/src/components/table/task-logs/modals/ContentModal.jsx

@@ -17,8 +17,11 @@ 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 { Modal } from '@douyinfe/semi-ui';
+import React, { useState, useEffect } from 'react';
+import { Modal, Button, Typography, Spin } from '@douyinfe/semi-ui';
+import { IconExternalOpen, IconCopy } from '@douyinfe/semi-icons';
+
+const { Text } = Typography;
 
 const ContentModal = ({
   isModalOpen,
@@ -26,17 +29,120 @@ const ContentModal = ({
   modalContent,
   isVideo,
 }) => {
+  const [videoError, setVideoError] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
+
+  useEffect(() => {
+    if (isModalOpen && isVideo) {
+      setVideoError(false);
+      setIsLoading(true);
+    }
+  }, [isModalOpen, isVideo]);
+
+  const handleVideoError = () => {
+    setVideoError(true);
+    setIsLoading(false);
+  };
+
+  const handleVideoLoaded = () => {
+    setIsLoading(false);
+  };
+
+  const handleCopyUrl = () => {
+    navigator.clipboard.writeText(modalContent);
+  };
+
+  const handleOpenInNewTab = () => {
+    window.open(modalContent, '_blank');
+  };
+
+  const renderVideoContent = () => {
+    if (videoError) {
+      return (
+        <div style={{ textAlign: 'center', padding: '40px' }}>
+          <Text type="tertiary" style={{ display: 'block', marginBottom: '16px' }}>
+            视频无法在当前浏览器中播放,这可能是由于:
+          </Text>
+          <Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
+            • 视频服务商的跨域限制
+          </Text>
+          <Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
+            • 需要特定的请求头或认证
+          </Text>
+          <Text type="tertiary" style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}>
+            • 防盗链保护机制
+          </Text>
+          
+          <div style={{ marginTop: '20px' }}>
+            <Button 
+              icon={<IconExternalOpen />}
+              onClick={handleOpenInNewTab}
+              style={{ marginRight: '8px' }}
+            >
+              在新标签页中打开
+            </Button>
+            <Button 
+              icon={<IconCopy />}
+              onClick={handleCopyUrl}
+            >
+              复制链接
+            </Button>
+          </div>
+          
+          <div style={{ marginTop: '16px', padding: '8px', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
+            <Text 
+              type="tertiary" 
+              style={{ fontSize: '10px', wordBreak: 'break-all' }}
+            >
+              {modalContent}
+            </Text>
+          </div>
+        </div>
+      );
+    }
+
+    return (
+      <div style={{ position: 'relative' }}>
+        {isLoading && (
+          <div style={{
+            position: 'absolute',
+            top: '50%',
+            left: '50%',
+            transform: 'translate(-50%, -50%)',
+            zIndex: 10
+          }}>
+            <Spin size="large" />
+          </div>
+        )}
+        <video 
+          src={modalContent} 
+          controls 
+          style={{ width: '100%' }} 
+          autoPlay
+          crossOrigin="anonymous"
+          onError={handleVideoError}
+          onLoadedData={handleVideoLoaded}
+          onLoadStart={() => setIsLoading(true)}
+        />
+      </div>
+    );
+  };
+
   return (
     <Modal
       visible={isModalOpen}
       onOk={() => setIsModalOpen(false)}
       onCancel={() => setIsModalOpen(false)}
       closable={null}
-      bodyStyle={{ height: '400px', overflow: 'auto' }}
+      bodyStyle={{ 
+        height: isVideo ? '450px' : '400px', 
+        overflow: 'auto',
+        padding: isVideo && videoError ? '0' : '24px'
+      }}
       width={800}
     >
       {isVideo ? (
-        <video src={modalContent} controls style={{ width: '100%' }} autoPlay />
+        renderVideoContent()
       ) : (
         <p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
       )}

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

@@ -837,6 +837,7 @@
   "确定要充值 $": "Confirm to top up $",
   "微信/支付宝 实付金额:": "WeChat/Alipay actual payment amount:",
   "Stripe 实付金额:": "Stripe actual payment amount:",
+  "允许在 Stripe 支付中输入促销码": "Allow entering promotion codes during Stripe checkout",
   "支付中...": "Paying",
   "支付宝": "Alipay",
   "收益统计": "Income statistics",

+ 2 - 1
web/src/i18n/locales/zh.json

@@ -35,5 +35,6 @@
   "域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。",
   "common": {
     "changeLanguage": "切换语言"
-  }
+  },
+  "允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码"
 }

+ 24 - 0
web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx

@@ -45,6 +45,7 @@ export default function SettingsPaymentGateway(props) {
     StripePriceId: '',
     StripeUnitPrice: 8.0,
     StripeMinTopUp: 1,
+    StripePromotionCodesEnabled: false,
   });
   const [originInputs, setOriginInputs] = useState({});
   const formApiRef = useRef(null);
@@ -63,6 +64,10 @@ export default function SettingsPaymentGateway(props) {
           props.options.StripeMinTopUp !== undefined
             ? parseFloat(props.options.StripeMinTopUp)
             : 1,
+        StripePromotionCodesEnabled:
+          props.options.StripePromotionCodesEnabled !== undefined
+            ? props.options.StripePromotionCodesEnabled
+            : false,
       };
       setInputs(currentInputs);
       setOriginInputs({ ...currentInputs });
@@ -114,6 +119,16 @@ export default function SettingsPaymentGateway(props) {
           value: inputs.StripeMinTopUp.toString(),
         });
       }
+      if (
+        originInputs['StripePromotionCodesEnabled'] !==
+          inputs.StripePromotionCodesEnabled &&
+        inputs.StripePromotionCodesEnabled !== undefined
+      ) {
+        options.push({
+          key: 'StripePromotionCodesEnabled',
+          value: inputs.StripePromotionCodesEnabled ? 'true' : 'false',
+        });
+      }
 
       // 发送请求
       const requestQueue = options.map((opt) =>
@@ -225,6 +240,15 @@ export default function SettingsPaymentGateway(props) {
                 placeholder={t('例如:2,就是最低充值2$')}
               />
             </Col>
+            <Col xs={24} sm={24} md={8} lg={8} xl={8}>
+              <Form.Switch
+                field='StripePromotionCodesEnabled'
+                size='default'
+                checkedText='|'
+                uncheckedText='〇'
+                label={t('允许在 Stripe 支付中输入促销码')}
+              />
+            </Col>
           </Row>
           <Button onClick={submitStripeSetting}>{t('更新 Stripe 设置')}</Button>
         </Form.Section>