From 09fbf258e2845a1a80d4ef18b75f8ad680af04e6 Mon Sep 17 00:00:00 2001 From: Cita Date: Sat, 21 Mar 2026 20:42:25 +0800 Subject: [PATCH] feat: overhaul mobile management layouts --- .../oauth/localCallbackServer.test.ts | 2 + src/web/App.mobile-layout.test.tsx | 190 +++++++- src/web/App.sidebar-mobile.test.tsx | 203 ++++++++- src/web/App.tsx | 126 +++--- src/web/components/MobileBatchBar.tsx | 15 + src/web/components/MobileCard.tsx | 38 +- src/web/components/MobileDrawer.tsx | 61 ++- src/web/components/MobileFilterSheet.tsx | 24 + src/web/components/ResponsiveFormGrid.tsx | 21 + src/web/components/mobile-batch-bar.test.tsx | 22 + src/web/components/mobile-card.test.tsx | 24 +- src/web/components/mobile-drawer.test.tsx | 79 +++- .../components/mobile-filter-sheet.test.tsx | 40 ++ src/web/components/mobileLayout.ts | 3 + .../components/responsive-form-grid.test.tsx | 18 + src/web/components/useIsMobile.test.tsx | 128 ++++++ src/web/components/useIsMobile.ts | 31 +- src/web/index.css | 274 ++++++++++- src/web/pages/Accounts.tsx | 283 +++++++----- src/web/pages/CheckinLog.tsx | 37 +- src/web/pages/Dashboard.tsx | 6 +- src/web/pages/DownstreamKeys.mobile.test.tsx | 234 ++++++++++ src/web/pages/DownstreamKeys.tsx | 229 +++++++--- src/web/pages/ModelTester.tsx | 37 +- .../pages/Models.marketplace-text.test.tsx | 69 +++ src/web/pages/Models.tsx | 379 ++++++++++------ src/web/pages/ProgramLogs.tsx | 155 +++++-- src/web/pages/ProxyLogs.tsx | 81 ++-- src/web/pages/Settings.tsx | 16 +- src/web/pages/Sites.tsx | 409 ++++++++++------- src/web/pages/TokenRoutes.tsx | 159 +++++-- src/web/pages/Tokens.tsx | 425 +++++++++++------- .../pages/accounts.mobile-actions.test.tsx | 195 ++++++++ src/web/pages/checkin.mobile.test.tsx | 17 + .../pages/dashboard.mobile-layout.test.tsx | 14 + .../downstreamKeys.mobile-layout.test.tsx | 223 +++++++++ .../siteAnnouncementPresentation.test.tsx | 36 +- src/web/pages/logs.mobile.test.tsx | 6 +- src/web/pages/mobile-actions.test.tsx | 22 +- src/web/pages/mobile-layout-shell.test.tsx | 13 + .../pages/mobile-layout-utilities.test.tsx | 13 + .../pages/modelTester.mobile-layout.test.tsx | 17 + src/web/pages/models.mobile-layout.test.tsx | 167 +++++++ src/web/pages/oauth.mobile-layout.test.tsx | 13 + .../pages/programLogs.mobile-layout.test.tsx | 16 + src/web/pages/programLogs.mobile.test.tsx | 102 +++++ src/web/pages/settings.mobile-layout.test.tsx | 15 + src/web/pages/sites.mobile-actions.test.tsx | 112 +++++ src/web/pages/token-routes/RouteCard.tsx | 199 ++++---- .../pages/token-routes/SortableChannelRow.tsx | 306 ++++++++++++- src/web/pages/token-routes/types.ts | 1 + .../pages/tokenRoutes.mobile-layout.test.tsx | 212 +++++++++ src/web/pages/tokenRoutes.mobile.test.tsx | 169 +++++++ src/web/pages/tokens.mobile-actions.test.tsx | 153 +++++++ vitest.config.ts | 6 +- 55 files changed, 4815 insertions(+), 1030 deletions(-) create mode 100644 src/web/components/MobileBatchBar.tsx create mode 100644 src/web/components/MobileFilterSheet.tsx create mode 100644 src/web/components/ResponsiveFormGrid.tsx create mode 100644 src/web/components/mobile-batch-bar.test.tsx create mode 100644 src/web/components/mobile-filter-sheet.test.tsx create mode 100644 src/web/components/mobileLayout.ts create mode 100644 src/web/components/responsive-form-grid.test.tsx create mode 100644 src/web/components/useIsMobile.test.tsx create mode 100644 src/web/pages/DownstreamKeys.mobile.test.tsx create mode 100644 src/web/pages/accounts.mobile-actions.test.tsx create mode 100644 src/web/pages/checkin.mobile.test.tsx create mode 100644 src/web/pages/dashboard.mobile-layout.test.tsx create mode 100644 src/web/pages/downstreamKeys.mobile-layout.test.tsx create mode 100644 src/web/pages/mobile-layout-shell.test.tsx create mode 100644 src/web/pages/mobile-layout-utilities.test.tsx create mode 100644 src/web/pages/modelTester.mobile-layout.test.tsx create mode 100644 src/web/pages/models.mobile-layout.test.tsx create mode 100644 src/web/pages/oauth.mobile-layout.test.tsx create mode 100644 src/web/pages/programLogs.mobile-layout.test.tsx create mode 100644 src/web/pages/programLogs.mobile.test.tsx create mode 100644 src/web/pages/settings.mobile-layout.test.tsx create mode 100644 src/web/pages/sites.mobile-actions.test.tsx create mode 100644 src/web/pages/tokenRoutes.mobile-layout.test.tsx create mode 100644 src/web/pages/tokenRoutes.mobile.test.tsx create mode 100644 src/web/pages/tokens.mobile-actions.test.tsx diff --git a/src/server/services/oauth/localCallbackServer.test.ts b/src/server/services/oauth/localCallbackServer.test.ts index ed7c5f6..f81e381 100644 --- a/src/server/services/oauth/localCallbackServer.test.ts +++ b/src/server/services/oauth/localCallbackServer.test.ts @@ -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`); diff --git a/src/web/App.mobile-layout.test.tsx b/src/web/App.mobile-layout.test.tsx index f9f2e3f..464744d 100644 --- a/src/web/App.mobile-layout.test.tsx +++ b/src/web/App.mobile-layout.test.tsx @@ -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('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 }) =>
{adminName || 'Dashboard'}
, +})); + +function createLocalStorage() { + const store = new Map([ + ['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(); + 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 | null = null; + + try { + await act(async () => { + root = create( + + + , + ); + }); + 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(); + }); + } + } + }, + ); }); diff --git a/src/web/App.sidebar-mobile.test.tsx b/src/web/App.sidebar-mobile.test.tsx index 147f320..7280c3b 100644 --- a/src/web/App.sidebar-mobile.test.tsx +++ b/src/web/App.sidebar-mobile.test.tsx @@ -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('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: () =>
Dashboard
, +})); + +function createLocalStorage() { + const store = new Map([ + ['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 | null = null; + + try { + await act(async () => { + root = create( + + + , + ); + }); + 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(); + }); + } + } }); }); diff --git a/src/web/App.tsx b/src/web/App.tsx index a2fc437..abd5ba4 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -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 ( -
-
e.stopPropagation()} style={{ maxWidth: 440 }}> -
{t('个人信息')}
-
-
-
- {name.trim() -
-
{t('右上角头像实时预览')}
-
- -
-
{t('用户名')}
- { - setName(e.target.value); - setError(''); - }} - placeholder={t('例如:小王')} - style={inputStyle} - /> -
- -
-
- {t('头像(Dicebear 随机) · 风格:')}{avatarStyle} -
- -
- - {error && ( -
- {error} -
- )} -
-
+ + + )} + > +
+
+ {name.trim()
+
{t('右上角头像实时预览')}
-
+ +
+
{t('用户名')}
+ { + setName(e.target.value); + setError(''); + }} + placeholder={t('例如:小王')} + style={inputStyle} + /> +
+ +
+
+ {t('头像(Dicebear 随机) · 风格:')}{avatarStyle} +
+ +
+ + {error && ( +
+ {error} +
+ )} + ); } @@ -469,7 +472,7 @@ function AppShell() { const notifBtnRef = useRef(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 && (
-
{ setShowUserMenu(!showUserMenu); setShowThemeMenu(false); @@ -742,7 +755,7 @@ function AppShell() { alt={displayName} style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }} /> -
+ {userMenuPresence.shouldRender && (
+
{children}
); + + const portalTarget = typeof document !== 'undefined' ? document.body : null; + return portalTarget ? createPortal(drawer, portalTarget) : drawer; } export { MobileDrawer }; diff --git a/src/web/components/MobileFilterSheet.tsx b/src/web/components/MobileFilterSheet.tsx new file mode 100644 index 0000000..0a6fed4 --- /dev/null +++ b/src/web/components/MobileFilterSheet.tsx @@ -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 ( + +
+ {children} +
+
+ ); +} diff --git a/src/web/components/ResponsiveFormGrid.tsx b/src/web/components/ResponsiveFormGrid.tsx new file mode 100644 index 0000000..32847a2 --- /dev/null +++ b/src/web/components/ResponsiveFormGrid.tsx @@ -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
{children}
; +} diff --git a/src/web/components/mobile-batch-bar.test.tsx b/src/web/components/mobile-batch-bar.test.tsx new file mode 100644 index 0000000..48bed77 --- /dev/null +++ b/src/web/components/mobile-batch-bar.test.tsx @@ -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( + + + , + ); + + 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(); + }); +}); diff --git a/src/web/components/mobile-card.test.tsx b/src/web/components/mobile-card.test.tsx index 45a3d84..d99455c 100644 --- a/src/web/components/mobile-card.test.tsx +++ b/src/web/components/mobile-card.test.tsx @@ -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( - + Meta} + footerActions={} + > + , ); @@ -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(); }); }); diff --git a/src/web/components/mobile-drawer.test.tsx b/src/web/components/mobile-drawer.test.tsx index 1bdfe9b..c251945 100644 --- a/src/web/components/mobile-drawer.test.tsx +++ b/src/web/components/mobile-drawer.test.tsx @@ -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( - -
DrawerContent
-
, - ); + let root: ReturnType | 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( + +
DrawerContent
+
, + ); + }); - 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/); }); }); diff --git a/src/web/components/mobile-filter-sheet.test.tsx b/src/web/components/mobile-filter-sheet.test.tsx new file mode 100644 index 0000000..694cf22 --- /dev/null +++ b/src/web/components/mobile-filter-sheet.test.tsx @@ -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( + {}} title="筛选条件"> +
FilterContent
+
, + ); + + 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(); + } + }); +}); diff --git a/src/web/components/mobileLayout.ts b/src/web/components/mobileLayout.ts new file mode 100644 index 0000000..75e1bfb --- /dev/null +++ b/src/web/components/mobileLayout.ts @@ -0,0 +1,3 @@ +export const MOBILE_BREAKPOINT = 768; + +export const MOBILE_MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT}px)`; diff --git a/src/web/components/responsive-form-grid.test.tsx b/src/web/components/responsive-form-grid.test.tsx new file mode 100644 index 0000000..c9e5ec7 --- /dev/null +++ b/src/web/components/responsive-form-grid.test.tsx @@ -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( + +
Field A
+
Field B
+
, + ); + + const container = root.root.findByType('div'); + expect(container.props.className).toContain('responsive-form-grid'); + expect(container.props.className).toContain('responsive-form-grid-3'); + }); +}); diff --git a/src/web/components/useIsMobile.test.tsx b/src/web/components/useIsMobile.test.tsx new file mode 100644 index 0000000..23534e2 --- /dev/null +++ b/src/web/components/useIsMobile.test.tsx @@ -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
; +} + +describe('useIsMobile', () => { + const widthRef = { current: 768 }; + const listeners = new Map 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 | null = null; + + try { + await act(async () => { + root = create(); + }); + + 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 | null = null; + + try { + await act(async () => { + root = create(); + }); + + 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 | null = null; + + try { + await act(async () => { + root = create(); + }); + + 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(); + }); + } + } + }); +}); diff --git a/src/web/components/useIsMobile.ts b/src/web/components/useIsMobile.ts index 10174dd..994078c 100644 --- a/src/web/components/useIsMobile.ts +++ b/src/web/components/useIsMobile.ts @@ -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; diff --git a/src/web/index.css b/src/web/index.css index 63c8f7f..506cfac 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -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; diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 179096e..7df1f55 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -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('custom'); const [highlightAccountId, setHighlightAccountId] = useState(null); const [expandedAccountIds, setExpandedAccountIds] = useState([]); - 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() {

{tr('连接管理')}

{activeSegment !== 'tokens' && (
-
- setSortMode(nextValue as SortMode)} - options={[ - { value: 'custom', label: '自定义排序' }, - { value: 'balance-desc', label: '余额高到低' }, - { value: 'balance-asc', label: '余额低到高' }, - ]} - placeholder="自定义排序" - /> -
- {activeSegment === 'session' && ( - + {isMobile ? ( + <> + + + + ) : ( + <> +
+ setSortMode(nextValue as SortMode)} + options={[ + { value: 'custom', label: '自定义排序' }, + { value: 'balance-desc', label: '余额高到低' }, + { value: 'balance-asc', label: '余额低到高' }, + ]} + placeholder="自定义排序" + /> +
+ {activeSegment === 'session' && ( + + )} + + )} -
+ setShowMobileTools(false)} title="连接排序与操作"> +
+
+
排序方式
+ setSortMode(nextValue as SortMode)} + options={[ + { value: 'custom', label: '自定义排序' }, + { value: 'balance-desc', label: '余额高到低' }, + { value: 'balance-asc', label: '余额低到高' }, + ]} + placeholder="自定义排序" + /> +
+ {activeSegment === 'session' && ( + + )} + +
+
+
0 && ( -
- 已选 {selectedAccountIds.length} 项 -
+ @@ -1047,8 +1116,7 @@ export default function Accounts() { -
-
+ )} {activeSegment === 'tokens' ? ( @@ -1502,7 +1570,7 @@ export default function Accounts() { )} > {editingAccount ? ( -
+ )} -
+ ) : null} @@ -1590,7 +1658,7 @@ export default function Accounts() { )} + footerActions={( + <> + + + + + )} > +
+ + {sortMode === 'custom' && ( + <> + + + + )} + {capabilities.canRefreshBalance && ( + + )} + {capabilities.canCheckin && ( + + )} + {a.status === 'expired' && !capabilities.proxyOnly && ( + + )} + +
) : null} -
- - - {sortMode === 'custom' && ( - <> - - - - )} - {capabilities.canRefreshBalance && ( - - )} - - {capabilities.canCheckin && ( - - )} - {a.status === 'expired' && !capabilities.proxyOnly && ( - - )} - - -
); })} @@ -1772,7 +1845,7 @@ export default function Accounts() { 0 && selectedAccountIds.length === visibleAccounts.length} + checked={allVisibleAccountsSelected} onChange={(e) => toggleSelectAllVisibleAccounts(e.target.checked)} /> diff --git a/src/web/pages/CheckinLog.tsx b/src/web/pages/CheckinLog.tsx index 4d43f8e..94ece24 100644 --- a/src/web/pages/CheckinLog.tsx +++ b/src/web/pages/CheckinLog.tsx @@ -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("all"); const [expandedLogId, setExpandedLogId] = useState(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() { 筛选
- setShowFilters(false)} + title="筛选签到记录" > -
+
{timeRangeControls} {hasInvalidTimeRange && (
@@ -293,7 +294,7 @@ export default function CheckinLog() { )} {filterTabs}
- + ) : (
@@ -356,7 +357,7 @@ export default function CheckinLog() { } + footerActions={ + + } >
) : null} -
- -
); })} diff --git a/src/web/pages/Dashboard.tsx b/src/web/pages/Dashboard.tsx index c07c3c5..7168764 100644 --- a/src/web/pages/Dashboard.tsx +++ b/src/web/pages/Dashboard.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -579,7 +581,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
-
+
}> @@ -694,7 +696,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN )}
-
+
diff --git a/src/web/pages/DownstreamKeys.mobile.test.tsx b/src/web/pages/DownstreamKeys.mobile.test.tsx new file mode 100644 index 0000000..14ee8f3 --- /dev/null +++ b/src/web/pages/DownstreamKeys.mobile.test.tsx @@ -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('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 }> }) => ( +
{`trend:${buckets.length}`}
+ ), +})); + +vi.mock('../components/ModernSelect.js', () => ({ + default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => ( + + ), +})); + +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) { + 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) { + 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 | null = null; + + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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(); + } + }); +}); diff --git a/src/web/pages/DownstreamKeys.tsx b/src/web/pages/DownstreamKeys.tsx index e13b061..83a6868 100644 --- a/src/web/pages/DownstreamKeys.tsx +++ b/src/web/pages/DownstreamKeys.tsx @@ -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(null); const [selectedIds, setSelectedIds] = useState([]); + const [showFilters, setShowFilters] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(null); const [batchMetadataOpen, setBatchMetadataOpen] = useState(false); const [batchMetadataForm, setBatchMetadataForm] = useState({ @@ -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 = ( +
+
+
+
+ + + + setSearchInput(e.target.value)} + placeholder="搜索名称、备注、模型、主分组或标签" + /> +
+ +
+
+ setStatus((value as Status) || 'all')} options={statusOptions} /> +
+
+ setGroupFilter(String(value || '__all__'))} options={groupFilterOptions} /> +
+ +
+ + {(activeTagFilters.length > 0 || tagSuggestions.length > 0) ? ( +
+ {activeTagFilters.map((tag) => { + const fromPinnedTags = selectedTags.some((item) => item.toLowerCase() === tag.toLowerCase()); + return ( + + ); + })} + {tagSuggestions.filter((tag) => !activeTagFilters.some((current) => current.toLowerCase() === tag.toLowerCase())).slice(0, 8).map((tag) => ( + + ))} +
+ ) : null} +
+ ); + const empty = !loading && visibleItems.length === 0; return ( @@ -1618,14 +1687,24 @@ export default function DownstreamKeys() {
{selectedIds.length > 0 ? ( -
- 已选 {selectedIds.length} 个密钥 - - - - - -
+ isMobile ? ( + + + + + + + + ) : ( +
+ 已选 {selectedIds.length} 个密钥 + + + + + +
+ ) ) : null}
@@ -1635,65 +1714,28 @@ export default function DownstreamKeys() {
筛选与列表
按名称、状态、主分组和标签快速定位下游密钥。
-
-
-
-
- - - - setSearchInput(e.target.value)} - placeholder="搜索名称、备注、模型、主分组或标签" - /> -
- -
-
- setStatus((value as Status) || 'all')} options={statusOptions} /> -
-
- setGroupFilter(String(value || '__all__'))} options={groupFilterOptions} /> -
- -
- - {(activeTagFilters.length > 0 || tagSuggestions.length > 0) ? ( -
- {activeTagFilters.map((tag) => { - const fromPinnedTags = selectedTags.some((item) => item.toLowerCase() === tag.toLowerCase()); - return ( - - ); - })} - {tagSuggestions.filter((tag) => !activeTagFilters.some((current) => current.toLowerCase() === tag.toLowerCase())).slice(0, 8).map((tag) => ( - - ))} -
- ) : null} + +
+ )} +
+ {isMobile ? ( + setShowFilters(false)} title="筛选下游密钥"> + {filterControls} + + ) : ( + filterControls + )}
{loading ? ( @@ -1703,6 +1745,61 @@ export default function DownstreamKeys() {
暂无下游密钥
可以先新增一条密钥,或调整筛选条件查看已有数据。
+ ) : isMobile ? ( +
+ {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 ( + + + toggleSelection(row.id, e.target.checked)} + /> +
+ )} + footerActions={( + <> + + + + + + + )} + > + + {row.keyMasked} + + + )} + stacked + /> + {row.description ? : null} + + + + + + + + + + ); + })} +
) : (
diff --git a/src/web/pages/ModelTester.tsx b/src/web/pages/ModelTester.tsx index a930b21..64de6e3 100644 --- a/src/web/pages/ModelTester.tsx +++ b/src/web/pages/ModelTester.tsx @@ -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([]); const [modelSearch, setModelSearch] = useState(''); const [messages, setMessages] = useState([]); @@ -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() { -
+
模型数量
{models.length}
@@ -2279,7 +2283,7 @@ export default function ModelTester() { alignItems: 'stretch', }} > -
+

设置

@@ -2299,7 +2303,7 @@ export default function ModelTester() {
模型
-
+
setModelSearch(event.target.value)} @@ -2575,7 +2579,7 @@ export default function ModelTester() {
-
+
-
+
{showReasoning && (
+
{sending ? ( @@ -3030,7 +3035,7 @@ export default function ModelTester() { placeholder="输入搜索查询" style={{ ...inputBaseStyle, resize: 'vertical' }} /> -
+
setSearchAllowedDomains(event.target.value)} placeholder="allowed_domains (逗号分隔)" style={inputBaseStyle} /> setSearchBlockedDomains(event.target.value)} placeholder="blocked_domains (逗号分隔)" style={inputBaseStyle} /> 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') && ( -
+
- - - - - - - - - - - {m.accounts.map(a => ( - - - - - - - + {isMobile ? ( +
+
{tr('账号明细')}
+ {m.accounts.map((a) => ( +
+
+ + {a.username || `ID:${a.id}`} +
+
+
+ {tr('延迟')} + + {a.latency != null ? `${a.latency}ms` : '—'} + +
+
+ {tr('余额')} + ${(a.balance || 0).toFixed(2)} +
+
+ {tr('令牌')} +
+ {a.tokens.length > 0 ? a.tokens.map((t) => ( + {t.name} + )) : —} +
+
+
+
))} -
-
{tr('站点')}{tr('账号')}{tr('令牌')}{tr('延迟')}{tr('余额')}
{a.username || `ID:${a.id}`} - {a.tokens.length > 0 ? a.tokens.map(t => ( - {t.name} - )) : —} - - {a.latency != null ? ( - {a.latency}ms - ) : '—'} - ${(a.balance || 0).toFixed(2)}
+
+ ) : ( + + + + + + + + + + + + {m.accounts.map(a => ( + + + + + + + + ))} + +
{tr('站点')}{tr('账号')}{tr('令牌')}{tr('延迟')}{tr('余额')}
{a.username || `ID:${a.id}`} + {a.tokens.length > 0 ? a.tokens.map(t => ( + {t.name} + )) : —} + + {a.latency != null ? ( + {a.latency}ms + ) : '—'} + ${(a.balance || 0).toFixed(2)}
+ )} diff --git a/src/web/pages/ProgramLogs.tsx b/src/web/pages/ProgramLogs.tsx index 57c5889..4b03811 100644 --- a/src/web/pages/ProgramLogs.tsx +++ b/src/web/pages/ProgramLogs.tsx @@ -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>({}); + 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() { -
-
- setFilterType(nextValue)} - options={TYPE_OPTIONS.map((item) => ({ - value: item.value, - label: item.label, - }))} - placeholder="全部类型" - /> -
+ {isMobile ? ( + <> +
+ +
+ setShowFilters(false)} + title="筛选程序日志" + > +
+ setFilterType(nextValue)} + options={TYPE_OPTIONS.map((item) => ({ + value: item.value, + label: item.label, + }))} + placeholder="全部类型" + /> + +
+ 共 {visibleRows.length} 条 +
+
+
+ + ) : ( +
+
+ setFilterType(nextValue)} + options={TYPE_OPTIONS.map((item) => ({ + value: item.value, + label: item.label, + }))} + placeholder="全部类型" + /> +
- + -
- 共 {visibleRows.length} 条 +
+ 共 {visibleRows.length} 条 +
-
+ )}
{loading ? ( @@ -247,6 +300,52 @@ export default function ProgramLogs() {
+ ) : isMobile ? ( +
+ {visibleRows.length > 0 ? visibleRows.map((row) => { + const level = levelLabel(row.level || 'info'); + const eventStatus = eventStatusLabel(row); + return ( + + {eventStatus.label} + + )} + footerActions={( + row.read ? ( + 已读 + ) : ( + + ) + )} + > + + {row.type || '-'}} /> + {level.label}} /> + {eventStatus.label}} /> + + + ); + }) : ( +
+ + + +
暂无日志
+
当前筛选条件下没有程序日志。
+
+ )} +
) : visibleRows.length > 0 ? ( diff --git a/src/web/pages/ProxyLogs.tsx b/src/web/pages/ProxyLogs.tsx index ef2eb91..b46ee4e 100644 --- a/src/web/pages/ProxyLogs.tsx +++ b/src/web/pages/ProxyLogs.tsx @@ -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() { 筛选 - setShowFilters(false)}> -
- {filterControls} -
-
+ setShowFilters(false)} + title={tr('筛选日志')} + > + {filterControls} + ) : (
@@ -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 ( {log.status === 'success' ? '成功' : '失败'} )} + footerActions={( + + )} > - - } /> - - {downstreamKeySummary ? : null} - - - - +
+ + {clientDisplay.primary ? ( + + {clientDisplay.primary} + + ) : null} + {clientDisplay.secondary ? ( + + {clientDisplay.secondary} + + ) : null} +
+
+
+
用时
+
{formatLatency(log.latencyMs)}
+
+
+
输入
+
{log.promptTokens?.toLocaleString() || '-'}
+
+
+
输出
+
{log.completionTokens?.toLocaleString() || '-'}
+
+
+
花费
+
{typeof log.estimatedCost === 'number' ? `$${log.estimatedCost.toFixed(6)}` : '-'}
+
+
{isExpanded ? (
+ + } /> 0 ? log.retryCount : 0} /> {detailState?.loading &&
加载详情中...
} {detailState?.error &&
{detailState.error}
} @@ -758,15 +794,6 @@ export default function ProxyLogs() { )}
) : null} -
- -
); })} diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index b15f20f..a3ea17c 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -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({ checkinCron: '0 8 * * *', checkinScheduleMode: 'cron', @@ -1020,7 +1022,7 @@ export default function Settings() {
定时任务
-
+
签到方式
-
+
签到 Cron
自动清理日志
-
+
清理 Cron
-
+
{([ ['baseWeightFactor', '基础权重因子'], ['valueScoreFactor', '价值分因子'], @@ -1430,7 +1432,7 @@ export default function Settings() { 可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。
-
+
setMigrationDialect(value as DbDialect)} @@ -1471,7 +1473,7 @@ export default function Settings() { /> ) : (
-
+
setShorthandConnection((prev) => ({ ...prev, host: e.target.value }))} @@ -1502,7 +1504,7 @@ export default function Settings() {
{showShorthandOptional && ( -
+
setShorthandConnection((prev) => ({ ...prev, port: e.target.value }))} diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index d4b0968..adcf76e 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -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(null); const [selectedSiteIds, setSelectedSiteIds] = useState([]); const [expandedSiteIds, setExpandedSiteIds] = useState([]); - const isMobile = useIsMobile(768); + const isMobile = useIsMobile(); + const [showMobileTools, setShowMobileTools] = useState(false); const [batchActionLoading, setBatchActionLoading] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState 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() {

{tr('站点管理')}

-
+ {isMobile ? ( + <> + + + + ) : ( +
+ setSortMode(nextValue as SortMode)} + options={[ + { value: 'custom', label: '自定义排序' }, + { value: 'balance-desc', label: '余额高到低' }, + { value: 'balance-asc', label: '余额低到高' }, + ]} + placeholder="自定义排序" + /> +
+ )} + +
+
+ + setShowMobileTools(false)} title="站点排序与操作"> +
+
+
排序方式
setSortMode(nextValue as SortMode)} options={[ @@ -627,11 +675,19 @@ export default function Sites() { placeholder="自定义排序" />
-
-
+ {!isMobile && selectedSiteIds.length > 0 && (
@@ -666,9 +722,7 @@ export default function Sites() { )} {isMobile && selectedSiteIds.length > 0 && ( -
- 已选 {selectedSiteIds.length} 项 -
+ -
-
+ )}
@@ -747,54 +800,56 @@ export default function Sites() { )} > - setForm((prev) => ({ ...prev, name: e.target.value }))} - style={formInputStyle} - /> -
+ 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} + /> +
+ setForm((prev) => ({ ...prev, url: e.target.value }))} + onBlur={() => { + if (form.url.trim() && !form.platform.trim()) { + handleDetect(); + } + }} + style={{ ...formInputStyle, flex: 1 }} + /> + +
+
- -
-
- setForm((prev) => ({ ...prev, platform: value }))} - options={platformOptions} - placeholder="平台类型(可自动检测)" + setForm((prev) => ({ ...prev, platform: value }))} + options={platformOptions} + placeholder="平台类型(可自动检测)" + /> +
+ setForm((prev) => ({ ...prev, externalCheckinUrl: e.target.value }))} + style={formInputStyle} /> -
- setForm((prev) => ({ ...prev, externalCheckinUrl: e.target.value }))} - style={formInputStyle} - /> +
)}
- setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} - style={formInputStyle} - /> -
- 填写后优先使用站点代理;留空则使用系统代理或直连(取决于设置开关状态)。 -
- - setForm((prev) => ({ ...prev, globalWeight: e.target.value }))} - style={formInputStyle} - /> -
- 越大越容易被路由选中。建议 0.5-3,默认 1。 -
+ +
+ setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} + style={formInputStyle} + /> +
+ 填写后优先使用站点代理;留空则使用系统代理或直连(取决于设置开关状态)。 +
+
+ +
+ setForm((prev) => ({ ...prev, globalWeight: e.target.value }))} + style={formInputStyle} + /> +
+ 越大越容易被路由选中。建议 0.5-3,默认 1。 +
+
+
)} @@ -968,8 +1029,29 @@ export default function Sites() { return ( + {site.name || '-'} + {site.url ? ( + + {site.url} + + ) : null} +
+ )} + headerActions={( toggleSiteSelection(site.id, event.target.checked)} /> )} + footerActions={( + <> + + + + + )} > {isExpanded ? (
+ + {site.url} + + ) : '-'} + /> +
+ + {sortMode === 'custom' && ( + <> + + + + )} + +
) : null} -
- - - {sortMode === 'custom' && ( - <> - - - - )} - - - -
); })} @@ -1111,7 +1218,7 @@ export default function Sites() {
diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 8949d85..6608af8 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -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>({}); const [expandedRouteIds, setExpandedRouteIds] = useState([]); const [addChannelModalRouteId, setAddChannelModalRouteId] = useState(null); - const isMobile = useIsMobile(768); + const isMobile = useIsMobile(); const { channelsByRouteId, @@ -1122,27 +1122,25 @@ export default function TokenRoutes() { > {tr('筛选')} - setShowFilters(false)}> -
- setShowFilters(false)} - /> -
-
+ setShowFilters(false)} title={tr('筛选路由')}> + setShowFilters(false)} + /> + ) : ( { 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 ( - - {isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} - +
+ + {isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} + + )} + footerActions={( + <> + + {!isReadOnlyRoute && ( + + )} + {!isReadOnlyRoute && ( + + )} + {!isReadOnlyRoute && !channelManagementDisabled && ( + + )} + + )} + > + + + + + {explicitGroupRoute && ( + + )} + {!exactRoute && !explicitGroupRoute && ( + + )} + + {isExpanded && ( + )} - > - - - - -
- -
- +
); } diff --git a/src/web/pages/Tokens.tsx b/src/web/pages/Tokens.tsx index 0c3c13c..837cebb 100644 --- a/src/web/pages/Tokens.tsx +++ b/src/web/pages/Tokens.tsx @@ -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(null); const [rowLoading, setRowLoading] = useState>({}); const [selectedTokenIds, setSelectedTokenIds] = useState([]); + const [expandedTokenIds, setExpandedTokenIds] = useState([]); + const [showMobileTools, setShowMobileTools] = useState(false); const [batchActionLoading, setBatchActionLoading] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState 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(() => (
-
- 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="选择账号后同步站点令牌" - /> -
- - + {isMobile ? ( + <> + + + + ) : ( + <> +
+ 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="选择账号后同步站点令牌" + /> +
+ + + + )}
- ), [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 )} + setShowMobileTools(false)} title="令牌同步与筛选"> +
+
+
同步账号
+ 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="选择账号后同步站点令牌" + /> +
+ + +
+
+
新增令牌会调用站点 API 创建新密钥,再自动同步到本地。支持设置分组、额度、过期时间和 IP 白名单;已存在密钥可直接用“同步站点令牌”读取。
@@ -866,48 +941,50 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token ) : null}
基本信息
-
-
令牌名称
- setEditForm((prev) => ({ ...prev, name: e.target.value }))} - style={inputStyle} - /> -
-
-
令牌值
-
0 && selectedSiteIds.length === sortedSites.length} + checked={allVisibleSitesSelected} onChange={(e) => toggleSelectAllVisible(e.target.checked)} />