feat: overhaul mobile management layouts

This commit is contained in:
Cita
2026-03-21 20:42:25 +08:00
parent 6a19d809f0
commit 09fbf258e2
55 changed files with 4815 additions and 1030 deletions
@@ -13,6 +13,7 @@ describe('oauth loopback callback server', () => {
const callbackHandler = vi.fn(async () => ({ accountId: 12, siteId: 34 }));
const started = await startOAuthLoopbackCallbackServer('codex', {
callbackHandler,
port: 0,
});
const response = await fetch(`${started.origin}/auth/callback?state=test-state&code=test-code`);
@@ -34,6 +35,7 @@ describe('oauth loopback callback server', () => {
});
const started = await startOAuthLoopbackCallbackServer('claude', {
callbackHandler,
port: 0,
});
const response = await fetch(`${started.origin}/callback?state=test-state&code=test-code`);
+182 -8
View File
@@ -1,12 +1,186 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import App from './App.js';
const { apiMock, authSessionMock } = vi.hoisted(() => ({
apiMock: {
getEvents: vi.fn(),
},
authSessionMock: {
hasValidAuthSession: vi.fn(),
persistAuthSession: vi.fn(),
clearAuthSession: vi.fn(),
},
}));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('./api.js', () => ({
api: apiMock,
}));
vi.mock('./authSession.js', () => ({
hasValidAuthSession: authSessionMock.hasValidAuthSession,
persistAuthSession: authSessionMock.persistAuthSession,
clearAuthSession: authSessionMock.clearAuthSession,
}));
vi.mock('./components/SearchModal.js', () => ({
default: () => null,
}));
vi.mock('./components/NotificationPanel.js', () => ({
default: () => null,
}));
vi.mock('./components/TooltipLayer.js', () => ({
default: () => null,
}));
vi.mock('./components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: (open: boolean) => ({
shouldRender: open,
isVisible: open,
}),
}));
vi.mock('./i18n.js', () => ({
I18nProvider: ({ children }: { children: ReactNode }) => children,
useI18n: () => ({
language: 'zh',
toggleLanguage: vi.fn(),
t: (text: string) => text,
}),
}));
vi.mock('./pages/Dashboard.js', () => ({
default: ({ adminName }: { adminName?: string }) => <div>{adminName || 'Dashboard'}</div>,
}));
function createLocalStorage() {
const store = new Map<string, string>([
['metapi.theme.mode', 'light'],
['metapi.firstUseDocReminder', '1'],
['metapi.userProfile', JSON.stringify({
name: '管理员',
avatarSeed: 'seed-1',
avatarStyle: 'identicon',
})],
]);
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
};
}
function setupRuntime(width: number) {
const matchMedia = (query: string) => ({
matches: query.includes('prefers-color-scheme')
? false
: width <= 768,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
onchange: null,
});
const documentElementAttributes = new Map<string, string>();
const documentElement = {
setAttribute: (name: string, value: string) => {
documentElementAttributes.set(name, value);
},
getAttribute: (name: string) => documentElementAttributes.get(name) ?? null,
};
vi.stubGlobal('localStorage', createLocalStorage());
vi.stubGlobal('window', {
innerWidth: width,
matchMedia,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('document', {
body: { style: {} },
documentElement,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
describe('App mobile layout', () => {
it('sets data-layout attribute when switching layouts', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
expect(source).toContain('useIsMobile(768)');
expect(source).toContain("document.documentElement.setAttribute('data-layout', isMobile ? 'mobile' : 'desktop');");
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
apiMock.getEvents.mockResolvedValue([]);
authSessionMock.hasValidAuthSession.mockReturnValue(true);
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it.each([
{ width: 767, expectedLayout: 'mobile', hasHamburger: true },
{ width: 768, expectedLayout: 'mobile', hasHamburger: true },
{ width: 769, expectedLayout: 'desktop', hasHamburger: false },
])(
'uses the shared breakpoint at width $width',
async ({ width, expectedLayout, hasHamburger }) => {
setupRuntime(width);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
});
await flushMicrotasks();
const hamburgerButtons = root.root.findAll((node) => (
node.type === 'button'
&& node.props['aria-label'] === '打开导航'
));
expect(document.documentElement.getAttribute('data-layout')).toBe(expectedLayout);
expect(hamburgerButtons.length > 0).toBe(hasHamburger);
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
},
);
});
+195 -8
View File
@@ -1,13 +1,200 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import App from './App.js';
const { apiMock, authSessionMock } = vi.hoisted(() => ({
apiMock: {
getEvents: vi.fn(),
},
authSessionMock: {
hasValidAuthSession: vi.fn(),
persistAuthSession: vi.fn(),
clearAuthSession: vi.fn(),
},
}));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('./api.js', () => ({
api: apiMock,
}));
vi.mock('./authSession.js', () => ({
hasValidAuthSession: authSessionMock.hasValidAuthSession,
persistAuthSession: authSessionMock.persistAuthSession,
clearAuthSession: authSessionMock.clearAuthSession,
}));
vi.mock('./components/SearchModal.js', () => ({
default: () => null,
}));
vi.mock('./components/NotificationPanel.js', () => ({
default: () => null,
}));
vi.mock('./components/TooltipLayer.js', () => ({
default: () => null,
}));
vi.mock('./components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: (open: boolean) => ({
shouldRender: open,
isVisible: open,
}),
}));
vi.mock('./i18n.js', () => ({
I18nProvider: ({ children }: { children: ReactNode }) => children,
useI18n: () => ({
language: 'zh',
toggleLanguage: vi.fn(),
t: (text: string) => text,
}),
}));
vi.mock('./pages/Dashboard.js', () => ({
default: () => <div>Dashboard</div>,
}));
function createLocalStorage() {
const store = new Map<string, string>([
['metapi.theme.mode', 'light'],
['metapi.firstUseDocReminder', '1'],
['metapi.userProfile', JSON.stringify({
name: '管理员',
avatarSeed: 'seed-1',
avatarStyle: 'identicon',
})],
]);
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
};
}
function setupRuntime(width: number) {
const matchMedia = (query: string) => ({
matches: query.includes('prefers-color-scheme')
? false
: width <= 768,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
onchange: null,
});
vi.stubGlobal('localStorage', createLocalStorage());
vi.stubGlobal('window', {
innerWidth: width,
matchMedia,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('document', {
body: { style: {} },
documentElement: {
setAttribute: vi.fn(),
getAttribute: vi.fn(),
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
}
function collectText(node: ReactTestInstance): string {
return (node.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();
await Promise.resolve();
});
}
describe('App mobile sidebar', () => {
it('renders hamburger trigger for mobile navigation', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
apiMock.getEvents.mockResolvedValue([]);
authSessionMock.hasValidAuthSession.mockReturnValue(true);
});
expect(source).toContain('aria-label="Open navigation"');
expect(source).toContain('MobileDrawer');
expect(source).toContain('mobile-nav');
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('opens the mobile drawer from the hamburger trigger and exposes the close affordance', async () => {
setupRuntime(768);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
});
await flushMicrotasks();
const openButton = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '打开导航'
));
await act(async () => {
openButton.props.onClick();
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('导航菜单');
const closeButton = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '关闭导航'
));
await act(async () => {
closeButton.props.onClick();
});
await act(async () => {
vi.advanceTimersByTime(300);
});
await flushMicrotasks();
expect(collectText(root.root)).not.toContain('导航菜单');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
});
+72 -54
View File
@@ -18,6 +18,7 @@ import { SITE_DOCS_URL, SITE_GITHUB_URL } from './docsLink.js';
import { useAnimatedVisibility } from './components/useAnimatedVisibility.js';
import { useIsMobile } from './components/useIsMobile.js';
import { MobileDrawer } from './components/MobileDrawer.js';
import CenteredModal from './components/CenteredModal.js';
const Dashboard = lazy(() => import('./pages/Dashboard.js'));
const Sites = lazy(() => import('./pages/Sites.js'));
const Accounts = lazy(() => import('./pages/Accounts.js'));
@@ -293,7 +294,6 @@ 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);
@@ -307,8 +307,6 @@ function UserProfileModal({
setError('');
}, [open, profile]);
if (!presence.shouldRender) return null;
const avatarUrl = buildDicebearAvatarUrl(avatarStyle, avatarSeed);
const inputStyle: React.CSSProperties = {
@@ -348,55 +346,60 @@ function UserProfileModal({
};
return (
<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 }}>
<div className="topbar-avatar" style={{ width: 40, height: 40, fontSize: 14 }}>
<img
src={avatarUrl}
alt={name.trim() || 'avatar'}
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
/>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{t('右上角头像实时预览')}</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('用户名')}</div>
<input
value={name}
onChange={(e) => {
setName(e.target.value);
setError('');
}}
placeholder={t('例如:小王')}
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
{t('头像(Dicebear 随机) · 风格:')}{avatarStyle}
</div>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleRandomAvatar}>
{t('换一个随机头像')}
</button>
</div>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
</div>
<div className="modal-footer">
<CenteredModal
open={open}
onClose={onClose}
title={t('个人信息')}
maxWidth={440}
closeOnBackdrop
closeOnEscape
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={onClose} className="btn btn-ghost">{t('取消')}</button>
<button onClick={handleSubmit} className="btn btn-primary">{t('保存')}</button>
</>
)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
<div className="topbar-avatar" style={{ width: 40, height: 40, fontSize: 14 }}>
<img
src={avatarUrl}
alt={name.trim() || 'avatar'}
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
/>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{t('右上角头像实时预览')}</div>
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('用户名')}</div>
<input
value={name}
onChange={(e) => {
setName(e.target.value);
setError('');
}}
placeholder={t('例如:小王')}
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
{t('头像(Dicebear 随机) · 风格:')}{avatarStyle}
</div>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleRandomAvatar}>
{t('换一个随机头像')}
</button>
</div>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
</CenteredModal>
);
}
@@ -469,7 +472,7 @@ function AppShell() {
const notifBtnRef = useRef<HTMLButtonElement>(null);
const latestTaskEventIdRef = useRef(0);
const toast = useToast();
const isMobile = useIsMobile(768);
const isMobile = useIsMobile();
const resolvedTheme: 'light' | 'dark' = themeMode === 'system'
? (systemPrefersDark ? 'dark' : 'light')
: themeMode;
@@ -506,6 +509,12 @@ function AppShell() {
document.documentElement.setAttribute('data-layout', isMobile ? 'mobile' : 'desktop');
}, [isMobile]);
useEffect(() => {
if (!isMobile && drawerOpen) {
setDrawerOpen(false);
}
}, [drawerOpen, isMobile]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
@@ -639,8 +648,9 @@ function AppShell() {
{isMobile && (
<button
className="topbar-icon-btn"
aria-label="Open navigation"
aria-label={t('打开导航')}
onClick={() => setDrawerOpen(true)}
type="button"
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
@@ -729,9 +739,12 @@ function AppShell() {
)}
</div>
<div ref={userMenuRef} style={{ position: 'relative' }}>
<div
<button
type="button"
className="topbar-avatar"
aria-label={displayName}
aria-haspopup="menu"
aria-expanded={showUserMenu}
onClick={() => {
setShowUserMenu(!showUserMenu);
setShowThemeMenu(false);
@@ -742,7 +755,7 @@ function AppShell() {
alt={displayName}
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
/>
</div>
</button>
{userMenuPresence.shouldRender && (
<div className={`user-dropdown ${userMenuPresence.isVisible ? '' : 'is-closing'}`.trim()}>
<button
@@ -770,7 +783,12 @@ function AppShell() {
<div className="app-layout">
{isMobile ? (
<MobileDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)}>
<MobileDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
title={t('导航菜单')}
closeLabel={t('关闭导航')}
>
<div className="mobile-drawer-header">
<img src="/logo.png" alt="Metapi" />
<span>Metapi</span>
@@ -795,7 +813,7 @@ function AppShell() {
))}
<div className="mobile-nav-group">
<div className="mobile-nav-label">{t('更多')}</div>
{topNavItems.filter(n => n.to !== '/').map((item) => (
{topNavItems.filter((n) => n.to !== '/').map((item) => (
<NavLink
key={item.to}
to={item.to}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
type MobileBatchBarProps = {
info: React.ReactNode;
children: React.ReactNode;
};
export default function MobileBatchBar({ info, children }: MobileBatchBarProps) {
return (
<div className="mobile-actions-bar mobile-batch-bar">
<span className="mobile-actions-info">{info}</span>
<div className="mobile-actions-row">{children}</div>
</div>
);
}
+31 -7
View File
@@ -2,30 +2,54 @@ import React from 'react';
type MobileCardProps = {
title: React.ReactNode;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
headerActions?: React.ReactNode;
footerActions?: React.ReactNode;
compact?: boolean;
className?: string;
bodyClassName?: string;
children: React.ReactNode;
};
type MobileFieldProps = {
label: React.ReactNode;
value: React.ReactNode;
stacked?: boolean;
};
export function MobileCard({ title, actions, children }: MobileCardProps) {
export function MobileCard({
title,
subtitle,
actions,
headerActions,
footerActions,
compact = false,
className = '',
bodyClassName = '',
children,
}: MobileCardProps) {
const resolvedHeaderActions = headerActions ?? actions;
const cardClassName = ['mobile-card', compact ? 'is-compact' : '', className].filter(Boolean).join(' ');
const cardBodyClassName = ['mobile-card-body', bodyClassName].filter(Boolean).join(' ');
return (
<div className="mobile-card">
<div className={cardClassName}>
<div className="mobile-card-header">
<div className="mobile-card-title">{title}</div>
{actions ? <div className="mobile-card-actions">{actions}</div> : null}
<div className="mobile-card-title-block">
<div className="mobile-card-title">{title}</div>
{subtitle ? <div className="mobile-card-subtitle">{subtitle}</div> : null}
</div>
{resolvedHeaderActions ? <div className="mobile-card-header-actions">{resolvedHeaderActions}</div> : null}
</div>
<div className="mobile-card-body">{children}</div>
<div className={cardBodyClassName}>{children}</div>
{footerActions ? <div className="mobile-card-footer-actions">{footerActions}</div> : null}
</div>
);
}
export function MobileField({ label, value }: MobileFieldProps) {
export function MobileField({ label, value, stacked = false }: MobileFieldProps) {
return (
<div className="mobile-field">
<div className={`mobile-field${stacked ? ' is-stacked' : ''}`}>
<div className="mobile-field-label">{label}</div>
<div className="mobile-field-value">{value}</div>
</div>
+54 -7
View File
@@ -1,14 +1,27 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useId, useMemo, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
type MobileDrawerProps = {
open: boolean;
onClose: () => void;
children: React.ReactNode;
title?: React.ReactNode;
closeLabel?: string;
side?: 'left' | 'right';
};
function MobileDrawer({ open, onClose, children }: MobileDrawerProps) {
function MobileDrawer({
open,
onClose,
children,
title,
closeLabel = '关闭导航',
side = 'left',
}: MobileDrawerProps) {
const [shouldRender, setShouldRender] = useState(open);
const [isClosing, setIsClosing] = useState(false);
const titleId = useId();
const labelledBy = title ? titleId : undefined;
useEffect(() => {
if (open) {
@@ -29,21 +42,55 @@ function MobileDrawer({ open, onClose, children }: MobileDrawerProps) {
onClose();
}, [onClose]);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [open]);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [handleClose, open]);
if (!shouldRender) return null;
return (
const drawer = (
<div className={`mobile-drawer-root ${isClosing ? 'is-closing' : ''}`}>
<button
type="button"
<div
className="mobile-drawer-backdrop"
onClick={handleClose}
aria-label="Close navigation"
aria-hidden="true"
/>
<div className="mobile-drawer-panel" role="dialog" aria-modal="true">
<div className={`mobile-drawer-panel mobile-drawer-panel-${side}`} role="dialog" aria-modal="true" aria-labelledby={labelledBy}>
<div className="mobile-drawer-toolbar">
{title ? (
<div className="mobile-drawer-title" id={titleId}>
{title}
</div>
) : <div />}
<button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel}>
×
</button>
</div>
{children}
</div>
</div>
);
const portalTarget = typeof document !== 'undefined' ? document.body : null;
return portalTarget ? createPortal(drawer, portalTarget) : drawer;
}
export { MobileDrawer };
+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import MobileDrawer from './MobileDrawer.js';
type MobileFilterSheetProps = {
open: boolean;
onClose: () => void;
title?: React.ReactNode;
children: React.ReactNode;
};
export default function MobileFilterSheet({
open,
onClose,
title = '筛选',
children,
}: MobileFilterSheetProps) {
return (
<MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选">
<div className="mobile-filter-panel">
{children}
</div>
</MobileDrawer>
);
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
type ResponsiveFormGridProps = {
columns?: 1 | 2 | 3;
children: React.ReactNode;
className?: string;
};
export default function ResponsiveFormGrid({
columns = 2,
children,
className,
}: ResponsiveFormGridProps) {
const classes = [
'responsive-form-grid',
`responsive-form-grid-${columns}`,
className,
].filter(Boolean).join(' ');
return <div className={classes}>{children}</div>;
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import MobileBatchBar from './MobileBatchBar.js';
describe('MobileBatchBar', () => {
it('renders info text and action content inside the shared batch bar shell', () => {
const root = create(
<MobileBatchBar info="已选 2 项">
<button type="button"></button>
</MobileBatchBar>,
);
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
expect(text).toContain('已选 2 项');
expect(text).toContain('批量删除');
expect(root.root.find((node) => node.props?.className === 'mobile-actions-bar mobile-batch-bar')).toBeTruthy();
});
});
+22 -2
View File
@@ -3,10 +3,17 @@ import { create } from 'react-test-renderer';
import { MobileCard, MobileField } from './MobileCard.js';
describe('MobileCard', () => {
it('renders title and fields', () => {
it('renders separate header and footer action slots plus stacked fields', () => {
const root = create(
<MobileCard title="CardTitle">
<MobileCard
title="CardTitle"
subtitle="CardSubtitle"
compact
headerActions={<span>Meta</span>}
footerActions={<button type="button">Action</button>}
>
<MobileField label="Status" value="OK" />
<MobileField label="URL" value="https://example.com/very/long/path" stacked />
</MobileCard>,
);
@@ -16,7 +23,20 @@ describe('MobileCard', () => {
.join('');
expect(text).toContain('CardTitle');
expect(text).toContain('CardSubtitle');
expect(text).toContain('Meta');
expect(text).toContain('Status');
expect(text).toContain('OK');
expect(text).toContain('Action');
const headerActions = root.root.find((node) => node.props?.className === 'mobile-card-header-actions');
const footerActions = root.root.find((node) => node.props?.className === 'mobile-card-footer-actions');
const stackedField = root.root.find((node) => node.props?.className === 'mobile-field is-stacked');
const compactCard = root.root.find((node) => node.props?.className === 'mobile-card is-compact');
expect(headerActions).toBeTruthy();
expect(footerActions).toBeTruthy();
expect(stackedField).toBeTruthy();
expect(compactCard).toBeTruthy();
});
});
+64 -15
View File
@@ -1,25 +1,74 @@
import { describe, expect, it, vi } from 'vitest';
import { create } from 'react-test-renderer';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { act, create } from 'react-test-renderer';
import MobileDrawer from './MobileDrawer.js';
vi.mock('react-dom', () => ({
createPortal: (node: unknown) => node,
}));
describe('MobileDrawer', () => {
it('renders content when open and closes on backdrop click', () => {
it('renders content, locks body scroll, and exposes explicit close affordances', async () => {
const onClose = vi.fn();
const root = create(
<MobileDrawer open onClose={onClose}>
<div>DrawerContent</div>
</MobileDrawer>,
);
let root: ReturnType<typeof create> | null = null;
vi.stubGlobal('document', {
body: {
style: {
overflow: '',
},
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
try {
await act(async () => {
root = create(
<MobileDrawer open onClose={onClose} title="导航菜单">
<div>DrawerContent</div>
</MobileDrawer>,
);
});
expect(text).toContain('DrawerContent');
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
const backdrop = root.root.find((node) => node.props?.className === 'mobile-drawer-backdrop');
backdrop.props.onClick();
expect(onClose).toHaveBeenCalledTimes(1);
expect(text).toContain('DrawerContent');
expect(text).toContain('导航菜单');
expect(document.body.style.overflow).toBe('hidden');
const backdrop = root.root.find((node) => node.props.className === 'mobile-drawer-backdrop');
expect(backdrop.type).toBe('div');
const closeButton = root.root.find((node) => (
node.type === 'button'
&& node.props.className === 'mobile-drawer-close'
));
await act(async () => {
closeButton.props.onClick();
});
expect(onClose).toHaveBeenCalledTimes(1);
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
expect(document.body.style.overflow).toBe('');
vi.unstubAllGlobals();
}
});
it('defines independent right-side drawer animations in shared css', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('@keyframes drawer-slide-in-right');
expect(css).toContain('@keyframes drawer-slide-out-right');
expect(css).toMatch(/\.mobile-drawer-panel-right\s*\{[\s\S]*animation:\s*drawer-slide-in-right/);
expect(css).toMatch(/\.mobile-drawer-root\.is-closing \.mobile-drawer-panel-right\s*\{[\s\S]*animation:\s*drawer-slide-out-right/);
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest';
import { create } from 'react-test-renderer';
import MobileFilterSheet from './MobileFilterSheet.js';
vi.mock('react-dom', () => ({
createPortal: (node: unknown) => node,
}));
describe('MobileFilterSheet', () => {
it('wraps content with the shared filter panel shell', () => {
vi.stubGlobal('document', {
body: {
style: {
overflow: '',
},
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
try {
const root = create(
<MobileFilterSheet open onClose={() => {}} title="筛选条件">
<div>FilterContent</div>
</MobileFilterSheet>,
);
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
expect(text).toContain('筛选条件');
expect(text).toContain('FilterContent');
expect(root.root.find((node) => node.props?.className === 'mobile-filter-panel')).toBeTruthy();
} finally {
vi.unstubAllGlobals();
}
});
});
+3
View File
@@ -0,0 +1,3 @@
export const MOBILE_BREAKPOINT = 768;
export const MOBILE_MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT}px)`;
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import ResponsiveFormGrid from './ResponsiveFormGrid.js';
describe('ResponsiveFormGrid', () => {
it('applies the shared responsive grid class contract', () => {
const root = create(
<ResponsiveFormGrid columns={3}>
<div>Field A</div>
<div>Field B</div>
</ResponsiveFormGrid>,
);
const container = root.root.findByType('div');
expect(container.props.className).toContain('responsive-form-grid');
expect(container.props.className).toContain('responsive-form-grid-3');
});
});
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { act, create } from 'react-test-renderer';
import { useIsMobile } from './useIsMobile.js';
function Probe() {
return <div data-mobile={String(useIsMobile())} />;
}
describe('useIsMobile', () => {
const widthRef = { current: 768 };
const listeners = new Map<string, Set<() => void>>();
beforeEach(() => {
widthRef.current = 768;
listeners.clear();
vi.stubGlobal('window', {
innerWidth: widthRef.current,
addEventListener: (event: string, handler: () => void) => {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event)!.add(handler);
},
removeEventListener: (event: string, handler: () => void) => {
listeners.get(event)?.delete(handler);
},
dispatchEvent: (event: { type: string }) => {
listeners.get(event.type)?.forEach((handler) => handler());
return true;
},
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('does not throw when a partial window mock lacks resize listener APIs', async () => {
vi.stubGlobal('window', {
innerWidth: 767,
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(<Probe />);
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('does not throw when matchMedia only exposes addEventListener', async () => {
vi.stubGlobal('window', {
innerWidth: 767,
matchMedia: () => ({
matches: true,
media: '(max-width: 768px)',
addEventListener: () => {},
}),
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(<Probe />);
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('treats 767 and 768 as mobile, but 769 as desktop', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(<Probe />);
});
const probe = root.root.findByType('div');
expect(probe.props['data-mobile']).toBe('true');
widthRef.current = 767;
window.innerWidth = 767;
await act(async () => {
window.dispatchEvent({ type: 'resize' });
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
widthRef.current = 768;
window.innerWidth = 768;
await act(async () => {
window.dispatchEvent({ type: 'resize' });
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
widthRef.current = 769;
window.innerWidth = 769;
await act(async () => {
window.dispatchEvent({ type: 'resize' });
});
expect(root.root.findByType('div').props['data-mobile']).toBe('false');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
});
+27 -4
View File
@@ -1,16 +1,39 @@
import { useEffect, useState } from 'react';
import { MOBILE_BREAKPOINT, MOBILE_MEDIA_QUERY } from './mobileLayout.js';
export function useIsMobile(breakpoint = 768) {
export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
const [isMobile, setIsMobile] = useState(() => (
typeof window !== 'undefined' ? window.innerWidth < breakpoint : false
typeof window !== 'undefined' ? window.innerWidth <= breakpoint : false
));
useEffect(() => {
if (typeof window === 'undefined') return;
const update = () => setIsMobile(window.innerWidth < breakpoint);
const query = breakpoint === MOBILE_BREAKPOINT
? MOBILE_MEDIA_QUERY
: `(max-width: ${breakpoint}px)`;
const media = typeof window.matchMedia === 'function' ? window.matchMedia(query) : null;
const update = () => setIsMobile(media ? media.matches : window.innerWidth <= breakpoint);
update();
if (media && typeof media.addEventListener === 'function') {
media.addEventListener('change', update);
return () => {
if (typeof media.removeEventListener === 'function') {
media.removeEventListener('change', update);
}
};
}
const addResizeListener = typeof window.addEventListener === 'function';
const removeResizeListener = typeof window.removeEventListener === 'function';
if (!addResizeListener) return;
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
return () => {
if (removeResizeListener) {
window.removeEventListener('resize', update);
}
};
}, [breakpoint]);
return isMobile;
+255 -19
View File
@@ -35,6 +35,10 @@
--topbar-height: 56px;
--sidebar-width: 220px;
--sidebar-collapsed-width: 64px;
--z-topbar: 100;
--z-mobile-batch-bar: 210;
--z-overlay: 320;
--z-drawer: 340;
}
[data-theme="dark"] {
@@ -615,6 +619,9 @@ body {
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
padding: 0;
appearance: none;
-webkit-appearance: none;
}
.topbar-avatar:hover {
@@ -622,6 +629,11 @@ body {
transform: scale(1.05);
}
.topbar-avatar:focus-visible {
outline: 2px solid color-mix(in srgb, var(--color-primary) 45%, transparent);
outline-offset: 2px;
}
/* ===== Sidebar ===== */
.sidebar {
width: var(--sidebar-width);
@@ -974,7 +986,7 @@ body {
.mobile-card-list {
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
}
.mobile-card {
@@ -982,7 +994,11 @@ body {
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
box-shadow: var(--shadow-card);
padding: 14px;
padding: 12px;
}
.mobile-card.is-compact {
padding: 12px;
}
.mobile-card-header {
@@ -990,36 +1006,70 @@ body {
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
margin-bottom: 8px;
}
.mobile-card-title-block {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
flex: 1;
}
.mobile-card-header-actions {
display: flex;
align-items: flex-start;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
min-width: 0;
}
.mobile-card-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
line-height: 1.3;
word-break: break-word;
}
.mobile-card-actions {
.mobile-card-subtitle {
font-size: 11px;
color: var(--color-text-muted);
line-height: 1.35;
}
.mobile-card-actions,
.mobile-card-footer-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.mobile-card-footer-actions {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--color-border-light);
}
.mobile-card-body {
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
}
.mobile-field {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
gap: 8px;
}
.mobile-field-label {
font-size: 11px;
color: var(--color-text-muted);
flex-shrink: 0;
}
.mobile-field-value {
@@ -1027,6 +1077,18 @@ body {
font-weight: 500;
color: var(--color-text-primary);
text-align: right;
min-width: 0;
overflow-wrap: anywhere;
}
.mobile-field.is-stacked {
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.mobile-field.is-stacked .mobile-field-value {
text-align: left;
}
.mobile-card-extra {
@@ -1035,7 +1097,45 @@ body {
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
}
.mobile-inline-meta-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.mobile-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 10px;
}
.mobile-summary-metric {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid var(--color-border-light);
background: color-mix(in srgb, var(--color-bg-card) 78%, var(--color-bg-subtle) 22%);
}
.mobile-summary-metric-label {
font-size: 10px;
color: var(--color-text-muted);
line-height: 1.2;
}
.mobile-summary-metric-value {
font-size: 13px;
font-weight: 600;
color: var(--color-text-primary);
line-height: 1.3;
word-break: break-word;
}
/* ===== Mobile Drawer ===== */
@@ -1079,10 +1179,30 @@ body {
}
}
@keyframes drawer-slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes drawer-slide-out-right {
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
.mobile-drawer-root {
position: fixed;
inset: 0;
z-index: 220;
z-index: var(--z-drawer);
display: flex;
}
@@ -1115,14 +1235,64 @@ body {
flex-direction: column;
}
.mobile-drawer-panel-right {
margin-left: auto;
border-left: 1px solid var(--color-border-light);
border-right: none;
box-shadow: -6px 0 24px rgba(0, 0, 0, 0.12);
animation: drawer-slide-in-right 0.3s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.mobile-drawer-root.is-closing .mobile-drawer-panel {
animation: drawer-slide-out 0.22s cubic-bezier(0.4, 0, 1, 1) both;
}
.mobile-drawer-root.is-closing .mobile-drawer-panel-right {
animation: drawer-slide-out-right 0.22s cubic-bezier(0.4, 0, 1, 1) both;
}
[data-theme="dark"] .mobile-drawer-panel {
box-shadow: 6px 0 32px rgba(0, 0, 0, 0.4);
}
[data-theme="dark"] .mobile-drawer-panel-right {
box-shadow: -6px 0 32px rgba(0, 0, 0, 0.4);
}
.mobile-drawer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 0;
}
.mobile-drawer-title {
font-size: 14px;
font-weight: 700;
color: var(--color-text-primary);
}
.mobile-drawer-close {
border: none;
background: transparent;
color: var(--color-text-secondary);
width: 32px;
height: 32px;
border-radius: 999px;
cursor: pointer;
font-size: 22px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.mobile-drawer-close:hover {
background: rgba(0, 0, 0, 0.05);
color: var(--color-text-primary);
}
/* --- Drawer Header (logo area) --- */
.mobile-drawer-header {
display: flex;
@@ -1239,7 +1409,7 @@ body {
left: 16px;
right: 16px;
bottom: 16px;
z-index: 210;
z-index: var(--z-mobile-batch-bar);
display: flex;
flex-wrap: wrap;
gap: 8px;
@@ -2661,6 +2831,8 @@ textarea {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
}
.accounts-page-actions {
@@ -3196,7 +3368,7 @@ textarea {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
z-index: var(--z-overlay);
display: flex;
align-items: center;
justify-content: center;
@@ -3279,6 +3451,24 @@ textarea {
display: flex;
gap: 8px;
justify-content: flex-end;
flex-wrap: wrap;
}
.responsive-form-grid {
display: grid;
gap: 12px;
}
.responsive-form-grid-1 {
grid-template-columns: 1fr;
}
.responsive-form-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.responsive-form-grid-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
/* ===== Modern Select ===== */
@@ -4126,6 +4316,7 @@ textarea {
justify-content: center;
gap: 4px;
padding: 16px 0;
flex-wrap: wrap;
}
.pagination-btn {
@@ -4174,6 +4365,7 @@ textarea {
gap: 6px;
font-size: 13px;
color: var(--color-text-muted);
flex-wrap: wrap;
}
.pagination-size select {
@@ -4652,6 +4844,16 @@ textarea {
gap: 8px;
}
.page-actions {
width: 100%;
flex-wrap: wrap;
}
.page-actions {
width: 100%;
flex-wrap: wrap;
}
.page-header h1 {
font-size: 18px;
}
@@ -4740,19 +4942,19 @@ textarea {
/* --- Mobile Cards --- */
.mobile-card {
padding: 16px;
padding: 12px;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.mobile-card-header {
padding-bottom: 10px;
margin-bottom: 12px;
padding-bottom: 8px;
margin-bottom: 8px;
border-bottom: 1px solid var(--color-border-light);
}
.mobile-card-title {
font-size: 15px;
font-size: 14px;
}
.mobile-card-actions {
@@ -4760,7 +4962,9 @@ textarea {
}
.mobile-card-actions .btn,
.mobile-card-actions .btn-link {
.mobile-card-actions .btn-link,
.mobile-card-footer-actions .btn,
.mobile-card-footer-actions .btn-link {
min-height: 32px;
min-width: 32px;
padding: 4px 10px;
@@ -4768,21 +4972,21 @@ textarea {
}
.mobile-card-body {
gap: 10px;
gap: 6px;
}
.mobile-field {
gap: 16px;
gap: 8px;
}
.mobile-field-label {
font-size: 12px;
font-size: 11px;
color: var(--color-text-muted);
flex-shrink: 0;
}
.mobile-field-value {
font-size: 13px;
font-size: 12px;
font-weight: 500;
}
@@ -4806,6 +5010,31 @@ textarea {
min-height: 36px;
}
.pagination {
flex-wrap: wrap;
}
.pagination {
flex-wrap: wrap;
}
.pagination-info {
width: 100%;
margin: 0;
text-align: center;
}
.pagination-size {
width: 100%;
margin-left: 0;
justify-content: center;
}
.responsive-form-grid-2,
.responsive-form-grid-3 {
grid-template-columns: 1fr;
}
/* --- Stat Cards --- */
.dashboard-stat-grid {
grid-template-columns: repeat(2, 1fr);
@@ -5530,6 +5759,13 @@ textarea {
grid-column: 1 / -1;
}
.route-card-expanded-compact {
margin-top: -2px;
border-top-left-radius: 18px;
border-top-right-radius: 18px;
background: color-mix(in srgb, var(--color-bg-card) 92%, var(--color-primary) 8%);
}
@media (max-width: 768px) {
.route-card-grid {
grid-template-columns: 1fr;
+178 -105
View File
@@ -2,6 +2,9 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js';
import MobileBatchBar from '../components/MobileBatchBar.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js';
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
@@ -82,7 +85,8 @@ export default function Accounts() {
const [sortMode, setSortMode] = useState<SortMode>('custom');
const [highlightAccountId, setHighlightAccountId] = useState<number | null>(null);
const [expandedAccountIds, setExpandedAccountIds] = useState<number[]>([]);
const isMobile = useIsMobile(768);
const isMobile = useIsMobile();
const [showMobileTools, setShowMobileTools] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
const [loginForm, setLoginForm] = useState(createLoginForm);
@@ -216,6 +220,7 @@ export default function Accounts() {
if (activeSegment === 'tokens') return [];
return sortedAccounts.filter((account) => resolveAccountCredentialMode(account) === activeSegment);
}, [activeSegment, sortedAccounts]);
const allVisibleAccountsSelected = visibleAccounts.length > 0 && visibleAccounts.every((account) => selectedAccountIds.includes(account.id));
const verifyFailureHint = buildVerifyFailureHint(verifyResult);
const addAccountPrereqHint = buildAddAccountPrereqHint(verifyResult);
@@ -728,10 +733,10 @@ export default function Accounts() {
const toggleSelectAllVisibleAccounts = (checked: boolean) => {
if (!checked) {
setSelectedAccountIds([]);
setSelectedAccountIds((current) => current.filter((id) => !visibleAccounts.some((account) => account.id === id)));
return;
}
setSelectedAccountIds(visibleAccounts.map((account) => account.id));
setSelectedAccountIds((current) => Array.from(new Set([...current, ...visibleAccounts.map((account) => account.id)])));
};
const toggleAccountDetails = (accountId: number) => {
@@ -914,35 +919,59 @@ export default function Accounts() {
<h2 className="page-title">{tr('连接管理')}</h2>
{activeSegment !== 'tokens' && (
<div className="page-actions accounts-page-actions">
<div className="accounts-sort-select" style={{ minWidth: 156, position: 'relative', zIndex: 20 }}>
<ModernSelect
size="sm"
value={sortMode}
onChange={(nextValue) => setSortMode(nextValue as SortMode)}
options={[
{ value: 'custom', label: '自定义排序' },
{ value: 'balance-desc', label: '余额高到低' },
{ value: 'balance-asc', label: '余额低到高' },
]}
placeholder="自定义排序"
/>
</div>
{activeSegment === 'session' && (
<button
onClick={() => withLoading('checkin-all', () => api.triggerCheckinAll(), '已触发全部签到')}
disabled={actionLoading['checkin-all']}
className="btn btn-soft-primary"
>
{actionLoading['checkin-all'] ? <><span className="spinner spinner-sm" />{tr('签到中...')}</> : tr('全部签到')}
</button>
{isMobile ? (
<>
<button
type="button"
onClick={() => setShowMobileTools(true)}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
</button>
<button
type="button"
data-testid="accounts-mobile-select-all"
onClick={() => toggleSelectAllVisibleAccounts(!allVisibleAccountsSelected)}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{allVisibleAccountsSelected ? '取消全选' : '全选可见项'}
</button>
</>
) : (
<>
<div className="accounts-sort-select" style={{ minWidth: 156, position: 'relative', zIndex: 20 }}>
<ModernSelect
size="sm"
value={sortMode}
onChange={(nextValue) => setSortMode(nextValue as SortMode)}
options={[
{ value: 'custom', label: '自定义排序' },
{ value: 'balance-desc', label: '余额高到低' },
{ value: 'balance-asc', label: '余额低到高' },
]}
placeholder="自定义排序"
/>
</div>
{activeSegment === 'session' && (
<button
onClick={() => withLoading('checkin-all', () => api.triggerCheckinAll(), '已触发全部签到')}
disabled={actionLoading['checkin-all']}
className="btn btn-soft-primary"
>
{actionLoading['checkin-all'] ? <><span className="spinner spinner-sm" />{tr('签到中...')}</> : tr('全部签到')}
</button>
)}
<button
onClick={handleRefreshRuntimeHealth}
disabled={actionLoading['health-refresh']}
className="btn btn-soft-primary"
>
{actionLoading['health-refresh'] ? <><span className="spinner spinner-sm" />{tr('刷新状态中...')}</> : tr('刷新账户状态')}
</button>
</>
)}
<button
onClick={handleRefreshRuntimeHealth}
disabled={actionLoading['health-refresh']}
className="btn btn-soft-primary"
>
{actionLoading['health-refresh'] ? <><span className="spinner spinner-sm" />{tr('刷新状态中...')}</> : tr('刷新账户状态')}
</button>
<button
onClick={() => {
const nextOpen = !showAdd;
@@ -964,6 +993,48 @@ export default function Accounts() {
{activeSegment === 'tokens' && embeddedTokenActions}
</div>
<MobileFilterSheet open={showMobileTools} onClose={() => setShowMobileTools(false)} title="连接排序与操作">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}></div>
<ModernSelect
value={sortMode}
onChange={(nextValue) => setSortMode(nextValue as SortMode)}
options={[
{ value: 'custom', label: '自定义排序' },
{ value: 'balance-desc', label: '余额高到低' },
{ value: 'balance-asc', label: '余额低到高' },
]}
placeholder="自定义排序"
/>
</div>
{activeSegment === 'session' && (
<button
onClick={async () => {
setShowMobileTools(false);
await withLoading('checkin-all', () => api.triggerCheckinAll(), '已触发全部签到');
}}
disabled={actionLoading['checkin-all']}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{actionLoading['checkin-all'] ? <><span className="spinner spinner-sm" />{tr('签到中...')}</> : tr('全部签到')}
</button>
)}
<button
onClick={async () => {
setShowMobileTools(false);
await handleRefreshRuntimeHealth();
}}
disabled={actionLoading['health-refresh']}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{actionLoading['health-refresh'] ? <><span className="spinner spinner-sm" />{tr('刷新状态中...')}</> : tr('刷新账户状态')}
</button>
</div>
</MobileFilterSheet>
<div
style={{
display: 'inline-flex',
@@ -1032,9 +1103,7 @@ export default function Accounts() {
)}
{isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
<div className="mobile-actions-bar">
<span className="mobile-actions-info"> {selectedAccountIds.length} </span>
<div className="mobile-actions-row">
<MobileBatchBar info={`已选 ${selectedAccountIds.length}`}>
<button data-testid="accounts-batch-refresh-balance" onClick={() => runBatchAccountAction('refreshBalance')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
</button>
@@ -1047,8 +1116,7 @@ export default function Accounts() {
<button onClick={() => runBatchAccountAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
</button>
</div>
</div>
</MobileBatchBar>
)}
{activeSegment === 'tokens' ? (
@@ -1502,7 +1570,7 @@ export default function Accounts() {
)}
>
{editingAccount ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<ResponsiveFormGrid>
<input
placeholder="账号名称"
value={editForm.username}
@@ -1570,7 +1638,7 @@ export default function Accounts() {
/>
</>
)}
</div>
</ResponsiveFormGrid>
) : null}
</CenteredModal>
@@ -1590,7 +1658,7 @@ export default function Accounts() {
<MobileCard
key={a.id}
title={resolveAccountDisplayName(a)}
actions={(
headerActions={(
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
@@ -1606,6 +1674,27 @@ export default function Accounts() {
)}
</div>
)}
footerActions={(
<>
<button
type="button"
onClick={() => toggleAccountDetails(a.id)}
className="btn btn-link"
>
{isExpanded ? '收起' : '详情'}
</button>
<button onClick={() => openEditPanel(a)} className="btn btn-link btn-link-info">
</button>
<button
onClick={() => openModelModal(a)}
disabled={actionLoading[`models-${a.id}`]}
className="btn btn-link btn-link-info"
>
</button>
</>
)}
>
<MobileField
label="运行健康状态"
@@ -1692,75 +1781,59 @@ export default function Accounts() {
/>
<MobileField
label="提示"
stacked
value={hintMessage}
/>
<div className="mobile-card-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>
)}
{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' && !capabilities.proxyOnly && (
<button
onClick={() => openRebindPanel(a)}
className="btn btn-link btn-link-warning"
>
</button>
)}
<button onClick={() => setDeleteConfirm({ mode: 'single', accountId: a.id, accountName: resolveAccountDisplayName(a) })} disabled={actionLoading[`delete-${a.id}`]} className="btn btn-link btn-link-danger">
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
</button>
</div>
</div>
) : null}
<div className="mobile-card-actions">
<button
type="button"
onClick={() => toggleAccountDetails(a.id)}
className="btn btn-link"
>
{isExpanded ? '收起' : '详情'}
</button>
<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={() => openModelModal(a)}
disabled={actionLoading[`models-${a.id}`]}
className="btn btn-link btn-link-info"
>
</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' && !capabilities.proxyOnly && (
<button
onClick={() => openRebindPanel(a)}
className="btn btn-link btn-link-warning"
>
</button>
)}
<button onClick={() => openEditPanel(a)} className="btn btn-link btn-link-info">
</button>
<button onClick={() => setDeleteConfirm({ mode: 'single', accountId: a.id, accountName: resolveAccountDisplayName(a) })} disabled={actionLoading[`delete-${a.id}`]} className="btn btn-link btn-link-danger">
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
</button>
</div>
</MobileCard>
);
})}
@@ -1772,7 +1845,7 @@ export default function Accounts() {
<th style={{ width: 44 }}>
<input
type="checkbox"
checked={visibleAccounts.length > 0 && selectedAccountIds.length === visibleAccounts.length}
checked={allVisibleAccountsSelected}
onChange={(e) => toggleSelectAllVisibleAccounts(e.target.checked)}
/>
</th>
+20 -17
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "../api.js";
import { MobileCard, MobileField } from "../components/MobileCard.js";
import { MobileDrawer } from "../components/MobileDrawer.js";
import MobileFilterSheet from "../components/MobileFilterSheet.js";
import { useToast } from "../components/Toast.js";
import { useIsMobile } from "../components/useIsMobile.js";
import {
@@ -71,7 +71,7 @@ export default function CheckinLog() {
const [filter, setFilter] = useState<LogFilter>("all");
const [expandedLogId, setExpandedLogId] = useState<number | null>(null);
const [showFilters, setShowFilters] = useState(false);
const isMobile = useIsMobile(768);
const isMobile = useIsMobile();
const toast = useToast();
function getStatus(log: any): "success" | "failed" | "skipped" {
@@ -280,11 +280,12 @@ export default function CheckinLog() {
</button>
</div>
<MobileDrawer
<MobileFilterSheet
open={showFilters}
onClose={() => setShowFilters(false)}
title="筛选签到记录"
>
<div className="mobile-filter-panel" style={{ gap: 12 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{timeRangeControls}
{hasInvalidTimeRange && (
<div className="alert alert-error">
@@ -293,7 +294,7 @@ export default function CheckinLog() {
)}
{filterTabs}
</div>
</MobileDrawer>
</MobileFilterSheet>
</>
) : (
<div className="toolbar" style={{ marginBottom: "12px" }}>
@@ -356,7 +357,7 @@ export default function CheckinLog() {
<MobileCard
key={logId}
title={log.accounts?.username || "未知"}
actions={
headerActions={
<span
className={`badge ${statusClass(status)}`}
style={{ fontSize: 10 }}
@@ -364,6 +365,17 @@ export default function CheckinLog() {
{statusLabel(status)}
</span>
}
footerActions={
<button
type="button"
className="btn btn-link"
onClick={() =>
setExpandedLogId(isExpanded ? null : logId)
}
>
{isExpanded ? "收起" : "详情"}
</button>
}
>
<MobileField
label="时间"
@@ -421,25 +433,16 @@ export default function CheckinLog() {
<div className="mobile-card-extra">
<MobileField
label="信息"
stacked
value={log.checkin_logs?.message || log.message}
/>
<MobileField
label="建议"
stacked
value={reason?.actionHint || "-"}
/>
</div>
) : null}
<div className="mobile-card-actions">
<button
type="button"
className="btn btn-link"
onClick={() =>
setExpandedLogId(isExpanded ? null : logId)
}
>
{isExpanded ? "收起" : "详情"}
</button>
</div>
</MobileCard>
);
})}
+4 -2
View File
@@ -2,6 +2,7 @@ import { Suspense, lazy, useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { useIsMobile } from '../components/useIsMobile.js';
import { formatCompactTokenMetric } from '../numberFormat.js';
const ModelAnalysisPanel = lazy(() => import('../components/ModelAnalysisPanel.js'));
@@ -159,6 +160,7 @@ function buildAvailabilityBucketLogsRoute(siteId: number, bucket: SiteAvailabili
}
export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminName?: string }) {
const isMobile = useIsMobile();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -579,7 +581,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 24 }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 16, marginBottom: 24 }}>
<div className="chart-panel-enter animate-slide-up stagger-6">
<Suspense fallback={<ChartFallback height={320} />}>
<SiteDistributionChart data={siteDistribution} loading={siteLoading} />
@@ -694,7 +696,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16 }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 300px', gap: 16 }}>
<div className="chart-container animate-slide-up stagger-8">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, fontWeight: 600, color: 'var(--color-text-primary)' }}>
@@ -0,0 +1,234 @@
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 DownstreamKeys from './DownstreamKeys.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDownstreamApiKeysSummary: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(),
getDownstreamApiKeyOverview: vi.fn(),
getDownstreamApiKeyTrend: vi.fn(),
createDownstreamApiKey: vi.fn(),
batchDownstreamApiKeys: vi.fn(),
updateDownstreamApiKey: vi.fn(),
deleteDownstreamApiKey: vi.fn(),
resetDownstreamApiKeyUsage: vi.fn(),
},
}));
vi.mock('../api.js', () => ({ api: apiMock }));
vi.mock('../components/useIsMobile.js', () => ({
useIsMobile: () => true,
}));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('../components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: (open: boolean) => ({
shouldRender: open,
isVisible: open,
}),
}));
vi.mock('../components/charts/DownstreamKeyTrendChart.js', () => ({
default: ({ buckets }: { buckets: Array<{ totalTokens: number }> }) => (
<div data-testid="downstream-trend-chart">{`trend:${buckets.length}`}</div>
),
}));
vi.mock('../components/ModernSelect.js', () => ({
default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => (
<select value={value} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
),
}));
function collectText(node: ReactTestInstance): string {
return (node.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) === text
));
}
function findGhostButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
return root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node) === text
&& String(node.props.className || '').includes('btn btn-ghost')
));
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
function buildSummaryItem(id: number, overrides?: Partial<any>) {
return {
id,
name: `mobile-key-${id}`,
keyMasked: `sk-k****${id}`,
enabled: true,
description: 'mobile smoke',
groupName: '项目A',
tags: ['移动端'],
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
createdAt: '2026-03-15T08:27:25.378Z',
updatedAt: '2026-03-15T08:27:25.378Z',
rangeUsage: {
totalRequests: 3,
successRequests: 2,
failedRequests: 1,
successRate: 66.7,
totalTokens: 4200,
totalCost: 0.42,
},
...overrides,
};
}
function buildRawItem(id: number, overrides?: Partial<any>) {
return {
id,
name: `mobile-key-${id}`,
key: `sk-mobile-${id}`,
keyMasked: `sk-k****${id}`,
description: 'mobile smoke',
groupName: '项目A',
tags: ['移动端'],
enabled: true,
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
...overrides,
};
}
describe('DownstreamKeys mobile layout', () => {
beforeEach(() => {
vi.clearAllMocks();
(globalThis as any).document = {
body: { style: {} },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({
success: true,
items: [buildSummaryItem(1), buildSummaryItem(2)],
});
apiMock.getDownstreamApiKeys.mockResolvedValue({
success: true,
items: [buildRawItem(1), buildRawItem(2)],
});
apiMock.getRoutesLite.mockResolvedValue([
{ id: 11, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
]);
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
success: true,
item: buildSummaryItem(1),
usage: {
last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 },
last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 },
all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 },
},
});
apiMock.getDownstreamApiKeyTrend.mockResolvedValue({
success: true,
buckets: [
{ startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 },
],
});
apiMock.createDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.batchDownstreamApiKeys.mockResolvedValue({ success: true, successIds: [1, 2], failedItems: [] });
apiMock.updateDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.deleteDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.resetDownstreamApiKeyUsage.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
delete (globalThis as any).document;
});
it('renders mobile cards and supports select-all-visible batch actions', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const cards = root!.root.findAll((node) => node.props?.className === 'mobile-card');
expect(cards.length).toBe(2);
expect(collectText(root!.root)).toContain('全选可见');
const selectAllButton = findButtonByText(root!.root, '全选可见');
await act(async () => {
selectAllButton.props.onClick();
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('已选 2 个密钥');
expect(collectText(root!.root)).toContain('取消全选');
const disableButton = findGhostButtonByText(root!.root, '禁用');
await act(async () => {
disableButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith({
ids: [1, 2],
action: 'disable',
});
} finally {
root?.unmount();
}
});
});
+163 -66
View File
@@ -3,9 +3,13 @@ import { createPortal } from 'react-dom';
import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
import MobileBatchBar from '../components/MobileBatchBar.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { useIsMobile } from '../components/useIsMobile.js';
import { tr } from '../i18n.js';
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
@@ -1199,6 +1203,7 @@ export default function DownstreamKeys() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [showFilters, setShowFilters] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmState>(null);
const [batchMetadataOpen, setBatchMetadataOpen] = useState(false);
const [batchMetadataForm, setBatchMetadataForm] = useState<BatchMetadataForm>({
@@ -1207,6 +1212,7 @@ export default function DownstreamKeys() {
tagOperation: 'keep',
tags: [],
});
const isMobile = useIsMobile();
const load = async () => {
setLoading(true);
@@ -1571,6 +1577,69 @@ export default function DownstreamKeys() {
await batchRun('批量归类', selectedIds);
};
const filterControls = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div className="toolbar" style={{ marginBottom: 0, alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 420px', minWidth: 280, flexWrap: 'wrap' }}>
<div className="toolbar-search" style={{ maxWidth: 'unset', flex: '1 1 320px' }}>
<svg width="16" height="16" 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>
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="搜索名称、备注、模型、主分组或标签"
/>
</div>
<InlineToggle value={tagMatchMode} onChange={setTagMatchMode} />
</div>
<div style={{ minWidth: 170 }}>
<ModernSelect value={status} onChange={(value) => setStatus((value as Status) || 'all')} options={statusOptions} />
</div>
<div style={{ minWidth: 170 }}>
<ModernSelect value={groupFilter} onChange={(value) => setGroupFilter(String(value || '__all__'))} options={groupFilterOptions} />
</div>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => { setSearchInput(''); setStatus('all'); setGroupFilter('__all__'); setSelectedTags([]); setTagMatchMode('any'); }}>
</button>
</div>
{(activeTagFilters.length > 0 || tagSuggestions.length > 0) ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{activeTagFilters.map((tag) => {
const fromPinnedTags = selectedTags.some((item) => item.toLowerCase() === tag.toLowerCase());
return (
<button
key={tag}
className="btn btn-ghost"
style={{ ...tagChipStyle('accent'), cursor: 'pointer', opacity: fromPinnedTags ? 1 : 0.82 }}
onClick={() => {
if (fromPinnedTags) {
setSelectedTags((current) => current.filter((item) => item.toLowerCase() !== tag.toLowerCase()));
return;
}
setSearchInput((current) => current
.split(/[\r\n,]+/g)
.map((item) => item.trim())
.filter(Boolean)
.filter((item) => item.toLowerCase() !== tag.toLowerCase())
.join(', '));
}}
>
{tag} ×
</button>
);
})}
{tagSuggestions.filter((tag) => !activeTagFilters.some((current) => current.toLowerCase() === tag.toLowerCase())).slice(0, 8).map((tag) => (
<button key={tag} className="btn btn-ghost" style={tagChipStyle()} onClick={() => addTagFilter(tag)}>
{tag}
</button>
))}
</div>
) : null}
</div>
);
const empty = !loading && visibleItems.length === 0;
return (
@@ -1618,14 +1687,24 @@ export default function DownstreamKeys() {
</div>
{selectedIds.length > 0 ? (
<div className="card" style={{ padding: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}> {selectedIds.length} </span>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={openBatchMetadata} disabled={batchActionLoading}>/</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量启用', selectedIds)} disabled={batchActionLoading}></button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量禁用', selectedIds)} disabled={batchActionLoading}></button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量清零用量', selectedIds)} disabled={batchActionLoading}></button>
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'batch', ids: [...selectedIds] })} disabled={batchActionLoading}></button>
</div>
isMobile ? (
<MobileBatchBar info={`已选 ${selectedIds.length} 个密钥`}>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={openBatchMetadata} disabled={batchActionLoading}>/</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量启用', selectedIds)} disabled={batchActionLoading}></button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量禁用', selectedIds)} disabled={batchActionLoading}></button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量清零用量', selectedIds)} disabled={batchActionLoading}></button>
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'batch', ids: [...selectedIds] })} disabled={batchActionLoading}></button>
</MobileBatchBar>
) : (
<div className="card" style={{ padding: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}> {selectedIds.length} </span>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={openBatchMetadata} disabled={batchActionLoading}>/</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量启用', selectedIds)} disabled={batchActionLoading}></button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量禁用', selectedIds)} disabled={batchActionLoading}></button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量清零用量', selectedIds)} disabled={batchActionLoading}></button>
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'batch', ids: [...selectedIds] })} disabled={batchActionLoading}></button>
</div>
)
) : null}
<div className="card" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -1635,65 +1714,28 @@ export default function DownstreamKeys() {
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}></div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}></div>
</div>
</div>
<div className="toolbar" style={{ marginBottom: 0, alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 420px', minWidth: 280, flexWrap: 'wrap' }}>
<div className="toolbar-search" style={{ maxWidth: 'unset', flex: '1 1 320px' }}>
<svg width="16" height="16" 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>
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="搜索名称、备注、模型、主分组或标签"
/>
</div>
<InlineToggle value={tagMatchMode} onChange={setTagMatchMode} />
</div>
<div style={{ minWidth: 170 }}>
<ModernSelect value={status} onChange={(value) => setStatus((value as Status) || 'all')} options={statusOptions} />
</div>
<div style={{ minWidth: 170 }}>
<ModernSelect value={groupFilter} onChange={(value) => setGroupFilter(String(value || '__all__'))} options={groupFilterOptions} />
</div>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => { setSearchInput(''); setStatus('all'); setGroupFilter('__all__'); setSelectedTags([]); setTagMatchMode('any'); }}>
</button>
</div>
{(activeTagFilters.length > 0 || tagSuggestions.length > 0) ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{activeTagFilters.map((tag) => {
const fromPinnedTags = selectedTags.some((item) => item.toLowerCase() === tag.toLowerCase());
return (
<button
key={tag}
className="btn btn-ghost"
style={{ ...tagChipStyle('accent'), cursor: 'pointer', opacity: fromPinnedTags ? 1 : 0.82 }}
onClick={() => {
if (fromPinnedTags) {
setSelectedTags((current) => current.filter((item) => item.toLowerCase() !== tag.toLowerCase()));
return;
}
setSearchInput((current) => current
.split(/[\r\n,]+/g)
.map((item) => item.trim())
.filter(Boolean)
.filter((item) => item.toLowerCase() !== tag.toLowerCase())
.join(', '));
}}
>
{tag} ×
</button>
);
})}
{tagSuggestions.filter((tag) => !activeTagFilters.some((current) => current.toLowerCase() === tag.toLowerCase())).slice(0, 8).map((tag) => (
<button key={tag} className="btn btn-ghost" style={tagChipStyle()} onClick={() => addTagFilter(tag)}>
{tag}
{isMobile && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => setShowFilters(true)}>
</button>
))}
</div>
) : null}
<button
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => toggleSelectAllVisible(!allVisibleSelected)}
>
{allVisibleSelected ? '取消全选' : '全选可见'}
</button>
</div>
)}
</div>
{isMobile ? (
<MobileFilterSheet open={showFilters} onClose={() => setShowFilters(false)} title="筛选下游密钥">
{filterControls}
</MobileFilterSheet>
) : (
filterControls
)}
</div>
{loading ? (
@@ -1703,6 +1745,61 @@ export default function DownstreamKeys() {
<div className="empty-state-title"></div>
<div className="empty-state-desc"></div>
</div>
) : isMobile ? (
<div className="mobile-card-list">
{visibleItems.map((row) => {
const loadingToggle = !!rowLoading[`toggle-${row.id}`];
const loadingReset = !!rowLoading[`reset-${row.id}`];
const loadingDelete = !!rowLoading[`delete-${row.id}`];
const checked = selectedIds.includes(row.id);
return (
<MobileCard
key={row.id}
title={row.name}
headerActions={(
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StatusBadge enabled={row.enabled} />
<input
type="checkbox"
aria-label={`选择 ${row.name}`}
checked={checked}
onChange={(e) => toggleSelection(row.id, e.target.checked)}
/>
</div>
)}
footerActions={(
<>
<button className="btn btn-link" onClick={() => { setSelectedId(row.id); setDrawerOpen(true); }}></button>
<button className="btn btn-link" onClick={() => openEdit(row)}></button>
<button className="btn btn-link" onClick={() => void toggleEnabled(row)} disabled={loadingToggle}>{loadingToggle ? '处理中...' : (row.enabled ? '禁用' : '启用')}</button>
<button className="btn btn-link" onClick={() => void resetUsage(row)} disabled={loadingReset}>{loadingReset ? '处理中...' : '清零用量'}</button>
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'single', item: row })} disabled={loadingDelete}>{loadingDelete ? '处理中...' : '删除'}</button>
</>
)}
>
<MobileField
label="密钥"
value={(
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-text-muted)' }}>{row.keyMasked}</span>
<DownstreamKeyCopyIconButton fullKey={row.key} />
</span>
)}
stacked
/>
{row.description ? <MobileField label="备注" value={row.description} stacked /> : null}
<MobileField label="主分组" value={row.groupName || '未分组'} />
<MobileField label="标签" value={summarizeTags(row.tags || [])} stacked />
<MobileField label="模型" value={summarizeModelLimit(row.supportedModels || [])} stacked />
<MobileField label="群组" value={summarizeRouteLimit(row.allowedRouteIds || [], routeMap)} stacked />
<MobileField label="倍率" value={summarizeSiteWeightMultipliers(row.siteWeightMultipliers || {})} stacked />
<MobileField label="额度" value={`${row.maxRequests == null ? '不限' : row.maxRequests.toLocaleString()} / ${row.maxCost == null ? '成本不限' : formatMoney(row.maxCost)}`} stacked />
<MobileField label="用量" value={`${(row.rangeUsage?.totalRequests || 0).toLocaleString()} 请求 · ${formatCompactTokens(row.rangeUsage?.totalTokens || 0)}`} stacked />
<MobileField label="最近使用" value={formatIso(row.lastUsedAt)} stacked />
</MobileCard>
);
})}
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="data-table" style={{ width: '100%' }}>
+21 -16
View File
@@ -55,6 +55,7 @@ import {
} from './helpers/conversationFileCapabilities.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { useIsMobile } from '../components/useIsMobile.js';
import { tr } from '../i18n.js';
type ChatJobResponse = {
@@ -646,6 +647,7 @@ function ParameterRow(props: {
}
export default function ModelTester() {
const isMobile = useIsMobile();
const [models, setModels] = useState<string[]>([]);
const [modelSearch, setModelSearch] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>([]);
@@ -2181,7 +2183,9 @@ export default function ModelTester() {
return debugResponse;
}, [activeDebugTab, debugPreview, debugRequest, debugResponse]);
const layoutColumns = debugPanelPresence.shouldRender
const layoutColumns = isMobile
? '1fr'
: debugPanelPresence.shouldRender
? '340px minmax(0, 1fr) 360px'
: '340px minmax(0, 1fr)';
@@ -2239,7 +2243,7 @@ export default function ModelTester() {
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 16 }} className="animate-slide-up stagger-1">
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? 'repeat(2, minmax(0, 1fr))' : 'repeat(4, 1fr)', gap: 12, marginBottom: 16 }} className="animate-slide-up stagger-1">
<div className="stat-summary-card stat-summary-purple">
<div className="stat-summary-card-label"></div>
<div className="stat-summary-card-value">{models.length}</div>
@@ -2279,7 +2283,7 @@ export default function ModelTester() {
alignItems: 'stretch',
}}
>
<div className="card" style={{ padding: 16, minHeight: 680, maxHeight: 740, overflowY: 'auto' }}>
<div className="card" style={{ padding: 16, minHeight: isMobile ? 'auto' : 680, maxHeight: isMobile ? 'none' : 740, overflowY: isMobile ? 'visible' : 'auto', order: isMobile ? 2 : 0 }}>
<h3 style={{ margin: '0 0 12px', fontSize: 15 }}></h3>
<div style={{ marginBottom: 14 }}>
@@ -2299,7 +2303,7 @@ export default function ModelTester() {
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6, fontWeight: 600 }}></div>
<div style={{ display: 'flex', gap: 8, marginBottom: 6 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 6, flexDirection: isMobile ? 'column' : 'row' }}>
<input
value={modelSearch}
onChange={(event) => setModelSearch(event.target.value)}
@@ -2575,7 +2579,7 @@ export default function ModelTester() {
</ParameterRow>
</div>
<div className="card" style={{ padding: 0, overflow: 'hidden', minHeight: 680, maxHeight: 740, display: 'flex', flexDirection: 'column' }}>
<div className="card" style={{ padding: 0, overflow: 'hidden', minHeight: isMobile ? 'auto' : 680, maxHeight: isMobile ? 'none' : 740, display: 'flex', flexDirection: 'column', order: isMobile ? 1 : 0 }}>
<div style={{
padding: '14px 16px',
borderBottom: '1px solid var(--color-border-light)',
@@ -2699,7 +2703,7 @@ export default function ModelTester() {
{isUser ? 'U' : (isSystem ? 'SYS' : 'AI')}
</div>
<div style={{ maxWidth: '78%', display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ maxWidth: isMobile ? '100%' : '78%', display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0, flex: isMobile ? 1 : 'initial' }}>
{showReasoning && (
<div style={{
border: '1px solid color-mix(in srgb, var(--color-primary) 28%, transparent)',
@@ -2854,7 +2858,7 @@ export default function ModelTester() {
)}
{inputs.mode === 'conversation' ? (
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end' }}>
<div style={{ display: 'flex', gap: 10, alignItems: isMobile ? 'stretch' : 'flex-end', flexDirection: isMobile ? 'column' : 'row' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{
padding: '10px 12px',
@@ -2983,16 +2987,17 @@ export default function ModelTester() {
disabled={sending ? false : !canSend}
className="btn btn-primary"
style={{
height: 78,
padding: '0 20px',
height: isMobile ? 50 : 78,
padding: isMobile ? '0 16px' : '0 20px',
fontSize: 14,
fontWeight: 600,
display: 'flex',
flexDirection: 'column',
flexDirection: isMobile ? 'row' : 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
minWidth: 88,
minWidth: isMobile ? '100%' : 88,
width: isMobile ? '100%' : 'auto',
}}
>
{sending ? (
@@ -3030,7 +3035,7 @@ export default function ModelTester() {
placeholder="输入搜索查询"
style={{ ...inputBaseStyle, resize: 'vertical' }}
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 120px', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr 120px', gap: 10 }}>
<input value={searchAllowedDomains} onChange={(event) => setSearchAllowedDomains(event.target.value)} placeholder="allowed_domains (逗号分隔)" style={inputBaseStyle} />
<input value={searchBlockedDomains} onChange={(event) => setSearchBlockedDomains(event.target.value)} placeholder="blocked_domains (逗号分隔)" style={inputBaseStyle} />
<input value={searchMaxResults} onChange={(event) => setSearchMaxResults(toNumber(event.target.value, 10))} type="number" min={1} max={20} style={inputBaseStyle} />
@@ -3047,7 +3052,7 @@ export default function ModelTester() {
style={{ ...inputBaseStyle, resize: 'vertical' }}
/>
{(inputs.mode === 'images.edit' || inputs.mode === 'videos.create') && (
<div style={{ display: 'grid', gridTemplateColumns: inputs.mode === 'images.edit' ? '1fr 1fr' : '1fr', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : (inputs.mode === 'images.edit' ? '1fr 1fr' : '1fr'), gap: 10 }}>
<label style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
<div style={{ marginBottom: 6 }}>{inputs.mode === 'images.edit' ? '原图' : '参考图'}</div>
<input type="file" accept="image/*" onChange={(event) => { void handleUploadChange(event.target.files, setImageSourceFile); }} />
@@ -3063,7 +3068,7 @@ export default function ModelTester() {
</>
)}
{inputs.mode === 'videos.inspect' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 160px', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 160px', gap: 10 }}>
<input
value={videoInspectId}
onChange={(event) => setVideoInspectId(event.target.value)}
@@ -3100,7 +3105,7 @@ export default function ModelTester() {
</div>
{debugPanelPresence.shouldRender && (
<div className={`card panel-presence ${debugPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 14, minHeight: 680, maxHeight: 740, display: 'flex', flexDirection: 'column' }}>
<div className={`card panel-presence ${debugPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 14, minHeight: isMobile ? 'auto' : 680, maxHeight: isMobile ? 'none' : 740, display: 'flex', flexDirection: 'column', order: isMobile ? 3 : 0 }}>
<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)' }}>
@@ -3108,7 +3113,7 @@ export default function ModelTester() {
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
<button
className="btn btn-ghost"
style={{
@@ -32,6 +32,8 @@ async function flushMicrotasks() {
describe('Models marketplace text', () => {
const originalDocument = globalThis.document;
const originalMutationObserver = globalThis.MutationObserver;
const originalWindow = globalThis.window;
const originalMatchMedia = globalThis.matchMedia;
beforeEach(() => {
vi.clearAllMocks();
@@ -75,6 +77,8 @@ describe('Models marketplace text', () => {
vi.clearAllMocks();
globalThis.document = originalDocument;
globalThis.MutationObserver = originalMutationObserver;
globalThis.window = originalWindow;
globalThis.matchMedia = originalMatchMedia;
});
it('renders readable Chinese labels and fallback descriptions for marketplace models', async () => {
@@ -191,6 +195,71 @@ describe('Models marketplace text', () => {
}
});
it('keeps a visible mobile filter entry on small screens', async () => {
const nextWindow = (originalWindow ? { ...originalWindow } : {}) as Window & typeof globalThis;
nextWindow.innerWidth = 768;
nextWindow.addEventListener = nextWindow.addEventListener || (() => {});
nextWindow.removeEventListener = nextWindow.removeEventListener || (() => {});
nextWindow.matchMedia = (() => ({
matches: true,
media: '(max-width: 768px)',
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as typeof window.matchMedia;
globalThis.window = nextWindow;
globalThis.matchMedia = nextWindow.matchMedia;
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('筛选');
} finally {
root?.unmount();
}
});
it('keeps the mobile filter entry visible even while the first screen is still loading', async () => {
globalThis.window = {
innerWidth: 768,
addEventListener: () => {},
removeEventListener: () => {},
} as unknown as Window & typeof globalThis;
apiMock.getModelsMarketplace.mockImplementation(() => new Promise(() => {}));
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
expect(collectText(root!.root)).toContain('筛选');
} finally {
root?.unmount();
}
});
it('limits expanded account and pricing detail to the selected site filter', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
+240 -139
View File
@@ -5,7 +5,9 @@ import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../c
import SiteBadgeLink from '../components/SiteBadgeLink.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { useIsMobile } from '../components/useIsMobile.js';
import { mergeMarketplaceMetadata, shouldHydrateMarketplaceMetadata } from './helpers/modelsMarketplaceMetadata.js';
import { tr } from '../i18n.js';
@@ -164,8 +166,10 @@ export default function Models() {
const [pageSize, setPageSize] = useState(20);
const [copied, setCopied] = useState<string | null>(null);
const [filterCollapsed, setFilterCollapsed] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const [metadataHydrating, setMetadataHydrating] = useState(false);
const filterPanelPresence = useAnimatedVisibility(!filterCollapsed, 220);
const isMobile = useIsMobile();
const filterPanelPresence = useAnimatedVisibility(!isMobile && !filterCollapsed, 220);
const latestPrimaryRequestRef = useRef(0);
const latestMetadataRequestRef = useRef(0);
const location = useLocation();
@@ -242,6 +246,16 @@ export default function Models() {
}
}, []);
useEffect(() => {
if (!isMobile) return;
if (viewMode !== 'card') {
setViewMode('card');
}
if (!filterCollapsed) {
setFilterCollapsed(true);
}
}, [filterCollapsed, isMobile, viewMode]);
useEffect(() => {
let cancelled = false;
let metadataTimer: ReturnType<typeof setTimeout> | null = null;
@@ -387,18 +401,137 @@ export default function Models() {
setTimeout(() => setCopied(null), 1500);
};
const filterControls = (
<>
<div className="filter-panel-section">
<div className="filter-panel-title">
{tr('品牌')}
{activeBrand && <button onClick={() => setActiveBrand(null)}>{tr('重置')}</button>}
</div>
<div
className={`filter-item ${!activeBrand ? 'active' : ''}`}
onClick={() => setActiveBrand(null)}
>
<span className="filter-item-icon" style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}></span>
{tr('全部品牌')}
<span className="filter-item-count">{data.models.length}</span>
</div>
{brandList.list.map(([brandName, { count, brand }]) => (
<div
key={brandName}
className={`filter-item ${activeBrand === brandName ? 'active' : ''}`}
onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
>
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<BrandGlyph brand={brand} size={14} fallbackText={brandName} />
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{brandName}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
{brandList.otherCount > 0 && (
<div
className={`filter-item ${activeBrand === '__other__' ? 'active' : ''}`}
onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')}
>
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', color: 'var(--color-text-muted)', fontSize: 10, borderRadius: 4 }}>?</span>
{tr('其他')}
<span className="filter-item-count">{brandList.otherCount}</span>
</div>
)}
</div>
<div className="filter-panel-section">
<div className="filter-panel-title">
{tr('供应商')}
{activeSite && <button onClick={() => setActiveSite(null)}>{tr('重置')}</button>}
</div>
{siteMap.map(([site, count]) => (
<div
key={site}
className={`filter-item ${activeSite === site ? 'active' : ''}`}
onClick={() => setActiveSite(activeSite === site ? null : site)}
>
<span className="filter-item-icon" style={{ background: hashColor(site), color: 'white', fontSize: 9, borderRadius: 4 }}>
{site.slice(0, 2).toUpperCase()}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
</div>
<div className="filter-panel-section">
<div className="filter-panel-title">{tr('排序方式')}</div>
{[
{ key: 'accountCount' as SortColumn, label: tr('账号数') },
{ key: 'tokenCount' as SortColumn, label: tr('令牌数') },
{ key: 'avgLatency' as SortColumn, label: tr('延迟') },
{ key: 'successRate' as SortColumn, label: tr('成功率') },
{ key: 'name' as SortColumn, label: tr('名称') },
].map(opt => (
<div
key={opt.key}
className={`filter-item ${sortBy === opt.key ? 'active' : ''}`}
onClick={() => {
if (sortBy === opt.key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(opt.key);
setSortDir(opt.key === 'name' ? 'asc' : 'desc');
}
}}
>
{opt.label}
{sortBy === opt.key && (
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--color-primary)' }}>
{sortDir === 'desc' ? '↓' : '↑'}
</span>
)}
</div>
))}
</div>
</>
);
/* ---- loading skeleton ---- */
if (loading) {
return (
<div className="animate-fade-in">
<div className="skeleton" style={{ width: 260, height: 28, marginBottom: 20 }} />
<div style={{ display: 'flex', gap: 24 }}>
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
{!isMobile && (
<div style={{ width: 240 }}>
{[...Array(6)].map((_, i) => <div key={i} className="skeleton" style={{ height: 28, marginBottom: 8, borderRadius: 8 }} />)}
</div>
<div style={{ flex: 1 }}>
{[...Array(4)].map((_, i) => <div key={i} className="skeleton" style={{ height: 100, marginBottom: 12, borderRadius: 12 }} />)}
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="page-header" style={{ marginBottom: 16 }}>
<div>
<div className="skeleton" style={{ width: 220, height: 28, marginBottom: 8 }} />
<div className="skeleton" style={{ width: 160, height: 16 }} />
</div>
<div className="page-actions">
{isMobile && (
<button
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '6px 12px' }}
onClick={() => setShowFilters(true)}
>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" /></svg>
{tr('筛选')}
</button>
)}
</div>
</div>
{isMobile && (
<MobileFilterSheet
open={showFilters}
onClose={() => setShowFilters(false)}
title={tr('筛选模型')}
>
{filterControls}
</MobileFilterSheet>
)}
{[...Array(4)].map((_, i) => <div key={i} className="skeleton" style={{ height: 100, marginBottom: 12, borderRadius: 12 }} />)}
</div>
</div>
);
@@ -406,101 +539,9 @@ export default function Models() {
return (
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
{/* ====== LEFT: Filter Panel ====== */}
{filterPanelPresence.shouldRender && (
{!isMobile && 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">
{tr('品牌')}
{activeBrand && <button onClick={() => setActiveBrand(null)}>{tr('重置')}</button>}
</div>
<div
className={`filter-item ${!activeBrand ? 'active' : ''}`}
onClick={() => setActiveBrand(null)}
>
<span className="filter-item-icon" style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}></span>
{tr('全部品牌')}
<span className="filter-item-count">{data.models.length}</span>
</div>
{brandList.list.map(([brandName, { count, brand }]) => (
<div
key={brandName}
className={`filter-item ${activeBrand === brandName ? 'active' : ''}`}
onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
>
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<BrandGlyph brand={brand} size={14} fallbackText={brandName} />
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{brandName}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
{brandList.otherCount > 0 && (
<div
className={`filter-item ${activeBrand === '__other__' ? 'active' : ''}`}
onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')}
>
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', color: 'var(--color-text-muted)', fontSize: 10, borderRadius: 4 }}>?</span>
{tr('其他')}
<span className="filter-item-count">{brandList.otherCount}</span>
</div>
)}
</div>
{/* Supplier filter */}
<div className="filter-panel-section">
<div className="filter-panel-title">
{tr('供应商')}
{activeSite && <button onClick={() => setActiveSite(null)}>{tr('重置')}</button>}
</div>
{siteMap.map(([site, count]) => (
<div
key={site}
className={`filter-item ${activeSite === site ? 'active' : ''}`}
onClick={() => setActiveSite(activeSite === site ? null : site)}
>
<span className="filter-item-icon" style={{ background: hashColor(site), color: 'white', fontSize: 9, borderRadius: 4 }}>
{site.slice(0, 2).toUpperCase()}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
</div>
{/* Sort */}
<div className="filter-panel-section">
<div className="filter-panel-title">{tr('排序方式')}</div>
{[
{ key: 'accountCount' as SortColumn, label: tr('账号数') },
{ key: 'tokenCount' as SortColumn, label: tr('令牌数') },
{ key: 'avgLatency' as SortColumn, label: tr('延迟') },
{ key: 'successRate' as SortColumn, label: tr('成功率') },
{ key: 'name' as SortColumn, label: tr('名称') },
].map(opt => (
<div
key={opt.key}
className={`filter-item ${sortBy === opt.key ? 'active' : ''}`}
onClick={() => {
if (sortBy === opt.key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(opt.key);
setSortDir(opt.key === 'name' ? 'asc' : 'desc');
}
}}
>
{opt.label}
{sortBy === opt.key && (
<span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--color-primary)' }}>
{sortDir === 'desc' ? '↓' : '↑'}
</span>
)}
</div>
))}
</div>
{filterControls}
<button
className="btn btn-ghost"
style={{ width: '100%', fontSize: 12, padding: '6px 10px', marginTop: 8, justifyContent: 'center', border: '1px solid var(--color-border)' }}
@@ -529,8 +570,18 @@ export default function Models() {
)}
</div>
<div className="page-actions">
{filterCollapsed && (
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '6px 12px' }} onClick={() => setFilterCollapsed(false)}>
{(isMobile || filterCollapsed) && (
<button
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '6px 12px' }}
onClick={() => {
if (isMobile) {
setShowFilters(true);
return;
}
setFilterCollapsed(false);
}}
>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" /></svg>
{tr('筛选')}
</button>
@@ -543,17 +594,29 @@ export default function Models() {
{metadataHydrating && (
<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')} 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')} 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>
{!isMobile && (
<div className="view-toggle">
<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')} 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>
)}
</div>
</div>
{isMobile && (
<MobileFilterSheet
open={showFilters}
onClose={() => setShowFilters(false)}
title={tr('筛选模型')}
>
{filterControls}
</MobileFilterSheet>
)}
{/* Toolbar */}
<div className="toolbar">
<div className="toolbar-search">
@@ -719,36 +782,74 @@ export default function Models() {
</div>
</div>
<table className="data-table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ fontWeight: 500 }}>{tr('站点')}</th>
<th style={{ fontWeight: 500 }}>{tr('账号')}</th>
<th style={{ fontWeight: 500 }}>{tr('令牌')}</th>
<th style={{ fontWeight: 500 }}>{tr('延迟')}</th>
<th style={{ fontWeight: 500 }}>{tr('余额')}</th>
</tr>
</thead>
<tbody>
{m.accounts.map(a => (
<tr key={a.id}>
<td><SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} /></td>
<td style={{ fontSize: 12 }}>{a.username || `ID:${a.id}`}</td>
<td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{a.tokens.length > 0 ? a.tokens.map(t => (
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>{t.name}</span>
)) : <span style={{ color: 'var(--color-text-muted)' }}></span>}
</td>
<td>
{a.latency != null ? (
<span style={{ color: getMetricColor(a.latency), fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{a.latency}ms</span>
) : '—'}
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>${(a.balance || 0).toFixed(2)}</td>
</tr>
{isMobile ? (
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-secondary)' }}>{tr('账号明细')}</div>
{m.accounts.map((a) => (
<div
key={a.id}
className="card"
style={{ padding: 10, display: 'grid', gap: 8 }}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
<SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} />
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{a.username || `ID:${a.id}`}</span>
</div>
<div style={{ display: 'grid', gap: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 12 }}>
<span style={{ color: 'var(--color-text-muted)' }}>{tr('延迟')}</span>
<span style={{ color: getMetricColor(a.latency), fontVariantNumeric: 'tabular-nums' }}>
{a.latency != null ? `${a.latency}ms` : '—'}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 12 }}>
<span style={{ color: 'var(--color-text-muted)' }}>{tr('余额')}</span>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>${(a.balance || 0).toFixed(2)}</span>
</div>
<div style={{ display: 'grid', gap: 6 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{tr('令牌')}</span>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{a.tokens.length > 0 ? a.tokens.map((t) => (
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>{t.name}</span>
)) : <span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}></span>}
</div>
</div>
</div>
</div>
))}
</tbody>
</table>
</div>
) : (
<table className="data-table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ fontWeight: 500 }}>{tr('站点')}</th>
<th style={{ fontWeight: 500 }}>{tr('账号')}</th>
<th style={{ fontWeight: 500 }}>{tr('令牌')}</th>
<th style={{ fontWeight: 500 }}>{tr('延迟')}</th>
<th style={{ fontWeight: 500 }}>{tr('余额')}</th>
</tr>
</thead>
<tbody>
{m.accounts.map(a => (
<tr key={a.id}>
<td><SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} /></td>
<td style={{ fontSize: 12 }}>{a.username || `ID:${a.id}`}</td>
<td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{a.tokens.length > 0 ? a.tokens.map(t => (
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>{t.name}</span>
)) : <span style={{ color: 'var(--color-text-muted)' }}></span>}
</td>
<td>
{a.latency != null ? (
<span style={{ color: getMetricColor(a.latency), fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{a.latency}ms</span>
) : '—'}
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>${(a.balance || 0).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
+127 -28
View File
@@ -1,6 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js';
import { useToast } from '../components/Toast.js';
import { useIsMobile } from '../components/useIsMobile.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import ModernSelect from '../components/ModernSelect.js';
import { tr } from '../i18n.js';
@@ -94,6 +97,8 @@ export default function ProgramLogs() {
const [markingAll, setMarkingAll] = useState(false);
const [clearing, setClearing] = useState(false);
const [rowLoading, setRowLoading] = useState<Record<number, boolean>>({});
const [showFilters, setShowFilters] = useState(false);
const isMobile = useIsMobile();
const toast = useToast();
const load = async (silent = false, append = false) => {
@@ -208,37 +213,85 @@ export default function ProgramLogs() {
</div>
</div>
<div className="card" style={{ padding: 14, marginBottom: 12, display: 'flex', gap: 10, alignItems: 'center' }}>
<div style={{ minWidth: 170 }}>
<ModernSelect
size="sm"
value={filterType}
onChange={(nextValue) => setFilterType(nextValue)}
options={TYPE_OPTIONS.map((item) => ({
value: item.value,
label: item.label,
}))}
placeholder="全部类型"
/>
</div>
{isMobile ? (
<>
<div className="mobile-filter-row">
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => setShowFilters(true)}
>
</button>
</div>
<MobileFilterSheet
open={showFilters}
onClose={() => setShowFilters(false)}
title="筛选程序日志"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<ModernSelect
size="sm"
value={filterType}
onChange={(nextValue) => setFilterType(nextValue)}
options={TYPE_OPTIONS.map((item) => ({
value: item.value,
label: item.label,
}))}
placeholder="全部类型"
/>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={onlyUnread}
onChange={(e) => {
setOffset(0);
setHasMore(true);
setOnlyUnread(e.target.checked);
}}
/>
</label>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{visibleRows.length}
</div>
</div>
</MobileFilterSheet>
</>
) : (
<div className="card" style={{ padding: 14, marginBottom: 12, display: 'flex', gap: 10, alignItems: 'center' }}>
<div style={{ minWidth: 170 }}>
<ModernSelect
size="sm"
value={filterType}
onChange={(nextValue) => setFilterType(nextValue)}
options={TYPE_OPTIONS.map((item) => ({
value: item.value,
label: item.label,
}))}
placeholder="全部类型"
/>
</div>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={onlyUnread}
onChange={(e) => {
setOffset(0);
setHasMore(true);
setOnlyUnread(e.target.checked);
}}
/>
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={onlyUnread}
onChange={(e) => {
setOffset(0);
setHasMore(true);
setOnlyUnread(e.target.checked);
}}
/>
</label>
<div style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--color-text-muted)' }}>
{visibleRows.length}
<div style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--color-text-muted)' }}>
{visibleRows.length}
</div>
</div>
</div>
)}
<div className="card" style={{ overflowX: 'auto' }}>
{loading ? (
@@ -247,6 +300,52 @@ export default function ProgramLogs() {
<div className="skeleton" style={{ width: '100%', height: 34, marginBottom: 8 }} />
<div className="skeleton" style={{ width: '100%', height: 34 }} />
</div>
) : isMobile ? (
<div className="mobile-card-list">
{visibleRows.length > 0 ? visibleRows.map((row) => {
const level = levelLabel(row.level || 'info');
const eventStatus = eventStatusLabel(row);
return (
<MobileCard
key={row.id}
title={row.title || '-'}
headerActions={(
<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 10 }}>
{eventStatus.label}
</span>
)}
footerActions={(
row.read ? (
<span className="badge badge-muted" style={{ fontSize: 11 }}></span>
) : (
<button
type="button"
onClick={() => markOneRead(row.id)}
disabled={!!rowLoading[row.id]}
className="btn btn-link btn-link-primary"
>
{rowLoading[row.id] ? <span className="spinner spinner-sm" /> : '标记已读'}
</button>
)
)}
>
<MobileField label="时间" value={formatDateTimeLocal(row.createdAt)} />
<MobileField label="类型" value={<span className="badge badge-muted" style={{ fontSize: 11 }}>{row.type || '-'}</span>} />
<MobileField label="级别" value={<span className={`badge ${level.cls}`} style={{ fontSize: 11 }}>{level.label}</span>} />
<MobileField label="状态" value={<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 11 }}>{eventStatus.label}</span>} />
<MobileField label="内容" value={row.message || '-'} stacked />
</MobileCard>
);
}) : (
<div className="empty-state">
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div className="empty-state-title"></div>
<div className="empty-state-desc"></div>
</div>
)}
</div>
) : visibleRows.length > 0 ? (
<table className="data-table program-logs-table">
<colgroup>
+54 -27
View File
@@ -13,7 +13,7 @@ import { useToast } from '../components/Toast.js';
import { ModelBadge } from '../components/BrandIcon.js';
import SiteBadgeLink from '../components/SiteBadgeLink.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import { MobileDrawer } from '../components/MobileDrawer.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js';
import { useIsMobile } from '../components/useIsMobile.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import ModernSelect from '../components/ModernSelect.js';
@@ -673,11 +673,13 @@ export default function ProxyLogs() {
</button>
</div>
<MobileDrawer open={showFilters} onClose={() => setShowFilters(false)}>
<div className="mobile-filter-panel">
{filterControls}
</div>
</MobileDrawer>
<MobileFilterSheet
open={showFilters}
onClose={() => setShowFilters(false)}
title={tr('筛选日志')}
>
{filterControls}
</MobileFilterSheet>
</>
) : (
<div className="toolbar" style={{ marginBottom: 12 }}>
@@ -716,30 +718,64 @@ export default function ProxyLogs() {
const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : [];
const downstreamKeySummary = renderDownstreamKeySummary(detailLog);
const isExpanded = expanded === log.id;
const clientDisplay = resolveProxyLogClientDisplay(detailLog);
return (
<MobileCard
key={log.id}
title={detailLog.modelRequested || 'unknown'}
actions={(
subtitle={formatDateTimeLocal(log.createdAt)}
compact
headerActions={(
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 10 }}>
{log.status === 'success' ? '成功' : '失败'}
</span>
)}
footerActions={(
<button
type="button"
className="btn btn-link"
onClick={() => handleToggleExpand(log.id)}
>
{isExpanded ? '收起详情' : '详情'}
</button>
)}
>
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
<MobileField label="站点" value={<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />} />
<MobileField label="客户端" value={renderProxyLogClientCell(detailLog)} />
{downstreamKeySummary ? <MobileField label="下游 Key" value={downstreamKeySummary} /> : null}
<MobileField label="用时" value={formatLatency(log.latencyMs)} />
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
<MobileField label="输出" value={log.completionTokens?.toLocaleString() || '-'} />
<MobileField
label="花费"
value={typeof log.estimatedCost === 'number' ? `$${log.estimatedCost.toFixed(6)}` : '-'}
/>
<div className="mobile-inline-meta-row">
<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />
{clientDisplay.primary ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{clientDisplay.primary}
</span>
) : null}
{clientDisplay.secondary ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{clientDisplay.secondary}
</span>
) : null}
</div>
<div className="mobile-summary-grid">
<div className="mobile-summary-metric">
<div className="mobile-summary-metric-label"></div>
<div className="mobile-summary-metric-value">{formatLatency(log.latencyMs)}</div>
</div>
<div className="mobile-summary-metric">
<div className="mobile-summary-metric-label"></div>
<div className="mobile-summary-metric-value">{log.promptTokens?.toLocaleString() || '-'}</div>
</div>
<div className="mobile-summary-metric">
<div className="mobile-summary-metric-label"></div>
<div className="mobile-summary-metric-value">{log.completionTokens?.toLocaleString() || '-'}</div>
</div>
<div className="mobile-summary-metric">
<div className="mobile-summary-metric-label"></div>
<div className="mobile-summary-metric-value">{typeof log.estimatedCost === 'number' ? `$${log.estimatedCost.toFixed(6)}` : '-'}</div>
</div>
</div>
{isExpanded ? (
<div className="mobile-card-extra">
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
<MobileField label="站点" value={<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />} />
<MobileField label="重试" value={log.retryCount > 0 ? log.retryCount : 0} />
{detailState?.loading && <div style={{ color: 'var(--color-text-muted)' }}>...</div>}
{detailState?.error && <div style={{ color: 'var(--color-danger)' }}>{detailState.error}</div>}
@@ -758,15 +794,6 @@ export default function ProxyLogs() {
)}
</div>
) : null}
<div className="mobile-card-actions">
<button
type="button"
className="btn btn-link"
onClick={() => handleToggleExpand(log.id)}
>
{isExpanded ? '收起' : '详情'}
</button>
</div>
</MobileCard>
);
})}
+9 -7
View File
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { useIsMobile } from '../components/useIsMobile.js';
import ChangeKeyModal from '../components/ChangeKeyModal.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { BrandGlyph, InlineBrandIcon, getBrand, normalizeBrandIconKey } from '../components/BrandIcon.js';
@@ -204,6 +205,7 @@ function resolveRouteBrandSource(route: RouteSelectorItem): string {
export default function Settings() {
const navigate = useNavigate();
const isMobile = useIsMobile();
const [runtime, setRuntime] = useState<RuntimeSettings>({
checkinCron: '0 8 * * *',
checkinScheduleMode: 'cron',
@@ -1020,7 +1022,7 @@ export default function Settings() {
<div className="card animate-slide-up stagger-2" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}></div>
<div style={{ display: 'grid', gridTemplateColumns: '180px 180px auto', gap: 12, alignItems: 'end', marginBottom: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 180px auto', gap: 12, alignItems: 'end', marginBottom: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}></div>
<ModernSelect
@@ -1053,7 +1055,7 @@ export default function Settings() {
{testingCheckin ? '触发中...' : '测试一次签到'}
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}> Cron</div>
<input
@@ -1082,7 +1084,7 @@ export default function Settings() {
}}
>
<div style={{ fontWeight: 600, fontSize: 13 }}></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 160px', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 160px', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}> Cron</div>
<input
@@ -1384,7 +1386,7 @@ export default function Settings() {
<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 }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12 }}>
{([
['baseWeightFactor', '基础权重因子'],
['valueScoreFactor', '价值分因子'],
@@ -1430,7 +1432,7 @@ export default function Settings() {
</div>
<div style={{ display: 'grid', gridTemplateColumns: '180px 1fr', gap: 10, marginBottom: 10, alignItems: 'center' }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 1fr', gap: 10, marginBottom: 10, alignItems: 'center' }}>
<ModernSelect
value={migrationDialect}
onChange={(value) => setMigrationDialect(value as DbDialect)}
@@ -1471,7 +1473,7 @@ export default function Settings() {
/>
) : (
<div style={{ marginBottom: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10, marginBottom: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr 1fr', gap: 10, marginBottom: 8 }}>
<input
value={shorthandConnection.host}
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, host: e.target.value }))}
@@ -1502,7 +1504,7 @@ export default function Settings() {
</button>
</div>
{showShorthandOptional && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 10, marginBottom: 8 }}>
<input
value={shorthandConnection.port}
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, port: e.target.value }))}
+258 -151
View File
@@ -2,9 +2,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js';
import MobileBatchBar from '../components/MobileBatchBar.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
import { useIsMobile } from '../components/useIsMobile.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
@@ -202,7 +205,8 @@ export default function Sites() {
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
const [selectedSiteIds, setSelectedSiteIds] = useState<number[]>([]);
const [expandedSiteIds, setExpandedSiteIds] = useState<number[]>([]);
const isMobile = useIsMobile(768);
const isMobile = useIsMobile();
const [showMobileTools, setShowMobileTools] = useState(false);
const [batchActionLoading, setBatchActionLoading] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<null | {
mode: 'single' | 'batch';
@@ -254,6 +258,7 @@ export default function Sites() {
() => sortItemsForDisplay(sites, sortMode, (site) => site.totalBalance || 0),
[sites, sortMode],
);
const allVisibleSitesSelected = sortedSites.length > 0 && sortedSites.every((site) => selectedSiteIds.includes(site.id));
const platformOptions = useMemo(() => {
const current = form.platform.trim();
@@ -538,10 +543,10 @@ export default function Sites() {
const toggleSelectAllVisible = (checked: boolean) => {
if (!checked) {
setSelectedSiteIds([]);
setSelectedSiteIds((current) => current.filter((id) => !sortedSites.some((site) => site.id === id)));
return;
}
setSelectedSiteIds(sortedSites.map((site) => site.id));
setSelectedSiteIds((current) => Array.from(new Set([...current, ...sortedSites.map((site) => site.id)])));
};
const toggleSiteDetails = (siteId: number) => {
@@ -614,9 +619,52 @@ export default function Sites() {
<div className="page-header">
<h2 className="page-title">{tr('站点管理')}</h2>
<div className="page-actions sites-page-actions">
<div className="sites-sort-select" style={{ minWidth: 156, position: 'relative', zIndex: 20 }}>
{isMobile ? (
<>
<button
type="button"
onClick={() => setShowMobileTools(true)}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
</button>
<button
type="button"
data-testid="sites-mobile-select-all"
onClick={() => toggleSelectAllVisible(!allVisibleSitesSelected)}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{allVisibleSitesSelected ? '取消全选' : '全选可见项'}
</button>
</>
) : (
<div className="sites-sort-select" style={{ minWidth: 156, position: 'relative', zIndex: 20 }}>
<ModernSelect
size="sm"
value={sortMode}
onChange={(nextValue) => setSortMode(nextValue as SortMode)}
options={[
{ value: 'custom', label: '自定义排序' },
{ value: 'balance-desc', label: '余额高到低' },
{ value: 'balance-asc', label: '余额低到高' },
]}
placeholder="自定义排序"
/>
</div>
)}
<button onClick={openAdd} className="btn btn-primary">
{isAdding ? '取消' : '+ 添加站点'}
</button>
</div>
</div>
<MobileFilterSheet open={showMobileTools} onClose={() => setShowMobileTools(false)} title="站点排序与操作">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}></div>
<ModernSelect
size="sm"
value={sortMode}
onChange={(nextValue) => setSortMode(nextValue as SortMode)}
options={[
@@ -627,11 +675,19 @@ export default function Sites() {
placeholder="自定义排序"
/>
</div>
<button onClick={openAdd} className="btn btn-primary">
{isAdding ? '取消' : '+ 添加站点'}
<button
type="button"
onClick={() => {
toggleSelectAllVisible(!allVisibleSitesSelected);
setShowMobileTools(false);
}}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{allVisibleSitesSelected ? '取消全选可见项' : '全选可见项'}
</button>
</div>
</div>
</MobileFilterSheet>
{!isMobile && selectedSiteIds.length > 0 && (
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
@@ -666,9 +722,7 @@ export default function Sites() {
)}
{isMobile && selectedSiteIds.length > 0 && (
<div className="mobile-actions-bar">
<span className="mobile-actions-info"> {selectedSiteIds.length} </span>
<div className="mobile-actions-row">
<MobileBatchBar info={`已选 ${selectedSiteIds.length}`}>
<button
data-testid="sites-batch-enable-system-proxy"
onClick={() => runBatchAction('enableSystemProxy')}
@@ -695,8 +749,7 @@ export default function Sites() {
<button onClick={() => runBatchAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
</button>
</div>
</div>
</MobileBatchBar>
)}
<div className="info-tip" style={{ marginBottom: 12 }}>
@@ -747,54 +800,56 @@ export default function Sites() {
</>
)}
>
<input
placeholder="站点名称"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
style={formInputStyle}
/>
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row' }}>
<ResponsiveFormGrid>
<input
placeholder="站点 URL (例如 https://api.example.com)"
value={form.url}
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
onBlur={() => {
if (form.url.trim() && !form.platform.trim()) {
handleDetect();
}
placeholder="站点名称"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
style={formInputStyle}
/>
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row' }}>
<input
placeholder="站点 URL (例如 https://api.example.com)"
value={form.url}
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
onBlur={() => {
if (form.url.trim() && !form.platform.trim()) {
handleDetect();
}
}}
style={{ ...formInputStyle, flex: 1 }}
/>
<button
onClick={handleDetect}
disabled={detecting || !form.url.trim()}
className="btn btn-ghost"
style={{ padding: '10px 14px', minWidth: 96, border: '1px solid var(--color-border)' }}
>
{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',
}}
style={{ ...formInputStyle, flex: 1 }}
/>
<button
onClick={handleDetect}
disabled={detecting || !form.url.trim()}
className="btn btn-ghost"
style={{ padding: '10px 14px', minWidth: 96, border: '1px solid var(--color-border)' }}
>
{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="平台类型(可自动检测)"
<ModernSelect
value={form.platform}
onChange={(value) => setForm((prev) => ({ ...prev, platform: value }))}
options={platformOptions}
placeholder="平台类型(可自动检测)"
/>
</div>
<input
placeholder="外部签到/福利站点 URL(可选)"
value={form.externalCheckinUrl}
onChange={(e) => setForm((prev) => ({ ...prev, externalCheckinUrl: e.target.value }))}
style={formInputStyle}
/>
</div>
<input
placeholder="外部签到/福利站点 URL(可选)"
value={form.externalCheckinUrl}
onChange={(e) => setForm((prev) => ({ ...prev, externalCheckinUrl: e.target.value }))}
style={formInputStyle}
/>
</ResponsiveFormGrid>
<div
style={{
display: 'flex',
@@ -920,42 +975,48 @@ export default function Sites() {
</div>
)}
</div>
<input
placeholder="站点代理(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080"
value={form.proxyUrl}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
style={formInputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
使使()
</div>
<label style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}>
<input
type="checkbox"
checked={form.useSystemProxy}
onChange={(e) => setForm((prev) => ({ ...prev, useSystemProxy: e.target.checked }))}
/>
使
</label>
<input
placeholder="站点全局权重(默认 1"
value={form.globalWeight}
onChange={(e) => setForm((prev) => ({ ...prev, globalWeight: e.target.value }))}
style={formInputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
0.5-3 1
</div>
<ResponsiveFormGrid>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<input
placeholder="站点代理(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080"
value={form.proxyUrl}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
style={formInputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
使使()
</div>
</div>
<label style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}>
<input
type="checkbox"
checked={form.useSystemProxy}
onChange={(e) => setForm((prev) => ({ ...prev, useSystemProxy: e.target.checked }))}
/>
使
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<input
placeholder="站点全局权重(默认 1"
value={form.globalWeight}
onChange={(e) => setForm((prev) => ({ ...prev, globalWeight: e.target.value }))}
style={formInputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
0.5-3 1
</div>
</div>
</ResponsiveFormGrid>
</CenteredModal>
)}
@@ -968,8 +1029,29 @@ export default function Sites() {
return (
<MobileCard
key={site.id}
title={site.name || '-'}
actions={(
title={(
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span>{site.name || '-'}</span>
{site.url ? (
<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>
) : null}
</div>
)}
headerActions={(
<input
type="checkbox"
aria-label={`选择站点 ${site.name || site.id}`}
@@ -977,6 +1059,30 @@ export default function Sites() {
onChange={(event) => toggleSiteSelection(site.id, event.target.checked)}
/>
)}
footerActions={(
<>
<button
type="button"
onClick={() => toggleSiteDetails(site.id)}
className="btn btn-link"
>
{isExpanded ? '收起' : '详情'}
</button>
<button
onClick={() => openEdit(site)}
className="btn btn-link btn-link-primary"
>
</button>
<button
onClick={() => handleToggleStatus(site)}
disabled={togglingSiteId === site.id}
className={`btn btn-link ${site.status === 'disabled' ? 'btn-link-primary' : 'btn-link-warning'}`}
>
{togglingSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.status === 'disabled' ? '启用' : '禁用')}
</button>
</>
)}
>
<MobileField
label="状态"
@@ -1007,6 +1113,27 @@ export default function Sites() {
<MobileField label="权重" value={(site.globalWeight || 1).toFixed(2)} />
{isExpanded ? (
<div className="mobile-card-extra">
<MobileField
label="主站点 URL"
stacked
value={site.url ? (
<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>
) : '-'}
/>
<MobileField
label="系统代理"
value={(
@@ -1043,63 +1170,43 @@ export default function Sites() {
label="创建时间"
value={formatDateTimeLocal(site.createdAt)}
/>
<div className="mobile-card-actions">
<button
onClick={() => handleTogglePin(site)}
disabled={pinningSiteId === site.id}
className={`btn btn-link ${site.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
>
{pinningSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.isPinned ? '取消置顶' : '置顶')}
</button>
{sortMode === 'custom' && (
<>
<button
onClick={() => handleMoveCustomOrder(site, 'up')}
disabled={orderingSiteId === site.id}
className="btn btn-link btn-link-muted"
>
</button>
<button
onClick={() => handleMoveCustomOrder(site, 'down')}
disabled={orderingSiteId === site.id}
className="btn btn-link btn-link-muted"
>
</button>
</>
)}
<button
onClick={() => handleDelete(site)}
disabled={deleting === site.id}
className="btn btn-link btn-link-danger"
>
{deleting === site.id ? <span className="spinner spinner-sm" /> : null}
</button>
</div>
</div>
) : null}
<div className="mobile-card-actions">
<button
type="button"
onClick={() => toggleSiteDetails(site.id)}
className="btn btn-link"
>
{isExpanded ? '收起' : '详情'}
</button>
<button
onClick={() => handleTogglePin(site)}
disabled={pinningSiteId === site.id}
className={`btn btn-link ${site.isPinned ? 'btn-link-warning' : 'btn-link-primary'}`}
>
{pinningSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.isPinned ? '取消置顶' : '置顶')}
</button>
{sortMode === 'custom' && (
<>
<button
onClick={() => handleMoveCustomOrder(site, 'up')}
disabled={orderingSiteId === site.id}
className="btn btn-link btn-link-muted"
>
</button>
<button
onClick={() => handleMoveCustomOrder(site, 'down')}
disabled={orderingSiteId === site.id}
className="btn btn-link btn-link-muted"
>
</button>
</>
)}
<button
onClick={() => openEdit(site)}
className="btn btn-link btn-link-primary"
>
</button>
<button
onClick={() => handleToggleStatus(site)}
disabled={togglingSiteId === site.id}
className={`btn btn-link ${site.status === 'disabled' ? 'btn-link-primary' : 'btn-link-warning'}`}
>
{togglingSiteId === site.id ? <span className="spinner spinner-sm" /> : (site.status === 'disabled' ? '启用' : '禁用')}
</button>
<button
onClick={() => handleDelete(site)}
disabled={deleting === site.id}
className="btn btn-link btn-link-danger"
>
{deleting === site.id ? <span className="spinner spinner-sm" /> : null}
</button>
</div>
</MobileCard>
);
})}
@@ -1111,7 +1218,7 @@ export default function Sites() {
<th style={{ width: 44 }}>
<input
type="checkbox"
checked={sortedSites.length > 0 && selectedSiteIds.length === sortedSites.length}
checked={allVisibleSitesSelected}
onChange={(e) => toggleSelectAllVisible(e.target.checked)}
/>
</th>
+114 -45
View File
@@ -7,7 +7,7 @@ import { BrandGlyph, getBrand, InlineBrandIcon, type BrandInfo } from '../compon
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import { MobileDrawer } from '../components/MobileDrawer.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js';
import { useIsMobile } from '../components/useIsMobile.js';
import { tr } from '../i18n.js';
import {
@@ -143,7 +143,7 @@ export default function TokenRoutes() {
const [expandedSourceGroupMap, setExpandedSourceGroupMap] = useState<Record<string, boolean>>({});
const [expandedRouteIds, setExpandedRouteIds] = useState<number[]>([]);
const [addChannelModalRouteId, setAddChannelModalRouteId] = useState<number | null>(null);
const isMobile = useIsMobile(768);
const isMobile = useIsMobile();
const {
channelsByRouteId,
@@ -1122,27 +1122,25 @@ export default function TokenRoutes() {
>
{tr('筛选')}
</button>
<MobileDrawer open={showFilters} onClose={() => setShowFilters(false)}>
<div className="mobile-filter-panel">
<RouteFilterBar
totalRouteCount={listVisibleRoutes.length}
activeBrand={activeBrand}
setActiveBrand={setActiveBrand}
activeSite={activeSite}
setActiveSite={setActiveSite}
activeEndpointType={activeEndpointType}
setActiveEndpointType={setActiveEndpointType}
activeGroupFilter={activeGroupFilter}
setActiveGroupFilter={setActiveGroupFilter}
brandList={brandList}
siteList={siteList}
endpointTypeList={endpointTypeList}
groupRouteList={groupRouteList}
collapsed={false}
onToggle={() => setShowFilters(false)}
/>
</div>
</MobileDrawer>
<MobileFilterSheet open={showFilters} onClose={() => setShowFilters(false)} title={tr('筛选路由')}>
<RouteFilterBar
totalRouteCount={listVisibleRoutes.length}
activeBrand={activeBrand}
setActiveBrand={setActiveBrand}
activeSite={activeSite}
setActiveSite={setActiveSite}
activeEndpointType={activeEndpointType}
setActiveEndpointType={setActiveEndpointType}
activeGroupFilter={activeGroupFilter}
setActiveGroupFilter={setActiveGroupFilter}
brandList={brandList}
siteList={siteList}
endpointTypeList={endpointTypeList}
groupRouteList={groupRouteList}
collapsed={false}
onToggle={() => setShowFilters(false)}
/>
</MobileFilterSheet>
</>
) : (
<RouteFilterBar
@@ -1190,32 +1188,103 @@ export default function TokenRoutes() {
{visibleRoutes.map((route) => {
const isExpanded = expandedRouteIds.includes(route.id);
const isReadOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
const exactRoute = isRouteExactModel(route);
const explicitGroupRoute = isExplicitGroupRoute(route);
const channelManagementDisabled = explicitGroupRoute;
if (isMobile) {
return (
<MobileCard
key={route.id}
title={resolveRouteTitle(route)}
actions={(
<span className={`badge ${isReadOnlyRoute ? 'badge-muted' : (route.enabled ? 'badge-success' : 'badge-muted')}`} style={{ fontSize: 10 }}>
{isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))}
</span>
<div key={route.id} style={{ display: 'grid', gap: 8 }}>
<MobileCard
title={resolveRouteTitle(route)}
headerActions={(
<span className={`badge ${isReadOnlyRoute ? 'badge-muted' : (route.enabled ? 'badge-success' : 'badge-muted')}`} style={{ fontSize: 10 }}>
{isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))}
</span>
)}
footerActions={(
<>
<button
type="button"
className="btn btn-link"
onClick={() => toggleExpand(route.id)}
>
{isExpanded ? tr('收起') : tr('详情')}
</button>
{!isReadOnlyRoute && (
<button
type="button"
className="btn btn-link"
onClick={() => handleEditRoute(route)}
>
{tr('编辑')}
</button>
)}
{!isReadOnlyRoute && (
<button
type="button"
className="btn btn-link"
onClick={() => handleToggleRouteEnabled(route)}
>
{route.enabled ? tr('禁用') : tr('启用')}
</button>
)}
{!isReadOnlyRoute && !channelManagementDisabled && (
<button
type="button"
className="btn btn-link"
onClick={() => setAddChannelModalRouteId(route.id)}
>
{tr('添加通道')}
</button>
)}
</>
)}
>
<MobileField label="模型" value={route.modelPattern} stacked />
<MobileField label="通道" value={route.channelCount} />
<MobileField label="策略" value={isReadOnlyRoute ? tr('未生成') : getRouteRoutingStrategyLabel(route.routingStrategy)} />
<MobileField label="状态" value={isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} />
{explicitGroupRoute && (
<MobileField label="模式" value={tr('群组聚合')} />
)}
{!exactRoute && !explicitGroupRoute && (
<MobileField label="模式" value={tr('通配符路由')} />
)}
</MobileCard>
{isExpanded && (
<RouteCard
route={route}
brand={routeBrandById.get(route.id) || null}
expanded
compact
onToggleExpand={stableToggleExpand}
onEdit={stableEditRoute}
onDelete={stableDeleteRoute}
onToggleEnabled={stableToggleEnabled}
onRoutingStrategyChange={stableRoutingStrategyChange}
updatingRoutingStrategy={!!updatingRoutingStrategyByRoute[route.id]}
channels={channelsByRouteId[route.id]}
loadingChannels={!!loadingChannelsByRouteId[route.id]}
routeDecision={decisionByRoute[route.id] || null}
loadingDecision={loadingDecision}
candidateView={getRouteCandidateView(route.id)}
channelTokenDraft={channelTokenDraft}
updatingChannel={updatingChannel}
savingPriority={!!savingPriorityByRoute[route.id]}
onTokenDraftChange={stableTokenDraftChange}
onSaveToken={stableChannelTokenSave}
onDeleteChannel={stableDeleteChannel}
onChannelDragEnd={stableChannelDragEnd}
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS}
missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS}
onCreateTokenForMissing={stableCreateTokenForMissing}
onAddChannel={stableAddChannel}
expandedSourceGroupMap={expandedSourceGroupMap}
onToggleSourceGroup={stableToggleSourceGroup}
/>
)}
>
<MobileField label="模型" value={route.modelPattern} />
<MobileField label="通道" value={route.channelCount} />
<MobileField label="策略" value={isReadOnlyRoute ? tr('未生成') : getRouteRoutingStrategyLabel(route.routingStrategy)} />
<MobileField label="状态" value={isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} />
<div className="mobile-card-actions">
<button
type="button"
className="btn btn-link"
onClick={() => toggleExpand(route.id)}
>
{isExpanded ? '收起' : '详情'}
</button>
</div>
</MobileCard>
</div>
);
}
+258 -167
View File
@@ -2,6 +2,9 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js';
import MobileBatchBar from '../components/MobileBatchBar.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js';
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
import { useToast } from '../components/Toast.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import ModernSelect from '../components/ModernSelect.js';
@@ -117,7 +120,7 @@ function isTruthyFlag(input: string | null): boolean {
export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: TokensPanelProps) {
const location = useLocation();
const navigate = useNavigate();
const isMobile = useIsMobile(768);
const isMobile = useIsMobile();
const initialCreateForm = {
accountId: 0,
name: '',
@@ -145,6 +148,8 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const [pendingAutoOpenTokenId, setPendingAutoOpenTokenId] = useState<number | null>(null);
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
const [selectedTokenIds, setSelectedTokenIds] = useState<number[]>([]);
const [expandedTokenIds, setExpandedTokenIds] = useState<number[]>([]);
const [showMobileTools, setShowMobileTools] = useState(false);
const [batchActionLoading, setBatchActionLoading] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<null | {
mode: 'single' | 'batch';
@@ -303,6 +308,8 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
return Number(left?.id || 0) - Number(right?.id || 0);
});
}, [tokens]);
const allVisibleTokensSelected = accountClusteredTokens.length > 0
&& accountClusteredTokens.every((token) => selectedTokenIds.includes(token.id));
const activeAccounts = useMemo(() => accounts.filter(isAccountSyncable), [accounts]);
@@ -396,10 +403,18 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const toggleSelectAllTokens = (checked: boolean) => {
if (!checked) {
setSelectedTokenIds([]);
setSelectedTokenIds((current) => current.filter((id) => !accountClusteredTokens.some((token) => token.id === id)));
return;
}
setSelectedTokenIds(accountClusteredTokens.map((token) => token.id));
setSelectedTokenIds((current) => Array.from(new Set([...current, ...accountClusteredTokens.map((token) => token.id)])));
};
const toggleTokenDetails = (tokenId: number) => {
setExpandedTokenIds((current) => (
current.includes(tokenId)
? current.filter((id) => id !== tokenId)
: [...current, tokenId]
));
};
const runBatchTokenAction = async (action: 'enable' | 'disable' | 'delete', skipDeleteConfirm = false) => {
@@ -748,37 +763,61 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const headerActions = useMemo(() => (
<div className={`page-actions ${embedded ? 'accounts-page-actions' : ''}`.trim()}>
<div style={{ minWidth: 220, position: 'relative', zIndex: 20 }}>
<ModernSelect
size="sm"
value={String(syncingAccountId || 0)}
onChange={(nextValue) => setSyncingAccountId(Number.parseInt(nextValue, 10) || 0)}
options={[
{ value: '0', label: '选择账号后同步站点令牌' },
...activeAccounts.map((account) => ({
value: String(account.id),
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
})),
]}
placeholder="选择账号后同步站点令牌"
/>
</div>
<button
onClick={handleSync}
disabled={syncing || syncingAll || !syncingAccountId}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{syncing ? <><span className="spinner spinner-sm" /> ...</> : '同步站点令牌'}
</button>
<button
onClick={handleSyncAll}
disabled={syncing || syncingAll || activeAccounts.length === 0}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{syncingAll ? <><span className="spinner spinner-sm" /> ...</> : '同步全部账号'}
</button>
{isMobile ? (
<>
<button
type="button"
onClick={() => setShowMobileTools(true)}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
</button>
<button
type="button"
data-testid="tokens-mobile-select-all"
onClick={() => toggleSelectAllTokens(!allVisibleTokensSelected)}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{allVisibleTokensSelected ? '取消全选' : '全选可见项'}
</button>
</>
) : (
<>
<div style={{ minWidth: 220, position: 'relative', zIndex: 20 }}>
<ModernSelect
size="sm"
value={String(syncingAccountId || 0)}
onChange={(nextValue) => setSyncingAccountId(Number.parseInt(nextValue, 10) || 0)}
options={[
{ value: '0', label: '选择账号后同步站点令牌' },
...activeAccounts.map((account) => ({
value: String(account.id),
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
})),
]}
placeholder="选择账号后同步站点令牌"
/>
</div>
<button
onClick={handleSync}
disabled={syncing || syncingAll || !syncingAccountId}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{syncing ? <><span className="spinner spinner-sm" /> ...</> : '同步站点令牌'}
</button>
<button
onClick={handleSyncAll}
disabled={syncing || syncingAll || activeAccounts.length === 0}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{syncingAll ? <><span className="spinner spinner-sm" /> ...</> : '同步全部账号'}
</button>
</>
)}
<button
onClick={handleToggleAdd}
className="btn btn-primary"
@@ -786,7 +825,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
{showAdd ? '取消' : '+ 新增令牌'}
</button>
</div>
), [activeAccounts, embedded, handleSync, handleSyncAll, handleToggleAdd, showAdd, syncing, syncingAccountId, syncingAll]);
), [activeAccounts, allVisibleTokensSelected, embedded, handleSync, handleSyncAll, handleToggleAdd, isMobile, showAdd, syncing, syncingAccountId, syncingAll]);
useEffect(() => {
if (!embedded || !onEmbeddedActionsChange) return;
@@ -805,6 +844,42 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
</div>
)}
<MobileFilterSheet open={showMobileTools} onClose={() => setShowMobileTools(false)} title="令牌同步与筛选">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}></div>
<ModernSelect
value={String(syncingAccountId || 0)}
onChange={(nextValue) => setSyncingAccountId(Number.parseInt(nextValue, 10) || 0)}
options={[
{ value: '0', label: '选择账号后同步站点令牌' },
...activeAccounts.map((account) => ({
value: String(account.id),
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
})),
]}
placeholder="选择账号后同步站点令牌"
/>
</div>
<button
onClick={handleSync}
disabled={syncing || syncingAll || !syncingAccountId}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{syncing ? <><span className="spinner spinner-sm" /> ...</> : '同步站点令牌'}
</button>
<button
onClick={handleSyncAll}
disabled={syncing || syncingAll || activeAccounts.length === 0}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{syncingAll ? <><span className="spinner spinner-sm" /> ...</> : '同步全部账号'}
</button>
</div>
</MobileFilterSheet>
<div className="info-tip" style={{ marginBottom: 12 }}>
API IP
</div>
@@ -866,48 +941,50 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
) : null}
<div style={sectionCardStyle}>
<div style={sectionLabelStyle}></div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}></div>
<input
placeholder="令牌名称"
value={editForm.name}
onChange={(e) => setEditForm((prev) => ({ ...prev, name: e.target.value }))}
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}></div>
<textarea
placeholder={editingTokenValueLoading ? '令牌加载中...' : '令牌值'}
value={editForm.token}
onChange={(e) => setEditForm((prev) => ({ ...prev, token: e.target.value }))}
style={{
...inputStyle,
minHeight: 96,
resize: 'vertical',
fontFamily: 'var(--font-mono)',
lineHeight: 1.5,
}}
disabled={editingTokenValueLoading}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}></div>
<ModernSelect
value={editForm.group || 'default'}
onChange={(nextValue) => setEditForm((prev) => ({ ...prev, group: nextValue || 'default' }))}
options={(editGroupOptions.length > 0 ? editGroupOptions : ['default']).map((group) => ({
value: group,
label: group,
}))}
placeholder={editGroupLoading ? '分组加载中...' : '选择分组'}
disabled={editGroupLoading}
/>
</div>
<ResponsiveFormGrid>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}></div>
<input
placeholder="令牌名称"
value={editForm.name}
onChange={(e) => setEditForm((prev) => ({ ...prev, name: e.target.value }))}
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}></div>
<ModernSelect
value={editForm.group || 'default'}
onChange={(nextValue) => setEditForm((prev) => ({ ...prev, group: nextValue || 'default' }))}
options={(editGroupOptions.length > 0 ? editGroupOptions : ['default']).map((group) => ({
value: group,
label: group,
}))}
placeholder={editGroupLoading ? '分组加载中...' : '选择分组'}
disabled={editGroupLoading}
/>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}></div>
<textarea
placeholder={editingTokenValueLoading ? '令牌加载中...' : '令牌值'}
value={editForm.token}
onChange={(e) => setEditForm((prev) => ({ ...prev, token: e.target.value }))}
style={{
...inputStyle,
minHeight: 96,
resize: 'vertical',
fontFamily: 'var(--font-mono)',
lineHeight: 1.5,
}}
disabled={editingTokenValueLoading}
/>
</div>
</ResponsiveFormGrid>
</div>
<div style={sectionCardStyle}>
<div style={sectionLabelStyle}></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 }}>
<ResponsiveFormGrid>
<label style={toggleCardStyle}>
<input
type="checkbox"
@@ -932,7 +1009,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}></span>
</div>
</label>
</div>
</ResponsiveFormGrid>
</div>
</>
) : null}
@@ -954,9 +1031,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
)}
{isMobile && selectedTokenIds.length > 0 && (
<div className="mobile-actions-bar">
<span className="mobile-actions-info"> {selectedTokenIds.length} </span>
<div className="mobile-actions-row">
<MobileBatchBar info={`已选 ${selectedTokenIds.length}`}>
<button onClick={() => runBatchTokenAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
</button>
@@ -966,8 +1041,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<button data-testid="tokens-batch-delete" onClick={() => runBatchTokenAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
</button>
</div>
</div>
</MobileBatchBar>
)}
<CenteredModal
@@ -977,8 +1051,8 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
maxWidth={820}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div style={{ gridColumn: '1 / span 2' }}>
<ResponsiveFormGrid>
<div style={{ gridColumn: '1 / -1' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}></div>
<ModernSelect
value={String(form.accountId || 0)}
@@ -1002,7 +1076,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
{createHintModelName ? (
<div
style={{
gridColumn: '1 / span 2',
gridColumn: '1 / -1',
fontSize: 12,
color: 'var(--color-text-secondary)',
background: 'color-mix(in srgb, var(--color-info) 10%, var(--color-bg))',
@@ -1036,7 +1110,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
disabled={!form.accountId || groupLoading}
/>
</div>
<div style={{ gridColumn: '1 / span 2', display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
@@ -1072,10 +1146,10 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
style={inputStyle}
/>
</div>
<div style={{ gridColumn: '1 / span 2', display: 'flex', alignItems: 'center', fontSize: 12, color: 'var(--color-text-muted)' }}>
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', fontSize: 12, color: 'var(--color-text-muted)' }}>
</div>
</div>
</ResponsiveFormGrid>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 8 }}>
<button onClick={handleToggleAdd} className="btn btn-ghost"></button>
@@ -1102,11 +1176,12 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
{accountClusteredTokens.map((token: any) => {
const loadingPrefix = `token-${token.id}`;
const isPending = isMaskedPendingToken(token);
const isExpanded = expandedTokenIds.includes(token.id);
return (
<MobileCard
key={token.id}
title={token.name || '-'}
actions={(
headerActions={(
<input
type="checkbox"
aria-label={`选择令牌 ${token.name || token.id}`}
@@ -1114,30 +1189,34 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
onChange={(event) => toggleTokenSelection(token.id, event.target.checked)}
/>
)}
>
<MobileField
label="令牌值"
value={<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{token.tokenMasked || '***'}</span>}
/>
<MobileField
label="来源站点"
value={token.site?.url ? (
<a
href={token.site.url}
target="_blank"
rel="noopener noreferrer"
className="badge-link"
footerActions={(
<>
<button
type="button"
onClick={() => toggleTokenDetails(token.id)}
className="btn btn-link"
>
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{token.site?.name || 'unknown'}
</span>
</a>
) : (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{token.site?.name || 'unknown'}
</span>
)}
/>
{isExpanded ? '收起' : '详情'}
</button>
{!isPending ? (
<button
onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary"
data-testid={`token-copy-${token.id}`}
>
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button>
) : null}
<button
onClick={() => openEditPanel(token)}
className="btn btn-link btn-link-info"
>
{isPending ? '编辑补全' : '编辑'}
</button>
</>
)}
>
<MobileField label="账号" value={token.account?.username || `account-${token.accountId}`} />
<MobileField label="分组" value={token.tokenGroup || 'default'} />
<MobileField
@@ -1148,62 +1227,74 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
</span>
)}
/>
<MobileField
label="默认"
value={token.isDefault ? <span className="badge badge-warning" style={{ fontSize: 11 }}></span> : '-'}
/>
<MobileField label="更新时间" value={formatDateTimeLocal(token.updatedAt)} />
<div className="mobile-card-actions">
{!isPending && !token.isDefault && (
<button
onClick={() => withRowLoading(`${loadingPrefix}-default`, async () => {
await api.setDefaultAccountToken(token.id);
toast.success('默认令牌已更新');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-default`]}
className="btn btn-link btn-link-info"
>
{rowLoading[`${loadingPrefix}-default`] ? <span className="spinner spinner-sm" /> : '设默认'}
</button>
)}
{!isPending ? (
<button
onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary"
data-testid={`token-copy-${token.id}`}
>
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button>
) : null}
<button
onClick={() => openEditPanel(token)}
className="btn btn-link btn-link-info"
>
{isPending ? '编辑补全' : '编辑'}
</button>
{!isPending ? (
<button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled });
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'}`}
>
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
</button>
) : null}
<button
onClick={() => setDeleteConfirm({ mode: 'single', tokenId: token.id, tokenName: token.name || '' })}
disabled={!!rowLoading[`${loadingPrefix}-delete`]}
className="btn btn-link btn-link-danger"
>
{rowLoading[`${loadingPrefix}-delete`] ? <span className="spinner spinner-sm" /> : '删除'}
</button>
</div>
{isExpanded ? (
<div className="mobile-card-extra">
<MobileField
label="令牌值"
stacked
value={<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, wordBreak: 'break-all' }}>{token.tokenMasked || '***'}</span>}
/>
<MobileField
label="来源站点"
value={token.site?.url ? (
<a
href={token.site.url}
target="_blank"
rel="noopener noreferrer"
className="badge-link"
>
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{token.site?.name || 'unknown'}
</span>
</a>
) : (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{token.site?.name || 'unknown'}
</span>
)}
/>
<MobileField
label="默认"
value={token.isDefault ? <span className="badge badge-warning" style={{ fontSize: 11 }}></span> : '-'}
/>
<MobileField label="更新时间" value={formatDateTimeLocal(token.updatedAt)} />
<div className="mobile-card-actions">
{!isPending && !token.isDefault && (
<button
onClick={() => withRowLoading(`${loadingPrefix}-default`, async () => {
await api.setDefaultAccountToken(token.id);
toast.success('默认令牌已更新');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-default`]}
className="btn btn-link btn-link-info"
>
{rowLoading[`${loadingPrefix}-default`] ? <span className="spinner spinner-sm" /> : '设默认'}
</button>
)}
{!isPending ? (
<button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled });
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'}`}
>
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
</button>
) : null}
<button
onClick={() => setDeleteConfirm({ mode: 'single', tokenId: token.id, tokenName: token.name || '' })}
disabled={!!rowLoading[`${loadingPrefix}-delete`]}
className="btn btn-link btn-link-danger"
>
{rowLoading[`${loadingPrefix}-delete`] ? <span className="spinner spinner-sm" /> : '删除'}
</button>
</div>
</div>
) : null}
</MobileCard>
);
})}
@@ -1215,7 +1306,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<th style={{ width: 44 }}>
<input
type="checkbox"
checked={accountClusteredTokens.length > 0 && selectedTokenIds.length === accountClusteredTokens.length}
checked={allVisibleTokensSelected}
onChange={(e) => toggleSelectAllTokens(e.target.checked)}
/>
</th>
@@ -0,0 +1,195 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getSites: vi.fn(),
batchUpdateAccounts: vi.fn(),
refreshAccountHealth: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/useIsMobile.js', () => ({
useIsMobile: () => true,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function collectText(node: any): string {
return (node.children || []).map((child: any) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function findButtonByText(root: any, text: string) {
return root.find((node: any) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes(text)
));
}
describe('Accounts mobile actions', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getSites.mockResolvedValue([
{ id: 1, name: 'Site A', platform: 'new-api', status: 'active' },
]);
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
siteId: 1,
username: 'alpha',
accessToken: 'session-alpha',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
{
id: 2,
siteId: 1,
username: 'beta',
accessToken: 'session-beta',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
]);
apiMock.batchUpdateAccounts.mockResolvedValue({
success: true,
successIds: [1, 2],
failedItems: [],
});
apiMock.refreshAccountHealth.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it('supports select-all-visible from the mobile toolbar', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const selectAllButton = root.root.find((node) => node.props['data-testid'] === 'accounts-mobile-select-all');
await act(async () => {
selectAllButton.props.onClick();
});
await flushMicrotasks();
const batchButton = root.root.find((node) => node.props['data-testid'] === 'accounts-batch-refresh-balance');
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchUpdateAccounts).toHaveBeenCalledWith({
ids: [1, 2],
action: 'refreshBalance',
});
} finally {
root?.unmount();
}
});
it('clears only the visible segment selection when toggling mobile select-all off', async () => {
let root: ReturnType<typeof create> | null = null;
try {
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
siteId: 1,
username: 'alpha',
accessToken: 'session-alpha',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
{
id: 2,
siteId: 1,
username: 'beta',
accessToken: '',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
},
]);
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const sessionCheckbox = root.root.find((node) => (
node.type === 'input'
&& node.props.type === 'checkbox'
&& node.props['aria-label'] === '选择账号 alpha'
));
await act(async () => {
sessionCheckbox.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
const apiKeySegmentButton = findButtonByText(root.root, 'API Key管理');
await act(async () => {
apiKeySegmentButton.props.onClick();
});
await flushMicrotasks();
const selectAllButton = root.root.find((node) => node.props['data-testid'] === 'accounts-mobile-select-all');
await act(async () => {
selectAllButton.props.onClick();
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('已选 2 项');
const clearVisibleButton = root.root.find((node) => node.props['data-testid'] === 'accounts-mobile-select-all');
await act(async () => {
clearVisibleButton.props.onClick();
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('已选 1 项');
const batchButton = root.root.find((node) => node.props['data-testid'] === 'accounts-batch-refresh-balance');
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchUpdateAccounts).toHaveBeenLastCalledWith({
ids: [1],
action: 'refreshBalance',
});
} finally {
root?.unmount();
}
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('CheckinLog mobile layout', () => {
it('uses the shared mobile filter sheet for mobile filters', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/CheckinLog.tsx'), 'utf8');
expect(source).toContain('MobileFilterSheet');
});
it('uses the shared mobile card header and footer action slots', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/CheckinLog.tsx'), 'utf8');
expect(source).toContain('const isMobile = useIsMobile();');
expect(source).toContain('headerActions={');
expect(source).toContain('footerActions={');
});
});
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Dashboard mobile layout', () => {
it('uses the shared mobile breakpoint to collapse fixed desktop grids', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Dashboard.tsx'), 'utf8');
expect(source).toContain("import { useIsMobile } from '../components/useIsMobile.js'");
expect(source).toContain('const isMobile = useIsMobile()');
expect(source).toContain("gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr'");
expect(source).toContain("gridTemplateColumns: isMobile ? '1fr' : '1fr 300px'");
});
});
@@ -0,0 +1,223 @@
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 DownstreamKeys from './DownstreamKeys.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDownstreamApiKeysSummary: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(),
getDownstreamApiKeyOverview: vi.fn(),
getDownstreamApiKeyTrend: vi.fn(),
createDownstreamApiKey: vi.fn(),
batchDownstreamApiKeys: vi.fn(),
updateDownstreamApiKey: vi.fn(),
deleteDownstreamApiKey: vi.fn(),
resetDownstreamApiKeyUsage: vi.fn(),
},
}));
vi.mock('../api.js', () => ({ api: apiMock }));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('../components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: (open: boolean) => ({
shouldRender: open,
isVisible: open,
}),
}));
vi.mock('../components/useIsMobile.js', () => ({
useIsMobile: () => true,
}));
vi.mock('../components/charts/DownstreamKeyTrendChart.js', () => ({
default: ({ buckets }: { buckets: Array<{ totalTokens: number }> }) => (
<div data-testid="downstream-trend-chart">{`trend:${buckets.length}`}</div>
),
}));
vi.mock('../components/ModernSelect.js', () => ({
default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => (
<select value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
),
}));
function buildSummaryItem(id: number, overrides?: Partial<any>) {
return {
id,
name: `mobile-key-${id}`,
keyMasked: `sk-m****0${id}`,
enabled: true,
description: `mobile item ${id}`,
groupName: '项目A',
tags: ['移动端'],
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
createdAt: '2026-03-15T08:27:25.378Z',
updatedAt: '2026-03-15T08:27:25.378Z',
rangeUsage: {
totalRequests: 3,
successRequests: 2,
failedRequests: 1,
successRate: 66.7,
totalTokens: 4200,
totalCost: 0.42,
},
...overrides,
};
}
function buildRawItem(id: number, overrides?: Partial<any>) {
return {
id,
name: `mobile-key-${id}`,
key: `sk-mobile-0${id}`,
keyMasked: `sk-m****0${id}`,
description: `mobile item ${id}`,
groupName: '项目A',
tags: ['移动端'],
enabled: true,
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
...overrides,
};
}
function collectText(node: ReactTestInstance): string {
return (node.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) === text
));
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
describe('DownstreamKeys mobile layout', () => {
beforeEach(() => {
vi.clearAllMocks();
(globalThis as any).document = {
body: { style: {} },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({
success: true,
items: [buildSummaryItem(1), buildSummaryItem(2)],
});
apiMock.getDownstreamApiKeys.mockResolvedValue({
success: true,
items: [buildRawItem(1), buildRawItem(2)],
});
apiMock.getRoutesLite.mockResolvedValue([
{ id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true },
]);
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
success: true,
item: buildSummaryItem(1),
usage: {
last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 },
last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 },
all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 },
},
});
apiMock.getDownstreamApiKeyTrend.mockResolvedValue({
success: true,
buckets: [],
});
apiMock.createDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.batchDownstreamApiKeys.mockResolvedValue({ success: true, successIds: [1, 2], failedItems: [] });
apiMock.updateDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.deleteDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.resetDownstreamApiKeyUsage.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
delete (globalThis as any).document;
});
it('renders mobile cards and supports select-all-visible batch actions', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('筛选');
expect(collectText(root!.root)).toContain('全选可见');
const mobileCards = root!.root.findAll((node) => node.props?.className === 'mobile-card');
expect(mobileCards).toHaveLength(2);
const selectAllButton = findButtonByText(root!.root, '全选可见');
await act(async () => {
selectAllButton.props.onClick();
});
await flushMicrotasks();
const batchEnableButton = findButtonByText(root!.root, '启用');
await act(async () => {
batchEnableButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith({
ids: [1, 2],
action: 'enable',
});
} finally {
root?.unmount();
}
});
});
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { JSDOM } from 'jsdom';
import { describe, expect, it } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
import {
SiteAnnouncementContent,
@@ -8,36 +7,11 @@ import {
resolveSiteAnnouncementTimeZone,
} from './siteAnnouncementPresentation.js';
type BrowserGlobals = {
window?: Window & typeof globalThis;
document?: Document;
DOMParser?: typeof DOMParser;
Node?: typeof Node;
};
const browserGlobals = globalThis as typeof globalThis & BrowserGlobals;
const originalWindow = browserGlobals.window;
const originalDocument = browserGlobals.document;
const originalDOMParser = browserGlobals.DOMParser;
const originalNode = browserGlobals.Node;
const hasDomSanitizerSupport = typeof DOMParser === 'function' && typeof Node !== 'undefined';
const itWithDomSupport = hasDomSanitizerSupport ? it : it.skip;
describe('siteAnnouncementPresentation helpers', () => {
beforeEach(() => {
const dom = new JSDOM('<!doctype html><html><body></body></html>');
browserGlobals.window = dom.window as unknown as Window & typeof globalThis;
browserGlobals.document = dom.window.document;
browserGlobals.DOMParser = dom.window.DOMParser;
browserGlobals.Node = dom.window.Node;
});
afterEach(() => {
browserGlobals.window = originalWindow;
browserGlobals.document = originalDocument;
browserGlobals.DOMParser = originalDOMParser;
browserGlobals.Node = originalNode;
});
it('renders sanitized html notices with safe links', () => {
itWithDomSupport('renders sanitized html notices with safe links', () => {
const markup = renderToStaticMarkup(
<SiteAnnouncementContent
content={[
@@ -59,7 +33,7 @@ describe('siteAnnouncementPresentation helpers', () => {
expect(markup).toContain('rel="noopener noreferrer"');
});
it('renders markdown notices as structured content', () => {
itWithDomSupport('renders markdown notices as structured content', () => {
const markup = renderToStaticMarkup(
<SiteAnnouncementContent
content={[
+5 -1
View File
@@ -3,8 +3,12 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('ProxyLogs mobile layout', () => {
it('renders mobile cards for proxy logs', () => {
it('renders compact mobile summary cards for proxy logs', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/ProxyLogs.tsx'), 'utf8');
expect(source).toContain('MobileCard');
expect(source).toContain('MobileFilterSheet');
expect(source).toContain('compact');
expect(source).toContain('mobile-summary-grid');
expect(source).toContain("subtitle={formatDateTimeLocal(log.createdAt)}");
});
});
+20 -2
View File
@@ -2,9 +2,27 @@ import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
function findRuleValue(source: string, selector: string, property: string): number | null {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = source.match(new RegExp(`${escapedSelector}\\s*\\{[^}]*${property}:\\s*([^;]+)`, 'm'));
if (!match) return null;
const rawValue = match[1].trim();
const directNumber = rawValue.match(/^(\d+)$/);
if (directNumber) return Number(directNumber[1]);
const variableName = rawValue.match(/^var\((--[^)]+)\)$/);
if (!variableName) return null;
const variableMatch = source.match(new RegExp(`${variableName[1]}:\\s*(\\d+)`, 'm'));
return variableMatch ? Number(variableMatch[1]) : null;
}
describe('Mobile actions bar styles', () => {
it('defines .mobile-actions-bar in index.css', () => {
it('keeps overlay layers above the mobile batch bar', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
expect(css).toContain('.mobile-actions-bar');
const batchBarZIndex = findRuleValue(css, '.mobile-actions-bar', 'z-index');
const modalBackdropZIndex = findRuleValue(css, '.modal-backdrop', 'z-index');
expect(batchBarZIndex).not.toBeNull();
expect(modalBackdropZIndex).not.toBeNull();
expect(modalBackdropZIndex!).toBeGreaterThan(batchBarZIndex!);
});
});
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Mobile layout shell styles', () => {
it('allows page actions and pagination to wrap on narrow screens', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('.page-actions {\n width: 100%;');
expect(css).toContain('.page-actions {\n width: 100%;\n flex-wrap: wrap;');
expect(css).toContain('.pagination {\n flex-wrap: wrap;');
});
});
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Mobile layout utilities', () => {
it('allows page actions and pagination controls to wrap on narrow screens', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8');
expect(css).toMatch(/\.page-actions\s*\{[^}]*flex-wrap:\s*wrap/s);
expect(css).toMatch(/\.pagination\s*\{[^}]*flex-wrap:\s*wrap/s);
expect(css).toMatch(/\.pagination-size\s*\{[^}]*flex-wrap:\s*wrap/s);
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('ModelTester mobile layout', () => {
it('switches the playground shell into a true single-column mobile layout', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/ModelTester.tsx'), 'utf8');
expect(source).toContain("import { useIsMobile } from '../components/useIsMobile.js'");
expect(source).toContain('const isMobile = useIsMobile()');
expect(source).toContain("const layoutColumns = isMobile");
expect(source).toContain("gridTemplateColumns: isMobile ? '1fr' : '1fr 160px'");
expect(source).toContain("flexDirection: isMobile ? 'column' : 'row'");
expect(source).toContain("order: isMobile ? 1 : 0");
expect(source).toContain("order: isMobile ? 2 : 0");
});
});
+167
View File
@@ -0,0 +1,167 @@
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 Models from './Models.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getModelsMarketplace: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.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('Models mobile layout', () => {
const originalDocument = globalThis.document;
const originalMutationObserver = globalThis.MutationObserver;
const originalWindow = globalThis.window;
const originalMatchMedia = globalThis.matchMedia;
beforeEach(() => {
vi.clearAllMocks();
globalThis.document = {
documentElement: {
getAttribute: () => 'light',
},
} as unknown as Document;
globalThis.MutationObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof MutationObserver;
const nextWindow = (originalWindow ? { ...originalWindow } : {}) as Window & typeof globalThis;
nextWindow.innerWidth = 768;
nextWindow.addEventListener = nextWindow.addEventListener || (() => {});
nextWindow.removeEventListener = nextWindow.removeEventListener || (() => {});
nextWindow.matchMedia = (() => ({
matches: true,
media: '(max-width: 768px)',
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as typeof window.matchMedia;
globalThis.window = nextWindow;
globalThis.matchMedia = nextWindow.matchMedia;
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'gpt-4o',
accountCount: 2,
tokenCount: 3,
avgLatency: 420,
successRate: 96,
description: '旗舰聊天模型',
tags: ['chat'],
supportedEndpointTypes: ['openai'],
pricingSources: [],
accounts: [
{
id: 1,
site: '站点 A',
username: 'alice',
latency: 320,
balance: 12.5,
tokens: [{ id: 1, name: 'default', isDefault: true }],
},
{
id: 2,
site: '站点 B',
username: 'bob',
latency: 540,
balance: 8.4,
tokens: [{ id: 2, name: 'backup', isDefault: false }],
},
],
},
],
});
});
afterEach(() => {
vi.clearAllMocks();
globalThis.document = originalDocument;
globalThis.MutationObserver = originalMutationObserver;
globalThis.window = originalWindow;
globalThis.matchMedia = originalMatchMedia;
});
it('keeps a mobile filter entry and hides the table-view toggle on small screens', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('筛选');
expect(root!.root.findAll((node) => (
node.type === 'button'
&& node.props['aria-label'] === '表格视图'
))).toHaveLength(0);
} finally {
root?.unmount();
}
});
it('renders stacked account detail cards instead of tables when a mobile card expands', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const modelCard = root!.root.find((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('model-card')
&& typeof node.props.onClick === 'function'
));
await act(async () => {
modelCard.props.onClick();
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('账号明细');
expect(collectText(root!.root)).toContain('站点 A');
expect(collectText(root!.root)).toContain('alice');
expect(root!.root.findAll((node) => node.type === 'table')).toHaveLength(0);
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('OAuthManagement mobile layout', () => {
it('allows narrow screens to wrap provider and connection action rows', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/OAuthManagement.tsx'), 'utf8');
expect(source).toContain("flexWrap: 'wrap'");
expect(source).toContain("justifyContent: 'space-between'");
expect(source).toContain("minWidth: 0");
});
});
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('ProgramLogs mobile layout', () => {
it('uses the shared mobile primitives for filters and list cards', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/ProgramLogs.tsx'), 'utf8');
expect(source).toContain("import MobileFilterSheet from '../components/MobileFilterSheet.js'");
expect(source).toContain("import { MobileCard, MobileField } from '../components/MobileCard.js'");
expect(source).toContain("import { useIsMobile } from '../components/useIsMobile.js'");
expect(source).toContain('const isMobile = useIsMobile()');
expect(source).toContain('mobile-card-list');
expect(source).toContain('<MobileFilterSheet');
});
});
+102
View File
@@ -0,0 +1,102 @@
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 ProgramLogs from './ProgramLogs.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getEvents: vi.fn(),
markEventRead: vi.fn(),
markAllEventsRead: vi.fn(),
clearEvents: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/useIsMobile.js', () => ({
useIsMobile: () => true,
}));
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('ProgramLogs mobile layout', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getEvents.mockResolvedValue([
{
id: 1,
type: 'status',
title: '同步完成',
message: '成功 3,失败 0',
level: 'info',
read: false,
createdAt: '2026-03-04T06:43:03.000Z',
},
]);
apiMock.markEventRead.mockResolvedValue({ success: true });
apiMock.markAllEventsRead.mockResolvedValue({ success: true });
apiMock.clearEvents.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders mobile cards instead of only the desktop table on small screens', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/events']}>
<ToastProvider>
<ProgramLogs />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const cards = root!.root.findAll((node) => node.props?.className === 'mobile-card');
expect(cards.length).toBeGreaterThan(0);
expect(collectText(root!.root)).toContain('同步完成');
expect(collectText(root!.root)).toContain('筛选');
expect(collectText(root!.root)).toContain('标记已读');
const markReadButton = findButtonByText(root!.root, '标记已读');
await act(async () => {
markReadButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.markEventRead).toHaveBeenCalledWith(1);
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Settings mobile layout', () => {
it('collapses fixed form grids behind the shared mobile breakpoint', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Settings.tsx'), 'utf8');
expect(source).toContain("import { useIsMobile } from '../components/useIsMobile.js'");
expect(source).toContain('const isMobile = useIsMobile()');
expect(source).toContain("gridTemplateColumns: isMobile ? '1fr' : '180px 180px auto'");
expect(source).toContain("gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr'");
expect(source).toContain("gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr 1fr'");
});
});
+112
View File
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } 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(),
batchUpdateSites: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/useIsMobile.js', () => ({
useIsMobile: () => true,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Sites mobile actions', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getSites.mockResolvedValue([
{
id: 1,
name: 'Site A',
url: 'https://a.example.com',
platform: 'new-api',
status: 'active',
useSystemProxy: false,
},
{
id: 2,
name: 'Site B',
url: 'https://b.example.com',
platform: 'new-api',
status: 'active',
useSystemProxy: false,
},
]);
apiMock.batchUpdateSites.mockResolvedValue({
success: true,
successIds: [1, 2],
failedItems: [],
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('supports select-all-visible and preserves the primary site url in mobile cards', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/sites']}>
<ToastProvider>
<Sites />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const selectAllButton = root.root.find((node) => node.props['data-testid'] === 'sites-mobile-select-all');
await act(async () => {
selectAllButton.props.onClick();
});
await flushMicrotasks();
expect(Array.isArray(selectAllButton.children) ? selectAllButton.children.join('') : '').toContain('取消全选');
const clearVisibleButton = root.root.find((node) => node.props['data-testid'] === 'sites-mobile-select-all');
await act(async () => {
clearVisibleButton.props.onClick();
});
await flushMicrotasks();
expect(Array.isArray(clearVisibleButton.children) ? clearVisibleButton.children.join('') : '').toContain('全选可见项');
const reselectVisibleButton = root.root.find((node) => node.props['data-testid'] === 'sites-mobile-select-all');
await act(async () => {
reselectVisibleButton.props.onClick();
});
await flushMicrotasks();
const batchButton = root.root.find((node) => node.props['data-testid'] === 'sites-batch-enable-system-proxy');
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchUpdateSites).toHaveBeenCalledWith({
ids: [1, 2],
action: 'enableSystemProxy',
});
const primaryLink = root.root.find((node) => node.type === 'a' && node.props.href === 'https://a.example.com');
expect(primaryLink.props.target).toBe('_blank');
} finally {
root?.unmount();
}
});
});
+122 -77
View File
@@ -40,6 +40,7 @@ type RouteCardProps = {
route: RouteSummaryRow;
brand: BrandInfo | null;
expanded: boolean;
compact?: boolean;
onToggleExpand: (routeId: number) => void;
onEdit: (route: RouteSummaryRow) => void;
onDelete: (routeId: number) => void;
@@ -100,6 +101,7 @@ function RouteCardInner({
route,
brand,
expanded,
compact = false,
onToggleExpand,
onEdit,
onDelete,
@@ -263,86 +265,127 @@ function RouteCardInner({
// Expanded card
return (
<div className="card route-card-expanded" style={{ padding: 16 }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 8, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<code style={{ fontWeight: 600, fontSize: 13, background: 'var(--color-bg)', padding: '4px 10px', borderRadius: 6, color: 'var(--color-text-primary)', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
{routeIcon.kind === 'brand' ? (
<BrandGlyph icon={routeIcon.value} alt={title} size={20} fallbackText={title} />
) : routeIcon.kind === 'text' ? (
<span style={{ width: 20, height: 20, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, background: 'var(--color-bg-card)', fontSize: 14, lineHeight: 1 }}>
{routeIcon.value}
</span>
) : routeIcon.kind === 'auto' && brand ? (
<BrandGlyph brand={brand} alt={title} size={20} fallbackText={title} />
) : routeIcon.kind === 'auto' ? (
<InlineBrandIcon model={route.modelPattern} size={20} />
<div className={`card route-card-expanded ${compact ? 'route-card-expanded-compact' : ''}`.trim()} style={{ padding: compact ? 14 : 16 }}>
{!compact ? (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 8, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<code style={{ fontWeight: 600, fontSize: 13, background: 'var(--color-bg)', padding: '4px 10px', borderRadius: 6, color: 'var(--color-text-primary)', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
{routeIcon.kind === 'brand' ? (
<BrandGlyph icon={routeIcon.value} alt={title} size={20} fallbackText={title} />
) : routeIcon.kind === 'text' ? (
<span style={{ width: 20, height: 20, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, background: 'var(--color-bg-card)', fontSize: 14, lineHeight: 1 }}>
{routeIcon.value}
</span>
) : routeIcon.kind === 'auto' && brand ? (
<BrandGlyph brand={brand} alt={title} size={20} fallbackText={title} />
) : routeIcon.kind === 'auto' ? (
<InlineBrandIcon model={route.modelPattern} size={20} />
) : null}
{title}
</code>
{route.displayName && route.displayName.trim() !== route.modelPattern ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>{route.modelPattern}</span>
) : null}
{title}
</code>
{route.displayName && route.displayName.trim() !== route.modelPattern ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>{route.modelPattern}</span>
) : null}
{readOnlyRoute ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{tr('未生成')}
</span>
) : (
<button
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
style={{ fontSize: 11, cursor: 'pointer', border: 'none' }}
onClick={(e) => { e.stopPropagation(); onToggleEnabled(route); }}
data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'}
>
{route.enabled ? tr('启用') : tr('禁用')}
</button>
)}
{explicitGroupRoute && explicitGroupSourceCount > 0 ? (
<>
<span className="badge badge-info" style={{ fontSize: 10 }}>
{explicitGroupSourceCount} {tr('来源模型')}
</span>
{readOnlyRoute ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{tr('未生成')}
</span>
) : (
<button
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
style={{ fontSize: 11, cursor: 'pointer', border: 'none' }}
onClick={(e) => { e.stopPropagation(); onToggleEnabled(route); }}
data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'}
>
{route.enabled ? tr('启用') : tr('禁用')}
</button>
)}
{explicitGroupRoute && explicitGroupSourceCount > 0 ? (
<>
<span className="badge badge-info" style={{ fontSize: 10 }}>
{explicitGroupSourceCount} {tr('来源模型')}
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{route.channelCount} {tr('通道')}
</span>
</>
) : (
<span className="badge badge-info" style={{ fontSize: 10 }}>
{route.channelCount} {tr('通道')}
</span>
</>
) : (
)}
{readOnlyRoute && (
<span className="badge badge-warning" style={{ fontSize: 10 }}>
{tr('0 通道')}
</span>
)}
{savingPriority && (
<span className="badge badge-warning" style={{ fontSize: 10 }}>{tr('排序保存中')}</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{!readOnlyRoute && (explicitGroupRoute || !exactRoute) && (
<button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button>
)}
{!readOnlyRoute && <button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button>}
<button
onClick={() => onToggleExpand(route.id)}
className="btn btn-ghost"
style={{ padding: '4px 8px', border: '1px solid var(--color-border)' }}
data-tooltip={tr('收起')}
>
<svg
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
style={{ transform: 'rotate(180deg)' }}
aria-hidden
>
<path d="m5 7 5 6 5-6" />
</svg>
</button>
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
{tr('路由详情与通道管理')}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
{title}
</div>
</div>
{!readOnlyRoute && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{!exactRoute && (
<button onClick={() => onEdit(route)} className="btn btn-link">{explicitGroupRoute ? tr('编辑群组') : tr('编辑路由')}</button>
)}
<button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button>
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
{readOnlyRoute ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>{tr('未生成')}</span>
) : (
<span className={`badge ${route.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 10 }}>
{route.enabled ? tr('启用') : tr('禁用')}
</span>
)}
<span className="badge badge-info" style={{ fontSize: 10 }}>
{route.channelCount} {tr('通道')}
</span>
)}
{readOnlyRoute && (
<span className="badge badge-warning" style={{ fontSize: 10 }}>
{tr('0 通道')}
</span>
)}
{savingPriority && (
<span className="badge badge-warning" style={{ fontSize: 10 }}>{tr('排序保存中')}</span>
)}
{explicitGroupRoute && explicitGroupSourceCount > 0 ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{explicitGroupSourceCount} {tr('来源模型')}
</span>
) : null}
{savingPriority ? <span className="badge badge-warning" style={{ fontSize: 10 }}>{tr('排序保存中')}</span> : null}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{!readOnlyRoute && (explicitGroupRoute || !exactRoute) && (
<button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button>
)}
{!readOnlyRoute && <button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button>}
<button
onClick={() => onToggleExpand(route.id)}
className="btn btn-ghost"
style={{ padding: '4px 8px', border: '1px solid var(--color-border)' }}
data-tooltip={tr('收起')}
>
<svg
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
style={{ transform: 'rotate(180deg)' }}
aria-hidden
>
<path d="m5 7 5 6 5-6" />
</svg>
</button>
</div>
</div>
)}
{explicitGroupRoute ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
@@ -355,11 +398,11 @@ function RouteCardInner({
) : null}
{!readOnlyRoute && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
<div style={{ display: 'flex', alignItems: compact ? 'stretch' : 'center', flexDirection: compact ? 'column' : 'row', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', minWidth: compact ? '100%' : undefined }}>
{tr('路由策略')}
</div>
<div style={{ minWidth: 220, maxWidth: 320, flex: '1 1 220px' }}>
<div style={{ minWidth: compact ? '100%' : 220, maxWidth: compact ? '100%' : 320, flex: compact ? '1 1 100%' : '1 1 220px', width: compact ? '100%' : undefined }}>
<ModernSelect
size="sm"
value={routingStrategy}
@@ -374,7 +417,7 @@ function RouteCardInner({
)}
{/* Missing token hints + Add channel button */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: compact ? 'stretch' : 'flex-start', flexDirection: compact ? 'column' : 'row', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
{!channelManagementDisabled && (missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0) ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}>
{missingTokenSiteItems.length > 0 && (
@@ -417,7 +460,7 @@ function RouteCardInner({
<button
onClick={() => onAddChannel(route.id)}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '6px 10px', color: 'var(--color-primary)', border: '1px solid var(--color-border)', whiteSpace: 'nowrap' }}
style={{ fontSize: 12, padding: '6px 10px', color: 'var(--color-primary)', border: '1px solid var(--color-border)', whiteSpace: compact ? 'normal' : 'nowrap', width: compact ? '100%' : 'auto' }}
>
+ {tr('添加通道')}
</button>
@@ -498,6 +541,7 @@ function RouteCardInner({
loadingDecision={loadingDecision}
isSavingPriority={!!savingPriority}
readOnly
mobile={compact}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
@@ -521,6 +565,7 @@ function RouteCardInner({
isExactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={savingPriority}
mobile={compact}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
+282 -24
View File
@@ -1,4 +1,4 @@
import type { CSSProperties } from 'react';
import { useState, type CSSProperties } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import ModernSelect from '../../components/ModernSelect.js';
@@ -18,6 +18,7 @@ export function SortableChannelRow({
loadingDecision,
isSavingPriority,
readOnly = false,
mobile = false,
tokenOptions,
activeTokenId,
isUpdatingToken,
@@ -44,10 +45,10 @@ export function SortableChannelRow({
opacity: isDragging ? 0.72 : 1,
zIndex: isDragging ? 10 : 1,
display: 'grid',
gridTemplateColumns: readOnly ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto',
alignItems: 'center',
gridTemplateColumns: readOnly || mobile ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto',
alignItems: mobile ? 'stretch' : 'center',
gap: 8,
padding: '8px 12px',
padding: mobile ? '10px 12px' : '8px 12px',
borderLeft: '2px solid var(--color-primary)',
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
background: isDragging ? 'rgba(59,130,246,0.08)' : 'rgba(79,70,229,0.02)',
@@ -65,9 +66,221 @@ export function SortableChannelRow({
},
);
const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false);
if (mobile) {
return (
<div ref={setNodeRef} style={{ ...rowStyle, display: 'block' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<button
type="button"
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
disabled={isSavingPriority || readOnly}
className="btn btn-ghost"
style={{
width: 22,
minWidth: 22,
height: 22,
padding: 0,
border: '1px solid var(--color-border-light)',
color: 'var(--color-text-muted)',
cursor: isSavingPriority || readOnly ? 'not-allowed' : 'grab',
opacity: readOnly ? 0.65 : 1,
marginTop: 2,
}}
data-tooltip={readOnly ? '来源群组继承通道优先级,不能在这里拖动' : '拖拽调整优先级'}
aria-label="拖拽调整优先级"
>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
<circle cx="3" cy="2" r="1" />
<circle cx="9" cy="2" r="1" />
<circle cx="3" cy="6" r="1" />
<circle cx="9" cy="6" r="1" />
<circle cx="3" cy="10" r="1" />
<circle cx="9" cy="10" r="1" />
</svg>
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span
className="badge"
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: 0.1,
...getPriorityTagStyle(channel.priority ?? 0),
}}
>
P{channel.priority ?? 0}
</span>
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)', fontSize: 14, minWidth: 0 }}>
{channel.account?.username || `account-${channel.accountId}`}
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{channel.site?.name || 'unknown'}
</span>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', marginLeft: 'auto' }}>
/ <span style={{ color: 'var(--color-success)', fontWeight: 600 }}>{channel.successCount || 0}</span>
<span style={{ color: 'var(--color-text-muted)', margin: '0 2px' }}>/</span>
<span style={{ color: 'var(--color-danger)', fontWeight: 600 }}>{channel.failCount || 0}</span>
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span
className="badge"
style={{
fontSize: 10,
background: tokenBinding.badgeTone === 'info'
? 'color-mix(in srgb, var(--color-info) 15%, transparent)'
: 'color-mix(in srgb, var(--color-warning) 15%, transparent)',
color: tokenBinding.badgeTone === 'info' ? 'var(--color-info)' : 'var(--color-warning)',
}}
>
{tokenBinding.bindingModeLabel}
</span>
<span
className="badge"
style={{
fontSize: 10,
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
color: 'var(--color-primary)',
maxWidth: 220,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
data-tooltip={`当前生效:${tokenBinding.effectiveTokenName}`}
>
{tokenBinding.effectiveTokenName}
</span>
{channel.sourceModel ? (
<span className="badge badge-info" style={{ fontSize: 10 }}>
{channel.sourceModel}
</span>
) : null}
{channel.manualOverride ? (
<span
className="badge badge-warning"
style={{ fontSize: 10 }}
data-tooltip="该通道由用户手动添加,而非系统自动生成"
>
</span>
) : null}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}></span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 120 }}>
<div
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
style={{
width: 80,
height: 6,
background: 'var(--color-border)',
borderRadius: 999,
overflow: 'hidden',
}}
>
<div
style={{
width: `${Math.max(0, Math.min(100, decisionState.probability))}%`,
height: '100%',
background: getProbabilityColor(decisionState.probability),
borderRadius: 999,
transition: 'width 0.3s ease',
}}
/>
</div>
<span
data-tooltip={decisionState.probability <= 0 ? decisionState.reasonText : undefined}
style={{
fontSize: 11,
color: decisionState.probability > 0 ? 'var(--color-text-secondary)' : decisionState.reasonColor,
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{decisionState.probability.toFixed(1)}%
</span>
</div>
{!readOnly && (
<button
type="button"
className="btn btn-link"
onClick={() => setMobileDetailsOpen((current) => !current)}
style={{ marginLeft: 'auto' }}
>
{mobileDetailsOpen ? '收起配置' : '配置通道'}
</button>
)}
</div>
{!readOnly && mobileDetailsOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, paddingTop: 8, borderTop: '1px solid var(--color-border-light)' }}>
<div style={{ width: '100%' }}>
<ModernSelect
size="sm"
value={String(activeTokenId || 0)}
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
disabled={isUpdatingToken}
options={[
{
value: '0',
label: tokenBinding.followOptionLabel,
description: tokenBinding.followOptionDescription,
},
...tokenOptions.map((token) => ({
value: String(token.id),
label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }),
description: buildFixedTokenOptionDescription(token),
})),
]}
placeholder="选择令牌绑定方式"
/>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
{tokenBinding.helperText}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap' }}>
<button
onClick={onSaveToken}
disabled={isUpdatingToken}
className="btn btn-link btn-link-info"
>
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
</button>
<button
onClick={onDeleteChannel}
className="btn btn-link btn-link-danger"
>
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
return (
<div ref={setNodeRef} style={rowStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, flexWrap: 'wrap', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: mobile ? 'stretch' : 'center', flexDirection: mobile ? 'column' : 'row', gap: 10, fontSize: 13, flexWrap: 'wrap', minWidth: 0 }}>
<button
type="button"
ref={setActivatorNodeRef}
@@ -163,7 +376,7 @@ export function SortableChannelRow({
</span>
) : null}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', marginTop: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', marginTop: mobile ? 0 : 4, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}></span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 120 }}>
<div
@@ -209,9 +422,9 @@ export function SortableChannelRow({
</div>
{!readOnly ? (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ minWidth: 220, flex: 1 }}>
mobile ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, width: '100%' }}>
<div style={{ width: '100%' }}>
<ModernSelect
size="sm"
value={String(activeTokenId || 0)}
@@ -235,22 +448,67 @@ export function SortableChannelRow({
{tokenBinding.helperText}
</div>
</div>
<button
onClick={onSaveToken}
disabled={isUpdatingToken}
className="btn btn-link btn-link-info"
>
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 12, flexWrap: 'wrap' }}>
<button
onClick={onSaveToken}
disabled={isUpdatingToken}
className="btn btn-link btn-link-info"
>
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
</button>
<button
onClick={onDeleteChannel}
className="btn btn-link btn-link-danger"
>
</button>
</>
<button
onClick={onDeleteChannel}
className="btn btn-link btn-link-danger"
>
</button>
</div>
</div>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ minWidth: 220, flex: 1 }}>
<ModernSelect
size="sm"
value={String(activeTokenId || 0)}
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
disabled={isUpdatingToken}
options={[
{
value: '0',
label: tokenBinding.followOptionLabel,
description: tokenBinding.followOptionDescription,
},
...tokenOptions.map((token) => ({
value: String(token.id),
label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }),
description: buildFixedTokenOptionDescription(token),
})),
]}
placeholder="选择令牌绑定方式"
/>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
{tokenBinding.helperText}
</div>
</div>
<button
onClick={onSaveToken}
disabled={isUpdatingToken}
className="btn btn-link btn-link-info"
>
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
</button>
</div>
<button
onClick={onDeleteChannel}
className="btn btn-link btn-link-danger"
>
</button>
</>
)
) : null}
</div>
);
+1
View File
@@ -154,6 +154,7 @@ export type SortableChannelRowProps = {
loadingDecision: boolean;
isSavingPriority: boolean;
readOnly?: boolean;
mobile?: boolean;
tokenOptions: RouteTokenOption[];
activeTokenId: number;
isUpdatingToken: boolean;
@@ -0,0 +1,212 @@
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, getBrandMock } = vi.hoisted(() => ({
apiMock: {
getRoutesSummary: vi.fn(),
getRouteChannels: vi.fn(),
getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(),
updateRoute: vi.fn(),
rebuildRoutes: vi.fn(),
deleteRoute: vi.fn(),
deleteChannel: vi.fn(),
},
getBrandMock: vi.fn(),
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('../components/useIsMobile.js', () => ({
useIsMobile: () => true,
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: ({ brand, icon, model }: { brand?: { name?: string } | null; icon?: string | null; model?: string | null }) => (
<span>{brand?.name || icon || model || ''}</span>
),
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
getBrand: (...args: unknown[]) => getBrandMock(...args),
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
normalizeBrandIconKey: (icon: string) => icon,
}));
function collectText(node: ReactTestInstance): string {
return (node.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('TokenRoutes mobile layout', () => {
const originalIntersectionObserver = globalThis.IntersectionObserver;
beforeEach(() => {
vi.clearAllMocks();
globalThis.IntersectionObserver = class {
observe() {}
disconnect() {}
unobserve() {}
takeRecords() { return []; }
readonly root = null;
readonly rootMargin = '0px';
readonly thresholds = [];
} as unknown as typeof IntersectionObserver;
getBrandMock.mockReset();
getBrandMock.mockReturnValue(null);
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 1,
modelPattern: 'gpt-4o-mini',
displayName: 'gpt-4o-mini',
displayIcon: null,
modelMapping: null,
routingStrategy: 'weighted',
enabled: true,
channelCount: 1,
enabledChannelCount: 1,
siteNames: ['site-a'],
decisionSnapshot: null,
decisionRefreshedAt: null,
},
]);
apiMock.getRouteChannels.mockResolvedValue([
{
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: 'gpt-4o-mini',
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 },
},
]);
apiMock.getModelTokenCandidates.mockResolvedValue({
models: {
'gpt-4o-mini': [
{
accountId: 101,
tokenId: 1001,
tokenName: 'token-a',
isDefault: true,
username: 'user_a',
siteId: 1,
siteName: 'site-a',
},
],
},
});
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.updateRoute.mockResolvedValue({});
apiMock.rebuildRoutes.mockResolvedValue({ rebuild: { createdRoutes: 0, createdChannels: 0 } });
apiMock.deleteRoute.mockResolvedValue({});
apiMock.deleteChannel.mockResolvedValue({});
});
afterEach(() => {
vi.clearAllMocks();
globalThis.IntersectionObserver = originalIntersectionObserver;
});
it('shows direct mobile actions and reveals the management panel after expansion', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const summaryText = collectText(root!.root);
expect(summaryText).toContain('详情');
expect(summaryText).toContain('禁用');
expect(summaryText).toContain('添加通道');
const expandButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node) === '详情'
));
await act(async () => {
await expandButton.props.onClick();
});
await flushMicrotasks();
const expandedText = collectText(root!.root);
expect(expandedText).toContain('路由策略');
expect(expandedText).toContain('权重随机');
expect(expandedText).toContain('user_a');
expect(expandedText).toContain('token-a');
} finally {
root?.unmount();
}
});
it('lets mobile users toggle route enabled state from the summary card', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const toggleButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node) === '禁用'
));
await act(async () => {
await toggleButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRoute).toHaveBeenCalledWith(1, { enabled: false });
expect(collectText(root!.root)).toContain('启用');
} finally {
root?.unmount();
}
});
});
+169
View File
@@ -0,0 +1,169 @@
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, getBrandMock } = vi.hoisted(() => ({
apiMock: {
getRoutesSummary: vi.fn(),
getRouteChannels: vi.fn(),
getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(),
updateRoute: vi.fn(),
addRoute: vi.fn(),
},
getBrandMock: vi.fn(),
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('../components/useIsMobile.js', () => ({
useIsMobile: () => true,
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: ({ brand, icon, model }: { brand?: { name?: string } | null; icon?: string | null; model?: string | null }) => (
<span>{brand?.name || icon || model || ''}</span>
),
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
getBrand: (...args: unknown[]) => getBrandMock(...args),
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
normalizeBrandIconKey: (icon: string) => icon,
}));
function collectText(node: ReactTestInstance): string {
return (node.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 mobile actions', () => {
const originalIntersectionObserver = globalThis.IntersectionObserver;
beforeEach(() => {
vi.clearAllMocks();
getBrandMock.mockReset();
getBrandMock.mockReturnValue(null);
globalThis.IntersectionObserver = class {
observe() {}
disconnect() {}
unobserve() {}
} as unknown as typeof IntersectionObserver;
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 1,
modelPattern: 'gpt-4o-mini',
displayName: 'gpt-4o-mini',
displayIcon: null,
modelMapping: null,
routeMode: 'pattern',
routingStrategy: 'weighted',
enabled: true,
channelCount: 1,
enabledChannelCount: 1,
siteNames: ['site-a'],
decisionSnapshot: null,
decisionRefreshedAt: null,
},
]);
apiMock.getRouteChannels.mockResolvedValue([
{
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: 'gpt-4o-mini',
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 },
},
]);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.updateRoute.mockResolvedValue({});
apiMock.addRoute.mockResolvedValue({});
});
afterEach(() => {
vi.clearAllMocks();
globalThis.IntersectionObserver = originalIntersectionObserver;
});
it('shows mobile detail expansion and direct management actions', async () => {
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)).toContain('筛选');
expect(collectText(root!.root)).toContain('详情');
expect(collectText(root!.root)).toContain('编辑');
expect(collectText(root!.root)).toContain('添加通道');
const disableButton = findButtonByText(root!.root, '禁用');
await act(async () => {
disableButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRoute).toHaveBeenCalledWith(1, { enabled: false });
const detailButton = findButtonByText(root!.root, '详情');
await act(async () => {
detailButton.props.onClick();
});
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('user_a');
expect(text).toContain('token-a');
expect(text).toContain('site-a');
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import { TokensPanel } from './Tokens.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccountTokens: vi.fn(),
getAccounts: vi.fn(),
getAccountTokenGroups: vi.fn(),
batchUpdateAccountTokens: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/useIsMobile.js', () => ({
useIsMobile: () => true,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Tokens mobile actions', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getAccountTokens.mockResolvedValue([
{
id: 1,
accountId: 1,
name: 'token-1',
tokenMasked: 'sk-***1',
enabled: true,
isDefault: false,
updatedAt: '2026-03-21T10:00:00.000Z',
account: { username: 'alpha' },
site: { name: 'Site A', url: 'https://site-a.example.com' },
},
{
id: 2,
accountId: 1,
name: 'token-2',
tokenMasked: 'sk-***2',
enabled: true,
isDefault: false,
updatedAt: '2026-03-21T11:00:00.000Z',
account: { username: 'alpha' },
site: { name: 'Site A', url: 'https://site-a.example.com' },
},
]);
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
username: 'alpha',
accessToken: 'session-alpha',
status: 'active',
site: { id: 1, name: 'Site A', status: 'active' },
},
]);
apiMock.getAccountTokenGroups.mockResolvedValue({ groups: ['default'] });
apiMock.batchUpdateAccountTokens.mockResolvedValue({
success: true,
successIds: [1, 2],
failedItems: [],
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('supports select-all-visible and expandable mobile token details', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
<TokensPanel />
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const detailsButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => Array.isArray(node.children) && node.children.includes('详情'));
expect(detailsButton).toBeTruthy();
await act(async () => {
detailsButton!.props.onClick();
});
await flushMicrotasks();
const selectAllButton = root.root.find((node) => node.props['data-testid'] === 'tokens-mobile-select-all');
await act(async () => {
selectAllButton.props.onClick();
});
await flushMicrotasks();
expect(Array.isArray(selectAllButton.children) ? selectAllButton.children.join('') : '').toContain('取消全选');
const clearVisibleButton = root.root.find((node) => node.props['data-testid'] === 'tokens-mobile-select-all');
await act(async () => {
clearVisibleButton.props.onClick();
});
await flushMicrotasks();
expect(Array.isArray(clearVisibleButton.children) ? clearVisibleButton.children.join('') : '').toContain('全选可见项');
const reselectVisibleButton = root.root.find((node) => node.props['data-testid'] === 'tokens-mobile-select-all');
await act(async () => {
reselectVisibleButton.props.onClick();
});
await flushMicrotasks();
const batchButton = root.root.find((node) => node.props['data-testid'] === 'tokens-batch-delete');
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
const confirmButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => Array.isArray(node.children) && node.children.some((child) => child === '确认删除'));
expect(confirmButton).toBeTruthy();
await act(async () => {
confirmButton!.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchUpdateAccountTokens).toHaveBeenCalledWith({
ids: [1, 2],
action: 'delete',
});
const expandedText = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
expect(expandedText).toContain('更新时间');
} finally {
root?.unmount();
}
});
});
+5 -1
View File
@@ -1,7 +1,11 @@
import { defineConfig } from 'vitest/config';
import { configDefaults, defineConfig } from 'vitest/config';
export default defineConfig({
test: {
exclude: [
...configDefaults.exclude,
'.worktrees/**',
],
// Many of our web tests rely on React's test utilities (act, etc.).
// If NODE_ENV is accidentally set to "production" in the environment,
// React switches to the production build where act() is not supported.