feat: overhaul mobile management layouts
This commit is contained in:
@@ -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`);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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%' }}>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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={[
|
||||
|
||||
@@ -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)}");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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]}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user