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
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('App topbar tooltips', () => {
it('removes topbar hover tooltips while preserving sidebar collapsed tooltips', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const topbarStart = source.indexOf('<div className="topbar-right">');
const topbarEnd = source.indexOf('</header>');
expect(topbarStart).toBeGreaterThanOrEqual(0);
expect(topbarEnd).toBeGreaterThan(topbarStart);
const topbarSection = source.slice(topbarStart, topbarEnd);
expect(topbarSection).not.toContain('data-tooltip=');
expect(source).toContain("data-tooltip={sidebarCollapsed ? t(item.label) : undefined}");
});
});
+4 -7
View File
@@ -3,6 +3,7 @@ import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom'
import { ToastProvider, useToast } from './components/Toast.js';
import SearchModal from './components/SearchModal.js';
import NotificationPanel from './components/NotificationPanel.js';
import TooltipLayer from './components/TooltipLayer.js';
import { api } from './api.js';
import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js';
import {
@@ -558,20 +559,19 @@ function AppShell() {
<div className="topbar-right">
<button
className="topbar-icon-btn"
data-tooltip={language === 'zh' ? 'Switch to English' : '切换到中文'}
aria-label={language === 'zh' ? 'Switch to English' : '切换到中文'}
onClick={toggleLanguage}
style={{ minWidth: 36, fontSize: 12, fontWeight: 700 }}
>
{language === 'zh' ? 'EN' : '中'}
</button>
<button className="topbar-search-trigger" data-tooltip={t('搜索 (Ctrl+K)')} aria-label={t('搜索 (Ctrl+K)')} onClick={() => setShowSearch(true)}>
<button className="topbar-search-trigger" aria-label={t('搜索 (Ctrl+K)')} onClick={() => setShowSearch(true)}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
<span className="topbar-search-label">{t('搜索')}</span>
<kbd className="topbar-search-kbd">Ctrl K</kbd>
</button>
<div style={{ position: 'relative' }}>
<button ref={notifBtnRef} className="topbar-icon-btn" data-tooltip={t('通知')} aria-label={t('通知')} onClick={() => setShowNotifications(!showNotifications)}>
<button ref={notifBtnRef} className="topbar-icon-btn" aria-label={t('通知')} onClick={() => setShowNotifications(!showNotifications)}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
{unreadCount > 0 && (
<span className="topbar-badge">
@@ -584,9 +584,6 @@ function AppShell() {
<div ref={themeMenuRef} style={{ position: 'relative' }}>
<button
className="topbar-icon-btn"
data-tooltip={themeMode === 'system'
? `${t('跟随系统')} (${resolvedThemeLabel})`
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
aria-label={themeMode === 'system'
? `${t('跟随系统')} (${resolvedThemeLabel})`
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
@@ -632,7 +629,6 @@ function AppShell() {
<div ref={userMenuRef} style={{ position: 'relative' }}>
<div
className="topbar-avatar"
data-tooltip={displayName}
aria-label={displayName}
onClick={() => {
setShowUserMenu(!showUserMenu);
@@ -741,6 +737,7 @@ export default function App() {
<I18nProvider>
<ToastProvider>
<AppShell />
<TooltipLayer />
</ToastProvider>
</I18nProvider>
);
+229
View File
@@ -0,0 +1,229 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
type TooltipSide = 'top' | 'bottom';
type TooltipAlign = 'start' | 'center' | 'end';
type ActiveTooltip = {
target: HTMLElement;
text: string;
side: TooltipSide;
align: TooltipAlign;
};
type TooltipPosition = {
left: number;
top: number;
arrowLeft: number;
side: TooltipSide;
};
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function readTooltipSide(target: HTMLElement): TooltipSide {
return target.getAttribute('data-tooltip-side') === 'bottom' ? 'bottom' : 'top';
}
function readTooltipAlign(target: HTMLElement): TooltipAlign {
const align = target.getAttribute('data-tooltip-align');
if (align === 'start' || align === 'end') return align;
return 'center';
}
function resolveTooltipTarget(eventTarget: EventTarget | null): HTMLElement | null {
if (!(eventTarget instanceof Element)) return null;
const target = eventTarget.closest<HTMLElement>('[data-tooltip]');
if (!target) return null;
const text = target.getAttribute('data-tooltip');
return text && text.trim() ? target : null;
}
export default function TooltipLayer() {
const [activeTooltip, setActiveTooltip] = useState<ActiveTooltip | null>(null);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const bubbleRef = useRef<HTMLDivElement | null>(null);
const rafRef = useRef<number | null>(null);
const cancelFrame = useCallback(() => {
if (rafRef.current === null || typeof window === 'undefined') return;
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}, []);
const hideTooltip = useCallback(() => {
cancelFrame();
setActiveTooltip(null);
setPosition(null);
}, [cancelFrame]);
const showTooltipForTarget = useCallback((target: HTMLElement | null) => {
if (!target) {
hideTooltip();
return;
}
const text = target.getAttribute('data-tooltip')?.trim();
if (!text) {
hideTooltip();
return;
}
setActiveTooltip({
target,
text,
side: readTooltipSide(target),
align: readTooltipAlign(target),
});
}, [hideTooltip]);
const refreshPosition = useCallback(() => {
if (!activeTooltip || !bubbleRef.current || typeof window === 'undefined') return;
if (!activeTooltip.target.isConnected) {
hideTooltip();
return;
}
const targetRect = activeTooltip.target.getBoundingClientRect();
const bubbleRect = bubbleRef.current.getBoundingClientRect();
const viewportPadding = 12;
const gap = 10;
let left = targetRect.left;
if (activeTooltip.align === 'center') {
left = targetRect.left + targetRect.width / 2 - bubbleRect.width / 2;
} else if (activeTooltip.align === 'end') {
left = targetRect.right - bubbleRect.width;
}
let top = activeTooltip.side === 'bottom'
? targetRect.bottom + gap
: targetRect.top - gap - bubbleRect.height;
left = clamp(left, viewportPadding, window.innerWidth - viewportPadding - bubbleRect.width);
top = clamp(top, viewportPadding, window.innerHeight - viewportPadding - bubbleRect.height);
const targetCenter = targetRect.left + targetRect.width / 2;
const arrowLeft = clamp(targetCenter - left, 14, bubbleRect.width - 14);
setPosition({
left,
top,
arrowLeft,
side: activeTooltip.side,
});
}, [activeTooltip, hideTooltip]);
const scheduleRefresh = useCallback(() => {
cancelFrame();
if (typeof window === 'undefined') return;
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
refreshPosition();
});
}, [cancelFrame, refreshPosition]);
useEffect(() => {
if (typeof document === 'undefined') return;
document.body.dataset.tooltipPortal = 'true';
return () => {
delete document.body.dataset.tooltipPortal;
};
}, []);
useEffect(() => {
if (typeof document === 'undefined') return;
const handleMouseOver = (event: MouseEvent) => {
showTooltipForTarget(resolveTooltipTarget(event.target));
};
const handleFocusIn = (event: FocusEvent) => {
showTooltipForTarget(resolveTooltipTarget(event.target));
};
const handleMouseOut = (event: MouseEvent) => {
if (!activeTooltip) return;
const nextTarget = resolveTooltipTarget(event.relatedTarget);
if (nextTarget === activeTooltip.target) return;
if (event.relatedTarget instanceof Node && activeTooltip.target.contains(event.relatedTarget)) return;
hideTooltip();
};
const handleFocusOut = (event: FocusEvent) => {
if (!activeTooltip) return;
const nextTarget = resolveTooltipTarget(event.relatedTarget);
if (nextTarget === activeTooltip.target) return;
if (event.relatedTarget instanceof Node && activeTooltip.target.contains(event.relatedTarget)) return;
hideTooltip();
};
const handlePointerDown = (event: Event) => {
if (!activeTooltip) return;
if (event.target instanceof Node && activeTooltip.target.contains(event.target)) return;
hideTooltip();
};
document.addEventListener('mouseover', handleMouseOver);
document.addEventListener('focusin', handleFocusIn);
document.addEventListener('mouseout', handleMouseOut);
document.addEventListener('focusout', handleFocusOut);
document.addEventListener('pointerdown', handlePointerDown);
return () => {
document.removeEventListener('mouseover', handleMouseOver);
document.removeEventListener('focusin', handleFocusIn);
document.removeEventListener('mouseout', handleMouseOut);
document.removeEventListener('focusout', handleFocusOut);
document.removeEventListener('pointerdown', handlePointerDown);
};
}, [activeTooltip, hideTooltip, showTooltipForTarget]);
useLayoutEffect(() => {
if (!activeTooltip) return;
setPosition(null);
scheduleRefresh();
}, [activeTooltip, scheduleRefresh]);
useEffect(() => {
if (!activeTooltip || typeof window === 'undefined') return;
const handleViewportChange = () => scheduleRefresh();
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
};
}, [activeTooltip, scheduleRefresh]);
useEffect(() => () => cancelFrame(), [cancelFrame]);
if (!activeTooltip || typeof document === 'undefined') return null;
const tooltip = (
<div className="tooltip-layer" aria-hidden="true">
<div
ref={bubbleRef}
className={`tooltip-bubble tooltip-bubble-${position?.side ?? activeTooltip.side} ${position ? 'is-visible' : ''}`.trim()}
style={position ? {
position: 'fixed',
left: position.left,
top: position.top,
} : {
position: 'fixed',
left: 0,
top: 0,
visibility: 'hidden',
}}
>
{activeTooltip.text}
<span
className={`tooltip-bubble-arrow tooltip-bubble-arrow-${position?.side ?? activeTooltip.side}`}
style={position ? { left: position.arrowLeft } : undefined}
/>
</div>
</div>
);
return createPortal(tooltip, document.body);
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('TooltipLayer component', () => {
it('uses a portal-based fixed tooltip layer and is mounted by App', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/TooltipLayer.tsx'), 'utf8');
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
expect(source).toContain('createPortal');
expect(source).toContain('[data-tooltip]');
expect(source).toContain("position: 'fixed'");
expect(source).toContain('document.body.dataset.tooltipPortal');
expect(appSource).toContain("import TooltipLayer from './components/TooltipLayer.js'");
expect(appSource).toContain('<TooltipLayer />');
});
});
+2
View File
@@ -69,6 +69,8 @@ const zhToEn: Record<string, string> = {
'任务状态已更新': 'Task status updated',
'会话已过期,请重新登录': 'Session expired, please sign in again',
'首次使用建议先阅读站点文档:': 'For first-time setup, read site docs: ',
'首次使用建议先阅读快速上手:': 'For first-time setup, start with Quick Start: ',
'首次使用建议先阅读快速开始:': 'For first-time setup, start with Quick Start: ',
'个人信息已保存': 'Profile saved',
'搜索': 'Search',
'搜索 (Ctrl+K)': 'Search (Ctrl+K)',
+131 -2
View File
@@ -1608,6 +1608,11 @@ body {
position: relative;
}
body[data-tooltip-portal="true"] [data-tooltip]::after,
body[data-tooltip-portal="true"] [data-tooltip]::before {
display: none !important;
}
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
@@ -1625,7 +1630,7 @@ body {
text-align: left;
white-space: normal;
pointer-events: none;
z-index: 40;
z-index: 20000;
opacity: 0;
color: color-mix(in srgb, var(--color-text-primary) 90%, #0f172a);
background: color-mix(in srgb, #ffffff 92%, var(--color-primary) 8%);
@@ -1643,7 +1648,7 @@ body {
height: 8px;
transform: translateX(-50%) rotate(45deg);
pointer-events: none;
z-index: 39;
z-index: 19999;
opacity: 0;
background: color-mix(in srgb, #ffffff 92%, var(--color-primary) 8%);
border-right: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
@@ -1651,6 +1656,45 @@ body {
transition: opacity 0.18s ease;
}
[data-tooltip][data-tooltip-side="bottom"]::after {
top: calc(100% + 10px);
bottom: auto;
transform: translate(-50%, -6px) scale(0.98);
}
[data-tooltip][data-tooltip-side="bottom"]::before {
top: calc(100% + 4px);
bottom: auto;
transform: translateX(-50%) rotate(225deg);
border-right: none;
border-bottom: none;
border-left: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
border-top: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
}
[data-tooltip][data-tooltip-align="start"]::after {
left: 0;
right: auto;
transform: translate(0, 6px) scale(0.98);
}
[data-tooltip][data-tooltip-align="start"]::before {
left: 24px;
right: auto;
}
[data-tooltip][data-tooltip-align="end"]::after {
left: auto;
right: 0;
transform: translate(0, 6px) scale(0.98);
}
[data-tooltip][data-tooltip-align="end"]::before {
left: auto;
right: 24px;
transform: translateX(50%) rotate(225deg);
}
[data-tooltip]:hover::after,
[data-tooltip]:hover::before,
[data-tooltip]:focus-visible::after,
@@ -1663,6 +1707,18 @@ body {
transform: translate(-50%, 0) scale(1);
}
[data-tooltip][data-tooltip-side="bottom"]:hover::after,
[data-tooltip][data-tooltip-side="bottom"]:focus-visible::after {
transform: translate(-50%, 0) scale(1);
}
[data-tooltip][data-tooltip-align="start"]:hover::after,
[data-tooltip][data-tooltip-align="start"]:focus-visible::after,
[data-tooltip][data-tooltip-align="end"]:hover::after,
[data-tooltip][data-tooltip-align="end"]:focus-visible::after {
transform: translate(0, 0) scale(1);
}
.missing-token-site-tag {
border: 1px solid color-mix(in srgb, var(--color-info) 35%, transparent);
background: color-mix(in srgb, var(--color-info) 12%, var(--color-bg));
@@ -1753,6 +1809,79 @@ body {
border-bottom-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
}
.tooltip-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 20000;
}
.tooltip-bubble {
width: max-content;
min-width: 220px;
max-width: min(520px, calc(100vw - 40px));
padding: 6px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
line-height: 1.45;
text-align: left;
white-space: normal;
color: color-mix(in srgb, var(--color-text-primary) 90%, #0f172a);
background: color-mix(in srgb, #ffffff 92%, var(--color-primary) 8%);
border: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.14);
opacity: 0;
transform: scale(0.98);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.tooltip-bubble.is-visible {
opacity: 1;
transform: scale(1);
}
.tooltip-bubble-arrow {
position: absolute;
width: 8px;
height: 8px;
background: color-mix(in srgb, #ffffff 92%, var(--color-primary) 8%);
transform: translateX(-50%) rotate(45deg);
}
.tooltip-bubble-arrow-top {
top: calc(100% - 5px);
border-right: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
}
.tooltip-bubble-arrow-bottom {
top: -5px;
border-left: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
border-top: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
}
[data-theme="dark"] .tooltip-bubble {
color: color-mix(in srgb, #e2e8f0 94%, white);
background: color-mix(in srgb, #0f172a 88%, var(--color-primary) 12%);
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
box-shadow: 0 12px 30px rgba(2, 6, 23, 0.48);
}
[data-theme="dark"] .tooltip-bubble-arrow {
background: color-mix(in srgb, #0f172a 88%, var(--color-primary) 12%);
}
[data-theme="dark"] .tooltip-bubble-arrow-top {
border-right-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
border-bottom-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
}
[data-theme="dark"] .tooltip-bubble-arrow-bottom {
border-left-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
border-top-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
}
[data-theme="dark"] .route-enable-toggle.is-enabled {
background: #052e16;
color: #4ade80;
+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([
{