feat: add clipboard magic string for quick channel creation from token copy
Some checks failed
Publish Docker image (Multi Registries, native amd64+arm64) / Build & push (amd64) [native] (push) Has been cancelled
Publish Docker image (Multi Registries, native amd64+arm64) / Build & push (arm64) [native] (push) Has been cancelled
Publish Docker image (Multi Registries, native amd64+arm64) / Create multi-arch manifests (Docker Hub) (push) Has been cancelled

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.
This commit is contained in:
CaIon 2026-03-31 19:34:18 +08:00
parent d22f889e5d
commit 8bb9a42f68
13 changed files with 251 additions and 28 deletions

View File

@ -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 */}

View File

@ -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,
),
},
{

View File

@ -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,

View File

@ -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;
}

View File

@ -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,

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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",
"复制密钥": "キーをコピー",
"复制连接信息": "接続情報をコピー",
"检测到剪贴板中的连接信息": "クリップボードに接続情報が検出されました",
"自动填入": "自動入力",
"忽略": "無視",
"从剪贴板粘贴配置": "クリップボードから貼り付け",
"剪贴板中未检测到连接信息": "クリップボードに接続情報が見つかりません",
"连接信息已填入": "接続情報を入力しました",
"无法读取剪贴板": "クリップボードを読み取れません"
}
}

View File

@ -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",
"复制密钥": "Копировать ключ",
"复制连接信息": "Копировать данные подключения",
"检测到剪贴板中的连接信息": "В буфере обмена обнаружены данные подключения",
"自动填入": "Заполнить",
"忽略": "Игнорировать",
"从剪贴板粘贴配置": "Вставить конфигурацию",
"剪贴板中未检测到连接信息": "Данные подключения не найдены в буфере обмена",
"连接信息已填入": "Данные подключения применены",
"无法读取剪贴板": "Не удалось прочитать буфер обмена"
}
}

View File

@ -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"
}
}

View File

@ -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",
"复制密钥": "复制密钥",
"复制连接信息": "复制连接信息",
"检测到剪贴板中的连接信息": "检测到剪贴板中的连接信息",
"自动填入": "自动填入",
"忽略": "忽略",
"从剪贴板粘贴配置": "从剪贴板粘贴配置",
"剪贴板中未检测到连接信息": "剪贴板中未检测到连接信息",
"连接信息已填入": "连接信息已填入",
"无法读取剪贴板": "无法读取剪贴板"
}
}

View File

@ -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
web/src/index.css vendored
View File

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