forked from boos/BoosAPI
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:
+389
-378
@@ -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>
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user