feat: enhance API key management and fix routing logic

* ```
feat(accounts): 支持批量添加API密钥连接

- 添加accessTokens字段到账户创建载荷模式,允许传递多个API密钥
- 实现批量API密钥解析功能,支持换行符、空格和逗号分隔
- 重构账户创建逻辑以支持单个和批量API密钥处理
- 在前端界面中添加批量API密钥输入检测和处理
- 添加批量创建结果返回和错误处理
- 更新验证流程以支持批量模式下的逐条验证
- 添加详细的批量API密钥测试用例

该更改允许用户一次提交多个API密钥,系统将为每个密钥创建独立的连接并参与负载均衡。
```

* ```
fix(tokenRouter): 调整路由匹配优先级以优先匹配显示名称别名

当精确路由和显式组显示名称冲突时,现在优先选择显式组显示名称
而不是精确路由模式。这修正了路由决策逻辑,使显式组路由能够正
确覆盖精确模型匹配。

相关的测试用例已更新以反映新的匹配行为,确保显式组路由在冲
突情况下获得更高优先级。
```

* ```
fix(accounts): 修复API密钥验证错误消息显示逻辑

当凭证模式不为apikey时显示会话令牌提示信息,否则显示API密钥验证失败信息

feat(accounts): 更新API密钥输入界面提示文本

将批量添加说明从输入框占位符移至独立提示文本,
使界面更清晰易懂
```

* ```
feat(workflow): 在CI/CD工作流中使用动态Docker Hub镜像名称

- 在ci.yml和release.yml工作流中添加DOCKERHUB_IMAGE环境变量
- 将硬编码的Docker Hub用户名替换为从secrets.DOCKERHUB_USERNAME动态获取
- 更新docker.metadata-action配置以使用环境变量中的镜像名称
- 添加测试用例验证Docker Hub镜像名称的正确配置
```

* fix(accounts): 修复批量创建回归并下沉创建流程
This commit is contained in:
puyujian
2026-04-07 14:28:25 +08:00
committed by GitHub
parent 5cab6c83f1
commit 569a15e14c
15 changed files with 655 additions and 282 deletions
+49 -7
View File
@@ -29,6 +29,7 @@ import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from '.
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
import { SITE_DOCS_URL } from '../docsLink.js';
import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
import { parseBatchApiKeys } from '../../shared/apiKeyBatch.js';
type ConnectionsSegment = 'session' | 'apikey' | 'tokens';
@@ -177,6 +178,11 @@ export default function Accounts() {
() => sites.find((item) => item.id === tokenForm.siteId) || null,
[sites, tokenForm.siteId],
);
const parsedApiKeys = useMemo(
() => activeSegment === 'apikey' ? parseBatchApiKeys(tokenForm.accessToken) : [],
[activeSegment, tokenForm.accessToken],
);
const isBatchApiKeyInput = activeSegment === 'apikey' && parsedApiKeys.length > 1;
const siteSelectOptions = useMemo(
() => [
{ value: '0', label: '选择站点' },
@@ -322,6 +328,10 @@ export default function Accounts() {
const handleVerifyToken = async () => {
if (!tokenForm.siteId || !tokenForm.accessToken) return;
if (isBatchApiKeyInput) {
toast.info(`检测到 ${parsedApiKeys.length} 个 API Key,批量模式会在添加时逐条校验`);
return;
}
const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
setVerifying(true);
setVerifyResult(null);
@@ -352,7 +362,7 @@ export default function Accounts() {
const handleTokenAdd = async () => {
if (!tokenForm.siteId || !tokenForm.accessToken) return;
if (!verifyResult?.success && !tokenForm.skipModelFetch) {
if (!isBatchApiKeyInput && !verifyResult?.success && !tokenForm.skipModelFetch) {
toast.error('请先验证 Token 成功后再添加账号');
return;
}
@@ -364,6 +374,7 @@ export default function Accounts() {
siteId: tokenForm.siteId,
username: tokenForm.username.trim() || undefined,
accessToken: tokenForm.accessToken,
accessTokens: isBatchApiKeyInput ? parsedApiKeys : undefined,
platformUserId: tokenForm.platformUserId ? parseInt(tokenForm.platformUserId) : undefined,
refreshToken: isSub2ApiSelected && tokenForm.refreshToken.trim()
? tokenForm.refreshToken.trim()
@@ -374,6 +385,23 @@ export default function Accounts() {
credentialMode,
skipModelFetch: tokenForm.skipModelFetch,
});
if (result?.batch) {
closeAddPanel();
const createdCount = Number(result.createdCount) || 0;
const failedCount = Number(result.failedCount) || 0;
if (createdCount > 0) {
toast.success(`批量添加完成:成功 ${createdCount},失败 ${failedCount}`);
}
const failedItems = Array.isArray(result.items)
? result.items.filter((item: any) => item?.status === 'failed')
: [];
if (failedItems.length > 0) {
const firstMessage = failedItems[0]?.message || '创建失败';
toast.error(`失败 ${failedItems.length} 条:${firstMessage}`);
}
load();
return;
}
let seededRecommendedModels = false;
const recommendedModels = initializationPreset?.recommendedModels || [];
const createdAccountId = Number(result?.id) || 0;
@@ -951,6 +979,9 @@ export default function Accounts() {
|| (activeSegment === 'session' && verifyResult.tokenType === 'session')
),
);
const canSubmitApiKeyConnection = activeSegment === 'apikey'
? (isBatchApiKeyInput || canAddVerifiedConnection || !!tokenForm.skipModelFetch)
: canAddVerifiedConnection;
return (
<div className="animate-fade-in">
@@ -1421,9 +1452,18 @@ export default function Accounts() {
<textarea
placeholder="粘贴 API Key"
value={tokenForm.accessToken}
onChange={(e) => { setTokenForm((f) => ({ ...f, accessToken: e.target.value.trim(), credentialMode: 'apikey' })); setVerifyResult(null); }}
onChange={(e) => { setTokenForm((f) => ({ ...f, accessToken: e.target.value, credentialMode: 'apikey' })); setVerifyResult(null); }}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 72, resize: 'none' as const }}
/>
{parsedApiKeys.length > 0 && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
已识别 {parsedApiKeys.length} 个 API Key
{isBatchApiKeyInput ? ',添加时会逐条创建同站点连接并参与轮询' : ''}
</div>
)}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
支持换行、空格、逗号批量粘贴多个 API Key。
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input
placeholder="用户 ID(可选)"
@@ -1479,23 +1519,25 @@ export default function Accounts() {
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleVerifyToken}
disabled={verifying || !tokenForm.siteId || !tokenForm.accessToken}
disabled={verifying || !tokenForm.siteId || !tokenForm.accessToken || isBatchApiKeyInput}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{verifying ? <><span className="spinner spinner-sm" />验证中...</> : '验证 API Key'}
{verifying ? <><span className="spinner spinner-sm" />验证中...</> : (isBatchApiKeyInput ? '批量添加时校验' : '验证 API Key')}
</button>
<button
onClick={handleTokenAdd}
disabled={saving || !tokenForm.siteId || !tokenForm.accessToken || (!canAddVerifiedConnection && !tokenForm.skipModelFetch)}
disabled={saving || !tokenForm.siteId || !tokenForm.accessToken || !canSubmitApiKeyConnection}
className="btn btn-success"
>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />添加中...</> : '添加连接'}
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />添加中...</> : (isBatchApiKeyInput ? '批量添加连接' : '添加连接')}
</button>
</div>
{!verifyResult?.success && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{addAccountPrereqHint}
{isBatchApiKeyInput
? '批量模式下无需先点验证,提交后会逐条校验并创建。'
: addAccountPrereqHint}
</div>
)}
</div>