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:
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 />');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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