From 4e3d82aae16aa274cf3b1c93880a8469f3168ff8 Mon Sep 17 00:00:00 2001 From: cita Date: Tue, 10 Mar 2026 14:39:57 +0800 Subject: [PATCH 01/12] feat: add mobile layout detection --- src/web/App.mobile-layout.test.tsx | 12 ++++++++++++ src/web/App.tsx | 6 ++++++ src/web/components/useIsMobile.ts | 16 ++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 src/web/App.mobile-layout.test.tsx create mode 100644 src/web/components/useIsMobile.ts diff --git a/src/web/App.mobile-layout.test.tsx b/src/web/App.mobile-layout.test.tsx new file mode 100644 index 0000000..a80cada --- /dev/null +++ b/src/web/App.mobile-layout.test.tsx @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +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'"); + }); +}); diff --git a/src/web/App.tsx b/src/web/App.tsx index d7b723f..ebdbec2 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -16,6 +16,7 @@ import { I18nProvider, useI18n } from './i18n.js'; import { resolveLoginErrorMessage } from './loginError.js'; import { SITE_DOCS_URL } from './docsLink.js'; import { useAnimatedVisibility } from './components/useAnimatedVisibility.js'; +import { useIsMobile } from './components/useIsMobile.js'; const Dashboard = lazy(() => import('./pages/Dashboard.js')); const Sites = lazy(() => import('./pages/Sites.js')); const Accounts = lazy(() => import('./pages/Accounts.js')); @@ -383,6 +384,7 @@ function AppShell() { const notifBtnRef = useRef(null); const latestTaskEventIdRef = useRef(0); const toast = useToast(); + const isMobile = useIsMobile(768); const resolvedTheme: 'light' | 'dark' = themeMode === 'system' ? (systemPrefersDark ? 'dark' : 'light') : themeMode; @@ -415,6 +417,10 @@ function AppShell() { } }, [resolvedTheme, themeMode]); + useEffect(() => { + document.documentElement.setAttribute('data-layout', isMobile ? 'mobile' : 'desktop'); + }, [isMobile]); + useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { diff --git a/src/web/components/useIsMobile.ts b/src/web/components/useIsMobile.ts new file mode 100644 index 0000000..38161c3 --- /dev/null +++ b/src/web/components/useIsMobile.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react'; + +export function useIsMobile(breakpoint = 768) { + const [isMobile, setIsMobile] = useState(() => ( + typeof window !== 'undefined' ? window.innerWidth < breakpoint : false + )); + + useEffect(() => { + const update = () => setIsMobile(window.innerWidth < breakpoint); + update(); + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }, [breakpoint]); + + return isMobile; +} From ed830563d21c30fb959e3b25d029701e2bcaa0de Mon Sep 17 00:00:00 2001 From: cita Date: Tue, 10 Mar 2026 14:49:22 +0800 Subject: [PATCH 02/12] test: assert mobile layout attribute --- src/web/App.mobile-layout.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/App.mobile-layout.test.tsx b/src/web/App.mobile-layout.test.tsx index a80cada..f9f2e3f 100644 --- a/src/web/App.mobile-layout.test.tsx +++ b/src/web/App.mobile-layout.test.tsx @@ -7,6 +7,6 @@ describe('App mobile layout', () => { const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); expect(source).toContain('useIsMobile(768)'); - expect(source).toContain("document.documentElement.setAttribute('data-layout'"); + expect(source).toContain("document.documentElement.setAttribute('data-layout', isMobile ? 'mobile' : 'desktop');"); }); }); From 2d8e1b9019716bbb2bc5d0e978124d185a6bb967 Mon Sep 17 00:00:00 2001 From: cita Date: Tue, 10 Mar 2026 14:55:21 +0800 Subject: [PATCH 03/12] feat: add mobile drawer shell --- src/web/components/MobileDrawer.tsx | 25 +++++++++++++++++++++++ src/web/components/mobile-drawer.test.tsx | 25 +++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/web/components/MobileDrawer.tsx create mode 100644 src/web/components/mobile-drawer.test.tsx diff --git a/src/web/components/MobileDrawer.tsx b/src/web/components/MobileDrawer.tsx new file mode 100644 index 0000000..1624f45 --- /dev/null +++ b/src/web/components/MobileDrawer.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +type MobileDrawerProps = { + open: boolean; + onClose: () => void; + children: React.ReactNode; +}; + +export default function MobileDrawer({ open, onClose, children }: MobileDrawerProps) { + if (!open) return null; + + return ( +
+
+ ); +} diff --git a/src/web/components/mobile-drawer.test.tsx b/src/web/components/mobile-drawer.test.tsx new file mode 100644 index 0000000..1bdfe9b --- /dev/null +++ b/src/web/components/mobile-drawer.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest'; +import { create } from 'react-test-renderer'; +import MobileDrawer from './MobileDrawer.js'; + +describe('MobileDrawer', () => { + it('renders content when open and closes on backdrop click', () => { + const onClose = vi.fn(); + const root = create( + +
DrawerContent
+
, + ); + + const text = root.root.findAll(() => true) + .flatMap((instance) => instance.children) + .filter((child): child is string => typeof child === 'string') + .join(''); + + expect(text).toContain('DrawerContent'); + + const backdrop = root.root.find((node) => node.props?.className === 'mobile-drawer-backdrop'); + backdrop.props.onClick(); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); From d91a160455a17ea51bf25dea337d02bf7d50b240 Mon Sep 17 00:00:00 2001 From: cita Date: Tue, 10 Mar 2026 15:00:43 +0800 Subject: [PATCH 04/12] feat: add mobile nav drawer --- src/web/App.sidebar-mobile.test.tsx | 13 +++++ src/web/App.tsx | 87 ++++++++++++++++++++--------- 2 files changed, 75 insertions(+), 25 deletions(-) create mode 100644 src/web/App.sidebar-mobile.test.tsx diff --git a/src/web/App.sidebar-mobile.test.tsx b/src/web/App.sidebar-mobile.test.tsx new file mode 100644 index 0000000..147f320 --- /dev/null +++ b/src/web/App.sidebar-mobile.test.tsx @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('App mobile sidebar', () => { + it('renders hamburger trigger for mobile navigation', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); + + expect(source).toContain('aria-label="Open navigation"'); + expect(source).toContain('MobileDrawer'); + expect(source).toContain('mobile-nav'); + }); +}); diff --git a/src/web/App.tsx b/src/web/App.tsx index ebdbec2..45c2e25 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -17,6 +17,7 @@ import { resolveLoginErrorMessage } from './loginError.js'; import { SITE_DOCS_URL } from './docsLink.js'; import { useAnimatedVisibility } from './components/useAnimatedVisibility.js'; import { useIsMobile } from './components/useIsMobile.js'; +import { MobileDrawer } from './components/MobileDrawer.js'; const Dashboard = lazy(() => import('./pages/Dashboard.js')); const Sites = lazy(() => import('./pages/Sites.js')); const Accounts = lazy(() => import('./pages/Accounts.js')); @@ -378,6 +379,7 @@ function AppShell() { const [showProfileModal, setShowProfileModal] = useState(false); const [showSearch, setShowSearch] = useState(false); const [showNotifications, setShowNotifications] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); const themeMenuPresence = useAnimatedVisibility(showThemeMenu, 160); const userMenuPresence = useAnimatedVisibility(showUserMenu, 160); const [unreadCount, setUnreadCount] = useState(0); @@ -551,6 +553,17 @@ function AppShell() { return ( <>
+ {isMobile && ( + + )}
Metapi Metapi @@ -673,32 +686,56 @@ function AppShell() {
- + + + ) : ( + + )}
From 37e325d3cf25425d0d894534a75d66780f6ef940 Mon Sep 17 00:00:00 2001 From: cita Date: Tue, 10 Mar 2026 15:09:41 +0800 Subject: [PATCH 05/12] feat: add mobile card components --- src/web/components/MobileCard.tsx | 33 ++++++++++++++ src/web/components/mobile-card.test.tsx | 22 +++++++++ src/web/index.css | 59 +++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 src/web/components/MobileCard.tsx create mode 100644 src/web/components/mobile-card.test.tsx diff --git a/src/web/components/MobileCard.tsx b/src/web/components/MobileCard.tsx new file mode 100644 index 0000000..108f977 --- /dev/null +++ b/src/web/components/MobileCard.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +type MobileCardProps = { + title: React.ReactNode; + actions?: React.ReactNode; + children: React.ReactNode; +}; + +type MobileFieldProps = { + label: React.ReactNode; + value: React.ReactNode; +}; + +export function MobileCard({ title, actions, children }: MobileCardProps) { + return ( +
+
+
{title}
+ {actions ?
{actions}
: null} +
+
{children}
+
+ ); +} + +export function MobileField({ label, value }: MobileFieldProps) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/web/components/mobile-card.test.tsx b/src/web/components/mobile-card.test.tsx new file mode 100644 index 0000000..45a3d84 --- /dev/null +++ b/src/web/components/mobile-card.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { create } from 'react-test-renderer'; +import { MobileCard, MobileField } from './MobileCard.js'; + +describe('MobileCard', () => { + it('renders title and fields', () => { + 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('CardTitle'); + expect(text).toContain('Status'); + expect(text).toContain('OK'); + }); +}); diff --git a/src/web/index.css b/src/web/index.css index a616567..6238769 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -964,6 +964,65 @@ body { transform: translateY(-2px); } +/* ===== Mobile Card ===== */ +.mobile-card-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.mobile-card { + background: var(--color-bg-card); + border-radius: var(--radius-md); + border: 1px solid var(--color-border-light); + box-shadow: var(--shadow-card); + padding: 14px; +} + +.mobile-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.mobile-card-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); +} + +.mobile-card-actions { + display: flex; + gap: 6px; +} + +.mobile-card-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +.mobile-field { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.mobile-field-label { + font-size: 11px; + color: var(--color-text-muted); +} + +.mobile-field-value { + font-size: 12px; + font-weight: 500; + color: var(--color-text-primary); + text-align: right; +} + /* ===== Stat Card (New API style) ===== */ .stat-card { background: var(--color-bg-card); From b23c5ece83d6196fc8b4478a1ff5aeb543653e43 Mon Sep 17 00:00:00 2001 From: cita Date: Tue, 10 Mar 2026 15:27:28 +0800 Subject: [PATCH 06/12] feat: convert core pages to mobile cards --- src/web/pages/Accounts.tsx | 200 +++++++++++++++++++++++++++- src/web/pages/Sites.tsx | 146 +++++++++++++++++++- src/web/pages/Tokens.tsx | 116 +++++++++++++++- src/web/pages/sites.mobile.test.tsx | 10 ++ 4 files changed, 469 insertions(+), 3 deletions(-) create mode 100644 src/web/pages/sites.mobile.test.tsx diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index b5220cd..0073e46 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -4,6 +4,8 @@ import { api } from '../api.js'; import CenteredModal from '../components/CenteredModal.js'; import { useToast } from '../components/Toast.js'; import ModernSelect from '../components/ModernSelect.js'; +import { MobileCard, MobileField } from '../components/MobileCard.js'; +import { useIsMobile } from '../components/useIsMobile.js'; import { buildAddAccountPrereqHint, buildVerifyFailureHint, @@ -76,6 +78,8 @@ export default function Accounts() { const [loaded, setLoaded] = useState(false); const [sortMode, setSortMode] = useState('custom'); const [highlightAccountId, setHighlightAccountId] = useState(null); + const [expandedAccountIds, setExpandedAccountIds] = useState([]); + const isMobile = useIsMobile(768); const [showAdd, setShowAdd] = useState(false); const [addMode, setAddMode] = useState<'token' | 'login'>('token'); const [loginForm, setLoginForm] = useState(createLoginForm); @@ -538,6 +542,14 @@ export default function Accounts() { setSelectedAccountIds(visibleAccounts.map((account) => account.id)); }; + const toggleAccountDetails = (accountId: number) => { + setExpandedAccountIds((current) => ( + current.includes(accountId) + ? current.filter((id) => id !== accountId) + : [...current, accountId] + )); + }; + const runBatchAccountAction = async (action: 'enable' | 'disable' | 'delete' | 'refreshBalance') => { if (selectedAccountIds.length === 0) return; if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedAccountIds.length} 个账号?`)) return; @@ -1303,7 +1315,192 @@ export default function Accounts() {
{visibleAccounts.length > 0 ? ( - + isMobile ? ( +
+ {visibleAccounts.map((a: any) => { + const capabilities = resolveAccountCapabilities(a); + const connectionMode = resolveAccountCredentialMode(a); + const health = resolveRuntimeHealth(a); + const isExpanded = expandedAccountIds.includes(a.id); + return ( + + toggleAccountSelection(a.id, event.target.checked)} + /> + + {connectionMode === 'apikey' ? 'API Key' : 'Session'} + +
+ )} + > + + + + {health.label} + + + {health.reason} + + + )} + /> + +
${(a.balance || 0).toFixed(2)}
+
0 ? 'var(--color-success)' : 'var(--color-text-muted)', fontWeight: 500 }}> + +{(a.todayReward || 0).toFixed(2)} +
+ + )} + /> + +
${(a.balanceUsed || 0).toFixed(2)}
+
0 ? 'var(--color-danger)' : 'var(--color-text-muted)', fontWeight: 500 }}> + -{(a.todaySpend || 0).toFixed(2)} +
+ + )} + /> + {isExpanded ? ( +
+ + + {a.site?.name || '-'} + + + ) : ( + + {a.site?.name || '-'} + + )} + /> + handleToggleCheckin(a)} + disabled={!!actionLoading[`checkin-toggle-${a.id}`]} + data-tooltip={a.checkinEnabled ? '点击关闭签到,全部签到会忽略此账号' : '点击开启签到'} + aria-label={a.checkinEnabled ? '点击关闭签到,全部签到会忽略此账号' : '点击开启签到'} + > + {actionLoading[`checkin-toggle-${a.id}`] + ? + : (a.checkinEnabled ? '开启' : '关闭')} + + ) : ( + + 不支持 + + )} + /> + +
+ ) : null} +
+ + + {sortMode === 'custom' && ( + <> + + + + )} + {capabilities.canRefreshBalance && ( + + )} + + {capabilities.canCheckin && ( + + )} + {a.status === 'expired' && !capabilities.proxyOnly && ( + + )} + + +
+ + ); + })} + + ) : ( +
@@ -1490,6 +1687,7 @@ export default function Accounts() { })}
+ ) ) : (
diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index e8e67cc..b40b160 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -3,7 +3,9 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { api } from '../api.js'; import { useToast } from '../components/Toast.js'; import ModernSelect from '../components/ModernSelect.js'; +import { MobileCard, MobileField } from '../components/MobileCard.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; +import { useIsMobile } from '../components/useIsMobile.js'; import { formatDateTimeLocal } from './helpers/checkinLogTime.js'; import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js'; import { tr } from '../i18n.js'; @@ -78,6 +80,8 @@ export default function Sites() { const [orderingSiteId, setOrderingSiteId] = useState(null); const [pinningSiteId, setPinningSiteId] = useState(null); const [selectedSiteIds, setSelectedSiteIds] = useState([]); + const [expandedSiteIds, setExpandedSiteIds] = useState([]); + const isMobile = useIsMobile(768); const [batchActionLoading, setBatchActionLoading] = useState(false); const editorPresence = useAnimatedVisibility(Boolean(editor), 220); const lastEditorRef = useRef(null); @@ -327,6 +331,14 @@ export default function Sites() { setSelectedSiteIds(sortedSites.map((site) => site.id)); }; + const toggleSiteDetails = (siteId: number) => { + setExpandedSiteIds((current) => ( + current.includes(siteId) + ? current.filter((id) => id !== siteId) + : [...current, siteId] + )); + }; + const runBatchAction = async (action: 'enable' | 'disable' | 'delete' | 'enableSystemProxy' | 'disableSystemProxy') => { if (selectedSiteIds.length === 0) return; if (action === 'delete' && typeof globalThis.confirm === 'function' && !globalThis.confirm(`确认删除选中的 ${selectedSiteIds.length} 个站点?`)) return; @@ -555,7 +567,138 @@ export default function Sites() {
{sites.length > 0 ? ( - + isMobile ? ( +
+ {sortedSites.map((site) => { + const isExpanded = expandedSiteIds.includes(site.id); + return ( + toggleSiteSelection(site.id, event.target.checked)} + /> + )} + > + + {site.status === 'disabled' ? '禁用' : '启用'} + + )} + /> + + {site.platform || '-'} + + )} + /> + + + {isExpanded ? ( +
+ + {site.useSystemProxy ? '已开启' : '未开启'} + + )} + /> + + {site.externalCheckinUrl} + + ) : '-'} + /> + +
+ ) : null} +
+ + + {sortMode === 'custom' && ( + <> + + + + )} + + + +
+
+ ); + })} +
+ ) : ( +
@@ -720,6 +863,7 @@ export default function Sites() { ))}
+ ) ) : (
diff --git a/src/web/pages/Tokens.tsx b/src/web/pages/Tokens.tsx index 7c699b4..46aca1d 100644 --- a/src/web/pages/Tokens.tsx +++ b/src/web/pages/Tokens.tsx @@ -5,6 +5,8 @@ import CenteredModal from '../components/CenteredModal.js'; import { useToast } from '../components/Toast.js'; import { formatDateTimeLocal } from './helpers/checkinLogTime.js'; import ModernSelect from '../components/ModernSelect.js'; +import { MobileCard, MobileField } from '../components/MobileCard.js'; +import { useIsMobile } from '../components/useIsMobile.js'; import { clearFocusParams, readFocusTokenId } from './helpers/navigationFocus.js'; import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js'; import { tr } from '../i18n.js'; @@ -105,6 +107,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 initialCreateForm = { accountId: 0, name: '', @@ -868,7 +871,117 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
) : tokens.length > 0 ? ( - + isMobile ? ( +
+ {tokens.map((token: any) => { + const loadingPrefix = `token-${token.id}`; + return ( + toggleTokenSelection(token.id, event.target.checked)} + /> + )} + > + {token.tokenMasked || '***'}} + /> + + + {token.site?.name || 'unknown'} + + + ) : ( + + {token.site?.name || 'unknown'} + + )} + /> + + + {token.enabled ? '启用' : '禁用'} + + )} + /> + 默认 : '-'} + /> + +
+ {!token.isDefault && ( + + )} + + + + +
+
+ ); + })} +
+ ) : ( +
@@ -998,6 +1111,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token })}
+ ) ) : (
diff --git a/src/web/pages/sites.mobile.test.tsx b/src/web/pages/sites.mobile.test.tsx new file mode 100644 index 0000000..3e957c3 --- /dev/null +++ b/src/web/pages/sites.mobile.test.tsx @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('Sites mobile layout', () => { + it('includes mobile-card usage in Sites page', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Sites.tsx'), 'utf8'); + expect(source).toContain('mobile-card'); + }); +}); From b8ebf98b688d9bd710321fca7bc1bfc12925eaa0 Mon Sep 17 00:00:00 2001 From: cita Date: Tue, 10 Mar 2026 15:35:13 +0800 Subject: [PATCH 07/12] fix: add account mobile hints --- src/web/pages/Accounts.tsx | 7 +++++++ src/web/pages/accounts.mobile.test.tsx | 10 ++++++++++ 2 files changed, 17 insertions(+) create mode 100644 src/web/pages/accounts.mobile.test.tsx diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 0073e46..a68572b 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -1322,6 +1322,9 @@ export default function Accounts() { const connectionMode = resolveAccountCredentialMode(a); const health = resolveRuntimeHealth(a); const isExpanded = expandedAccountIds.includes(a.id); + const hintMessage = (a.status === 'expired' && !capabilities.proxyOnly) + ? '账号已过期,请重新绑定' + : (health.reason || '-'); return ( +
) : null}
diff --git a/src/web/pages/accounts.mobile.test.tsx b/src/web/pages/accounts.mobile.test.tsx new file mode 100644 index 0000000..c7ca89d --- /dev/null +++ b/src/web/pages/accounts.mobile.test.tsx @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('Accounts mobile layout', () => { + it('includes hints field in mobile expanded section', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Accounts.tsx'), 'utf8'); + expect(source).toContain('label="提示"'); + }); +}); From f01379f7a39283017611ec40b985e3c4ebc3c3b8 Mon Sep 17 00:00:00 2001 From: cita Date: Tue, 10 Mar 2026 15:48:10 +0800 Subject: [PATCH 08/12] feat: convert logs and routes pages to mobile cards --- src/web/pages/CheckinLog.tsx | 77 ++++++++++++++++++++++++++++++ src/web/pages/ProxyLogs.tsx | 63 ++++++++++++++++++++++++ src/web/pages/TokenRoutes.tsx | 49 ++++++++++++++++++- src/web/pages/logs.mobile.test.tsx | 10 ++++ 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 src/web/pages/logs.mobile.test.tsx diff --git a/src/web/pages/CheckinLog.tsx b/src/web/pages/CheckinLog.tsx index ea317ce..3646354 100644 --- a/src/web/pages/CheckinLog.tsx +++ b/src/web/pages/CheckinLog.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react'; import { api } from '../api.js'; +import { MobileCard, MobileField } from '../components/MobileCard.js'; import { useToast } from '../components/Toast.js'; +import { useIsMobile } from '../components/useIsMobile.js'; import { formatCheckinLogTime } from './helpers/checkinLogTime.js'; import { tr } from '../i18n.js'; @@ -19,6 +21,8 @@ export default function CheckinLog() { const [loading, setLoading] = useState(true); const [triggering, setTriggering] = useState(false); const [filter, setFilter] = useState('all'); + const [expandedLogId, setExpandedLogId] = useState(null); + const isMobile = useIsMobile(768); const toast = useToast(); const load = async () => { @@ -127,6 +131,79 @@ export default function CheckinLog() {
))}
+ ) : isMobile ? ( +
+ {filtered.map((log: any) => { + const status = getStatus(log); + const reason = getFailureReason(log); + const isExpanded = expandedLogId === (log.checkin_logs?.id || log.id); + const logId = log.checkin_logs?.id || log.id; + return ( + + {statusLabel(status)} + + )} + > + + + + {log.sites?.name || '-'} + + + ) : ( + + {log.sites?.name || '-'} + + )} + /> + + {reason.title} + + ) : ( + - + )} + /> + + {isExpanded ? ( +
+ + +
+ ) : null} +
+ +
+
+ ); + })} +
) : ( diff --git a/src/web/pages/ProxyLogs.tsx b/src/web/pages/ProxyLogs.tsx index 7f3f5d1..5ca908f 100644 --- a/src/web/pages/ProxyLogs.tsx +++ b/src/web/pages/ProxyLogs.tsx @@ -9,6 +9,8 @@ import { } from '../api.js'; import { useToast } from '../components/Toast.js'; import { ModelBadge } from '../components/BrandIcon.js'; +import { MobileCard, MobileField } from '../components/MobileCard.js'; +import { useIsMobile } from '../components/useIsMobile.js'; import { formatDateTimeLocal } from './helpers/checkinLogTime.js'; import ModernSelect from '../components/ModernSelect.js'; import { parseProxyLogPathMeta } from './helpers/proxyLogPathMeta.js'; @@ -123,6 +125,7 @@ export default function ProxyLogs() { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(50); const [detailById, setDetailById] = useState>({}); + const isMobile = useIsMobile(768); const toast = useToast(); useEffect(() => { @@ -275,6 +278,66 @@ export default function ProxyLogs() { ))} + ) : isMobile ? ( +
+ {logs.map((log) => { + const detailState = detailById[log.id]; + const detail = detailState?.data; + const detailLog: ProxyLogRenderItem = detail ? { ...log, ...detail } : log; + const pathMeta = parseProxyLogPathMeta(detailLog.errorMessage); + const billingDetailSummary = detail ? formatBillingDetailSummary(detailLog) : null; + const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : []; + const isExpanded = expanded === log.id; + + return ( + + {log.status === 'success' ? '成功' : '失败'} + + )} + > + + + + + + {isExpanded ? ( +
+ 0 ? log.retryCount : 0} /> + {detailState?.loading &&
加载详情中...
} + {detailState?.error &&
{detailState.error}
} + {billingDetailSummary &&
{billingDetailSummary}
} + {billingProcessLines.length > 0 && ( +
+ {billingProcessLines.map((line, index) => ( + {line} + ))} +
+ )} + {detail && pathMeta.errorMessage.trim().length > 0 && ( +
{pathMeta.errorMessage}
+ )} +
+ ) : null} +
+ +
+
+ ); + })} +
) : (
diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 588244d..eb0f584 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -21,7 +21,9 @@ import { api } from '../api.js'; import { BrandGlyph, InlineBrandIcon, getBrand, hashColor, normalizeBrandIconKey, type BrandInfo } from '../components/BrandIcon.js'; import { useToast } from '../components/Toast.js'; import ModernSelect from '../components/ModernSelect.js'; +import { MobileCard, MobileField } from '../components/MobileCard.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; +import { useIsMobile } from '../components/useIsMobile.js'; import { tr } from '../i18n.js'; import { buildRouteModelCandidatesIndex, @@ -729,6 +731,8 @@ export default function TokenRoutes() { const [decisionAutoSkipped, setDecisionAutoSkipped] = useState(false); const [visibleRouteCount, setVisibleRouteCount] = useState(ROUTE_RENDER_CHUNK); const [expandedSourceGroupMap, setExpandedSourceGroupMap] = useState>({}); + const [expandedRouteIds, setExpandedRouteIds] = useState([]); + const isMobile = useIsMobile(768); const sensors = useSensors( useSensor(PointerSensor, { @@ -1878,7 +1882,10 @@ export default function TokenRoutes() { )} -
+
{visibleRoutes.map((route, i) => { const candidateView = getRouteCandidateView(route.id); const routeCandidates = candidateView.routeCandidates; @@ -1949,7 +1956,7 @@ export default function TokenRoutes() { const routeIcon = resolveRouteIcon(route); const routeBrand = routeBrandById.get(route.id) || null; - return ( + const routeCard = (
); + + const isExpanded = expandedRouteIds.includes(route.id); + + return isMobile ? ( + + {route.enabled ? tr('启用') : tr('禁用')} + + )} + > + + + + {isExpanded ? ( +
+ {routeCard} +
+ ) : null} +
+ +
+
+ ) : ( + routeCard + ); })} {filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && ( diff --git a/src/web/pages/logs.mobile.test.tsx b/src/web/pages/logs.mobile.test.tsx new file mode 100644 index 0000000..65ee955 --- /dev/null +++ b/src/web/pages/logs.mobile.test.tsx @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('ProxyLogs mobile layout', () => { + it('renders mobile cards for proxy logs', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/pages/ProxyLogs.tsx'), 'utf8'); + expect(source).toContain('MobileCard'); + }); +}); From 715a2dba9b738c818a7ca1a4bab23de8731e495a Mon Sep 17 00:00:00 2001 From: cita Date: Tue, 10 Mar 2026 16:05:24 +0800 Subject: [PATCH 09/12] feat: add mobile actions and CSS --- src/web/index.css | 104 +++++++ src/web/pages/Accounts.tsx | 22 +- src/web/pages/CheckinLog.tsx | 46 ++- src/web/pages/ProxyLogs.tsx | 72 +++-- src/web/pages/Sites.tsx | 36 ++- src/web/pages/TokenRoutes.tsx | 429 ++++++++++++++------------ src/web/pages/Tokens.tsx | 19 +- src/web/pages/mobile-actions.test.tsx | 10 + 8 files changed, 503 insertions(+), 235 deletions(-) create mode 100644 src/web/pages/mobile-actions.test.tsx diff --git a/src/web/index.css b/src/web/index.css index 6238769..a42987d 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -1023,6 +1023,83 @@ body { text-align: right; } +.mobile-card-extra { + border-top: 1px dashed var(--color-border-light); + padding-top: 8px; + margin-top: 4px; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* ===== Mobile Drawer ===== */ +.mobile-drawer-root { + position: fixed; + inset: 0; + z-index: 220; + display: flex; +} + +.mobile-drawer-backdrop { + position: absolute; + inset: 0; + background: rgba(15, 15, 15, 0.4); + border: none; +} + +.mobile-drawer-panel { + position: relative; + width: min(82vw, 320px); + height: 100%; + background: var(--color-bg-card); + border-right: 1px solid var(--color-border); + box-shadow: var(--shadow-lg); + padding: 16px; + overflow-y: auto; +} + +.mobile-filter-row { + display: flex; + justify-content: flex-end; + margin-bottom: 12px; +} + +.mobile-filter-panel { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* ===== Mobile Actions Bar ===== */ +.mobile-actions-bar { + position: fixed; + left: 16px; + right: 16px; + bottom: 16px; + z-index: 210; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 10px 12px; + border-radius: var(--radius-md); + background: var(--color-bg-card); + border: 1px solid var(--color-border-light); + box-shadow: var(--shadow-lg); +} + +.mobile-actions-info { + font-size: 12px; + font-weight: 600; + color: var(--color-text-primary); +} + +.mobile-actions-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + /* ===== Stat Card (New API style) ===== */ .stat-card { background: var(--color-bg-card); @@ -3545,6 +3622,33 @@ textarea { } } +@media (max-width: 768px) { + .main-content { + padding: 16px; + padding-bottom: 96px; + } + + .page-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .toolbar { + flex-direction: column; + align-items: stretch; + } + + .toolbar-search { + max-width: none; + } + + .mobile-actions-bar .btn { + justify-content: center; + flex: 1 1 auto; + } +} + /* ===== Responsive Adjustments ===== */ @media (max-width: 900px) { .topbar-search-trigger { diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index a68572b..314b471 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -795,7 +795,7 @@ export default function Accounts() { ))}
- {activeSegment !== 'tokens' && selectedAccountIds.length > 0 && ( + {!isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
已选 {selectedAccountIds.length} 项
)} + {isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && ( +
+ 已选 {selectedAccountIds.length} 项 +
+ + + + +
+
+ )} + {activeSegment === 'tokens' ? ( ) : ( diff --git a/src/web/pages/CheckinLog.tsx b/src/web/pages/CheckinLog.tsx index 3646354..b335c4b 100644 --- a/src/web/pages/CheckinLog.tsx +++ b/src/web/pages/CheckinLog.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { api } from '../api.js'; import { MobileCard, MobileField } from '../components/MobileCard.js'; +import { MobileDrawer } from '../components/MobileDrawer.js'; import { useToast } from '../components/Toast.js'; import { useIsMobile } from '../components/useIsMobile.js'; import { formatCheckinLogTime } from './helpers/checkinLogTime.js'; @@ -22,6 +23,7 @@ export default function CheckinLog() { const [triggering, setTriggering] = useState(false); const [filter, setFilter] = useState('all'); const [expandedLogId, setExpandedLogId] = useState(null); + const [showFilters, setShowFilters] = useState(false); const isMobile = useIsMobile(768); const toast = useToast(); @@ -86,6 +88,19 @@ export default function CheckinLog() { return reason; }; + const filterTabs = ( +
+ {(['all', 'success', 'failed', 'skipped'] as const).map((tab) => ( + + ))} +
+ ); + return (
@@ -106,16 +121,27 @@ export default function CheckinLog() {
-
- {(['all', 'success', 'failed', 'skipped'] as const).map((tab) => ( - - ))} -
+ {isMobile ? ( + <> +
+ +
+ setShowFilters(false)}> +
+ {filterTabs} +
+
+ + ) : ( + filterTabs + )}
{loading ? ( diff --git a/src/web/pages/ProxyLogs.tsx b/src/web/pages/ProxyLogs.tsx index 5ca908f..dd880b8 100644 --- a/src/web/pages/ProxyLogs.tsx +++ b/src/web/pages/ProxyLogs.tsx @@ -10,6 +10,7 @@ import { import { useToast } from '../components/Toast.js'; import { ModelBadge } from '../components/BrandIcon.js'; import { MobileCard, MobileField } from '../components/MobileCard.js'; +import { MobileDrawer } from '../components/MobileDrawer.js'; import { useIsMobile } from '../components/useIsMobile.js'; import { formatDateTimeLocal } from './helpers/checkinLogTime.js'; import ModernSelect from '../components/ModernSelect.js'; @@ -125,6 +126,7 @@ export default function ProxyLogs() { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(50); const [detailById, setDetailById] = useState>({}); + const [showFilters, setShowFilters] = useState(false); const isMobile = useIsMobile(768); const toast = useToast(); @@ -217,6 +219,35 @@ export default function ProxyLogs() { } }, [expanded, loadDetail]); + const filterControls = ( + <> +
+ {([ + { key: 'all' as ProxyLogStatusFilter, label: '全部', count: summary.totalCount }, + { key: 'success' as ProxyLogStatusFilter, label: '成功', count: summary.successCount }, + { key: 'failed' as ProxyLogStatusFilter, label: '失败', count: summary.failedCount }, + ]).map((tab) => ( + + ))} +
+
+ + + + setSearchInput(e.target.value)} placeholder="搜索模型名称..." /> +
+ + ); + return (
@@ -237,32 +268,29 @@ export default function ProxyLogs() {
-
-
- {([ - { key: 'all' as ProxyLogStatusFilter, label: '全部', count: summary.totalCount }, - { key: 'success' as ProxyLogStatusFilter, label: '成功', count: summary.successCount }, - { key: 'failed' as ProxyLogStatusFilter, label: '失败', count: summary.failedCount }, - ]).map((tab) => ( + {isMobile ? ( + <> +
- ))} +
+ setShowFilters(false)}> +
+ {filterControls} +
+
+ + ) : ( +
+ {filterControls}
-
- - - - setSearchInput(e.target.value)} placeholder="搜索模型名称..." /> -
-
+ )}
{loading ? ( diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index b40b160..a567ec0 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -395,7 +395,7 @@ export default function Sites() {
- {selectedSiteIds.length > 0 && ( + {!isMobile && selectedSiteIds.length > 0 && (
已选 {selectedSiteIds.length} 项 + + + + +
+
+ )} +
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index eb0f584..c03a5bd 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -22,6 +22,7 @@ import { BrandGlyph, InlineBrandIcon, getBrand, hashColor, normalizeBrandIconKey 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 { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; import { useIsMobile } from '../components/useIsMobile.js'; import { tr } from '../i18n.js'; @@ -710,6 +711,7 @@ export default function TokenRoutes() { const [activeEndpointType, setActiveEndpointType] = useState(null); const [activeGroupFilter, setActiveGroupFilter] = useState(null); const [filterCollapsed, setFilterCollapsed] = useState(false); + const [showFilters, setShowFilters] = useState(false); const [sortBy, setSortBy] = useState('channelCount'); const [sortDir, setSortDir] = useState('desc'); @@ -747,6 +749,217 @@ export default function TokenRoutes() { const toast = useToast(); + const openFilters = () => { + setFilterCollapsed(false); + setShowFilters(true); + }; + + const closeFilters = () => { + setFilterCollapsed(true); + setShowFilters(false); + }; + + const filterPanelContent = ( +
+
+
+ 品牌 + {activeBrand && } +
+ +
setActiveBrand(null)}> + + ✦ + + 全部品牌 + {routes.length} +
+ + {brandList.list.map(([brandName, { count, brand }]) => ( +
setActiveBrand(activeBrand === brandName ? null : brandName)} + > + + + + {brandName} + {count} +
+ ))} + + {brandList.otherCount > 0 && ( +
setActiveBrand(activeBrand === '__other__' ? null : '__other__')} + > + + ? + + 其他 + {brandList.otherCount} +
+ )} +
+ +
+
+ {tr('群组')} + {activeGroupFilter !== null && } +
+ +
setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')} + > + + ◎ + + {tr('全部群组')} + {groupRouteList.length} +
+ + {groupRouteList.map((groupRoute) => ( +
setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)} + > + + {groupRoute.icon.kind === 'brand' ? ( + + ) : groupRoute.icon.kind === 'text' ? ( + {groupRoute.icon.value} + ) : groupRoute.brand ? ( + + ) : ( + + )} + + {groupRoute.title} + {groupRoute.channelCount} +
+ ))} +
+ + {siteList.length > 0 && ( +
+
+ 站点 + {activeSite && } +
+ +
setActiveSite(null)}> + + ⚡ + + 全部站点 + {routes.length} +
+ + {siteList.map(([siteName, { count }]) => ( +
setActiveSite(activeSite === siteName ? null : siteName)} + > + + {siteAvatarLetters(siteName)} + + {siteName} + {count} +
+ ))} +
+ )} + +
+
+ 接口能力 + {activeEndpointType && } +
+ +
setActiveEndpointType(null)}> + + ⚙ + + 全部能力 + {routes.length} +
+ + {endpointTypeList.map(([endpointType, count]) => ( +
setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)} + > + + {(() => { + const iconModel = resolveEndpointTypeIconModel(endpointType); + if (!iconModel) return ⚙; + return ; + })()} + + {endpointType} + {count} +
+ ))} + + {endpointTypeList.length === 0 && ( +
+ 暂无接口能力数据 +
+ )} +
+ + +
+ ); + const loadRouteDecisions = async ( routeRows: RouteRow[], options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean }, @@ -1405,205 +1618,13 @@ export default function TokenRoutes() { return (
- {filterPanelPresence.shouldRender && ( -
-
-
- 品牌 - {activeBrand && } -
- -
setActiveBrand(null)}> - - ✦ - - 全部品牌 - {routes.length} -
- - {brandList.list.map(([brandName, { count, brand }]) => ( -
setActiveBrand(activeBrand === brandName ? null : brandName)} - > - - - - {brandName} - {count} -
- ))} - - {brandList.otherCount > 0 && ( -
setActiveBrand(activeBrand === '__other__' ? null : '__other__')} - > - - ? - - 其他 - {brandList.otherCount} -
- )} + {!isMobile && filterPanelPresence.shouldRender && filterPanelContent} + {isMobile && ( + +
+ {filterPanelPresence.shouldRender && filterPanelContent}
- -
-
- {tr('群组')} - {activeGroupFilter !== null && } -
- -
setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')} - > - - ◎ - - {tr('全部群组')} - {groupRouteList.length} -
- - {groupRouteList.map((groupRoute) => ( -
setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)} - > - - {groupRoute.icon.kind === 'brand' ? ( - - ) : groupRoute.icon.kind === 'text' ? ( - {groupRoute.icon.value} - ) : groupRoute.brand ? ( - - ) : ( - - )} - - {groupRoute.title} - {groupRoute.channelCount} -
- ))} -
- - {siteList.length > 0 && ( -
-
- 站点 - {activeSite && } -
- -
setActiveSite(null)}> - - ⚡ - - 全部站点 - {routes.length} -
- - {siteList.map(([siteName, { count }]) => ( -
setActiveSite(activeSite === siteName ? null : siteName)} - > - - {siteAvatarLetters(siteName)} - - {siteName} - {count} -
- ))} -
- )} - -
-
- 接口能力 - {activeEndpointType && } -
- -
setActiveEndpointType(null)}> - - ⚙ - - 全部能力 - {routes.length} -
- - {endpointTypeList.map(([endpointType, count]) => ( -
setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)} - > - - {(() => { - const iconModel = resolveEndpointTypeIconModel(endpointType); - if (!iconModel) return ⚙; - return ; - })()} - - {endpointType} - {count} -
- ))} - - {endpointTypeList.length === 0 && ( -
- 暂无接口能力数据 -
- )} -
- - -
+ )}
@@ -1631,7 +1652,15 @@ export default function TokenRoutes() {
- {filterCollapsed && ( + {isMobile ? ( + + ) : (filterCollapsed && ( - )} + ))}
)} + {isMobile && selectedTokenIds.length > 0 && ( +
+ 已选 {selectedTokenIds.length} 项 +
+ + + +
+
+ )} + { + it('defines .mobile-actions-bar in index.css', () => { + const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8'); + expect(css).toContain('.mobile-actions-bar'); + }); +}); From 1dbde9346a36a48e397fbdf2b3a9918c5e32bbdb Mon Sep 17 00:00:00 2001 From: cita Date: Tue, 10 Mar 2026 16:14:37 +0800 Subject: [PATCH 10/12] fix: guard useIsMobile in non-browser --- src/web/components/useIsMobile.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/web/components/useIsMobile.ts b/src/web/components/useIsMobile.ts index 38161c3..10174dd 100644 --- a/src/web/components/useIsMobile.ts +++ b/src/web/components/useIsMobile.ts @@ -6,6 +6,7 @@ export function useIsMobile(breakpoint = 768) { )); useEffect(() => { + if (typeof window === 'undefined') return; const update = () => setIsMobile(window.innerWidth < breakpoint); update(); window.addEventListener('resize', update); From ab4033c1fa0bc884deeaf5ce7e6d2dbb6c4fe56d Mon Sep 17 00:00:00 2001 From: cita Date: Tue, 10 Mar 2026 16:18:44 +0800 Subject: [PATCH 11/12] fix: avoid brandList TDZ in TokenRoutes --- src/web/pages/TokenRoutes.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index c03a5bd..cd50bc3 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -759,7 +759,7 @@ export default function TokenRoutes() { setShowFilters(false); }; - const filterPanelContent = ( + const renderFilterPanelContent = () => (
@@ -1618,11 +1618,11 @@ export default function TokenRoutes() { return (
- {!isMobile && filterPanelPresence.shouldRender && filterPanelContent} + {!isMobile && filterPanelPresence.shouldRender && renderFilterPanelContent()} {isMobile && (
- {filterPanelPresence.shouldRender && filterPanelContent} + {filterPanelPresence.shouldRender && renderFilterPanelContent()}
)} From ab2220bc095dc613e78765d9f385018afeb05a08 Mon Sep 17 00:00:00 2001 From: cita Date: Tue, 10 Mar 2026 16:25:44 +0800 Subject: [PATCH 12/12] fix: export MobileDrawer --- src/web/components/MobileDrawer.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/web/components/MobileDrawer.tsx b/src/web/components/MobileDrawer.tsx index 1624f45..fc37b59 100644 --- a/src/web/components/MobileDrawer.tsx +++ b/src/web/components/MobileDrawer.tsx @@ -6,7 +6,7 @@ type MobileDrawerProps = { children: React.ReactNode; }; -export default function MobileDrawer({ open, onClose, children }: MobileDrawerProps) { +function MobileDrawer({ open, onClose, children }: MobileDrawerProps) { if (!open) return null; return ( @@ -23,3 +23,6 @@ export default function MobileDrawer({ open, onClose, children }: MobileDrawerPr
); } + +export { MobileDrawer }; +export default MobileDrawer;