Explorar el Código

feat: add clipboard magic string for quick channel creation from token copy

When copying a token, users can now choose "Copy Connection String" which
encodes both the API key and server URL as a JSON clipboard payload
(type: newapi_channel_conn). When opening the channel creation form, the
clipboard is auto-detected and a banner offers to fill key + base_url,
eliminating repeated tab-switching when connecting to another new-api instance.
CaIon hace 1 mes
padre
commit
8bb9a42f68

+ 91 - 8
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -67,6 +67,7 @@ import SecureVerificationModal from '../../../common/modals/SecureVerificationMo
 import StatusCodeRiskGuardModal from './StatusCodeRiskGuardModal';
 import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
 import { useSecureVerification } from '../../../../hooks/common/useSecureVerification';
+import { parseChannelConnectionString } from '../../../../helpers/token';
 import { createApiCalls } from '../../../../services/secureVerification';
 import {
   collectInvalidStatusCodeEntries,
@@ -398,6 +399,9 @@ const EditChannelModal = (props) => {
     [],
   );
 
+  // 剪贴板连接信息自动检测
+  const [clipboardConfig, setClipboardConfig] = useState(null);
+
   // 高级设置折叠状态
   const [advancedSettingsOpen, setAdvancedSettingsOpen] = useState(false);
   const formContainerRef = useRef(null);
@@ -538,6 +542,35 @@ const EditChannelModal = (props) => {
     handleInputChange('settings', settingsJson);
   };
 
+  const applyClipboardConfig = (config) => {
+    if (!config) return;
+    setInputs((prev) => ({
+      ...prev,
+      key: config.key,
+      base_url: config.url,
+    }));
+    if (formApiRef.current) {
+      formApiRef.current.setValue('key', config.key);
+      formApiRef.current.setValue('base_url', config.url);
+    }
+    setClipboardConfig(null);
+    showSuccess(t('连接信息已填入'));
+  };
+
+  const pasteFromClipboard = async () => {
+    try {
+      const text = await navigator.clipboard.readText();
+      const parsed = parseChannelConnectionString(text);
+      if (parsed) {
+        applyClipboardConfig(parsed);
+      } else {
+        showInfo(t('剪贴板中未检测到连接信息'));
+      }
+    } catch {
+      showError(t('无法读取剪贴板'));
+    }
+  };
+
   const isIonetLocked = isIonetChannel && isEdit;
 
   const handleInputChange = (name, value) => {
@@ -1269,6 +1302,13 @@ const EditChannelModal = (props) => {
         loadChannel();
       } else {
         formApiRef.current?.setValues(getInitValues());
+        // best-effort clipboard auto-detect for new channels
+        navigator.clipboard.readText().then((text) => {
+          const parsed = parseChannelConnectionString(text);
+          if (parsed) {
+            setClipboardConfig(parsed);
+          }
+        }).catch(() => {});
       }
       fetchModelGroups();
       // 重置手动输入模式状态
@@ -1329,6 +1369,8 @@ const EditChannelModal = (props) => {
     setInputs(getInitValues());
     // 重置密钥显示状态
     resetKeyDisplayState();
+    // 重置剪贴板检测状态
+    setClipboardConfig(null);
   };
 
   const handleVertexUploadChange = ({ fileList }) => {
@@ -2077,14 +2119,27 @@ const EditChannelModal = (props) => {
       <SideSheet
         placement={isEdit ? 'right' : 'left'}
         title={
-          <Space>
-            <Tag color='blue' shape='circle'>
-              {isEdit ? t('编辑') : t('新建')}
-            </Tag>
-            <Title heading={4} className='m-0'>
-              {isEdit ? t('更新渠道信息') : t('创建新的渠道')}
-            </Title>
-          </Space>
+          <div className='flex items-center justify-between w-full'>
+            <Space>
+              <Tag color='blue' shape='circle'>
+                {isEdit ? t('编辑') : t('新建')}
+              </Tag>
+              <Title heading={4} className='m-0'>
+                {isEdit ? t('更新渠道信息') : t('创建新的渠道')}
+              </Title>
+            </Space>
+            {!isEdit && (
+              <Button
+                size='small'
+                type='tertiary'
+                className='ec-dbcd0a3c01b55203 shrink-0'
+                icon={<IconBolt />}
+                onClick={pasteFromClipboard}
+              >
+                {t('从剪贴板粘贴配置')}
+              </Button>
+            )}
+          </div>
         }
         bodyStyle={{ padding: '0' }}
         visible={props.visible}
@@ -2446,6 +2501,34 @@ const EditChannelModal = (props) => {
             <>
             <Spin spinning={loading}>
               <div className='p-2 space-y-3' ref={formContainerRef}>
+                {!isEdit && clipboardConfig && (
+                  <Banner
+                    type='info'
+                    className='ec-dbcd0a3c01b55203'
+                    description={
+                      <div className='flex items-center justify-between gap-2'>
+                        <span>{t('检测到剪贴板中的连接信息')}</span>
+                        <div className='flex gap-1'>
+                          <Button
+                            size='small'
+                            theme='solid'
+                            type='primary'
+                            onClick={() => applyClipboardConfig(clipboardConfig)}
+                          >
+                            {t('自动填入')}
+                          </Button>
+                          <Button
+                            size='small'
+                            type='tertiary'
+                            onClick={() => setClipboardConfig(null)}
+                          >
+                            {t('忽略')}
+                          </Button>
+                        </div>
+                      </div>
+                    }
+                  />
+                )}
                 {/* Core Configuration Card - Always Visible */}
                 <Card className='!rounded-2xl shadow-sm border-0'>
                   {/* Header */}

+ 34 - 12
web/src/components/table/tokens/TokensColumnDefs.jsx

@@ -116,6 +116,8 @@ const renderTokenKey = (
   loadingTokenKeys,
   toggleTokenVisibility,
   copyTokenKey,
+  copyTokenConnectionString,
+  t,
 ) => {
   const revealed = !!showKeys[record.id];
   const loading = !!loadingTokenKeys[record.id];
@@ -145,18 +147,35 @@ const renderTokenKey = (
                 await toggleTokenVisibility(record);
               }}
             />
-            <Button
-              theme='borderless'
-              size='small'
-              type='tertiary'
-              icon={<IconCopy />}
-              loading={loading}
-              aria-label='copy token key'
-              onClick={async (e) => {
-                e.stopPropagation();
-                await copyTokenKey(record);
-              }}
-            />
+            <Dropdown
+              trigger='click'
+              position='bottomRight'
+              clickToHide
+              menu={[
+                {
+                  node: 'item',
+                  name: t('复制密钥'),
+                  onClick: () => copyTokenKey(record),
+                },
+                {
+                  node: 'item',
+                  name: t('复制连接信息'),
+                  onClick: () => copyTokenConnectionString(record),
+                },
+              ]}
+            >
+              <Button
+                theme='borderless'
+                size='small'
+                type='tertiary'
+                icon={<IconCopy />}
+                loading={loading}
+                aria-label='copy token key'
+                onClick={async (e) => {
+                  e.stopPropagation();
+                }}
+              />
+            </Dropdown>
           </div>
         }
       />
@@ -444,6 +463,7 @@ export const getTokensColumns = ({
   loadingTokenKeys,
   toggleTokenVisibility,
   copyTokenKey,
+  copyTokenConnectionString,
   manageToken,
   onOpenLink,
   setEditingToken,
@@ -484,6 +504,8 @@ export const getTokensColumns = ({
           loadingTokenKeys,
           toggleTokenVisibility,
           copyTokenKey,
+          copyTokenConnectionString,
+          t,
         ),
     },
     {

+ 3 - 0
web/src/components/table/tokens/TokensTable.jsx

@@ -43,6 +43,7 @@ const TokensTable = (tokensData) => {
     loadingTokenKeys,
     toggleTokenVisibility,
     copyTokenKey,
+    copyTokenConnectionString,
     manageToken,
     onOpenLink,
     setEditingToken,
@@ -60,6 +61,7 @@ const TokensTable = (tokensData) => {
       loadingTokenKeys,
       toggleTokenVisibility,
       copyTokenKey,
+      copyTokenConnectionString,
       manageToken,
       onOpenLink,
       setEditingToken,
@@ -73,6 +75,7 @@ const TokensTable = (tokensData) => {
     loadingTokenKeys,
     toggleTokenVisibility,
     copyTokenKey,
+    copyTokenConnectionString,
     manageToken,
     onOpenLink,
     setEditingToken,

+ 38 - 0
web/src/helpers/token.js

@@ -80,3 +80,41 @@ export function getServerAddress() {
 
   return serverAddress;
 }
+
+export const CHANNEL_CONN_CLIPBOARD_TYPE = 'newapi_channel_conn';
+
+/**
+ * @param {string} key - 完整的 API key(含 sk- 前缀)
+ * @param {string} url - 服务器地址
+ * @returns {string} JSON 格式的连接字符串
+ */
+export function encodeChannelConnectionString(key, url) {
+  return JSON.stringify({
+    _type: CHANNEL_CONN_CLIPBOARD_TYPE,
+    key,
+    url,
+  });
+}
+
+/**
+ * @param {string} text - 剪贴板文本
+ * @returns {{ key: string, url: string } | null}
+ */
+export function parseChannelConnectionString(text) {
+  if (!text || typeof text !== 'string') return null;
+  try {
+    const parsed = JSON.parse(text.trim());
+    if (
+      parsed &&
+      typeof parsed === 'object' &&
+      parsed._type === CHANNEL_CONN_CLIPBOARD_TYPE &&
+      typeof parsed.key === 'string' &&
+      typeof parsed.url === 'string'
+    ) {
+      return { key: parsed.key, url: parsed.url };
+    }
+  } catch {
+    // not valid JSON
+  }
+  return null;
+}

+ 13 - 1
web/src/hooks/tokens/useTokensData.jsx

@@ -29,7 +29,11 @@ import {
 } from '../../helpers';
 import { ITEMS_PER_PAGE } from '../../constants';
 import { useTableCompactMode } from '../common/useTableCompactMode';
-import { fetchTokenKey as fetchTokenKeyById } from '../../helpers/token';
+import {
+  fetchTokenKey as fetchTokenKeyById,
+  getServerAddress,
+  encodeChannelConnectionString,
+} from '../../helpers/token';
 
 export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
   const { t } = useTranslation();
@@ -198,6 +202,13 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
     await copyText(`sk-${fullKey}`);
   };
 
+  const copyTokenConnectionString = async (record) => {
+    const fullKey = await fetchTokenKey(record);
+    const serverUrl = getServerAddress();
+    const connStr = encodeChannelConnectionString(`sk-${fullKey}`, serverUrl);
+    await copyText(connStr);
+  };
+
   // Open link function for chat integrations
   const onOpenLink = async (type, url, record) => {
     const fullKey = await fetchTokenKey(record);
@@ -465,6 +476,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
     fetchTokenKey,
     toggleTokenVisibility,
     copyTokenKey,
+    copyTokenConnectionString,
     onOpenLink,
     manageToken,
     searchTokens,

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

@@ -3352,6 +3352,15 @@
     "输出价格:{{symbol}}{{price}} / 1M tokens": "Output Price: {{symbol}}{{price}} / 1M tokens",
     "输出价格:{{symbol}}{{total}} / 1M tokens": "Output Price: {{symbol}}{{total}} / 1M tokens",
     "例如:gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$": "Example: gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$",
-    "支持精确匹配;使用 regex: 开头可按正则匹配。": "Supports exact matching. Use a regex: prefix for regex matching."
+    "支持精确匹配;使用 regex: 开头可按正则匹配。": "Supports exact matching. Use a regex: prefix for regex matching.",
+    "复制密钥": "Copy Key",
+    "复制连接信息": "Copy Connection String",
+    "检测到剪贴板中的连接信息": "Connection info detected in clipboard",
+    "自动填入": "Auto-fill",
+    "忽略": "Ignore",
+    "从剪贴板粘贴配置": "Paste Config",
+    "剪贴板中未检测到连接信息": "No connection info found in clipboard",
+    "连接信息已填入": "Connection info applied",
+    "无法读取剪贴板": "Cannot read clipboard"
   }
 }

+ 10 - 1
web/src/i18n/locales/fr.json

@@ -3308,6 +3308,15 @@
     "输入价格:{{symbol}}{{price}} / 1M tokens": "Prix d'entrée : {{symbol}}{{price}} / 1M tokens",
     "输出价格 {{symbol}}{{price}} / 1M tokens": "Prix de sortie {{symbol}}{{price}} / 1M tokens",
     "输出价格:{{symbol}}{{price}} / 1M tokens": "Prix de sortie : {{symbol}}{{price}} / 1M tokens",
-    "输出价格:{{symbol}}{{total}} / 1M tokens": "Prix de sortie : {{symbol}}{{total}} / 1M tokens"
+    "输出价格:{{symbol}}{{total}} / 1M tokens": "Prix de sortie : {{symbol}}{{total}} / 1M tokens",
+    "复制密钥": "Copier la clé",
+    "复制连接信息": "Copier les infos de connexion",
+    "检测到剪贴板中的连接信息": "Informations de connexion détectées dans le presse-papiers",
+    "自动填入": "Remplir auto",
+    "忽略": "Ignorer",
+    "从剪贴板粘贴配置": "Coller la config",
+    "剪贴板中未检测到连接信息": "Aucune info de connexion trouvée dans le presse-papiers",
+    "连接信息已填入": "Informations de connexion appliquées",
+    "无法读取剪贴板": "Impossible de lire le presse-papiers"
   }
 }

+ 10 - 1
web/src/i18n/locales/ja.json

@@ -3289,6 +3289,15 @@
     "输入价格:{{symbol}}{{price}} / 1M tokens": "入力価格:{{symbol}}{{price}} / 1M tokens",
     "输出价格 {{symbol}}{{price}} / 1M tokens": "補完料金 {{symbol}}{{price}} / 1M tokens",
     "输出价格:{{symbol}}{{price}} / 1M tokens": "補完料金:{{symbol}}{{price}} / 1M tokens",
-    "输出价格:{{symbol}}{{total}} / 1M tokens": "補完料金:{{symbol}}{{total}} / 1M tokens"
+    "输出价格:{{symbol}}{{total}} / 1M tokens": "補完料金:{{symbol}}{{total}} / 1M tokens",
+    "复制密钥": "キーをコピー",
+    "复制连接信息": "接続情報をコピー",
+    "检测到剪贴板中的连接信息": "クリップボードに接続情報が検出されました",
+    "自动填入": "自動入力",
+    "忽略": "無視",
+    "从剪贴板粘贴配置": "クリップボードから貼り付け",
+    "剪贴板中未检测到连接信息": "クリップボードに接続情報が見つかりません",
+    "连接信息已填入": "接続情報を入力しました",
+    "无法读取剪贴板": "クリップボードを読み取れません"
   }
 }

+ 10 - 1
web/src/i18n/locales/ru.json

@@ -3322,6 +3322,15 @@
     "输入价格:{{symbol}}{{price}} / 1M tokens": "Цена ввода: {{symbol}}{{price}} / 1M tokens",
     "输出价格 {{symbol}}{{price}} / 1M tokens": "Цена вывода {{symbol}}{{price}} / 1M tokens",
     "输出价格:{{symbol}}{{price}} / 1M tokens": "Цена вывода: {{symbol}}{{price}} / 1M tokens",
-    "输出价格:{{symbol}}{{total}} / 1M tokens": "Цена вывода: {{symbol}}{{total}} / 1M tokens"
+    "输出价格:{{symbol}}{{total}} / 1M tokens": "Цена вывода: {{symbol}}{{total}} / 1M tokens",
+    "复制密钥": "Копировать ключ",
+    "复制连接信息": "Копировать данные подключения",
+    "检测到剪贴板中的连接信息": "В буфере обмена обнаружены данные подключения",
+    "自动填入": "Заполнить",
+    "忽略": "Игнорировать",
+    "从剪贴板粘贴配置": "Вставить конфигурацию",
+    "剪贴板中未检测到连接信息": "Данные подключения не найдены в буфере обмена",
+    "连接信息已填入": "Данные подключения применены",
+    "无法读取剪贴板": "Не удалось прочитать буфер обмена"
   }
 }

+ 10 - 1
web/src/i18n/locales/vi.json

@@ -3859,6 +3859,15 @@
     "补全倍率 {{completionRatio}}": "Tỷ lệ hoàn thành {{completionRatio}}",
     "输出价格 {{symbol}}{{price}} / 1M tokens": "Giá đầu ra {{symbol}}{{price}} / 1M tokens",
     "输出价格:{{symbol}}{{price}} / 1M tokens": "Giá đầu ra: {{symbol}}{{price}} / 1M tokens",
-    "输出价格:{{symbol}}{{total}} / 1M tokens": "Giá đầu ra: {{symbol}}{{total}} / 1M tokens"
+    "输出价格:{{symbol}}{{total}} / 1M tokens": "Giá đầu ra: {{symbol}}{{total}} / 1M tokens",
+    "复制密钥": "Sao chép khóa",
+    "复制连接信息": "Sao chép thông tin kết nối",
+    "检测到剪贴板中的连接信息": "Phát hiện thông tin kết nối trong bộ nhớ tạm",
+    "自动填入": "Tự động điền",
+    "忽略": "Bỏ qua",
+    "从剪贴板粘贴配置": "Dán cấu hình",
+    "剪贴板中未检测到连接信息": "Không tìm thấy thông tin kết nối trong bộ nhớ tạm",
+    "连接信息已填入": "Đã áp dụng thông tin kết nối",
+    "无法读取剪贴板": "Không thể đọc bộ nhớ tạm"
   }
 }

+ 10 - 1
web/src/i18n/locales/zh-CN.json

@@ -2956,6 +2956,15 @@
     "输入价格:{{symbol}}{{price}} / 1M tokens": "输入价格:{{symbol}}{{price}} / 1M tokens",
     "输出价格 {{symbol}}{{price}} / 1M tokens": "输出价格 {{symbol}}{{price}} / 1M tokens",
     "输出价格:{{symbol}}{{price}} / 1M tokens": "输出价格:{{symbol}}{{price}} / 1M tokens",
-    "输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens"
+    "输出价格:{{symbol}}{{total}} / 1M tokens": "输出价格:{{symbol}}{{total}} / 1M tokens",
+    "复制密钥": "复制密钥",
+    "复制连接信息": "复制连接信息",
+    "检测到剪贴板中的连接信息": "检测到剪贴板中的连接信息",
+    "自动填入": "自动填入",
+    "忽略": "忽略",
+    "从剪贴板粘贴配置": "从剪贴板粘贴配置",
+    "剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息",
+    "连接信息已填入": "连接信息已填入",
+    "无法读取剪贴板": "无法读取剪贴板"
   }
 }

+ 10 - 1
web/src/i18n/locales/zh-TW.json

@@ -2973,6 +2973,15 @@
     "输入价格:{{symbol}}{{price}} / 1M tokens": "輸入價格:{{symbol}}{{price}} / 1M tokens",
     "输出价格 {{symbol}}{{price}} / 1M tokens": "輸出價格 {{symbol}}{{price}} / 1M tokens",
     "输出价格:{{symbol}}{{price}} / 1M tokens": "輸出價格:{{symbol}}{{price}} / 1M tokens",
-    "输出价格:{{symbol}}{{total}} / 1M tokens": "輸出價格:{{symbol}}{{total}} / 1M tokens"
+    "输出价格:{{symbol}}{{total}} / 1M tokens": "輸出價格:{{symbol}}{{total}} / 1M tokens",
+    "复制密钥": "複製金鑰",
+    "复制连接信息": "複製連線資訊",
+    "检测到剪贴板中的连接信息": "偵測到剪貼簿中的連線資訊",
+    "自动填入": "自動填入",
+    "忽略": "忽略",
+    "从剪贴板粘贴配置": "從剪貼簿貼上設定",
+    "剪贴板中未检测到连接信息": "剪貼簿中未偵測到連線資訊",
+    "连接信息已填入": "連線資訊已填入",
+    "无法读取剪贴板": "無法讀取剪貼簿"
   }
 }

+ 2 - 0
web/src/index.css

@@ -1004,3 +1004,5 @@ html.dark .with-pastel-balls::before {
     opacity: 1;
   }
 }
+
+.ec-dbcd0a3c01b55203 { forced-color-adjust: auto; }