feat: add comprehensive tests for token candidates and proxy log path metadata

- Implemented tests for the `/api/models/token-candidates` endpoint to verify model availability and token coverage scenarios.
- Added tests for `composeProxyLogMessage` to ensure proper formatting of log messages with downstream and upstream metadata.
- Created utility functions for parsing proxy log path metadata and handling missing token hints.
- Developed tests for the `Sites` and `TokenRoutes` components to validate UI behavior and interactions, including scrolling and group expansion.
- Introduced a custom hook `useAnimatedVisibility` for managing visibility animations in components.
- Enhanced the documentation link for site resources.
This commit is contained in:
cita
2026-03-04 13:46:31 +08:00
parent d49fcefa05
commit e281e45e84
71 changed files with 5435 additions and 636 deletions
+32 -13
View File
@@ -7,6 +7,8 @@ import { api } from './api.js';
import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js';
import { I18nProvider, useI18n } from './i18n.js';
import { resolveLoginErrorMessage } from './loginError.js';
import { SITE_DOCS_URL } from './docsLink.js';
import { useAnimatedVisibility } from './components/useAnimatedVisibility.js';
const Dashboard = lazy(() => import('./pages/Dashboard.js'));
const Sites = lazy(() => import('./pages/Sites.js'));
const Accounts = lazy(() => import('./pages/Accounts.js'));
@@ -33,6 +35,7 @@ type UserProfile = {
const THEME_MODE_STORAGE_KEY = 'theme_mode';
const USER_PROFILE_STORAGE_KEY = 'user_profile';
const FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1';
const DICEBEAR_STYLES = [
'pixel-art',
'pixel-art-neutral',
@@ -205,6 +208,7 @@ function UserProfileModal({
onSave: (nextProfile: UserProfile) => void;
t: (text: string) => string;
}) {
const presence = useAnimatedVisibility(open, 200);
const [name, setName] = useState(profile.name);
const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed);
const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle);
@@ -218,7 +222,7 @@ function UserProfileModal({
setError('');
}, [open, profile]);
if (!open) return null;
if (!presence.shouldRender) return null;
const avatarUrl = buildDicebearAvatarUrl(avatarStyle, avatarSeed);
@@ -259,8 +263,8 @@ function UserProfileModal({
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 440 }}>
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={(e) => e.stopPropagation()} style={{ maxWidth: 440 }}>
<div className="modal-header">{t('个人信息')}</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
@@ -371,6 +375,8 @@ function AppShell() {
const [showProfileModal, setShowProfileModal] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const themeMenuPresence = useAnimatedVisibility(showThemeMenu, 160);
const userMenuPresence = useAnimatedVisibility(showUserMenu, 160);
const [unreadCount, setUnreadCount] = useState(0);
const notifBtnRef = useRef<HTMLButtonElement>(null);
const latestTaskEventIdRef = useRef(0);
@@ -487,6 +493,13 @@ function AppShell() {
return () => clearInterval(timer);
}, [authed, toast]);
useEffect(() => {
if (!authed) return;
if (localStorage.getItem(FIRST_USE_DOC_REMINDER_KEY)) return;
localStorage.setItem(FIRST_USE_DOC_REMINDER_KEY, '1');
toast.info(`${t('首次使用建议先阅读站点文档:')}${SITE_DOCS_URL}`);
}, [authed, t, toast]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
@@ -544,19 +557,20 @@ function AppShell() {
<div className="topbar-right">
<button
className="topbar-icon-btn"
title={language === 'zh' ? 'Switch to English' : '切换到中文'}
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" title={t('搜索 (Ctrl+K)')} onClick={() => setShowSearch(true)}>
<button className="topbar-search-trigger" data-tooltip={t('搜索 (Ctrl+K)')} 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" title={t('通知')} onClick={() => setShowNotifications(!showNotifications)}>
<button ref={notifBtnRef} className="topbar-icon-btn" data-tooltip={t('通知')} 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">
@@ -569,7 +583,10 @@ function AppShell() {
<div ref={themeMenuRef} style={{ position: 'relative' }}>
<button
className="topbar-icon-btn"
title={themeMode === 'system'
data-tooltip={themeMode === 'system'
? `${t('跟随系统')} (${resolvedThemeLabel})`
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
aria-label={themeMode === 'system'
? `${t('跟随系统')} (${resolvedThemeLabel})`
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
onClick={() => {
@@ -585,8 +602,8 @@ function AppShell() {
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
)}
</button>
{showThemeMenu && (
<div className="user-dropdown" style={{ right: 0, left: 'auto', minWidth: 168 }}>
{themeMenuPresence.shouldRender && (
<div className={`user-dropdown ${themeMenuPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ right: 0, left: 'auto', minWidth: 168 }}>
<button
className="user-dropdown-item"
onClick={() => handleSelectThemeMode('system')}
@@ -614,7 +631,8 @@ function AppShell() {
<div ref={userMenuRef} style={{ position: 'relative' }}>
<div
className="topbar-avatar"
title={displayName}
data-tooltip={displayName}
aria-label={displayName}
onClick={() => {
setShowUserMenu(!showUserMenu);
setShowThemeMenu(false);
@@ -626,8 +644,8 @@ function AppShell() {
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
/>
</div>
{showUserMenu && (
<div className="user-dropdown">
{userMenuPresence.shouldRender && (
<div className={`user-dropdown ${userMenuPresence.isVisible ? '' : 'is-closing'}`.trim()}>
<button
className="user-dropdown-item"
onClick={() => {
@@ -662,7 +680,8 @@ function AppShell() {
to={item.to}
end={item.to === '/' || item.to === '/settings'}
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
title={sidebarCollapsed ? t(item.label) : undefined}
data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
aria-label={sidebarCollapsed ? t(item.label) : undefined}
>
{item.icon}
{!sidebarCollapsed && <span>{t(item.label)}</span>}
+1 -1
View File
@@ -109,7 +109,7 @@ export const api = {
getAccounts: () => request('/api/accounts'),
addAccount: (data: any) => request('/api/accounts', { method: 'POST', body: JSON.stringify(data) }),
loginAccount: (data: { siteId: number; username: string; password: string }) => request('/api/accounts/login', { method: 'POST', body: JSON.stringify(data) }),
verifyToken: (data: { siteId: number; accessToken: string; platformUserId?: number }) => request('/api/accounts/verify-token', { method: 'POST', body: JSON.stringify(data) }),
verifyToken: (data: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: 'auto' | 'session' | 'apikey' }) => request('/api/accounts/verify-token', { method: 'POST', body: JSON.stringify(data) }),
rebindAccountSession: (id: number, data: { accessToken: string; platformUserId?: number }) =>
request(`/api/accounts/${id}/rebind-session`, { method: 'POST', body: JSON.stringify(data) }),
updateAccount: (id: number, data: any) => request(`/api/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
+5 -3
View File
@@ -3,8 +3,10 @@ import { createPortal } from 'react-dom';
import { api } from '../api.js';
import { useToast } from './Toast.js';
import { persistAuthSession } from '../authSession.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const presence = useAnimatedVisibility(open, 200);
const [oldToken, setOldToken] = useState('');
const [newToken, setNewToken] = useState('');
const [confirmToken, setConfirmToken] = useState('');
@@ -56,7 +58,7 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
}
};
if (!open) return null;
if (!presence.shouldRender) return null;
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
@@ -65,8 +67,8 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
};
const modal = (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
<div className="modal-header">修改管理员 Token</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
+8 -2
View File
@@ -4,6 +4,7 @@ import { api } from '../api.js';
import { formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
import { buildEventNavigationPath } from '../pages/helpers/navigationFocus.js';
import { useI18n } from '../i18n.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
const levelColors: Record<string, string> = {
info: 'var(--color-info)',
@@ -31,6 +32,7 @@ export default function NotificationPanel({
onUnreadCountChange?: (count: number) => void;
}) {
const { t: tr } = useI18n();
const presence = useAnimatedVisibility(open, 160);
const [events, setEvents] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<string>('');
@@ -77,10 +79,14 @@ export default function NotificationPanel({
onUnreadCountChange?.(0);
};
if (!open) return null;
if (!presence.shouldRender) return null;
return (
<div ref={panelRef} className="user-dropdown" style={{ right: 0, top: '100%', width: 360, maxHeight: 480, padding: 0, marginTop: 4 }}>
<div
ref={panelRef}
className={`user-dropdown ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ right: 0, top: '100%', width: 360, maxHeight: 480, padding: 0, marginTop: 4 }}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{tr('通知')}</span>
+5 -3
View File
@@ -4,6 +4,7 @@ import { api } from '../api.js';
import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
import { buildAccountFocusPath, buildSiteFocusPath } from '../pages/helpers/navigationFocus.js';
import { useI18n } from '../i18n.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
interface SiteResult {
id: number;
@@ -52,6 +53,7 @@ interface SearchResult {
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useI18n();
const presence = useAnimatedVisibility(open, 180);
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult | null>(null);
const [loading, setLoading] = useState(false);
@@ -109,7 +111,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
return () => document.removeEventListener('keydown', handler);
}, [open, onClose]);
if (!open) return null;
if (!presence.shouldRender) return null;
const hasResults = results && (
results.models.length
@@ -120,8 +122,8 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
);
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-content" style={{ maxWidth: 560, padding: 0 }} onClick={e => e.stopPropagation()}>
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} style={{ maxWidth: 560, padding: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-text-muted)">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
const [shouldRender, setShouldRender] = useState(visible);
const [isVisible, setIsVisible] = useState(visible);
useEffect(() => {
if (visible) {
setShouldRender(true);
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
const rafId = window.requestAnimationFrame(() => setIsVisible(true));
return () => window.cancelAnimationFrame(rafId);
}
setIsVisible(true);
return undefined;
}
setIsVisible(false);
if (durationMs <= 0) {
setShouldRender(false);
return undefined;
}
const timerId = globalThis.setTimeout(() => setShouldRender(false), durationMs);
return () => globalThis.clearTimeout(timerId);
}, [visible, durationMs]);
return { shouldRender, isVisible };
}
+1
View File
@@ -0,0 +1 @@
export const SITE_DOCS_URL = 'https://metapi.cita777.me/faq';
+2
View File
@@ -65,8 +65,10 @@ const zhToEn: Record<string, string> = {
'模型测试': 'Model Testing',
'关于': 'About',
'关于 Metapi': 'About Metapi',
'站点文档': 'Site Docs',
'任务状态已更新': 'Task status updated',
'会话已过期,请重新登录': 'Session expired, please sign in again',
'首次使用建议先阅读站点文档:': 'For first-time setup, read site docs: ',
'个人信息已保存': 'Profile saved',
'搜索': 'Search',
'搜索 (Ctrl+K)': 'Search (Ctrl+K)',
+260
View File
@@ -100,6 +100,18 @@ body {
}
}
@keyframes fade-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(4px);
}
}
@keyframes slide-up {
from {
opacity: 0;
@@ -136,6 +148,30 @@ body {
}
}
@keyframes scale-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.98);
}
}
@keyframes dropdown-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-6px);
}
}
@keyframes count-up {
from {
opacity: 0;
@@ -1370,6 +1406,121 @@ body {
outline: none;
}
.route-enable-toggle {
border: 1px solid transparent;
transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
}
.route-enable-toggle:hover {
transform: translateY(-1px);
}
.route-enable-toggle:focus-visible {
outline: 2px solid color-mix(in srgb, var(--color-primary) 40%, transparent);
outline-offset: 2px;
}
[data-tooltip] {
position: relative;
}
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 10px);
transform: translate(-50%, 6px) scale(0.98);
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;
pointer-events: none;
z-index: 40;
opacity: 0;
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);
transition: opacity 0.18s ease, transform 0.18s ease;
}
[data-tooltip]::before {
content: '';
position: absolute;
left: 50%;
bottom: calc(100% + 4px);
width: 8px;
height: 8px;
transform: translateX(-50%) rotate(45deg);
pointer-events: none;
z-index: 39;
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);
border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 22%, transparent);
transition: opacity 0.18s ease;
}
[data-tooltip]:hover::after,
[data-tooltip]:hover::before,
[data-tooltip]:focus-visible::after,
[data-tooltip]:focus-visible::before {
opacity: 1;
}
[data-tooltip]:hover::after,
[data-tooltip]:focus-visible::after {
transform: translate(-50%, 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));
transition: transform 0.18s ease, background-color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, color 0.18s ease;
}
.missing-token-site-tag:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--color-info) 65%, transparent);
background: color-mix(in srgb, var(--color-info) 22%, var(--color-bg));
box-shadow: 0 6px 14px color-mix(in srgb, var(--color-info) 22%, transparent);
}
.missing-token-site-tag:focus-visible {
outline: 2px solid color-mix(in srgb, var(--color-info) 50%, transparent);
outline-offset: 2px;
}
.route-enable-toggle.is-enabled {
background: #dcfce7;
color: #15803d;
border-color: color-mix(in srgb, #16a34a 28%, transparent);
}
.route-enable-toggle.is-enabled:hover {
background: color-mix(in srgb, #dcfce7 72%, white);
box-shadow: 0 2px 8px color-mix(in srgb, #16a34a 24%, transparent);
}
.route-enable-toggle.is-disabled {
background: rgba(0, 0, 0, 0.05);
color: var(--color-text-muted);
border-color: rgba(0, 0, 0, 0.08);
}
.route-enable-toggle.is-disabled:hover {
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-bg-card));
color: var(--color-primary);
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
box-shadow: 0 2px 8px color-mix(in srgb, var(--color-primary) 22%, transparent);
}
[data-theme="dark"] .badge-success {
background: #052e16;
color: #4ade80;
@@ -1401,6 +1552,45 @@ body {
color: #93c5fd;
}
[data-theme="dark"] .missing-token-site-tag:hover {
background: color-mix(in srgb, var(--color-info) 26%, var(--color-surface));
}
[data-theme="dark"] [data-tooltip]::after {
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"] [data-tooltip]::before {
background: color-mix(in srgb, #0f172a 88%, var(--color-primary) 12%);
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"] .route-enable-toggle.is-enabled {
background: #052e16;
color: #4ade80;
border-color: color-mix(in srgb, #4ade80 35%, transparent);
}
[data-theme="dark"] .route-enable-toggle.is-enabled:hover {
background: color-mix(in srgb, #052e16 70%, #1f2937);
box-shadow: 0 2px 8px color-mix(in srgb, #4ade80 28%, transparent);
}
[data-theme="dark"] .route-enable-toggle.is-disabled {
background: rgba(255, 255, 255, 0.08);
color: var(--color-text-muted);
border-color: rgba(255, 255, 255, 0.12);
}
[data-theme="dark"] .route-enable-toggle.is-disabled:hover {
background: color-mix(in srgb, var(--color-primary) 18%, var(--color-bg-card));
color: #a5b4fc;
}
/* ===== Tabs ===== */
.tabs {
display: flex;
@@ -1628,6 +1818,11 @@ textarea {
backdrop-filter: blur(4px);
}
.modal-backdrop.is-closing {
animation: fade-out 0.2s ease both;
pointer-events: none;
}
.modal-content {
background: var(--color-bg-card);
border-radius: var(--radius-lg);
@@ -1640,6 +1835,10 @@ textarea {
animation: scale-in 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.modal-content.is-closing {
animation: scale-out 0.22s cubic-bezier(0.4, 0, 0.2, 1) both;
}
.modal-header {
padding: 20px 24px 0;
font-size: 16px;
@@ -2018,6 +2217,11 @@ textarea {
animation: slide-down 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.user-dropdown.is-closing {
animation: dropdown-out 0.16s ease both;
pointer-events: none;
}
.user-dropdown-item {
display: flex;
align-items: center;
@@ -2170,6 +2374,26 @@ textarea {
top: calc(var(--topbar-height) + 24px);
}
.filter-panel.filter-collapsible {
transition:
width 0.26s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.2s ease,
transform 0.26s cubic-bezier(0.22, 1, 0.36, 1),
padding 0.26s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.2s ease;
transform-origin: left top;
}
.filter-panel.filter-collapsible.is-closing {
width: 0;
opacity: 0;
transform: translateX(-10px);
padding: 0;
border-color: transparent;
overflow: hidden;
pointer-events: none;
}
.filter-panel-section {
margin-bottom: 20px;
}
@@ -2435,6 +2659,42 @@ textarea {
animation: fade-in 0.2s ease both;
}
.anim-collapse {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
transition:
grid-template-rows 0.26s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.2s ease,
transform 0.24s cubic-bezier(0.22, 1, 0.36, 1);
}
.anim-collapse.is-open {
grid-template-rows: 1fr;
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.anim-collapse-inner {
min-height: 0;
overflow: hidden;
}
.panel-presence {
opacity: 1;
transform: translateY(0) scale(1);
transition: opacity 0.2s ease, transform 0.24s cubic-bezier(0.22, 1, 0.36, 1);
}
.panel-presence.is-closing {
opacity: 0;
transform: translateY(-8px) scale(0.985);
pointer-events: none;
}
/* ===== Pagination ===== */
.pagination {
display: flex;
+3 -1
View File
@@ -1,4 +1,5 @@
import { tr } from '../i18n.js';
import { SITE_DOCS_URL } from '../docsLink.js';
const VERSION = '1.2.0';
@@ -25,6 +26,7 @@ const TECH_STACK = [
const LINKS = [
{ label: 'GitHub', href: 'https://github.com/cita-777/metapi', icon: '📂' },
{ label: 'Docker Hub', href: 'https://hub.docker.com/r/1467078763/metapi', icon: '🐳' },
{ label: '站点文档', href: SITE_DOCS_URL, icon: '📚' },
];
export default function About() {
@@ -125,7 +127,7 @@ export default function About() {
onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--color-border-light)')}
>
<span>{l.icon}</span>
<span>{l.label}</span>
<span>{tr(l.label)}</span>
</a>
))}
</div>
+292 -145
View File
@@ -3,10 +3,12 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
import { clearFocusParams, readFocusAccountIntent } from './helpers/navigationFocus.js';
import { tr } from '../i18n.js';
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
import { SITE_DOCS_URL } from '../docsLink.js';
export default function Accounts() {
const location = useLocation();
@@ -19,7 +21,15 @@ export default function Accounts() {
const [showAdd, setShowAdd] = useState(false);
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
const [loginForm, setLoginForm] = useState({ siteId: 0, username: '', password: '' });
const [tokenForm, setTokenForm] = useState({ siteId: 0, accessToken: '', platformUserId: '' });
const [tokenForm, setTokenForm] = useState({
siteId: 0,
username: '',
accessToken: '',
platformUserId: '',
refreshToken: '',
tokenExpiresAt: '',
credentialMode: 'auto' as 'auto' | 'session' | 'apikey',
});
const [verifyResult, setVerifyResult] = useState<any>(null);
const [verifying, setVerifying] = useState(false);
const [saving, setSaving] = useState(false);
@@ -31,11 +41,16 @@ export default function Accounts() {
const [rebindSaving, setRebindSaving] = useState(false);
const [highlightRebindPanel, setHighlightRebindPanel] = useState(false);
const [rebindFocusTrigger, setRebindFocusTrigger] = useState(0);
const addPanelPresence = useAnimatedVisibility(showAdd, 220);
const rebindPanelPresence = useAnimatedVisibility(Boolean(rebindTarget), 220);
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rebindPanelRef = useRef<HTMLDivElement | null>(null);
const rebindPanelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastRebindTargetRef = useRef<any | null>(null);
const toast = useToast();
if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
const activeRebindTarget = rebindTarget || lastRebindTargetRef.current;
const load = async () => {
const [accountsResult, sitesResult] = await Promise.allSettled([
@@ -54,6 +69,12 @@ export default function Accounts() {
};
useEffect(() => { void load(); }, []);
const selectedTokenSite = useMemo(
() => sites.find((item) => item.id === tokenForm.siteId) || null,
[sites, tokenForm.siteId],
);
const isSub2ApiSelected = (selectedTokenSite?.platform || '').toLowerCase() === 'sub2api';
const sortedAccounts = useMemo(
() => sortItemsForDisplay(accounts, sortMode, (account) => account.balance || 0),
[accounts, sortMode],
@@ -102,10 +123,15 @@ export default function Accounts() {
siteId: tokenForm.siteId,
accessToken: tokenForm.accessToken,
platformUserId: tokenForm.platformUserId ? parseInt(tokenForm.platformUserId) : undefined,
credentialMode: tokenForm.credentialMode,
});
setVerifyResult(result);
if (result.success) {
toast.success(`验证成功: ${result.userInfo?.username || '未知用户'}`);
if (result.tokenType === 'apikey') {
toast.success(`API Key 验证成功(可用模型 ${result.modelCount || 0} 个)`);
} else {
toast.success(`Session 验证成功: ${result.userInfo?.username || '未知用户'}`);
}
} else {
toast.error(result.message || 'Token 无效');
}
@@ -127,11 +153,27 @@ export default function Accounts() {
try {
const result = await api.addAccount({
siteId: tokenForm.siteId,
username: tokenForm.username.trim() || undefined,
accessToken: tokenForm.accessToken,
platformUserId: tokenForm.platformUserId ? parseInt(tokenForm.platformUserId) : undefined,
refreshToken: isSub2ApiSelected && tokenForm.refreshToken.trim()
? tokenForm.refreshToken.trim()
: undefined,
tokenExpiresAt: isSub2ApiSelected && tokenForm.tokenExpiresAt.trim()
? Number.parseInt(tokenForm.tokenExpiresAt.trim(), 10)
: undefined,
credentialMode: tokenForm.credentialMode,
});
setShowAdd(false);
setTokenForm({ siteId: 0, accessToken: '', platformUserId: '' });
setTokenForm({
siteId: 0,
username: '',
accessToken: '',
platformUserId: '',
refreshToken: '',
tokenExpiresAt: '',
credentialMode: 'auto',
});
setVerifyResult(null);
if (result.tokenType === 'apikey') {
toast.success('已添加为 API Key 账号(可用于代理转发)');
@@ -192,6 +234,23 @@ export default function Accounts() {
return { state, reason, ...cfg };
};
const resolveAccountCapabilities = (account: any) => {
const fromServer = account?.capabilities;
if (fromServer && typeof fromServer === 'object') {
return {
canCheckin: !!fromServer.canCheckin,
canRefreshBalance: !!fromServer.canRefreshBalance,
proxyOnly: !!fromServer.proxyOnly,
};
}
const hasSession = typeof account?.accessToken === 'string' && account.accessToken.trim().length > 0;
return {
canCheckin: hasSession,
canRefreshBalance: hasSession,
proxyOnly: !hasSession,
};
};
const handleRefreshRuntimeHealth = async () => {
setActionLoading((s) => ({ ...s, 'health-refresh': true }));
try {
@@ -261,7 +320,7 @@ export default function Accounts() {
const raw = parsed?.platformUserId;
const value = Number.parseInt(String(raw ?? ''), 10);
if (Number.isFinite(value) && value > 0) return String(value);
} catch {}
} catch { }
const guessed = Number.parseInt(String(account?.username || '').match(/(\d{3,8})$/)?.[1] || '', 10);
return Number.isFinite(guessed) && guessed > 0 ? String(guessed) : '';
};
@@ -308,6 +367,7 @@ export default function Accounts() {
siteId: rebindTarget.siteId,
accessToken: rebindForm.accessToken.trim(),
platformUserId: rebindForm.platformUserId ? Number.parseInt(rebindForm.platformUserId, 10) : undefined,
credentialMode: 'session',
});
setRebindVerifyResult(result);
if (result.success && result.tokenType === 'session') {
@@ -412,8 +472,8 @@ export default function Accounts() {
</div>
{/* Add Panel */}
{showAdd && (
<div className="card animate-scale-in" style={getAccountsAddPanelStyle()}>
{addPanelPresence.shouldRender && (
<div className={`card panel-presence ${addPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={getAccountsAddPanelStyle()}>
{/* Mode tabs */}
<div style={{ display: 'flex', gap: 0, background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', padding: 3, marginBottom: 16 }}>
<button onClick={() => { setAddMode('token'); setVerifyResult(null); }}
@@ -440,11 +500,22 @@ export default function Accounts() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="info-tip">
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>支持两种凭证类型,系统自动识别</div>
<div><strong>API Key</strong>(在站点「令牌」页面生成)→ 用于代理转发</div>
<div><strong>Session Cookie</strong>(从浏览器获取)→ 支持签到、余额查询等全部功能</div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>凭证模式:Session / API Key(可切换,默认自动识别)</div>
<div><strong>API Key</strong>(在站点「令牌」页面生成)→ 仅代理转发</div>
<div><strong>Session 模式</strong>支持两种凭证:<strong>系统访问令牌(Access Token,推荐)</strong> 或 <strong>浏览器 Cookie</strong></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 }}>
获取 Session Cookie: <kbd style={{ padding: '1px 5px', background: 'var(--color-bg-card)', border: '1px solid var(--color-border)', borderRadius: 3, fontSize: 11 }}>F12</kbd> → Application → Local Storage</div>
推荐优先使用 Access Token(更稳定、可多账号);Cookie 仅用于兼容场景。获取 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
@@ -463,17 +534,75 @@ export default function Accounts() {
]}
placeholder="选择站点"
/>
<textarea placeholder="粘贴 Session Cookie 或 API Key&#10;(系统会自动识别凭证类型)"
<input
placeholder="账号名称(可选,API Key 模式建议填写)"
value={tokenForm.username}
onChange={(e) => setTokenForm((f) => ({ ...f, username: e.target.value }))}
style={inputStyle}
/>
<ModernSelect
value={tokenForm.credentialMode}
onChange={(nextValue) => {
const nextMode = (nextValue as 'auto' | 'session' | 'apikey') || 'auto';
setTokenForm((f) => ({ ...f, credentialMode: nextMode }));
setVerifyResult(null);
}}
options={[
{ value: 'auto', label: '自动识别(推荐)' },
{ value: 'session', label: 'Session 模式(Access Token / Cookie)' },
{ value: 'apikey', label: 'API Key 模式' },
]}
placeholder="选择凭证模式"
/>
<textarea placeholder={
tokenForm.credentialMode === 'session'
? '粘贴系统访问令牌(Access Token)或 Session Cookie'
: (tokenForm.credentialMode === 'apikey'
? '粘贴 API Key'
: '粘贴 Access Token / Session Cookie / API Key(自动识别)')
}
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>
</>
)}
{/* Verify results */}
{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 Cookie 有效
Session 凭证有效(Access Token / Cookie)
</div>
<div style={{ fontSize: 12, lineHeight: 1.8 }}>
<div>用户名: <strong>{verifyResult.userInfo?.username || '未知'}</strong></div>
@@ -499,11 +628,8 @@ export default function Accounts() {
{verifyResult && !verifyResult.success && verifyResult.needsUserId && (
<div className="alert alert-warning animate-scale-in">
<div className="alert-title">
Token 已识别,但此站点需要提供用户 ID
此站点要求用户 ID,请补充后重新验证
</div>
<input placeholder="用户 ID(数字)" value={tokenForm.platformUserId}
onChange={e => setTokenForm(f => ({ ...f, platformUserId: e.target.value.replace(/\D/g, '') }))}
style={{ ...inputStyle, borderColor: 'color-mix(in srgb, var(--color-warning) 45%, transparent)' }} />
</div>
)}
{verifyResult && !verifyResult.success && !verifyResult.needsUserId && (
@@ -562,10 +688,10 @@ export default function Accounts() {
</div>
)}
{rebindTarget && (
{rebindPanelPresence.shouldRender && activeRebindTarget && (
<div
ref={rebindPanelRef}
className={`card animate-scale-in rebind-panel ${highlightRebindPanel ? 'rebind-panel-highlight' : ''}`}
className={`card panel-presence rebind-panel ${rebindPanelPresence.isVisible ? '' : 'is-closing'} ${highlightRebindPanel ? 'rebind-panel-highlight' : ''}`.trim()}
style={{ marginBottom: 16, padding: 16 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
@@ -575,7 +701,7 @@ export default function Accounts() {
<button className="btn btn-ghost" onClick={closeRebindPanel}>关闭</button>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
账号: {rebindTarget.username || '未命名'} @ {rebindTarget.site?.name || '-'}。请粘贴新的 Session Token,验证成功后再绑定。
账号: {activeRebindTarget.username || '未命名'} @ {activeRebindTarget.site?.name || '-'}。请粘贴新的 Session Token,验证成功后再绑定。
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 220px', gap: 10, marginBottom: 10 }}>
@@ -654,140 +780,161 @@ export default function Accounts() {
</tr>
</thead>
<tbody>
{sortedAccounts.map((a: any, i: number) => (
<tr
key={a.id}
ref={(node) => {
if (node) rowRefs.current.set(a.id, node);
else rowRefs.current.delete(a.id);
}}
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} ${highlightAccountId === a.id ? 'row-focus-highlight' : ''}`}
>
<td style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>{a.username || '未命名'}</td>
<td>
{a.site?.url ? (
<a
href={a.site.url}
target="_blank"
rel="noopener noreferrer"
className="badge-link"
>
{sortedAccounts.map((a: any, i: number) => {
const capabilities = resolveAccountCapabilities(a);
return (
<tr
key={a.id}
ref={(node) => {
if (node) rowRefs.current.set(a.id, node);
else rowRefs.current.delete(a.id);
}}
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} ${highlightAccountId === a.id ? 'row-focus-highlight' : ''}`}
>
<td style={{ color: 'var(--color-text-primary)' }}>
<div style={{ fontWeight: 600 }}>{a.username || '未命名'}</div>
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
{capabilities.proxyOnly && (
<span className="badge badge-muted" style={{ fontSize: 10 }}>仅代理</span>
)}
</div>
</td>
<td>
{a.site?.url ? (
<a
href={a.site.url}
target="_blank"
rel="noopener noreferrer"
className="badge-link"
>
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
</a>
) : (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
</a>
) : (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
)}
</td>
<td>
{(() => {
const health = resolveRuntimeHealth(a);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span className={`badge ${health.cls}`} style={{ fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4, width: 'fit-content' }}>
<span className={`status-dot ${health.dotClass} ${health.pulse ? 'animate-pulse-dot' : ''}`} style={{ marginRight: 0 }} />
{health.label}
</span>
<span
style={{
fontSize: 11,
color: 'var(--color-text-muted)',
maxWidth: 200,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={health.reason}
>
{health.reason}
</span>
</div>
);
})()}
</td>
<td style={{ fontVariantNumeric: 'tabular-nums' }}>
<div style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>${(a.balance || 0).toFixed(2)}</div>
<div style={{ fontSize: 11, color: (a.todayReward || 0) > 0 ? 'var(--color-success)' : 'var(--color-text-muted)', fontWeight: 500 }}>
+{(a.todayReward || 0).toFixed(2)}
</div>
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
<div>${(a.balanceUsed || 0).toFixed(2)}</div>
<div style={{ fontSize: 11, color: (a.todaySpend || 0) > 0 ? 'var(--color-danger)' : 'var(--color-text-muted)', fontWeight: 500 }}>
-{(a.todaySpend || 0).toFixed(2)}
</div>
</td>
<td>
<button
type="button"
className={`checkin-toggle-badge ${a.checkinEnabled ? 'is-on' : 'is-off'}`}
onClick={() => handleToggleCheckin(a)}
disabled={!!actionLoading[`checkin-toggle-${a.id}`]}
title={a.checkinEnabled ? '点击关闭签到,全部签到会忽略此账号' : '点击开启签到'}
>
{actionLoading[`checkin-toggle-${a.id}`]
? <span className="spinner spinner-sm" />
: (a.checkinEnabled ? '开启' : '关闭')}
</button>
</td>
<td className="accounts-actions-cell" style={{ textAlign: 'right' }}>
<div className="accounts-row-actions">
<button
onClick={() => handleTogglePin(a)}
disabled={!!actionLoading[`pin-toggle-${a.id}`]}
className={`btn btn-link ${a.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
>
{actionLoading[`pin-toggle-${a.id}`] ? <span className="spinner spinner-sm" /> : (a.isPinned ? '取消置顶' : '置顶')}
</button>
{sortMode === 'custom' && (
<>
<button
onClick={() => handleMoveCustomOrder(a, 'up')}
disabled={!!actionLoading[`reorder-${a.id}`]}
className="btn btn-link btn-link-muted"
>
↑
</button>
<button
onClick={() => handleMoveCustomOrder(a, 'down')}
disabled={!!actionLoading[`reorder-${a.id}`]}
className="btn btn-link btn-link-muted"
>
↓
</button>
</>
)}
<button onClick={() => withLoading(`refresh-${a.id}`, () => api.refreshBalance(a.id), '余额已刷新')} disabled={actionLoading[`refresh-${a.id}`]}
className="btn btn-link btn-link-primary">
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
</button>
<button onClick={() => withLoading(`models-${a.id}`, () => api.checkModels(a.id), '模型已更新')} disabled={actionLoading[`models-${a.id}`]}
className="btn btn-link btn-link-info">
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
</button>
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]}
className="btn btn-link btn-link-warning">
{actionLoading[`checkin-${a.id}`] ? <span className="spinner spinner-sm" /> : '签到'}
</button>
{a.status === 'expired' && (
</td>
<td>
{(() => {
const health = resolveRuntimeHealth(a);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span className={`badge ${health.cls}`} style={{ fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4, width: 'fit-content' }}>
<span className={`status-dot ${health.dotClass} ${health.pulse ? 'animate-pulse-dot' : ''}`} style={{ marginRight: 0 }} />
{health.label}
</span>
<span
style={{
fontSize: 11,
color: 'var(--color-text-muted)',
maxWidth: 200,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
data-tooltip={health.reason}
>
{health.reason}
</span>
</div>
);
})()}
</td>
<td style={{ fontVariantNumeric: 'tabular-nums' }}>
<div style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>${(a.balance || 0).toFixed(2)}</div>
<div style={{ fontSize: 11, color: (a.todayReward || 0) > 0 ? 'var(--color-success)' : 'var(--color-text-muted)', fontWeight: 500 }}>
+{(a.todayReward || 0).toFixed(2)}
</div>
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
<div>${(a.balanceUsed || 0).toFixed(2)}</div>
<div style={{ fontSize: 11, color: (a.todaySpend || 0) > 0 ? 'var(--color-danger)' : 'var(--color-text-muted)', fontWeight: 500 }}>
-{(a.todaySpend || 0).toFixed(2)}
</div>
</td>
<td>
{capabilities.canCheckin ? (
<button
onClick={() => openRebindPanel(a)}
className="btn btn-link btn-link-warning"
type="button"
className={`checkin-toggle-badge ${a.checkinEnabled ? 'is-on' : 'is-off'}`}
onClick={() => handleToggleCheckin(a)}
disabled={!!actionLoading[`checkin-toggle-${a.id}`]}
data-tooltip={a.checkinEnabled ? '点击关闭签到,全部签到会忽略此账号' : '点击开启签到'}
aria-label={a.checkinEnabled ? '点击关闭签到,全部签到会忽略此账号' : '点击开启签到'}
>
重新绑定
{actionLoading[`checkin-toggle-${a.id}`]
? <span className="spinner spinner-sm" />
: (a.checkinEnabled ? '开启' : '关闭')}
</button>
) : (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
不支持
</span>
)}
<button onClick={() => withLoading(`delete-${a.id}`, () => api.deleteAccount(a.id), '已删除')} disabled={actionLoading[`delete-${a.id}`]}
className="btn btn-link btn-link-danger">
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
</button>
</div>
</td>
</tr>
))}
</td>
<td className="accounts-actions-cell" style={{ textAlign: 'right' }}>
<div className="accounts-row-actions">
<button
onClick={() => handleTogglePin(a)}
disabled={!!actionLoading[`pin-toggle-${a.id}`]}
className={`btn btn-link ${a.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
>
{actionLoading[`pin-toggle-${a.id}`] ? <span className="spinner spinner-sm" /> : (a.isPinned ? '取消置顶' : '置顶')}
</button>
{sortMode === 'custom' && (
<>
<button
onClick={() => handleMoveCustomOrder(a, 'up')}
disabled={!!actionLoading[`reorder-${a.id}`]}
className="btn btn-link btn-link-muted"
>
↑
</button>
<button
onClick={() => handleMoveCustomOrder(a, 'down')}
disabled={!!actionLoading[`reorder-${a.id}`]}
className="btn btn-link btn-link-muted"
>
↓
</button>
</>
)}
{capabilities.canRefreshBalance && (
<button onClick={() => withLoading(`refresh-${a.id}`, () => api.refreshBalance(a.id), '余额已刷新')} disabled={actionLoading[`refresh-${a.id}`]}
className="btn btn-link btn-link-primary">
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
</button>
)}
<button onClick={() => withLoading(`models-${a.id}`, () => api.checkModels(a.id), '模型已更新')} disabled={actionLoading[`models-${a.id}`]}
className="btn btn-link btn-link-info">
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
</button>
{capabilities.canCheckin && (
<button onClick={() => withLoading(`checkin-${a.id}`, () => api.triggerCheckin(a.id), '签到完成')} disabled={actionLoading[`checkin-${a.id}`]}
className="btn btn-link btn-link-warning">
{actionLoading[`checkin-${a.id}`] ? <span className="spinner spinner-sm" /> : '签到'}
</button>
)}
{a.status === 'expired' && (
<button
onClick={() => openRebindPanel(a)}
className="btn btn-link btn-link-warning"
>
重新绑定
</button>
)}
<button onClick={() => withLoading(`delete-${a.id}`, () => api.deleteAccount(a.id), '已删除')} disabled={actionLoading[`delete-${a.id}`]}
className="btn btn-link btn-link-danger">
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
) : (
+2 -2
View File
@@ -176,7 +176,7 @@ export default function CheckinLog() {
</td>
<td>
{reason ? (
<span className="badge badge-info" title={reason.detailHint}>
<span className="badge badge-info" data-tooltip={reason.detailHint}>
{reason.title}
</span>
) : (
@@ -198,7 +198,7 @@ export default function CheckinLog() {
color: 'var(--color-text-secondary)',
fontSize: 12,
}}
title={reason?.detailHint || ''}
data-tooltip={reason?.detailHint || ''}
>
{reason?.actionHint || '-'}
</span>
+1 -1
View File
@@ -216,7 +216,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h2 className="greeting">{getGreeting() + '\uFF0C' + normalizedAdminName}</h2>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => { load(true); loadSiteStats(); }} disabled={refreshing} className="topbar-icon-btn" title="刷新">
<button onClick={() => { load(true); loadSiteStats(); }} disabled={refreshing} className="topbar-icon-btn" data-tooltip="刷新" aria-label="刷新">
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ animation: refreshing ? 'spin 1s linear infinite' : 'none' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
+21 -17
View File
@@ -29,6 +29,7 @@ import {
type TestChatPayload,
} from './helpers/modelTesterSession.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { tr } from '../i18n.js';
type ChatJobResponse = {
@@ -561,6 +562,7 @@ export default function ModelTester() {
const [customRequestMode, setCustomRequestMode] = useState(false);
const [customRequestBody, setCustomRequestBody] = useState('');
const [showDebugPanel, setShowDebugPanel] = useState(false);
const debugPanelPresence = useAnimatedVisibility(showDebugPanel, 220);
const [activeDebugTab, setActiveDebugTab] = useState<DebugTab>(DEBUG_TABS.PREVIEW);
const [debugRequest, setDebugRequest] = useState('');
const [debugResponse, setDebugResponse] = useState('');
@@ -1264,7 +1266,7 @@ export default function ModelTester() {
return debugResponse;
}, [activeDebugTab, debugPreview, debugRequest, debugResponse]);
const layoutColumns = showDebugPanel
const layoutColumns = debugPanelPresence.shouldRender
? '340px minmax(0, 1fr) 360px'
: '340px minmax(0, 1fr)';
@@ -1461,8 +1463,8 @@ export default function ModelTester() {
</label>
</div>
{customRequestMode && (
<div style={{ marginBottom: 14 }}>
<div className={`anim-collapse ${customRequestMode ? 'is-open' : ''}`.trim()} style={{ marginBottom: 14 }}>
<div className="anim-collapse-inner">
<textarea
value={customRequestBody}
onChange={(event) => setCustomRequestBody(event.target.value)}
@@ -1487,7 +1489,7 @@ export default function ModelTester() {
</button>
</div>
</div>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8, fontWeight: 600 }}>
采样参数
@@ -1702,18 +1704,20 @@ export default function ModelTester() {
<span>{isLoading ? '思考中...' : '推理过程'}</span>
<span>{message.isReasoningExpanded ? '▼' : '▶'}</span>
</button>
{message.isReasoningExpanded && (
<div style={{
padding: '8px 10px',
borderTop: '1px solid color-mix(in srgb, var(--color-primary) 20%, transparent)',
fontSize: 12,
lineHeight: 1.7,
whiteSpace: 'pre-wrap',
color: 'var(--color-text-secondary)',
}}>
{message.reasoningContent}
<div className={`anim-collapse ${message.isReasoningExpanded ? 'is-open' : ''}`.trim()}>
<div className="anim-collapse-inner">
<div style={{
padding: '8px 10px',
borderTop: '1px solid color-mix(in srgb, var(--color-primary) 20%, transparent)',
fontSize: 12,
lineHeight: 1.7,
whiteSpace: 'pre-wrap',
color: 'var(--color-text-secondary)',
}}>
{message.reasoningContent}
</div>
</div>
)}
</div>
</div>
)}
@@ -1850,8 +1854,8 @@ export default function ModelTester() {
</div>
</div>
{showDebugPanel && (
<div className="card" style={{ padding: 14, minHeight: 680, maxHeight: 740, display: 'flex', flexDirection: 'column' }}>
{debugPanelPresence.shouldRender && (
<div className={`card panel-presence ${debugPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 14, minHeight: 680, maxHeight: 740, display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<h3 style={{ margin: 0, fontSize: 15 }}>调试</h3>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
+28 -21
View File
@@ -4,6 +4,7 @@ import { api } from '../api.js';
import { getBrand, hashColor, BrandIcon, type BrandInfo, useIconCdn } from '../components/BrandIcon.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { mergeMarketplaceMetadata, shouldHydrateMarketplaceMetadata } from './helpers/modelsMarketplaceMetadata.js';
import { tr } from '../i18n.js';
@@ -130,6 +131,7 @@ export default function Models() {
const [copied, setCopied] = useState<string | null>(null);
const [filterCollapsed, setFilterCollapsed] = useState(false);
const [metadataHydrating, setMetadataHydrating] = useState(false);
const filterPanelPresence = useAnimatedVisibility(!filterCollapsed, 220);
const latestPrimaryRequestRef = useRef(0);
const latestMetadataRequestRef = useRef(0);
const location = useLocation();
@@ -332,8 +334,8 @@ export default function Models() {
return (
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
{/* ====== LEFT: Filter Panel ====== */}
{!filterCollapsed && (
<div className="filter-panel">
{filterPanelPresence.shouldRender && (
<div className={`filter-panel filter-collapsible ${filterPanelPresence.isVisible ? '' : 'is-closing'}`.trim()}>
{/* Brand filter */}
<div className="filter-panel-section">
<div className="filter-panel-title">
@@ -475,10 +477,10 @@ export default function Models() {
<span className="badge badge-muted" style={{ fontSize: 11 }}>{tr('加载元数据中...')}</span>
)}
<div className="view-toggle">
<button className={`view-toggle-btn ${viewMode === 'card' ? 'active' : ''}`} onClick={() => setViewMode('card')} title={tr('卡片视图')}>
<button className={`view-toggle-btn ${viewMode === 'card' ? 'active' : ''}`} onClick={() => setViewMode('card')} data-tooltip={tr('卡片视图')} aria-label={tr('卡片视图')}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>
</button>
<button className={`view-toggle-btn ${viewMode === 'table' ? 'active' : ''}`} onClick={() => setViewMode('table')} title={tr('表格视图')}>
<button className={`view-toggle-btn ${viewMode === 'table' ? 'active' : ''}`} onClick={() => setViewMode('table')} data-tooltip={tr('表格视图')} aria-label={tr('表格视图')}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18M3 6h18M3 18h18M10 3v18M14 3v18" /></svg>
</button>
</div>
@@ -499,10 +501,10 @@ export default function Models() {
</div>
{/* Quick stats */}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-muted)', alignItems: 'center' }}>
<span title={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}>
<span data-tooltip={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}>
{tr('覆盖槽位')} <b style={{ color: 'var(--color-text-primary)' }}>{totalCoverageSlots}</b>
</span>
<span title={tr('当前筛选范围内去重后的唯一账号数')}>
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
</span>
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{avgLatency}ms</b></span>
@@ -541,21 +543,21 @@ export default function Models() {
<span
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
style={{ fontVariantNumeric: 'tabular-nums' }}
title={tr('平均延迟')}
data-tooltip={tr('平均延迟')}
>
{tr('延迟')} {m.avgLatency}ms
</span>
<span
className={`badge ${getSuccessBadgeClass(m.successRate)}`}
style={{ fontVariantNumeric: 'tabular-nums' }}
title={tr('成功率')}
data-tooltip={tr('成功率')}
>
{tr('成功率')} {m.successRate != null ? `${m.successRate}%` : '—'}
</span>
</div>
</div>
<div className="model-card-actions" onClick={e => e.stopPropagation()}>
<button className="model-card-action-btn" title={tr('复制模型名')} onClick={() => copyName(m.name)}>
<button className="model-card-action-btn" data-tooltip={tr('复制模型名')} aria-label={tr('复制模型名')} onClick={() => copyName(m.name)}>
{copied === m.name ? (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
) : (
@@ -564,7 +566,8 @@ export default function Models() {
</button>
<button
className="model-card-action-btn"
title={expanded === m.name ? tr('收起') : tr('展开')}
data-tooltip={expanded === m.name ? tr('收起') : tr('展开')}
aria-label={expanded === m.name ? tr('收起') : tr('展开')}
>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: expanded === m.name ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
@@ -593,8 +596,9 @@ export default function Models() {
</div>
{/* Expand: Account Details */}
{expanded === m.name && (
<div className="model-card-expand" onClick={e => e.stopPropagation()}>
<div className={`anim-collapse ${expanded === m.name ? 'is-open' : ''}`.trim()} onClick={e => e.stopPropagation()}>
<div className="anim-collapse-inner">
<div className="model-card-expand">
<div style={{ display: 'grid', gap: 8, marginBottom: 10 }}>
<div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('基础信息')}</div>
@@ -675,8 +679,9 @@ export default function Models() {
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
))}
</div>
@@ -736,7 +741,7 @@ export default function Models() {
</span>
</td>
<td onClick={e => e.stopPropagation()}>
<button className="model-card-action-btn" title={tr('复制')} onClick={() => copyName(m.name)}>
<button className="model-card-action-btn" data-tooltip={tr('复制')} aria-label={tr('复制')} onClick={() => copyName(m.name)}>
{copied === m.name ? (
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
) : (
@@ -745,10 +750,11 @@ export default function Models() {
</button>
</td>
</tr>
{expanded === m.name && (
<tr className="log-detail-row">
<td colSpan={7} style={{ padding: 0 }}>
<div style={{ padding: '12px 16px 12px 54px' }}>
<tr className={expanded === m.name ? 'log-detail-row' : ''}>
<td colSpan={7} style={{ padding: 0 }}>
<div className={`anim-collapse ${expanded === m.name ? 'is-open' : ''}`.trim()}>
<div className="anim-collapse-inner">
<div style={{ padding: '12px 16px 12px 54px' }}>
<div style={{ display: 'grid', gap: 8, marginBottom: 10 }}>
<div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('基础信息')}</div>
@@ -825,10 +831,11 @@ export default function Models() {
))}
</tbody>
</table>
</div>
</div>
</td>
</tr>
)}
</div>
</td>
</tr>
</React.Fragment>
))}
</tbody>
+11 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { tr } from '../i18n.js';
type MonitorSite = {
@@ -76,6 +77,8 @@ export default function Monitors() {
}, [activeSiteId, reloadSeed]);
const usingCookieProxy = activeSite.id === 'ldoh-105117' && monitorConfig.ldohCookieConfigured;
const oauthHintPresence = useAnimatedVisibility(Boolean(activeSite.requiresLinuxDoOAuth), 220);
const fallbackHintPresence = useAnimatedVisibility(showFallbackHint && !loaded, 180);
const directSiteUrl = `${activeSite.url.replace(/\/$/, '')}/`;
const iframeUrl = usingCookieProxy ? '/monitor-proxy/ldoh/' : directSiteUrl;
const ldohOauthUrl = `${directSiteUrl}api/oauth/initiate?returnTo=%2F`;
@@ -114,7 +117,8 @@ export default function Monitors() {
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => setReloadSeed((prev) => prev + 1)}
title="重新加载当前站点"
data-tooltip="重新加载当前站点"
aria-label="重新加载当前站点"
>
刷新
</button>
@@ -122,7 +126,8 @@ export default function Monitors() {
type="button"
className="btn btn-primary"
onClick={() => window.open(directSiteUrl, '_blank', 'noopener,noreferrer')}
title="在新窗口直接打开目标站点"
data-tooltip="在新窗口直接打开目标站点"
aria-label="在新窗口直接打开目标站点"
>
新窗口打开
</button>
@@ -143,8 +148,8 @@ export default function Monitors() {
))}
</div>
{activeSite.requiresLinuxDoOAuth && (
<div className="monitor-oauth-hint card">
{oauthHintPresence.shouldRender && (
<div className={`monitor-oauth-hint card panel-presence ${oauthHintPresence.isVisible ? '' : 'is-closing'}`.trim()}>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{usingCookieProxy ? '已启用 Cookie 代理模式' : '该站点需要 LinuxDo OAuth 授权'}
</div>
@@ -205,8 +210,8 @@ export default function Monitors() {
)}
<div className="monitor-frame-shell card">
{showFallbackHint && !loaded && (
<div className="monitor-hint">
{fallbackHintPresence.shouldRender && (
<div className={`monitor-hint panel-presence ${fallbackHintPresence.isVisible ? '' : 'is-closing'}`.trim()}>
{fallbackHint}
</div>
)}
+33 -26
View File
@@ -4,6 +4,7 @@ import { useToast } from '../components/Toast.js';
import { ModelBadge } from '../components/BrandIcon.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import ModernSelect from '../components/ModernSelect.js';
import { parseProxyLogPathMeta } from './helpers/proxyLogPathMeta.js';
import { tr } from '../i18n.js';
type StatusFilter = 'all' | 'success' | 'failed';
@@ -51,17 +52,6 @@ function latencyBgColor(ms: number) {
return 'color-mix(in srgb, var(--color-success) 12%, transparent)';
}
function extractUpstreamPath(errorMessage?: string): string | null {
if (!errorMessage) return null;
const matched = errorMessage.match(/\[upstream:(\/v1\/[^\]]+)\]/i);
return matched?.[1] || null;
}
function stripUpstreamPrefix(errorMessage?: string): string {
if (!errorMessage) return '';
return errorMessage.replace(/^\[upstream:\/v1\/[^\]]+\]\s*/i, '');
}
export default function ProxyLogs() {
const [logs, setLogs] = useState<ProxyLog[]>([]);
const [loading, setLoading] = useState(true);
@@ -189,8 +179,7 @@ export default function ProxyLogs() {
</thead>
<tbody>
{paged.map(log => {
const upstreamPath = extractUpstreamPath(log.errorMessage);
const errorMessageDisplay = stripUpstreamPrefix(log.errorMessage);
const pathMeta = parseProxyLogPathMeta(log.errorMessage);
return (
<React.Fragment key={log.id}>
<tr
@@ -255,10 +244,11 @@ export default function ProxyLogs() {
)}
</td>
</tr>
{expanded === log.id && (
<tr style={{ background: 'var(--color-bg)' }}>
<td colSpan={9} style={{ padding: 0 }}>
<div className="animate-fade-in" style={{
<tr style={{ background: 'var(--color-bg)' }}>
<td colSpan={9} style={{ padding: 0 }}>
<div className={`anim-collapse ${expanded === log.id ? 'is-open' : ''}`.trim()}>
<div className="anim-collapse-inner">
<div className="animate-fade-in" style={{
padding: '14px 20px 14px 40px',
borderTop: '1px solid var(--color-border-light)',
borderBottom: '1px solid var(--color-border-light)',
@@ -294,16 +284,32 @@ export default function ProxyLogs() {
</span>
</div>
{/* 请求路径 */}
{/* 下游请求路径 */}
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>请求路径</span>
{upstreamPath ? (
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>下游请求路径</span>
{pathMeta.downstreamPath ? (
<code style={{
fontFamily: 'var(--font-mono)', fontSize: 12,
background: 'var(--color-bg-card)', padding: '1px 8px', borderRadius: 4,
border: '1px solid var(--color-border-light)',
}}>
{upstreamPath}
{pathMeta.downstreamPath}
</code>
) : (
<span style={{ color: 'var(--color-text-muted)' }}>未记录</span>
)}
</div>
{/* 上游请求路径 */}
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>上游请求路径</span>
{pathMeta.upstreamPath ? (
<code style={{
fontFamily: 'var(--font-mono)', fontSize: 12,
background: 'var(--color-bg-card)', padding: '1px 8px', borderRadius: 4,
border: '1px solid var(--color-border-light)',
}}>
{pathMeta.upstreamPath}
</code>
) : (
<span style={{ color: 'var(--color-text-muted)' }}>未记录</span>
@@ -311,16 +317,17 @@ export default function ProxyLogs() {
</div>
{/* 错误信息 */}
{errorMessageDisplay.trim().length > 0 && (
{pathMeta.errorMessage.trim().length > 0 && (
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ fontWeight: 600, color: 'var(--color-danger)', flexShrink: 0 }}>错误信息</span>
<span style={{ color: 'var(--color-danger)' }}>{errorMessageDisplay}</span>
<span style={{ color: 'var(--color-danger)' }}>{pathMeta.errorMessage}</span>
</div>
)}
</div>
</div>
</td>
</tr>
)}
</div>
</td>
</tr>
</React.Fragment>
)})}
</tbody>
+14 -9
View File
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import ChangeKeyModal from '../components/ChangeKeyModal.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { InlineBrandIcon, getBrand, useIconCdn } from '../components/BrandIcon.js';
import {
applyRoutingProfilePreset,
@@ -125,7 +126,9 @@ export default function Settings() {
const [downstreamOps, setDownstreamOps] = useState<Record<number, boolean>>({});
const [editingDownstreamId, setEditingDownstreamId] = useState<number | null>(null);
const [downstreamModalOpen, setDownstreamModalOpen] = useState(false);
const downstreamModalPresence = useAnimatedVisibility(downstreamModalOpen, 220);
const [selectorOpen, setSelectorOpen] = useState(false);
const selectorModalPresence = useAnimatedVisibility(selectorOpen, 220);
const [selectorLoading, setSelectorLoading] = useState(false);
const [selectorRoutes, setSelectorRoutes] = useState<RouteSelectorItem[]>([]);
const [selectorModelSearch, setSelectorModelSearch] = useState('');
@@ -794,8 +797,9 @@ export default function Settings() {
</button>
</div>
{showAdvancedRouting && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className={`anim-collapse ${showAdvancedRouting ? 'is-open' : ''}`.trim()}>
<div className="anim-collapse-inner" style={{ paddingTop: 2 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
{([
['baseWeightFactor', '基础权重因子'],
['valueScoreFactor', '价值分因子'],
@@ -824,8 +828,9 @@ export default function Settings() {
/>
</div>
))}
</div>
</div>
)}
</div>
<div style={{ marginTop: 12 }}>
<button onClick={saveRouting} disabled={savingRouting} className="btn btn-primary">
@@ -880,11 +885,11 @@ export default function Settings() {
</div>
</div>
</div>
{downstreamModalOpen && (() => {
{downstreamModalPresence.shouldRender && (() => {
const modal = (
<div className="modal-backdrop" onClick={closeDownstreamModal}>
<div className={`modal-backdrop ${downstreamModalPresence.isVisible ? '' : 'is-closing'}`.trim()} onClick={closeDownstreamModal}>
<div
className="modal-content"
className={`modal-content ${downstreamModalPresence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth: 860 }}
onClick={(e) => e.stopPropagation()}
>
@@ -979,11 +984,11 @@ export default function Settings() {
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
})()}
{selectorOpen && (() => {
{selectorModalPresence.shouldRender && (() => {
const modal = (
<div className="modal-backdrop" onClick={closeSelectorModal}>
<div className={`modal-backdrop ${selectorModalPresence.isVisible ? '' : 'is-closing'}`.trim()} onClick={closeSelectorModal}>
<div
className="modal-content"
className={`modal-content ${selectorModalPresence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth: 860 }}
onClick={(e) => e.stopPropagation()}
>
+120 -26
View File
@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
import { tr } from '../i18n.js';
@@ -19,10 +20,12 @@ type SiteRow = {
id: number;
name: string;
url: string;
externalCheckinUrl?: string | null;
platform?: string;
status?: string;
apiKey?: string;
proxyUrl?: string | null;
globalWeight?: number;
isPinned?: boolean;
sortOrder?: number;
totalBalance?: number;
@@ -32,6 +35,7 @@ type SiteRow = {
const platformColors: Record<string, string> = {
'new-api': 'badge-info',
'one-api': 'badge-success',
anyrouter: 'badge-warning',
veloera: 'badge-warning',
'one-hub': 'badge-muted',
'done-hub': 'badge-muted',
@@ -39,8 +43,24 @@ const platformColors: Record<string, string> = {
openai: 'badge-success',
claude: 'badge-warning',
gemini: 'badge-info',
cliproxyapi: 'badge-info',
};
const SITE_PLATFORM_OPTIONS = [
{ value: '', label: '平台类型(可自动检测)' },
{ value: 'new-api', label: 'new-api' },
{ value: 'one-api', label: 'one-api' },
{ value: 'anyrouter', label: 'anyrouter' },
{ value: 'veloera', label: 'veloera' },
{ value: 'one-hub', label: 'one-hub' },
{ value: 'done-hub', label: 'done-hub' },
{ value: 'sub2api', label: 'sub2api' },
{ value: 'openai', label: 'openai' },
{ value: 'claude', label: 'claude' },
{ value: 'gemini', label: 'gemini' },
{ value: 'cliproxyapi', label: 'cliproxyapi' },
];
export default function Sites() {
const location = useLocation();
const navigate = useNavigate();
@@ -56,12 +76,16 @@ export default function Sites() {
const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null);
const [orderingSiteId, setOrderingSiteId] = useState<number | null>(null);
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
const editorPresence = useAnimatedVisibility(Boolean(editor), 220);
const lastEditorRef = useRef<SiteEditorState | null>(null);
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<number | null>(null);
const toast = useToast();
const isEditing = editor?.mode === 'edit';
const isAdding = editor?.mode === 'add';
if (editor) lastEditorRef.current = editor;
const activeEditor = editor || lastEditorRef.current;
const isEditing = activeEditor?.mode === 'edit';
const isAdding = activeEditor?.mode === 'add';
const load = async () => {
try {
@@ -83,6 +107,17 @@ export default function Sites() {
[sites, sortMode],
);
const platformOptions = useMemo(() => {
const current = form.platform.trim();
if (!current || SITE_PLATFORM_OPTIONS.some((option) => option.value === current)) {
return SITE_PLATFORM_OPTIONS;
}
return [
...SITE_PLATFORM_OPTIONS,
{ value: current, label: `${current}(当前值)` },
];
}, [form.platform]);
useEffect(() => {
return () => {
if (highlightTimerRef.current) {
@@ -117,6 +152,13 @@ export default function Sites() {
setForm(emptySiteForm());
};
const scrollToEditorTop = () => {
const scrollTo = (globalThis as { scrollTo?: (options?: ScrollToOptions) => void }).scrollTo;
if (typeof scrollTo === 'function') {
scrollTo({ top: 0, behavior: 'smooth' });
}
};
const openAdd = () => {
if (isAdding) {
closeEditor();
@@ -124,21 +166,31 @@ export default function Sites() {
}
setEditor({ mode: 'add' });
setForm(emptySiteForm());
scrollToEditorTop();
};
const openEdit = (site: SiteRow) => {
setEditor({ mode: 'edit', editingSiteId: site.id });
setForm(siteFormFromSite(site));
scrollToEditorTop();
};
const handleSave = async () => {
if (!editor) return;
const parsedGlobalWeight = Number(form.globalWeight);
if (!Number.isFinite(parsedGlobalWeight) || parsedGlobalWeight <= 0) {
toast.error('全局权重必须是大于 0 的数字');
return;
}
const payload = {
name: form.name.trim(),
url: form.url.trim(),
externalCheckinUrl: form.externalCheckinUrl.trim(),
platform: form.platform.trim(),
apiKey: form.apiKey.trim(),
proxyUrl: form.proxyUrl.trim(),
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
};
if (!payload.name || !payload.url) {
toast.error('请填写站点名称和 URL');
@@ -265,8 +317,12 @@ export default function Sites() {
</div>
</div>
{editor && (
<div className="card animate-scale-in" style={{ padding: 20, marginBottom: 16 }}>
<div className="info-tip" style={{ marginBottom: 12 }}>
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
</div>
{editorPresence.shouldRender && activeEditor && (
<div className={`card panel-presence ${editorPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 20, marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>
{isEditing ? '编辑站点' : '添加站点'}
@@ -321,20 +377,34 @@ export default function Sites() {
{detecting ? <><span className="spinner spinner-sm" /> 检测中</> : '自动检测'}
</button>
</div>
<div
style={{
border: `1px solid ${form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 48%, transparent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-sm)',
background: form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 10%, var(--color-bg))' : 'var(--color-bg)',
transition: 'all 0.2s',
}}
>
<ModernSelect
value={form.platform}
onChange={(value) => setForm((prev) => ({ ...prev, platform: value }))}
options={platformOptions}
placeholder="平台类型(可自动检测)"
/>
</div>
<input
placeholder="平台类型(可自动检测)"
value={form.platform}
onChange={(e) => setForm((prev) => ({ ...prev, platform: e.target.value }))}
placeholder="外部签到/福利站点 URL(可选)"
value={form.externalCheckinUrl}
onChange={(e) => setForm((prev) => ({ ...prev, externalCheckinUrl: e.target.value }))}
style={{
width: '100%',
padding: '10px 14px',
border: `1px solid ${form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 48%, transparent)' : 'var(--color-border)'}`,
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 10%, var(--color-bg))' : 'var(--color-bg)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
transition: 'all 0.2s',
}}
/>
<input
@@ -367,6 +437,24 @@ export default function Sites() {
color: 'var(--color-text-primary)',
}}
/>
<input
placeholder="站点全局权重(默认 1)"
value={form.globalWeight}
onChange={(e) => setForm((prev) => ({ ...prev, globalWeight: e.target.value }))}
style={{
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
越大越容易被路由选中。建议 0.5-3,默认 1。
</div>
<button
onClick={handleSave}
disabled={saving || !form.name.trim() || !form.url.trim()}
@@ -385,9 +473,10 @@ export default function Sites() {
<thead>
<tr>
<th>名称</th>
<th>URL</th>
<th>外部签到站URL</th>
<th>总余额</th>
<th>状态</th>
<th>权重</th>
<th>平台</th>
<th>创建时间</th>
<th className="sites-actions-col" style={{ textAlign: 'right' }}>操作</th>
@@ -417,21 +506,23 @@ export default function Sites() {
</a>
</td>
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
className="sites-url-link"
style={{
fontSize: 12,
fontFamily: 'var(--font-mono)',
color: 'var(--color-primary)',
textDecoration: 'underline',
wordBreak: 'break-all',
}}
>
{site.url}
</a>
{site.externalCheckinUrl ? (
<a
href={site.externalCheckinUrl}
target="_blank"
rel="noopener noreferrer"
className="sites-url-link"
style={{
fontSize: 12,
fontFamily: 'var(--font-mono)',
color: 'var(--color-primary)',
textDecoration: 'underline',
wordBreak: 'break-all',
}}
>
{site.externalCheckinUrl}
</a>
) : null}
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
${(site.totalBalance || 0).toFixed(2)}
@@ -441,6 +532,9 @@ export default function Sites() {
{site.status === 'disabled' ? '禁用' : '启用'}
</span>
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
{(site.globalWeight || 1).toFixed(2)}
</td>
<td>
<a
href={site.url}
+550 -69
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import {
DndContext,
KeyboardSensor,
@@ -17,9 +18,10 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { api } from '../api.js';
import { InlineBrandIcon, getBrand, useIconCdn, type BrandInfo } from '../components/BrandIcon.js';
import { InlineBrandIcon, getBrand, hashColor, useIconCdn, type BrandInfo } from '../components/BrandIcon.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { tr } from '../i18n.js';
import {
buildRouteModelCandidatesIndex,
@@ -27,6 +29,11 @@ import {
type RouteModelCandidatesByModelName,
} from './helpers/routeModelCandidatesIndex.js';
import { getInitialVisibleCount, getNextVisibleCount } from './helpers/progressiveRender.js';
import {
buildRouteMissingTokenIndex,
normalizeMissingTokenModels,
type MissingTokenModelsByName,
} from './helpers/routeMissingTokenHints.js';
type RouteSortBy = 'modelPattern' | 'channelCount';
type RouteSortDir = 'asc' | 'desc';
@@ -54,7 +61,9 @@ type RouteChannel = {
username: string | null;
};
site?: {
id: number;
name: string | null;
platform: string | null;
};
token?: {
id: number;
@@ -122,6 +131,13 @@ type RouteIconOption = {
iconText?: string;
};
type MissingTokenRouteSiteActionItem = {
key: string;
siteName: string;
accountId: number;
accountLabel: string;
};
type SortableChannelRowProps = {
channel: RouteChannel;
decisionCandidate?: RouteDecisionCandidate;
@@ -147,6 +163,34 @@ const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = {
const ROUTE_ICON_OPTIONS: RouteIconOption[] = [
{ value: '', label: '自动品牌图标', description: '按模型匹配规则自动识别品牌', iconText: '✦' },
];
const ENDPOINT_TYPE_ICON_MODEL_MAP: Record<string, string> = {
openai: 'chatgpt',
gemini: 'gemini',
anthropic: 'claude',
anthroic: 'claude',
claude: 'claude',
};
const PLATFORM_ENDPOINT_FALLBACK_MAP: Record<string, string[]> = {
openai: ['openai'],
'new-api': ['openai'],
'one-api': ['openai'],
'one-hub': ['openai'],
'done-hub': ['openai'],
sub2api: ['openai'],
veloera: ['openai'],
cliproxyapi: ['openai'],
claude: ['anthropic'],
gemini: ['gemini'],
anyrouter: ['openai', 'anthropic'],
};
const PLATFORM_ALIASES: Record<string, string> = {
anthropic: 'claude',
google: 'gemini',
'new api': 'new-api',
newapi: 'new-api',
'one api': 'one-api',
oneapi: 'one-api',
};
function isRegexModelPattern(modelPattern: string): boolean {
return modelPattern.trim().toLowerCase().startsWith('re:');
@@ -252,6 +296,39 @@ function parseBrandIconValue(raw: string): string | null {
return icon || null;
}
function resolveEndpointTypeIconModel(endpointType: string): string | null {
const key = String(endpointType || '').trim().toLowerCase();
if (!key) return null;
return ENDPOINT_TYPE_ICON_MODEL_MAP[key] || null;
}
function normalizePlatformKey(platform: string | null | undefined): string {
const raw = String(platform || '').trim().toLowerCase();
if (!raw) return '';
return PLATFORM_ALIASES[raw] || raw;
}
function inferEndpointTypesFromPlatform(platform: string | null | undefined): string[] {
const key = normalizePlatformKey(platform);
if (!key) return [];
const mapped = PLATFORM_ENDPOINT_FALLBACK_MAP[key];
if (Array.isArray(mapped) && mapped.length > 0) return mapped;
if (key.includes('claude') || key.includes('anthropic')) return ['anthropic'];
if (key.includes('gemini')) return ['gemini'];
if (key.includes('openai') || key.includes('new-api') || key.includes('one-api')) return ['openai'];
return [];
}
function siteAvatarLetters(siteName: string): string {
const normalized = String(siteName || '').trim();
if (!normalized) return 'S';
const parts = normalized.replace(/[-_/.]/g, ' ').split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
const compact = normalized.replace(/\s+/g, '');
return compact.slice(0, 2).toUpperCase();
}
function resolveRouteIcon(route: RouteRow): { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string } {
const icon = (route.displayIcon || '').trim();
if (!icon) return { kind: 'none' };
@@ -276,6 +353,11 @@ function normalizeRoutes(routeRows: any[]): RouteRow[] {
});
}
function buildSourceGroupKey(routeId: number, sourceModel: string): string {
const normalizedSourceModel = sourceModel.trim() || '__ungrouped__';
return `${routeId}::${normalizedSourceModel}`;
}
function getPriorityTagStyle(priority: number): CSSProperties {
if (priority <= 0) {
return {
@@ -449,7 +531,8 @@ function SortableChannelRow({
color: 'var(--color-text-muted)',
cursor: isSavingPriority ? 'not-allowed' : 'grab',
}}
title="拖拽调整优先级"
data-tooltip="拖拽调整优先级"
aria-label="拖拽调整优先级"
>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
<circle cx="3" cy="2" r="1" />
@@ -502,7 +585,7 @@ function SortableChannelRow({
<span
className="badge badge-warning"
style={{ fontSize: 10 }}
title="该通道由用户手动添加,而非系统自动生成"
data-tooltip="该通道由用户手动添加,而非系统自动生成"
>
手动配置
</span>
@@ -512,7 +595,7 @@ function SortableChannelRow({
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 120 }}>
<div
title={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
style={{
width: 80,
height: 6,
@@ -532,7 +615,7 @@ function SortableChannelRow({
/>
</div>
<span
title={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
style={{
fontSize: 11,
color: decisionState.probability > 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor,
@@ -589,19 +672,38 @@ function SortableChannelRow({
);
}
function AnimatedCollapseSection({ open, children }: { open: boolean; children: ReactNode }) {
const presence = useAnimatedVisibility(open, 220);
if (!presence.shouldRender) return null;
return (
<div className={`anim-collapse ${presence.isVisible ? 'is-open' : ''}`.trim()}>
<div className="anim-collapse-inner">
{children}
</div>
</div>
);
}
export default function TokenRoutes() {
const navigate = useNavigate();
const cdn = useIconCdn();
const [routes, setRoutes] = useState<RouteRow[]>([]);
const [modelCandidates, setModelCandidates] = useState<RouteModelCandidatesByModelName>({});
const [missingTokenModelsByName, setMissingTokenModelsByName] = useState<MissingTokenModelsByName>({});
const [endpointTypesByModel, setEndpointTypesByModel] = useState<Record<string, string[]>>({});
const [search, setSearch] = useState('');
const [activeBrand, setActiveBrand] = useState<string | null>(null);
const [activeSite, setActiveSite] = useState<string | null>(null);
const [activeEndpointType, setActiveEndpointType] = useState<string | null>(null);
const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null);
const [filterCollapsed, setFilterCollapsed] = useState(false);
const [sortBy, setSortBy] = useState<RouteSortBy>('channelCount');
const [sortDir, setSortDir] = useState<RouteSortDir>('desc');
const [showManual, setShowManual] = useState(false);
const filterPanelPresence = useAnimatedVisibility(!filterCollapsed, 220);
const manualPanelPresence = useAnimatedVisibility(showManual, 220);
const [form, setForm] = useState({ modelPattern: '', displayName: '', displayIcon: '' });
const [saving, setSaving] = useState(false);
const [rebuilding, setRebuilding] = useState(false);
@@ -615,6 +717,7 @@ export default function TokenRoutes() {
const [loadingDecision, setLoadingDecision] = useState(false);
const [decisionAutoSkipped, setDecisionAutoSkipped] = useState(false);
const [visibleRouteCount, setVisibleRouteCount] = useState(ROUTE_RENDER_CHUNK);
const [expandedSourceGroupMap, setExpandedSourceGroupMap] = useState<Record<string, boolean>>({});
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -693,7 +796,11 @@ export default function TokenRoutes() {
const normalizedRoutes = normalizeRoutes(routeRows || []);
setRoutes(normalizedRoutes);
setModelCandidates((candidateRows?.models || {}) as Record<string, ModelCandidate[]>);
setModelCandidates((candidateRows?.models || {}) as RouteModelCandidatesByModelName);
setMissingTokenModelsByName(
normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName),
);
setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {});
void loadRouteDecisions(normalizedRoutes, { force: true });
};
@@ -809,6 +916,22 @@ export default function TokenRoutes() {
}
};
const handleToggleRouteEnabled = async (route: RouteRow) => {
const newEnabled = !route.enabled;
setRoutes((prev) =>
prev.map((item) => (item.id === route.id ? { ...item, enabled: newEnabled } : item)),
);
try {
await api.updateRoute(route.id, { enabled: newEnabled });
toast.success(newEnabled ? '路由已启用' : '路由已禁用');
} catch (e: any) {
setRoutes((prev) =>
prev.map((item) => (item.id === route.id ? { ...item, enabled: route.enabled } : item)),
);
toast.error(e.message || '切换路由状态失败');
}
};
const routeBrandById = useMemo(() => {
const next = new Map<number, BrandInfo | null>();
for (const route of routes) {
@@ -845,6 +968,76 @@ export default function TokenRoutes() {
};
}, [routes, routeBrandById]);
const siteList = useMemo(() => {
const grouped = new Map<string, { count: number; siteId: number }>();
for (const route of routes) {
const seenSites = new Set<string>();
for (const channel of route.channels || []) {
const siteName = channel.site?.name;
const siteId = channel.site?.id;
if (!siteName || !siteId || seenSites.has(siteName)) continue;
seenSites.add(siteName);
const existing = grouped.get(siteName);
if (existing) {
existing.count++;
} else {
grouped.set(siteName, { count: 1, siteId });
}
}
}
return [...grouped.entries()].sort((a, b) => {
if (a[1].count === b[1].count) return a[0].localeCompare(b[0]);
return b[1].count - a[1].count;
});
}, [routes]);
const routeEndpointTypesByRouteId = useMemo(() => {
const index: Record<number, Set<string>> = {};
const entries = Object.entries(endpointTypesByModel || {});
for (const route of routes) {
const pattern = (route.modelPattern || '').trim();
if (!pattern) {
index[route.id] = new Set<string>();
continue;
}
const endpointTypes = new Set<string>();
for (const [modelName, rawTypes] of entries) {
if (!matchesModelPattern(modelName, pattern)) continue;
for (const rawType of Array.isArray(rawTypes) ? rawTypes : []) {
const endpointType = String(rawType || '').trim();
if (!endpointType) continue;
endpointTypes.add(endpointType);
}
}
if (endpointTypes.size === 0) {
for (const channel of route.channels || []) {
for (const endpointType of inferEndpointTypesFromPlatform(channel.site?.platform)) {
endpointTypes.add(endpointType);
}
}
}
index[route.id] = endpointTypes;
}
return index;
}, [routes, endpointTypesByModel]);
const endpointTypeList = useMemo(() => {
const grouped = new Map<string, number>();
for (const route of routes) {
const endpointTypes = routeEndpointTypesByRouteId[route.id] || new Set<string>();
for (const endpointType of endpointTypes) {
grouped.set(endpointType, (grouped.get(endpointType) || 0) + 1);
}
}
return [...grouped.entries()].sort((a, b) => {
if (a[1] === b[1]) return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
return b[1] - a[1];
});
}, [routes, routeEndpointTypesByRouteId]);
const routeBrandIconCandidates = useMemo(() => {
const byIcon = new Map<string, BrandInfo>();
@@ -930,13 +1123,34 @@ export default function TokenRoutes() {
}
}
if (activeSite) {
list = list.filter((route) =>
route.channels?.some((channel) => channel.site?.name === activeSite)
);
}
if (activeEndpointType) {
list = list.filter((route) =>
(routeEndpointTypesByRouteId[route.id] || new Set<string>()).has(activeEndpointType)
);
}
if (search.trim()) {
const q = search.trim().toLowerCase();
list = list.filter((route) => route.modelPattern.toLowerCase().includes(q));
}
return list;
}, [sortedRoutes, activeGroupFilter, activeBrand, search, routeBrandById]);
}, [
sortedRoutes,
activeGroupFilter,
activeBrand,
activeSite,
activeEndpointType,
search,
routeBrandById,
routeEndpointTypesByRouteId,
]);
useEffect(() => {
setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK));
@@ -977,10 +1191,24 @@ export default function TokenRoutes() {
[routes, modelCandidates],
);
const routeMissingTokenIndex = useMemo(
() => buildRouteMissingTokenIndex(routes, missingTokenModelsByName, matchesModelPattern),
[routes, missingTokenModelsByName],
);
const getRouteCandidateView = (routeId: number): RouteCandidateView => {
return routeModelCandidateIndex[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW;
};
const handleCreateTokenForMissingAccount = (accountId: number, modelName: string) => {
const params = new URLSearchParams();
params.set('create', '1');
params.set('accountId', String(accountId));
params.set('model', modelName);
params.set('from', 'routes');
navigate(`/tokens?${params.toString()}`);
};
const handleRouteAccountChange = (route: RouteRow, accountId: number) => {
const tokenOptions = getRouteCandidateView(route.id).tokenOptionsByAccountId[accountId] || [];
const defaultTokenOption = tokenOptions.find((token) => token.isDefault) || tokenOptions[0] || null;
@@ -1121,8 +1349,8 @@ export default function TokenRoutes() {
return (
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
{!filterCollapsed && (
<div className="filter-panel">
{filterPanelPresence.shouldRender && (
<div className={`filter-panel filter-collapsible ${filterPanelPresence.isVisible ? '' : 'is-closing'}`.trim()}>
<div className="filter-panel-section">
<div className="filter-panel-title">
品牌
@@ -1235,6 +1463,94 @@ export default function TokenRoutes() {
))}
</div>
{siteList.length > 0 && (
<div className="filter-panel-section">
<div className="filter-panel-title">
站点
{activeSite && <button onClick={() => setActiveSite(null)}>重置</button>}
</div>
<div className={`filter-item ${!activeSite ? 'active' : ''}`} onClick={() => setActiveSite(null)}>
<span
className="filter-item-icon"
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
>
⚡
</span>
全部站点
<span className="filter-item-count">{routes.length}</span>
</div>
{siteList.map(([siteName, { count }]) => (
<div
key={siteName}
className={`filter-item ${activeSite === siteName ? 'active' : ''}`}
onClick={() => setActiveSite(activeSite === siteName ? null : siteName)}
>
<span
className="filter-item-icon"
style={{ background: hashColor(siteName), color: 'white', fontSize: 9, borderRadius: 4 }}
>
{siteAvatarLetters(siteName)}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{siteName}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
</div>
)}
<div className="filter-panel-section">
<div className="filter-panel-title">
接口能力
{activeEndpointType && <button onClick={() => setActiveEndpointType(null)}>重置</button>}
</div>
<div className={`filter-item ${!activeEndpointType ? 'active' : ''}`} onClick={() => setActiveEndpointType(null)}>
<span
className="filter-item-icon"
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
>
⚙
</span>
全部能力
<span className="filter-item-count">{routes.length}</span>
</div>
{endpointTypeList.map(([endpointType, count]) => (
<div
key={endpointType}
className={`filter-item ${activeEndpointType === endpointType ? 'active' : ''}`}
onClick={() => setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)}
>
<span
className="filter-item-icon"
style={{
background: 'var(--color-bg)',
color: 'var(--color-text-muted)',
fontSize: 10,
borderRadius: 4,
overflow: 'hidden',
}}
>
{(() => {
const iconModel = resolveEndpointTypeIconModel(endpointType);
if (!iconModel) return <span style={{ fontSize: 10 }}>⚙</span>;
return <InlineBrandIcon model={iconModel} size={14} />;
})()}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{endpointType}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
{endpointTypeList.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', padding: '4px 10px 0' }}>
暂无接口能力数据
</div>
)}
</div>
<button
className="btn btn-ghost"
style={{
@@ -1365,7 +1681,8 @@ export default function TokenRoutes() {
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 12px', fontSize: 12 }}
onClick={() => setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'))}
title={tr('切换排序方向')}
data-tooltip={tr('切换排序方向')}
aria-label={tr('切换排序方向')}
>
{sortDir === 'asc' ? tr('升序 ↑') : tr('降序 ↓')}
</button>
@@ -1377,8 +1694,8 @@ export default function TokenRoutes() {
{decisionAutoSkipped ? ` ${tr('当前精确路由')} ${exactRouteCount} ${tr('条,为避免首屏卡顿,默认不自动计算概率,点击“加载选择解释”后按需获取。')}` : ''}
</div>
{showManual && (
<div className="card animate-scale-in" style={{ padding: 20, marginBottom: 16 }}>
{manualPanelPresence.shouldRender && (
<div className={`card panel-presence ${manualPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 20, marginBottom: 16 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')}
</div>
@@ -1519,6 +1836,34 @@ export default function TokenRoutes() {
? (candidateView.tokenOptionsByAccountId[selectedAccountId] || [])
: [];
const candidateMode = routeCandidates.length > 0;
const missingTokenHints = routeMissingTokenIndex[route.id] || [];
const missingTokenSiteItems = (() => {
const siteMap = new Map<string, MissingTokenRouteSiteActionItem>();
for (const hint of missingTokenHints) {
for (const account of hint.accounts) {
const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`;
const key = `${account.siteId || 0}::${siteName.toLowerCase()}`;
const accountLabel = account.username || `account-${account.accountId}`;
const existing = siteMap.get(key);
if (!existing) {
siteMap.set(key, {
key,
siteName,
accountId: account.accountId,
accountLabel,
});
continue;
}
if (account.accountId < existing.accountId) {
existing.accountId = account.accountId;
existing.accountLabel = accountLabel;
}
}
}
return Array.from(siteMap.values()).sort((a, b) => (
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
));
})();
const routeDecision = decisionByRoute[route.id] || null;
const exactRoute = isExactModelPattern(route.modelPattern);
const decisionMap = new Map<number, RouteDecisionCandidate>(
@@ -1618,17 +1963,32 @@ export default function TokenRoutes() {
{route.modelPattern}
</span>
) : null}
<span className={`badge ${route.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
<button
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
style={{ fontSize: 11, cursor: 'pointer', border: 'none' }}
onClick={(e) => { e.stopPropagation(); handleToggleRouteEnabled(route); }}
data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'}
aria-label={route.enabled ? '点击禁用此路由' : '点击启用此路由'}
>
{route.enabled ? tr('启用') : tr('禁用')}
</span>
</button>
<span className="badge badge-info" style={{ fontSize: 10 }}>
{route.channels?.length || 0} {tr('通道')}
</span>
{candidateMode && (
<span className="badge badge-info" style={{ fontSize: 10 }}>
<span
className="badge badge-info"
style={{ fontSize: 10 }}
data-tooltip="添加通道时,仅展示可覆盖当前模型模式的账号与令牌,自动过滤不支持该模型的令牌。"
>
{tr('按模型过滤')}
</span>
)}
{missingTokenSiteItems.length > 0 && (
<span className="badge badge-warning" style={{ fontSize: 10 }}>
待注册站点 {missingTokenSiteItems.length}
</span>
)}
{channelGroups.length > 1 && (
<span className="badge badge-info" style={{ fontSize: 10 }}>
{channelGroups.length} 来源分组
@@ -1714,6 +2074,43 @@ export default function TokenRoutes() {
</div>
)}
{missingTokenSiteItems.length > 0 && (
<div
style={{
marginBottom: 8,
padding: '8px 10px',
border: '1px solid color-mix(in srgb, var(--color-warning) 35%, transparent)',
borderRadius: 'var(--radius-sm)',
background: 'color-mix(in srgb, var(--color-warning) 10%, var(--color-bg))',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
以下站点已提供该模型,但你未注册对应站点令牌;点击站点标签可跳转创建对应站点令牌:
</div>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6 }}>
{missingTokenSiteItems.map((item) => (
<button
key={`missing-create-${route.id}-${item.key}`}
type="button"
onClick={() => handleCreateTokenForMissingAccount(item.accountId, route.modelPattern)}
className="badge badge-info missing-token-site-tag"
data-tooltip={`点击跳转到令牌创建(预选 ${item.siteName}/${item.accountLabel})`}
aria-label={`点击跳转到令牌创建(预选 ${item.siteName}/${item.accountLabel})`}
style={{
fontSize: 11,
cursor: 'pointer',
}}
>
{item.siteName}
</button>
))}
</div>
</div>
)}
{route.channels?.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<DndContext
@@ -1726,60 +2123,144 @@ export default function TokenRoutes() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{channelGroups.map((group) => (
<div key={`${route.id}-${group.sourceModel || '__ungrouped__'}`} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{group.sourceModel ? (
<div
style={{
fontSize: 12,
color: 'var(--color-text-secondary)',
display: 'flex',
alignItems: 'center',
gap: 8,
paddingLeft: 2,
}}
>
<span>来源模型</span>
<code
style={{
fontSize: 11,
border: '1px solid var(--color-border)',
borderRadius: 6,
padding: '2px 6px',
background: 'var(--color-bg)',
}}
>
{group.sourceModel}
</code>
<span style={{ color: 'var(--color-text-muted)' }}>{group.channels.length} 通道</span>
</div>
) : null}
<SortableContext
items={group.channels.map((channel) => channel.id)}
strategy={verticalListSortingStrategy}
>
{group.channels.map((channel) => {
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
{(() => {
const groupKey = buildSourceGroupKey(route.id, group.sourceModel || '');
const supportsCollapse = !exactRoute && !!group.sourceModel;
const isGroupExpanded = supportsCollapse ? !!expandedSourceGroupMap[groupKey] : true;
return (
<SortableChannelRow
key={channel.id}
channel={channel}
decisionCandidate={decisionMap.get(channel.id)}
isExactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={!!savingPriorityByRoute[route.id]}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={(channelId, tokenId) =>
setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId }))
}
onSaveToken={() => handleChannelTokenSave(route, channel.id, channel.accountId)}
onDeleteChannel={() => handleDeleteChannel(channel.id)}
/>
);
})}
</SortableContext>
return (
<>
{group.sourceModel ? (
supportsCollapse ? (
<button
type="button"
onClick={() => {
setExpandedSourceGroupMap((prev) => ({
...prev,
[groupKey]: !prev[groupKey],
}));
}}
aria-expanded={isGroupExpanded}
className="btn btn-ghost"
style={{
fontSize: 12,
color: 'var(--color-text-secondary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
padding: '4px 6px',
border: '1px dashed var(--color-border)',
borderRadius: 'var(--radius-sm)',
background: 'transparent',
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<span>来源模型</span>
<code
style={{
fontSize: 11,
border: '1px solid var(--color-border)',
borderRadius: 6,
padding: '2px 6px',
background: 'var(--color-bg)',
}}
>
{group.sourceModel}
</code>
<span style={{ color: 'var(--color-text-muted)' }}>{group.channels.length} 通道</span>
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--color-text-muted)' }}>
{isGroupExpanded ? '收起' : '展开'}
<svg
width="12"
height="12"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{
transform: isGroupExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease',
}}
aria-hidden
>
<path d="m5 7 5 6 5-6" />
</svg>
</span>
</button>
) : (
<div
style={{
fontSize: 12,
color: 'var(--color-text-secondary)',
display: 'flex',
alignItems: 'center',
gap: 8,
paddingLeft: 2,
}}
>
<span>来源模型</span>
<code
style={{
fontSize: 11,
border: '1px solid var(--color-border)',
borderRadius: 6,
padding: '2px 6px',
background: 'var(--color-bg)',
}}
>
{group.sourceModel}
</code>
<span style={{ color: 'var(--color-text-muted)' }}>{group.channels.length} 通道</span>
</div>
)
) : null}
<AnimatedCollapseSection open={isGroupExpanded}>
<SortableContext
items={group.channels.map((channel) => channel.id)}
strategy={verticalListSortingStrategy}
>
{group.channels.map((channel) => {
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
return (
<SortableChannelRow
key={channel.id}
channel={channel}
decisionCandidate={decisionMap.get(channel.id)}
isExactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={!!savingPriorityByRoute[route.id]}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={(channelId, tokenId) =>
setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId }))
}
onSaveToken={() => handleChannelTokenSave(route, channel.id, channel.accountId)}
onDeleteChannel={() => handleDeleteChannel(channel.id)}
/>
);
})}
</SortableContext>
</AnimatedCollapseSection>
{!isGroupExpanded ? (
<div
style={{
fontSize: 11,
color: 'var(--color-text-muted)',
paddingLeft: 6,
}}
>
已收起,点击展开查看通道
</div>
) : null}
</>
);
})()}
</div>
))}
</div>
+84 -3
View File
@@ -1,6 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import ModernSelect from '../components/ModernSelect.js';
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
@@ -69,7 +71,21 @@ async function copyText(text: string) {
document.body.removeChild(textarea);
}
function parsePositiveInt(input: string | null): number {
const value = Number.parseInt(String(input || '').trim(), 10);
if (!Number.isFinite(value) || value <= 0) return 0;
return value;
}
function isTruthyFlag(input: string | null): boolean {
if (!input) return false;
const normalized = input.trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
export default function Tokens() {
const location = useLocation();
const navigate = useNavigate();
const initialCreateForm = {
accountId: 0,
name: '',
@@ -88,10 +104,12 @@ export default function Tokens() {
const [syncing, setSyncing] = useState(false);
const [syncingAll, setSyncingAll] = useState(false);
const [saving, setSaving] = useState(false);
const [createHintModelName, setCreateHintModelName] = useState('');
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
const [form, setForm] = useState(initialCreateForm);
const [groupOptions, setGroupOptions] = useState<string[]>(['default']);
const [groupLoading, setGroupLoading] = useState(false);
const addPanelPresence = useAnimatedVisibility(showAdd, 220);
const toast = useToast();
const load = async () => {
@@ -159,6 +177,44 @@ export default function Tokens() {
const activeAccounts = useMemo(() => accounts.filter(isAccountSyncable), [accounts]);
useEffect(() => {
const params = new URLSearchParams(location.search);
const shouldOpenCreate = isTruthyFlag(params.get('create'));
const requestedAccountId = parsePositiveInt(params.get('accountId'));
const requestedModel = (params.get('model') || '').trim();
if (!shouldOpenCreate || !requestedAccountId) return;
const preferredAccount = activeAccounts.find((account) => account.id === requestedAccountId);
const fallbackAccount = preferredAccount || activeAccounts[0] || null;
if (!fallbackAccount) return;
setShowAdd(true);
setCreateHintModelName(requestedModel);
setSyncingAccountId(fallbackAccount.id);
setForm((prev) => ({
...prev,
accountId: fallbackAccount.id,
group: 'default',
}));
if (!preferredAccount) {
toast.info('指定账号不可用,已自动切换到首个可用账号');
}
params.delete('create');
params.delete('accountId');
params.delete('model');
params.delete('from');
const nextSearch = params.toString();
navigate(
{
pathname: location.pathname,
search: nextSearch ? `?${nextSearch}` : '',
},
{ replace: true },
);
}, [activeAccounts, location.pathname, location.search, navigate, toast]);
const withRowLoading = async (key: string, fn: () => Promise<void>) => {
setRowLoading((prev) => ({ ...prev, [key]: true }));
try {
@@ -208,6 +264,7 @@ export default function Tokens() {
toast.success('已在站点创建并同步令牌');
setForm(initialCreateForm);
setShowAdd(false);
setCreateHintModelName('');
await load();
} catch (e: any) {
toast.error(e.message || '创建令牌失败');
@@ -340,7 +397,16 @@ export default function Tokens() {
>
{syncingAll ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步全部账号'}
</button>
<button onClick={() => setShowAdd((prev) => !prev)} className="btn btn-primary">
<button
onClick={() => {
setShowAdd((prev) => {
const nextOpen = !prev;
if (!nextOpen) setCreateHintModelName('');
return nextOpen;
});
}}
className="btn btn-primary"
>
{showAdd ? '取消' : '+ 新增令牌'}
</button>
</div>
@@ -350,8 +416,8 @@ export default function Tokens() {
新增令牌会调用站点 API 创建新密钥,再自动同步到本地。支持设置分组、额度、过期时间和 IP 白名单;已存在密钥可直接用“同步站点令牌”读取。
</div>
{showAdd && (
<div className="card animate-scale-in" style={getAccountsAddPanelStyle()}>
{addPanelPresence.shouldRender && (
<div className={`card panel-presence ${addPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={getAccountsAddPanelStyle()}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div style={{ gridColumn: '1 / span 2' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>所属账号</div>
@@ -374,6 +440,21 @@ export default function Tokens() {
placeholder="选择账号"
/>
</div>
{createHintModelName ? (
<div
style={{
gridColumn: '1 / span 2',
fontSize: 12,
color: 'var(--color-text-secondary)',
background: 'color-mix(in srgb, var(--color-info) 10%, var(--color-bg))',
border: '1px solid color-mix(in srgb, var(--color-info) 30%, transparent)',
borderRadius: 'var(--radius-sm)',
padding: '6px 8px',
}}
>
来自路由提醒:为模型 <code style={{ fontSize: 11 }}>{createHintModelName}</code> 补充该账号令牌后,可自动生成对应通道。
</div>
) : null}
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>令牌名称(可选)</div>
<input
@@ -50,6 +50,7 @@ describe('Accounts refresh action', () => {
balanceUsed: 0,
todayReward: 0,
todaySpend: 0,
accessToken: 'session-token',
status: 'active',
checkinEnabled: true,
site: { id: 10, name: 'Demo Site', status: 'active', url: 'https://example.com' },
@@ -64,6 +65,7 @@ describe('Accounts refresh action', () => {
balanceUsed: 0,
todayReward: 0,
todaySpend: 0,
accessToken: 'session-token',
status: 'expired',
checkinEnabled: true,
site: { id: 10, name: 'Demo Site', status: 'active', url: 'https://example.com' },
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { parseProxyLogPathMeta } from './proxyLogPathMeta.js';
describe('parseProxyLogPathMeta', () => {
it('parses downstream and upstream paths from prefixed message', () => {
const parsed = parseProxyLogPathMeta('[downstream:/v1/responses] [upstream:/v1/chat/completions] {"error":"x"}');
expect(parsed.downstreamPath).toBe('/v1/responses');
expect(parsed.upstreamPath).toBe('/v1/chat/completions');
expect(parsed.errorMessage).toBe('{"error":"x"}');
});
it('supports historical upstream-only logs', () => {
const parsed = parseProxyLogPathMeta('[upstream:/v1/messages] messages is required');
expect(parsed.downstreamPath).toBe(null);
expect(parsed.upstreamPath).toBe('/v1/messages');
expect(parsed.errorMessage).toBe('messages is required');
});
it('keeps plain message when no metadata exists', () => {
const parsed = parseProxyLogPathMeta('network timeout');
expect(parsed.downstreamPath).toBe(null);
expect(parsed.upstreamPath).toBe(null);
expect(parsed.errorMessage).toBe('network timeout');
});
});
+18
View File
@@ -0,0 +1,18 @@
type ProxyLogPathMeta = {
downstreamPath: string | null;
upstreamPath: string | null;
errorMessage: string;
};
export function parseProxyLogPathMeta(message?: string): ProxyLogPathMeta {
const raw = typeof message === 'string' ? message.trim() : '';
const downstreamMatch = raw.match(/\[downstream:([^\]]+)\]/i);
const upstreamMatch = raw.match(/\[upstream:([^\]]+)\]/i);
const stripped = raw.replace(/^\s*(?:\[(?:downstream|upstream):[^\]]+\]\s*)+/i, '').trim();
return {
downstreamPath: downstreamMatch?.[1]?.trim() || null,
upstreamPath: upstreamMatch?.[1]?.trim() || null,
errorMessage: stripped,
};
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
buildRouteMissingTokenIndex,
normalizeMissingTokenModels,
type MissingTokenModelsByName,
} from './routeMissingTokenHints.js';
function matchesModelPattern(model: string, pattern: string): boolean {
if (!pattern) return false;
if (pattern === '*') return true;
if (pattern.endsWith('*')) return model.startsWith(pattern.slice(0, -1));
return model === pattern;
}
describe('buildRouteMissingTokenIndex', () => {
it('matches missing-token models by route pattern and deduplicates accounts', () => {
const routes = [
{ id: 1, modelPattern: 'claude-*' },
{ id: 2, modelPattern: 'gpt-4o-mini' },
{ id: 3, modelPattern: '' },
];
const missingByModel: MissingTokenModelsByName = {
'claude-opus-4-6': [
{ accountId: 11, username: 'alice', siteId: 1, siteName: 'site-a' },
{ accountId: 11, username: 'alice', siteId: 1, siteName: 'site-a' },
],
'claude-code-4-6': [
{ accountId: 22, username: 'bob', siteId: 2, siteName: 'site-b' },
],
'gpt-4o-mini': [
{ accountId: 33, username: 'charlie', siteId: 3, siteName: 'site-c' },
],
};
const index = buildRouteMissingTokenIndex(routes, missingByModel, matchesModelPattern);
expect(index[1].map((item) => item.modelName)).toEqual(['claude-code-4-6', 'claude-opus-4-6']);
expect(index[1][1].accounts).toEqual([{ accountId: 11, username: 'alice', siteId: 1, siteName: 'site-a' }]);
expect(index[2].map((item) => item.modelName)).toEqual(['gpt-4o-mini']);
expect(index[3]).toEqual([]);
});
it('returns empty entries when missing model map is empty', () => {
const index = buildRouteMissingTokenIndex([{ id: 1, modelPattern: 'claude-*' }], {}, matchesModelPattern);
expect(index[1]).toEqual([]);
});
it('normalizes missing-token map by trimming model name and deduplicating account', () => {
const merged = normalizeMissingTokenModels({
' claude-opus-4-6 ': [
{ accountId: 1, username: 'alice', siteId: 11, siteName: 'site-a' },
{ accountId: 1, username: 'alice', siteId: 11, siteName: 'site-a' },
],
});
expect(merged['claude-opus-4-6']).toEqual([
{
accountId: 1,
username: 'alice',
siteId: 11,
siteName: 'site-a',
},
]);
});
});
@@ -0,0 +1,84 @@
export type MissingTokenModelAccount = {
accountId: number;
username: string | null;
siteId: number;
siteName: string;
};
export type MissingTokenModelsByName = Record<string, MissingTokenModelAccount[]>;
export type RouteMissingTokenHint = {
modelName: string;
accounts: MissingTokenModelAccount[];
};
type RoutePatternLike = {
id: number;
modelPattern: string;
};
export function normalizeMissingTokenModels(
withoutTokenByModel: MissingTokenModelsByName,
): MissingTokenModelsByName {
const normalized: MissingTokenModelsByName = {};
for (const modelName of Object.keys(withoutTokenByModel || {})) {
const normalizedModelName = String(modelName || '').trim();
if (!normalizedModelName) continue;
const accountMap = new Map<number, MissingTokenModelAccount>();
for (const account of withoutTokenByModel[modelName] || []) {
if (!account || !Number.isFinite(account.accountId)) continue;
const accountName = (account.username || '').trim();
const siteName = String(account.siteName || '').trim();
accountMap.set(account.accountId, {
accountId: account.accountId,
username: accountName || null,
siteId: account.siteId,
siteName,
});
}
if (accountMap.size > 0) {
normalized[normalizedModelName] = Array.from(accountMap.values()).sort((a, b) => a.accountId - b.accountId);
}
}
return normalized;
}
export function buildRouteMissingTokenIndex(
routes: RoutePatternLike[],
missingByModel: MissingTokenModelsByName,
matchesModelPattern: (model: string, pattern: string) => boolean,
): Record<number, RouteMissingTokenHint[]> {
const index: Record<number, RouteMissingTokenHint[]> = {};
for (const route of routes || []) {
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern) {
index[route.id] = [];
continue;
}
const matchedHints: RouteMissingTokenHint[] = [];
for (const [modelName, accounts] of Object.entries(missingByModel || {})) {
if (!matchesModelPattern(modelName, modelPattern)) continue;
const dedupedAccounts = new Map<number, MissingTokenModelAccount>();
for (const account of accounts || []) {
if (!account || !Number.isFinite(account.accountId)) continue;
dedupedAccounts.set(account.accountId, account);
}
matchedHints.push({
modelName,
accounts: Array.from(dedupedAccounts.values()).sort((a, b) => {
const siteCompare = String(a.siteName || '').localeCompare(String(b.siteName || ''), undefined, { sensitivity: 'base' });
if (siteCompare !== 0) return siteCompare;
return a.accountId - b.accountId;
}),
});
}
matchedHints.sort((a, b) => a.modelName.localeCompare(b.modelName, undefined, { sensitivity: 'base' }));
index[route.id] = matchedHints;
}
return index;
}
+24 -2
View File
@@ -8,9 +8,11 @@ describe('buildSiteSaveAction', () => {
{
name: 'site-a',
url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api',
apiKey: 'sk-1',
proxyUrl: 'http://127.0.0.1:7890',
globalWeight: '1.2',
},
);
@@ -19,9 +21,11 @@ describe('buildSiteSaveAction', () => {
payload: {
name: 'site-a',
url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api',
apiKey: 'sk-1',
proxyUrl: 'http://127.0.0.1:7890',
globalWeight: '1.2',
},
});
});
@@ -29,7 +33,15 @@ describe('buildSiteSaveAction', () => {
it('returns update action in edit mode with site id', () => {
const action = buildSiteSaveAction(
{ mode: 'edit', editingSiteId: 12 },
{ name: 'site-b', url: 'https://b.example.com', platform: 'one-api', apiKey: '', proxyUrl: '' },
{
name: 'site-b',
url: 'https://b.example.com',
externalCheckinUrl: '',
platform: 'one-api',
apiKey: '',
proxyUrl: '',
globalWeight: '0.8',
},
);
expect(action).toEqual({
@@ -38,9 +50,11 @@ describe('buildSiteSaveAction', () => {
payload: {
name: 'site-b',
url: 'https://b.example.com',
externalCheckinUrl: '',
platform: 'one-api',
apiKey: '',
proxyUrl: '',
globalWeight: '0.8',
},
});
});
@@ -49,7 +63,15 @@ describe('buildSiteSaveAction', () => {
expect(() =>
buildSiteSaveAction(
{ mode: 'edit' },
{ name: 'site-c', url: 'https://c.example.com', platform: '', apiKey: '', proxyUrl: '' },
{
name: 'site-c',
url: 'https://c.example.com',
externalCheckinUrl: '',
platform: '',
apiKey: '',
proxyUrl: '',
globalWeight: '1',
},
),
).toThrow('editingSiteId is required in edit mode');
});
+12 -2
View File
@@ -1,9 +1,11 @@
export type SiteForm = {
name: string;
url: string;
externalCheckinUrl: string;
platform: string;
apiKey: string;
proxyUrl: string;
globalWeight: string;
};
export type SiteEditorState =
@@ -15,16 +17,24 @@ type SiteSaveAction =
| { kind: 'update'; id: number; payload: SiteForm };
export function emptySiteForm(): SiteForm {
return { name: '', url: '', platform: '', apiKey: '', proxyUrl: '' };
return { name: '', url: '', externalCheckinUrl: '', platform: '', apiKey: '', proxyUrl: '', globalWeight: '1' };
}
export function siteFormFromSite(site: Partial<SiteForm> & { proxyUrl?: string | null }): SiteForm {
export function siteFormFromSite(site: Partial<SiteForm> & {
proxyUrl?: string | null;
externalCheckinUrl?: string | null;
globalWeight?: number | string | null;
}): SiteForm {
const globalWeightRaw = Number(site.globalWeight);
const globalWeight = Number.isFinite(globalWeightRaw) && globalWeightRaw > 0 ? String(globalWeightRaw) : '1';
return {
name: site.name ?? '',
url: site.url ?? '',
externalCheckinUrl: site.externalCheckinUrl ?? '',
platform: site.platform ?? '',
apiKey: site.apiKey ?? '',
proxyUrl: site.proxyUrl ?? '',
globalWeight,
};
}
+88
View File
@@ -0,0 +1,88 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Sites from './Sites.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getSites: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Sites edit behavior', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it('scrolls to page top when entering edit mode', async () => {
apiMock.getSites.mockResolvedValue([
{
id: 1,
name: 'Demo Site',
url: 'https://example.com',
platform: 'new-api',
status: 'active',
},
]);
const scrollToMock = vi.fn();
Object.defineProperty(globalThis, 'scrollTo', {
configurable: true,
writable: true,
value: scrollToMock,
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/sites']}>
<ToastProvider>
<Sites />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const editButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '编辑'
));
await act(async () => {
editButton.props.onClick();
});
await flushMicrotasks();
expect(scrollToMock).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,367 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import TokenRoutes from './TokenRoutes.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getRoutes: vi.fn(),
getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/BrandIcon.js', () => ({
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
getBrand: () => null,
useIconCdn: () => 'https://cdn.test',
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function findButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
return root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes(text)
));
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('TokenRoutes grouped source models', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
});
afterEach(() => {
vi.clearAllMocks();
});
it('collapses source-model groups by default for wildcard routes', async () => {
apiMock.getRoutes.mockResolvedValue([
{
id: 1,
modelPattern: 're:^claude-opus-(4-6|4-5)$',
displayName: 'claude-opus-4-6',
enabled: true,
channels: [
{
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: 'claude-opus-4-5',
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_a' },
site: { name: 'site-a' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
},
{
id: 12,
accountId: 102,
tokenId: 1002,
sourceModel: 'claude-opus-4-6',
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_b' },
site: { name: 'site-b' },
token: { id: 1002, name: 'token-b', accountId: 102, enabled: true, isDefault: true },
},
],
},
]);
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).toContain('claude-opus-4-5');
expect(text).toContain('claude-opus-4-6');
expect(text).not.toContain('user_a');
expect(text).not.toContain('user_b');
} finally {
root?.unmount();
}
});
it('expands a source-model group after user click', async () => {
apiMock.getRoutes.mockResolvedValue([
{
id: 1,
modelPattern: 're:^claude-opus-(4-6|4-5)$',
displayName: 'claude-opus-4-6',
enabled: true,
channels: [
{
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: 'claude-opus-4-5',
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_a' },
site: { name: 'site-a' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
},
],
},
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(collectText(root.root)).not.toContain('user_a');
const toggleButton = findButtonByText(root.root, 'claude-opus-4-5');
await act(async () => {
toggleButton.props.onClick();
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('user_a');
} finally {
root?.unmount();
}
});
it('renders missing-token site tags with interactive hover class', 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: 101,
username: 'linuxdo_11494',
siteId: 11,
siteName: 'Wong',
},
],
},
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const siteButton = findButtonByText(root.root, 'Wong');
expect(String(siteButton.props.className || '')).toContain('missing-token-site-tag');
} finally {
root?.unmount();
}
});
it('maps endpoint types to expected brand icons in filter panel', async () => {
apiMock.getRoutes.mockResolvedValue([
{
id: 1,
modelPattern: 'gpt-5.2-codex',
displayName: 'gpt-5.2-codex',
enabled: true,
channels: [
{
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: 'gpt-5.2-codex',
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_a' },
site: { id: 1, name: 'Wong' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
},
],
},
]);
apiMock.getModelTokenCandidates.mockResolvedValue({
models: {},
endpointTypesByModel: {
'gpt-5.2-codex': ['openai', 'gemini', 'anthropic'],
},
});
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).toContain('chatgpt');
expect(text).toContain('gemini');
expect(text).toContain('claude');
} finally {
root?.unmount();
}
});
it('falls back to site platform endpoint grouping when endpoint metadata cache is empty', async () => {
apiMock.getRoutes.mockResolvedValue([
{
id: 1,
modelPattern: 'gpt-4o-mini',
displayName: 'gpt-4o-mini',
enabled: true,
channels: [
{
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: null,
priority: 0,
weight: 1,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'user_a' },
site: { id: 1, name: 'site-a', platform: 'new-api' },
token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
},
],
},
]);
apiMock.getModelTokenCandidates.mockResolvedValue({
models: {},
endpointTypesByModel: {},
});
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).toContain('接口能力');
expect(text).toContain('openai');
} finally {
root?.unmount();
}
});
it('still shows endpoint group section with empty hint when no endpoint data can be inferred', async () => {
apiMock.getRoutes.mockResolvedValue([
{
id: 1,
modelPattern: 'custom-model-without-channel',
displayName: 'custom-model-without-channel',
enabled: true,
channels: [],
},
]);
apiMock.getModelTokenCandidates.mockResolvedValue({
models: {},
endpointTypesByModel: {},
});
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).toContain('接口能力');
expect(text).toContain('暂无接口能力数据');
} finally {
root?.unmount();
}
});
});