feat: Implement compatibility strategies for responses and chat endpoints

- Added `routeCompatibility.ts` to handle response compatibility for different endpoints.
- Introduced `chatEndpointStrategy.ts` to manage chat endpoint recovery and downgrading logic.
- Created tests for chat formats and input file handling in `chatFormatsCore.test.ts` and `inputFile.test.ts`.
- Developed `endpointCompatibility.ts` to define compatibility checks and header building for various endpoints.
- Implemented protocol lifecycle management in `protocolLifecycle.ts` and corresponding tests in `protocolModel.test.ts`.
- Enhanced tooltip functionality with a new `TooltipLayer` component and associated tests.
- Refactored existing components to improve tooltip handling and ensure proper rendering in the application.
This commit is contained in:
cita
2026-03-09 22:48:10 +08:00
parent 15d4b207cc
commit 0f8176cd04
74 changed files with 4645 additions and 1565 deletions
+389 -378
View File
@@ -18,6 +18,18 @@ import { SITE_DOCS_URL } from '../docsLink.js';
type ConnectionsSegment = 'session' | 'apikey' | 'tokens';
const ACCOUNT_SEGMENTS: Array<{
value: ConnectionsSegment;
label: string;
tooltip: string;
tooltipSide: 'top' | 'bottom';
tooltipAlign: 'start' | 'center' | 'end';
}> = [
{ value: 'session', label: '账号管理', tooltip: '用于签到、余额、状态维护', tooltipSide: 'bottom', tooltipAlign: 'start' },
{ value: 'apikey', label: 'API Key管理', tooltip: '只有 Base URL + Key 时使用,只负责代理调用', tooltipSide: 'bottom', tooltipAlign: 'center' },
{ value: 'tokens', label: '账号令牌管理', tooltip: '从账号同步或手动维护,供路由实际调用', tooltipSide: 'bottom', tooltipAlign: 'end' },
];
function createLoginForm() {
return { siteId: 0, username: '', password: '' };
}
@@ -745,15 +757,14 @@ export default function Accounts() {
borderRadius: 'var(--radius-md)',
}}
>
{[
{ value: 'session' as ConnectionsSegment, label: 'Session 连接' },
{ value: 'apikey' as ConnectionsSegment, label: 'API Key 连接' },
{ value: 'tokens' as ConnectionsSegment, label: '账号令牌' },
].map((segment) => (
{ACCOUNT_SEGMENTS.map((segment) => (
<button
key={segment.value}
type="button"
onClick={() => setSegment(segment.value)}
data-tooltip={segment.tooltip}
data-tooltip-side={segment.tooltipSide}
data-tooltip-align={segment.tooltipAlign}
style={{
padding: '8px 12px',
borderRadius: 8,
@@ -802,315 +813,315 @@ export default function Accounts() {
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={<button onClick={closeAddPanel} className="btn btn-ghost">取消</button>}
>
{activeSegment === 'session' ? (
<>
<div style={{ display: 'flex', gap: 0, background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', padding: 3, marginBottom: 16 }}>
<button
onClick={() => { setAddMode('token'); setVerifyResult(null); }}
style={{
flex: 1,
padding: '8px 0',
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
background: addMode === 'token' ? 'var(--color-bg-card)' : 'transparent',
color: addMode === 'token' ? 'var(--color-primary)' : 'var(--color-text-muted)',
boxShadow: addMode === 'token' ? 'var(--shadow-sm)' : 'none',
}}
>
Session Token / Cookie
</button>
<button
onClick={() => { setAddMode('login'); setVerifyResult(null); }}
style={{
flex: 1,
padding: '8px 0',
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
background: addMode === 'login' ? 'var(--color-bg-card)' : 'transparent',
color: addMode === 'login' ? 'var(--color-primary)' : 'var(--color-text-muted)',
boxShadow: addMode === 'login' ? 'var(--shadow-sm)' : 'none',
}}
>
账号密码登录
</button>
</div>
{addMode === 'token' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="info-tip">
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>当前分段仅创建 Session 连接</div>
<div><strong>推荐</strong> 使用系统访问令牌(Access Token);浏览器 Cookie 仅用于兼容场景。</div>
<div style={{ marginTop: 2 }}>以 NewAPI 为例:控制台 → 个人设置 → 安全设置 → 生成「系统访问令牌」</div>
<div style={{ opacity: 0.7, borderTop: '1px solid rgba(0,0,0,0.1)', paddingTop: 6, marginTop: 6 }}>
获取 Cookie: <kbd style={{ padding: '1px 5px', background: 'var(--color-bg-card)', border: '1px solid var(--color-border)', borderRadius: 3, fontSize: 11 }}>F12</kbd> → Application → Cookie
</div>
<div style={{ marginTop: 6 }}>
<a
href={SITE_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 12, color: 'var(--color-primary)', textDecoration: 'underline' }}
>
查看认证方式与特殊站点说明文档
</a>
</div>
</div>
</div>
<ModernSelect
value={String(tokenForm.siteId || 0)}
onChange={(nextValue) => {
const nextSiteId = Number.parseInt(nextValue, 10) || 0;
setTokenForm((f) => ({ ...f, siteId: nextSiteId }));
setVerifyResult(null);
}}
options={[
{ value: '0', label: '选择站点' },
...sites.map((s: any) => ({
value: String(s.id),
label: `${s.name} (${s.platform})`,
})),
]}
placeholder="选择站点"
/>
<input
placeholder="连接名称(可选)"
value={tokenForm.username}
onChange={(e) => setTokenForm((f) => ({ ...f, username: e.target.value }))}
style={inputStyle}
/>
<textarea
placeholder="粘贴 Session Access Token 或浏览器 Cookie"
value={tokenForm.accessToken}
onChange={(e) => { setTokenForm((f) => ({ ...f, accessToken: e.target.value.trim() })); setVerifyResult(null); }}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 72, resize: 'none' as const }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input
placeholder="用户 ID(可选)"
value={tokenForm.platformUserId}
onChange={(e) => { setTokenForm((f) => ({ ...f, platformUserId: e.target.value.replace(/\D/g, '') })); setVerifyResult(null); }}
style={inputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
若站点要求 New-Api-User / User-ID,请在这里提前填写。
</div>
</div>
{isSub2ApiSelected && (
<>
<input
placeholder="Sub2API refresh_token(可选,用于托管自动续期)"
value={tokenForm.refreshToken}
onChange={(e) => setTokenForm((f) => ({ ...f, refreshToken: e.target.value.trim() }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input
placeholder="token_expires_at(可选,毫秒时间戳)"
value={tokenForm.tokenExpiresAt}
onChange={(e) => setTokenForm((f) => ({ ...f, tokenExpiresAt: e.target.value.replace(/\D/g, '') }))}
style={inputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
配置 refresh_token 后,metapi 会在 JWT 临近过期或 401 时自动续期并回写新 token。
</div>
</div>
</>
)}
{verifyResult && verifyResult.success && verifyResult.tokenType === 'session' && (
<div className="alert alert-success animate-scale-in">
<div className="alert-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Session 凭证有效(Access Token / Cookie)
</div>
<div style={{ fontSize: 12, lineHeight: 1.8 }}>
<div>用户名: <strong>{verifyResult.userInfo?.username || '未知'}</strong></div>
{verifyResult.balance && <div>余额: <strong>${(verifyResult.balance.balance || 0).toFixed(2)}</strong></div>}
<div>API Key: <span style={{ fontWeight: 500, color: verifyResult.apiToken ? 'var(--color-success)' : 'var(--color-text-muted)' }}>
{verifyResult.apiToken ? `已找到 (${verifyResult.apiToken.substring(0, 8)}...)` : '未找到'}
</span></div>
</div>
</div>
)}
{verifyResult && verifyResult.success && verifyResult.tokenType === 'apikey' && (
<div className="alert alert-warning animate-scale-in">
<div className="alert-title">当前分段仅接受 Session 凭证,请切到「API Key 连接」分段创建。</div>
</div>
)}
{verifyResult && !verifyResult.success && verifyResult.needsUserId && (
<div className="alert alert-warning animate-scale-in">
<div className="alert-title">此站点要求用户 ID,请补充后重新验证</div>
</div>
)}
{verifyResult && !verifyResult.success && !verifyResult.needsUserId && (
<div className="alert alert-error animate-scale-in">
<div className="alert-title">
{normalizeVerifyFailureMessage(verifyResult.message) || 'Token 无效或已过期'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
{verifyFailureHint || '请检查 Token 是否正确'}
</div>
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleVerifyToken}
disabled={verifying || !tokenForm.siteId || !tokenForm.accessToken}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{verifying ? <><span className="spinner spinner-sm" />验证中...</> : '验证 Token'}
</button>
<button
onClick={handleTokenAdd}
disabled={saving || !tokenForm.siteId || !tokenForm.accessToken || !canAddVerifiedConnection}
className="btn btn-success"
>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />添加中...</> : '添加连接'}
</button>
</div>
{!verifyResult?.success && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{addAccountPrereqHint}
</div>
)}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="info-tip">
输入目标站点的账号密码,将自动登录并获取访问令牌和 API Key
</div>
<ModernSelect
value={String(loginForm.siteId || 0)}
onChange={(nextValue) => {
const nextSiteId = Number.parseInt(nextValue, 10) || 0;
setLoginForm((f) => ({ ...f, siteId: nextSiteId }));
}}
options={[
{ value: '0', label: '选择站点' },
...sites.map((s: any) => ({
value: String(s.id),
label: `${s.name} (${s.platform})`,
})),
]}
placeholder="选择站点"
/>
<input placeholder="用户名" value={loginForm.username} onChange={(e) => setLoginForm((f) => ({ ...f, username: e.target.value }))} style={inputStyle} />
<input type="password" placeholder="密码" value={loginForm.password} onChange={(e) => setLoginForm((f) => ({ ...f, password: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && handleLoginAdd()} style={inputStyle} />
<button onClick={handleLoginAdd} disabled={saving || !loginForm.siteId || !loginForm.username || !loginForm.password} className="btn btn-success" style={{ alignSelf: 'flex-start' }}>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />登录并添加...</> : '登录并添加'}
</button>
</div>
)}
</>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="info-tip">
API Key 连接只用于代理转发,不会自动派生账号令牌。系统会按站点平台能力自动引导到 Session 或 API Key 创建流程。
</div>
<ModernSelect
value={String(tokenForm.siteId || 0)}
onChange={(nextValue) => {
const nextSiteId = Number.parseInt(nextValue, 10) || 0;
setTokenForm((f) => ({ ...f, siteId: nextSiteId, credentialMode: 'apikey' }));
setVerifyResult(null);
{activeSegment === 'session' ? (
<>
<div style={{ display: 'flex', gap: 0, background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', padding: 3, marginBottom: 16 }}>
<button
onClick={() => { setAddMode('token'); setVerifyResult(null); }}
style={{
flex: 1,
padding: '8px 0',
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
background: addMode === 'token' ? 'var(--color-bg-card)' : 'transparent',
color: addMode === 'token' ? 'var(--color-primary)' : 'var(--color-text-muted)',
boxShadow: addMode === 'token' ? 'var(--shadow-sm)' : 'none',
}}
options={[
{ value: '0', label: '选择站点' },
...sites.map((s: any) => ({
value: String(s.id),
label: `${s.name} (${s.platform})`,
})),
]}
placeholder="选择站点"
/>
<input
placeholder="连接名称(可选)"
value={tokenForm.username}
onChange={(e) => setTokenForm((f) => ({ ...f, username: e.target.value, credentialMode: 'apikey' }))}
style={inputStyle}
/>
<textarea
placeholder="粘贴 API Key"
value={tokenForm.accessToken}
onChange={(e) => { setTokenForm((f) => ({ ...f, accessToken: e.target.value.trim(), credentialMode: 'apikey' })); setVerifyResult(null); }}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 72, resize: 'none' as const }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
>
Session Token / Cookie
</button>
<button
onClick={() => { setAddMode('login'); setVerifyResult(null); }}
style={{
flex: 1,
padding: '8px 0',
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
background: addMode === 'login' ? 'var(--color-bg-card)' : 'transparent',
color: addMode === 'login' ? 'var(--color-primary)' : 'var(--color-text-muted)',
boxShadow: addMode === 'login' ? 'var(--shadow-sm)' : 'none',
}}
>
账号密码登录
</button>
</div>
{addMode === 'token' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="info-tip">
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>当前分段仅创建 Session 连接</div>
<div><strong>推荐</strong> 使用系统访问令牌(Access Token);浏览器 Cookie 仅用于兼容场景。</div>
<div style={{ marginTop: 2 }}>以 NewAPI 为例:控制台 → 个人设置 → 安全设置 → 生成「系统访问令牌」</div>
<div style={{ opacity: 0.7, borderTop: '1px solid rgba(0,0,0,0.1)', paddingTop: 6, marginTop: 6 }}>
获取 Cookie: <kbd style={{ padding: '1px 5px', background: 'var(--color-bg-card)', border: '1px solid var(--color-border)', borderRadius: 3, fontSize: 11 }}>F12</kbd> → Application → Cookie
</div>
<div style={{ marginTop: 6 }}>
<a
href={SITE_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 12, color: 'var(--color-primary)', textDecoration: 'underline' }}
>
查看认证方式与特殊站点说明文档
</a>
</div>
</div>
</div>
<ModernSelect
value={String(tokenForm.siteId || 0)}
onChange={(nextValue) => {
const nextSiteId = Number.parseInt(nextValue, 10) || 0;
setTokenForm((f) => ({ ...f, siteId: nextSiteId }));
setVerifyResult(null);
}}
options={[
{ value: '0', label: '选择站点' },
...sites.map((s: any) => ({
value: String(s.id),
label: `${s.name} (${s.platform})`,
})),
]}
placeholder="选择站点"
/>
<input
placeholder="用户 ID(可选)"
value={tokenForm.platformUserId}
onChange={(e) => { setTokenForm((f) => ({ ...f, platformUserId: e.target.value.replace(/\D/g, ''), credentialMode: 'apikey' })); setVerifyResult(null); }}
placeholder="连接名称(可选)"
value={tokenForm.username}
onChange={(e) => setTokenForm((f) => ({ ...f, username: e.target.value }))}
style={inputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
若站点要求 New-Api-User / User-ID,请在这里提前填写。
<textarea
placeholder="粘贴 Session Access Token 或浏览器 Cookie"
value={tokenForm.accessToken}
onChange={(e) => { setTokenForm((f) => ({ ...f, accessToken: e.target.value.trim() })); setVerifyResult(null); }}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 72, resize: 'none' as const }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input
placeholder="用户 ID(可选)"
value={tokenForm.platformUserId}
onChange={(e) => { setTokenForm((f) => ({ ...f, platformUserId: e.target.value.replace(/\D/g, '') })); setVerifyResult(null); }}
style={inputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
若站点要求 New-Api-User / User-ID,请在这里提前填写。
</div>
</div>
{isSub2ApiSelected && (
<>
<input
placeholder="Sub2API refresh_token(可选,用于托管自动续期)"
value={tokenForm.refreshToken}
onChange={(e) => setTokenForm((f) => ({ ...f, refreshToken: e.target.value.trim() }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input
placeholder="token_expires_at(可选,毫秒时间戳)"
value={tokenForm.tokenExpiresAt}
onChange={(e) => setTokenForm((f) => ({ ...f, tokenExpiresAt: e.target.value.replace(/\D/g, '') }))}
style={inputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
配置 refresh_token 后,metapi 会在 JWT 临近过期或 401 时自动续期并回写新 token。
</div>
</div>
</>
)}
{verifyResult && verifyResult.success && verifyResult.tokenType === 'session' && (
<div className="alert alert-success animate-scale-in">
<div className="alert-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Session 凭证有效(Access Token / Cookie)
</div>
<div style={{ fontSize: 12, lineHeight: 1.8 }}>
<div>用户名: <strong>{verifyResult.userInfo?.username || '未知'}</strong></div>
{verifyResult.balance && <div>余额: <strong>${(verifyResult.balance.balance || 0).toFixed(2)}</strong></div>}
<div>API Key: <span style={{ fontWeight: 500, color: verifyResult.apiToken ? 'var(--color-success)' : 'var(--color-text-muted)' }}>
{verifyResult.apiToken ? `已找到 (${verifyResult.apiToken.substring(0, 8)}...)` : '未找到'}
</span></div>
</div>
</div>
)}
{verifyResult && verifyResult.success && verifyResult.tokenType === 'apikey' && (
<div className="alert alert-warning animate-scale-in">
<div className="alert-title">当前分段仅接受 Session 凭证,请切到「API Key 连接」分段创建。</div>
</div>
)}
{verifyResult && !verifyResult.success && verifyResult.needsUserId && (
<div className="alert alert-warning animate-scale-in">
<div className="alert-title">此站点要求用户 ID,请补充后重新验证</div>
</div>
)}
{verifyResult && !verifyResult.success && !verifyResult.needsUserId && (
<div className="alert alert-error animate-scale-in">
<div className="alert-title">
{normalizeVerifyFailureMessage(verifyResult.message) || 'Token 无效或已过期'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
{verifyFailureHint || '请检查 Token 是否正确'}
</div>
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleVerifyToken}
disabled={verifying || !tokenForm.siteId || !tokenForm.accessToken}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{verifying ? <><span className="spinner spinner-sm" />验证中...</> : '验证 Token'}
</button>
<button
onClick={handleTokenAdd}
disabled={saving || !tokenForm.siteId || !tokenForm.accessToken || !canAddVerifiedConnection}
className="btn btn-success"
>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />添加中...</> : '添加连接'}
</button>
</div>
{!verifyResult?.success && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{addAccountPrereqHint}
</div>
)}
</div>
{verifyResult && verifyResult.success && verifyResult.tokenType === 'apikey' && (
<div className="alert alert-info animate-scale-in">
<div className="alert-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
API Key 验证成功
</div>
<div style={{ fontSize: 12, lineHeight: 1.8 }}>
<div>可用模型: <strong>{verifyResult.modelCount} 个</strong></div>
{verifyResult.models && <div style={{ color: 'var(--color-text-muted)' }}>包含: {verifyResult.models.join(', ')}{verifyResult.modelCount > 10 ? ' ...' : ''}</div>}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="info-tip">
输入目标站点的账号密码,将自动登录并获取访问令牌和 API Key
</div>
)}
{verifyResult && verifyResult.success && verifyResult.tokenType === 'session' && (
<div className="alert alert-warning animate-scale-in">
<div className="alert-title">当前分段仅接受 API Key,请切到「Session 连接」分段创建。</div>
</div>
)}
{verifyResult && !verifyResult.success && verifyResult.needsUserId && (
<div className="alert alert-warning animate-scale-in">
<div className="alert-title">此站点要求用户 ID,请补充后重新验证</div>
</div>
)}
{verifyResult && !verifyResult.success && !verifyResult.needsUserId && (
<div className="alert alert-error animate-scale-in">
<div className="alert-title">
{normalizeVerifyFailureMessage(verifyResult.message) || 'Token 无效或已过期'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
{verifyFailureHint || '请检查 Token 是否正确'}
</div>
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleVerifyToken}
disabled={verifying || !tokenForm.siteId || !tokenForm.accessToken}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{verifying ? <><span className="spinner spinner-sm" />验证中...</> : '验证 API Key'}
</button>
<button
onClick={handleTokenAdd}
disabled={saving || !tokenForm.siteId || !tokenForm.accessToken || !canAddVerifiedConnection}
className="btn btn-success"
>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />添加中...</> : '添加连接'}
<ModernSelect
value={String(loginForm.siteId || 0)}
onChange={(nextValue) => {
const nextSiteId = Number.parseInt(nextValue, 10) || 0;
setLoginForm((f) => ({ ...f, siteId: nextSiteId }));
}}
options={[
{ value: '0', label: '选择站点' },
...sites.map((s: any) => ({
value: String(s.id),
label: `${s.name} (${s.platform})`,
})),
]}
placeholder="选择站点"
/>
<input placeholder="用户名" value={loginForm.username} onChange={(e) => setLoginForm((f) => ({ ...f, username: e.target.value }))} style={inputStyle} />
<input type="password" placeholder="密码" value={loginForm.password} onChange={(e) => setLoginForm((f) => ({ ...f, password: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && handleLoginAdd()} style={inputStyle} />
<button onClick={handleLoginAdd} disabled={saving || !loginForm.siteId || !loginForm.username || !loginForm.password} className="btn btn-success" style={{ alignSelf: 'flex-start' }}>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />登录并添加...</> : '登录并添加'}
</button>
</div>
{!verifyResult?.success && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{addAccountPrereqHint}
</div>
)}
)}
</>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="info-tip">
API Key 连接只用于代理转发,不会自动派生账号令牌。系统会按站点平台能力自动引导到 Session 或 API Key 创建流程。
</div>
)}
<ModernSelect
value={String(tokenForm.siteId || 0)}
onChange={(nextValue) => {
const nextSiteId = Number.parseInt(nextValue, 10) || 0;
setTokenForm((f) => ({ ...f, siteId: nextSiteId, credentialMode: 'apikey' }));
setVerifyResult(null);
}}
options={[
{ value: '0', label: '选择站点' },
...sites.map((s: any) => ({
value: String(s.id),
label: `${s.name} (${s.platform})`,
})),
]}
placeholder="选择站点"
/>
<input
placeholder="连接名称(可选)"
value={tokenForm.username}
onChange={(e) => setTokenForm((f) => ({ ...f, username: e.target.value, credentialMode: 'apikey' }))}
style={inputStyle}
/>
<textarea
placeholder="粘贴 API Key"
value={tokenForm.accessToken}
onChange={(e) => { setTokenForm((f) => ({ ...f, accessToken: e.target.value.trim(), credentialMode: 'apikey' })); setVerifyResult(null); }}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 72, resize: 'none' as const }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input
placeholder="用户 ID(可选)"
value={tokenForm.platformUserId}
onChange={(e) => { setTokenForm((f) => ({ ...f, platformUserId: e.target.value.replace(/\D/g, ''), credentialMode: 'apikey' })); setVerifyResult(null); }}
style={inputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
若站点要求 New-Api-User / User-ID,请在这里提前填写。
</div>
</div>
{verifyResult && verifyResult.success && verifyResult.tokenType === 'apikey' && (
<div className="alert alert-info animate-scale-in">
<div className="alert-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
API Key 验证成功
</div>
<div style={{ fontSize: 12, lineHeight: 1.8 }}>
<div>可用模型: <strong>{verifyResult.modelCount} 个</strong></div>
{verifyResult.models && <div style={{ color: 'var(--color-text-muted)' }}>包含: {verifyResult.models.join(', ')}{verifyResult.modelCount > 10 ? ' ...' : ''}</div>}
</div>
</div>
)}
{verifyResult && verifyResult.success && verifyResult.tokenType === 'session' && (
<div className="alert alert-warning animate-scale-in">
<div className="alert-title">当前分段仅接受 API Key,请切到「Session 连接」分段创建。</div>
</div>
)}
{verifyResult && !verifyResult.success && verifyResult.needsUserId && (
<div className="alert alert-warning animate-scale-in">
<div className="alert-title">此站点要求用户 ID,请补充后重新验证</div>
</div>
)}
{verifyResult && !verifyResult.success && !verifyResult.needsUserId && (
<div className="alert alert-error animate-scale-in">
<div className="alert-title">
{normalizeVerifyFailureMessage(verifyResult.message) || 'Token 无效或已过期'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
{verifyFailureHint || '请检查 Token 是否正确'}
</div>
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleVerifyToken}
disabled={verifying || !tokenForm.siteId || !tokenForm.accessToken}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{verifying ? <><span className="spinner spinner-sm" />验证中...</> : '验证 API Key'}
</button>
<button
onClick={handleTokenAdd}
disabled={saving || !tokenForm.siteId || !tokenForm.accessToken || !canAddVerifiedConnection}
className="btn btn-success"
>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />添加中...</> : '添加连接'}
</button>
</div>
{!verifyResult?.success && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{addAccountPrereqHint}
</div>
)}
</div>
)}
</CenteredModal>
{activeSegment === 'session' && (
@@ -1124,88 +1135,88 @@ export default function Accounts() {
>
{activeRebindTarget ? (
<>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
连接: {resolveAccountDisplayName(activeRebindTarget)} @ {activeRebindTarget.site?.name || '-'}。请粘贴新的 Session Token,验证成功后再绑定。
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
连接: {resolveAccountDisplayName(activeRebindTarget)} @ {activeRebindTarget.site?.name || '-'}。请粘贴新的 Session Token,验证成功后再绑定。
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 220px', gap: 10, marginBottom: 10 }}>
<textarea
placeholder="粘贴新的 Session Token"
value={rebindForm.accessToken}
onChange={(e) => {
setRebindForm((prev) => ({ ...prev, accessToken: e.target.value.trim() }));
setRebindVerifyResult(null);
}}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 74, resize: 'none' as const }}
/>
<input
placeholder="用户 ID(可选)"
value={rebindForm.platformUserId}
onChange={(e) => {
setRebindForm((prev) => ({ ...prev, platformUserId: e.target.value.replace(/\D/g, '') }));
setRebindVerifyResult(null);
}}
style={inputStyle}
/>
</div>
{isRebindSub2Api && (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 220px', gap: 10, marginBottom: 4 }}>
<input
placeholder="Sub2API refresh_token(可选)"
value={rebindForm.refreshToken}
onChange={(e) => setRebindForm((prev) => ({ ...prev, refreshToken: e.target.value.trim() }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 220px', gap: 10, marginBottom: 10 }}>
<textarea
placeholder="粘贴新的 Session Token"
value={rebindForm.accessToken}
onChange={(e) => {
setRebindForm((prev) => ({ ...prev, accessToken: e.target.value.trim() }));
setRebindVerifyResult(null);
}}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 74, resize: 'none' as const }}
/>
<input
placeholder="token_expires_at(可选)"
value={rebindForm.tokenExpiresAt}
onChange={(e) => setRebindForm((prev) => ({ ...prev, tokenExpiresAt: e.target.value.replace(/\D/g, '') }))}
placeholder="用户 ID(可选)"
value={rebindForm.platformUserId}
onChange={(e) => {
setRebindForm((prev) => ({ ...prev, platformUserId: e.target.value.replace(/\D/g, '') }));
setRebindVerifyResult(null);
}}
style={inputStyle}
/>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
留空将保持原有 refresh_token 不变。配置后可用于托管自动续期。
</div>
</>
)}
{isRebindSub2Api && (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 220px', gap: 10, marginBottom: 4 }}>
<input
placeholder="Sub2API refresh_token(可选)"
value={rebindForm.refreshToken}
onChange={(e) => setRebindForm((prev) => ({ ...prev, refreshToken: e.target.value.trim() }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
<input
placeholder="token_expires_at(可选)"
value={rebindForm.tokenExpiresAt}
onChange={(e) => setRebindForm((prev) => ({ ...prev, tokenExpiresAt: e.target.value.replace(/\D/g, '') }))}
style={inputStyle}
/>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
留空将保持原有 refresh_token 不变。配置后可用于托管自动续期。
</div>
</>
)}
{rebindVerifyResult && rebindVerifyResult.success && rebindVerifyResult.tokenType === 'session' && (
<div className="alert alert-success animate-scale-in" style={{ marginBottom: 10 }}>
<div className="alert-title">Session Token 有效</div>
<div style={{ fontSize: 12, marginTop: 4 }}>
用户: {rebindVerifyResult.userInfo?.username || '未知'}
{rebindVerifyResult.apiToken ? `,已识别 API Key (${String(rebindVerifyResult.apiToken).slice(0, 8)}...)` : ''}
</div>
</div>
)}
{rebindVerifyResult && (!rebindVerifyResult.success || rebindVerifyResult.tokenType !== 'session') && (
<div className="alert alert-error animate-scale-in" style={{ marginBottom: 10 }}>
<div className="alert-title">
{rebindVerifyResult.message || 'Token 无效或类型不正确'}
</div>
</div>
)}
{rebindVerifyResult && rebindVerifyResult.success && rebindVerifyResult.tokenType === 'session' && (
<div className="alert alert-success animate-scale-in" style={{ marginBottom: 10 }}>
<div className="alert-title">Session Token 有效</div>
<div style={{ fontSize: 12, marginTop: 4 }}>
用户: {rebindVerifyResult.userInfo?.username || '未知'}
{rebindVerifyResult.apiToken ? `,已识别 API Key (${String(rebindVerifyResult.apiToken).slice(0, 8)}...)` : ''}
</div>
</div>
)}
{rebindVerifyResult && (!rebindVerifyResult.success || rebindVerifyResult.tokenType !== 'session') && (
<div className="alert alert-error animate-scale-in" style={{ marginBottom: 10 }}>
<div className="alert-title">
{rebindVerifyResult.message || 'Token 无效或类型不正确'}
</div>
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleVerifyRebindToken}
disabled={rebindVerifying || !rebindForm.accessToken.trim()}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{rebindVerifying ? <><span className="spinner spinner-sm" />验证中...</> : '验证 Token'}
</button>
<button
onClick={handleSubmitRebind}
disabled={rebindSaving || !(rebindVerifyResult?.success && rebindVerifyResult?.tokenType === 'session')}
className="btn btn-success"
>
{rebindSaving
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />绑定中...</>
: '确认重新绑定'}
</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleVerifyRebindToken}
disabled={rebindVerifying || !rebindForm.accessToken.trim()}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{rebindVerifying ? <><span className="spinner spinner-sm" />验证中...</> : '验证 Token'}
</button>
<button
onClick={handleSubmitRebind}
disabled={rebindSaving || !(rebindVerifyResult?.success && rebindVerifyResult?.tokenType === 'session')}
className="btn btn-success"
>
{rebindSaving
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />绑定中...</>
: '确认重新绑定'}
</button>
</div>
</>
) : null}
</CenteredModal>
+2
View File
@@ -1252,6 +1252,7 @@ export default function TokenRoutes() {
};
const handleCreateTokenForMissingAccount = (accountId: number, modelName: string) => {
if (!Number.isFinite(accountId) || accountId <= 0) return;
const params = new URLSearchParams();
params.set('create', '1');
params.set('accountId', String(accountId));
@@ -1897,6 +1898,7 @@ export default function TokenRoutes() {
const siteMap = new Map<string, MissingTokenRouteSiteActionItem>();
for (const hint of missingTokenHints) {
for (const account of hint.accounts) {
if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue;
const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`;
const key = `${account.siteId || 0}::${siteName.toLowerCase()}`;
const accountLabel = account.username || `account-${account.accountId}`;
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
@@ -23,6 +23,12 @@ async function flushMicrotasks() {
});
}
function collectText(node: ReactTestInstance): string {
return (node.children || [])
.map((child) => (typeof child === 'string' ? child : collectText(child)))
.join('');
}
describe('Accounts segmented connections view', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -76,10 +82,28 @@ describe('Accounts segmented connections view', () => {
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('连接管理');
expect(rendered).toContain('API Key 连接');
expect(rendered).toContain('账号管理');
expect(rendered).toContain('API Key管理');
expect(rendered).toContain('账号令牌管理');
expect(rendered).toContain('用于签到、余额、状态维护');
expect(rendered).toContain('只有 Base URL + Key 时使用,只负责代理调用');
expect(rendered).toContain('从账号同步或手动维护,供路由实际调用');
expect(rendered).toContain('Key Site');
expect(rendered).not.toContain('仅代理');
expect(rendered).not.toContain('session-user');
const segmentButtons = root.root.findAll((node) => {
if (node.type !== 'button') return false;
const text = collectText(node);
return text === '账号管理' || text === 'API Key管理' || text === '账号令牌管理';
});
expect(segmentButtons).toHaveLength(3);
expect(segmentButtons[0]?.props['data-tooltip-side']).toBe('bottom');
expect(segmentButtons[0]?.props['data-tooltip-align']).toBe('start');
expect(segmentButtons[1]?.props['data-tooltip-side']).toBe('bottom');
expect(segmentButtons[1]?.props['data-tooltip-align']).toBe('center');
expect(segmentButtons[2]?.props['data-tooltip-side']).toBe('bottom');
expect(segmentButtons[2]?.props['data-tooltip-align']).toBe('end');
} finally {
root?.unmount();
}
@@ -240,6 +240,51 @@ describe('TokenRoutes grouped source models', () => {
}
});
it('does not render missing-token site tags when the hint lacks a valid account id', async () => {
apiMock.getRoutes.mockResolvedValue([
{
id: 1,
modelPattern: 'gpt-5.2-codex',
displayName: 'gpt-5.2-codex',
enabled: true,
channels: [],
},
]);
apiMock.getModelTokenCandidates.mockResolvedValue({
models: {},
modelsWithoutToken: {
'gpt-5.2-codex': [
{
accountId: 0,
username: 'shenmo-direct',
siteId: 12,
siteName: '神墨',
},
],
},
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const text = collectText(root.root);
expect(text).not.toContain('待注册站点');
expect(text).not.toContain('神墨');
} finally {
root?.unmount();
}
});
it('maps endpoint types to expected brand icons in filter panel', async () => {
apiMock.getRoutes.mockResolvedValue([
{