diff --git a/src/web/App.mobile-layout.test.tsx b/src/web/App.mobile-layout.test.tsx new file mode 100644 index 0000000..f9f2e3f --- /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', isMobile ? 'mobile' : 'desktop');"); + }); +}); 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 d7b723f..45c2e25 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -16,6 +16,8 @@ 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'; +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')); @@ -377,12 +379,14 @@ 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); 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 +419,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') { @@ -545,6 +553,17 @@ function AppShell() { return ( <>
+ {isMobile && ( + + )}
Metapi Metapi @@ -667,32 +686,56 @@ function AppShell() {
- + + + ) : ( + + )}
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/MobileDrawer.tsx b/src/web/components/MobileDrawer.tsx new file mode 100644 index 0000000..fc37b59 --- /dev/null +++ b/src/web/components/MobileDrawer.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +type MobileDrawerProps = { + open: boolean; + onClose: () => void; + children: React.ReactNode; +}; + +function MobileDrawer({ open, onClose, children }: MobileDrawerProps) { + if (!open) return null; + + return ( +
+
+ ); +} + +export { MobileDrawer }; +export default MobileDrawer; 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/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); + }); +}); diff --git a/src/web/components/useIsMobile.ts b/src/web/components/useIsMobile.ts new file mode 100644 index 0000000..10174dd --- /dev/null +++ b/src/web/components/useIsMobile.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export function useIsMobile(breakpoint = 768) { + const [isMobile, setIsMobile] = useState(() => ( + typeof window !== 'undefined' ? window.innerWidth < breakpoint : false + )); + + useEffect(() => { + if (typeof window === 'undefined') return; + const update = () => setIsMobile(window.innerWidth < breakpoint); + update(); + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }, [breakpoint]); + + return isMobile; +} diff --git a/src/web/index.css b/src/web/index.css index a616567..a42987d 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -964,6 +964,142 @@ 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; +} + +.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); @@ -3486,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 b5220cd..314b471 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; @@ -783,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' ? ( ) : ( @@ -1303,7 +1335,199 @@ 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); + const hintMessage = (a.status === 'expired' && !capabilities.proxyOnly) + ? '账号已过期,请重新绑定' + : (health.reason || '-'); + 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 +1714,7 @@ export default function Accounts() { })}
+ ) ) : (
diff --git a/src/web/pages/CheckinLog.tsx b/src/web/pages/CheckinLog.tsx index ea317ce..b335c4b 100644 --- a/src/web/pages/CheckinLog.tsx +++ b/src/web/pages/CheckinLog.tsx @@ -1,6 +1,9 @@ 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'; import { tr } from '../i18n.js'; @@ -19,6 +22,9 @@ 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 [showFilters, setShowFilters] = useState(false); + const isMobile = useIsMobile(768); const toast = useToast(); const load = async () => { @@ -82,6 +88,19 @@ export default function CheckinLog() { return reason; }; + const filterTabs = ( +
+ {(['all', 'success', 'failed', 'skipped'] as const).map((tab) => ( + + ))} +
+ ); + return (
@@ -102,16 +121,27 @@ export default function CheckinLog() {
-
- {(['all', 'success', 'failed', 'skipped'] as const).map((tab) => ( - - ))} -
+ {isMobile ? ( + <> +
+ +
+ setShowFilters(false)}> +
+ {filterTabs} +
+
+ + ) : ( + filterTabs + )}
{loading ? ( @@ -127,6 +157,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..dd880b8 100644 --- a/src/web/pages/ProxyLogs.tsx +++ b/src/web/pages/ProxyLogs.tsx @@ -9,6 +9,9 @@ import { } from '../api.js'; 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'; import { parseProxyLogPathMeta } from './helpers/proxyLogPathMeta.js'; @@ -123,6 +126,8 @@ 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(); useEffect(() => { @@ -214,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 (
@@ -234,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 ? ( @@ -275,6 +306,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/Sites.tsx b/src/web/pages/Sites.tsx index e8e67cc..a567ec0 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; @@ -383,7 +395,7 @@ export default function Sites() { - {selectedSiteIds.length > 0 && ( + {!isMobile && selectedSiteIds.length > 0 && (
已选 {selectedSiteIds.length} 项 + + + + +
+ + )} +
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
@@ -555,7 +601,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 +897,7 @@ export default function Sites() { ))}
+ ) ) : (
diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 588244d..cd50bc3 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -21,7 +21,10 @@ 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 { MobileDrawer } from '../components/MobileDrawer.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; +import { useIsMobile } from '../components/useIsMobile.js'; import { tr } from '../i18n.js'; import { buildRouteModelCandidatesIndex, @@ -708,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'); @@ -729,6 +733,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, { @@ -743,6 +749,217 @@ export default function TokenRoutes() { const toast = useToast(); + const openFilters = () => { + setFilterCollapsed(false); + setShowFilters(true); + }; + + const closeFilters = () => { + setFilterCollapsed(true); + setShowFilters(false); + }; + + const renderFilterPanelContent = () => ( +
+
+
+ 品牌 + {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 }, @@ -1401,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 && renderFilterPanelContent()} + {isMobile && ( + +
+ {filterPanelPresence.shouldRender && renderFilterPanelContent()}
- -
-
- {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 && ( -
- 暂无接口能力数据 -
- )} -
- - -
+ )}
@@ -1627,7 +1652,15 @@ export default function TokenRoutes() {
- {filterCollapsed && ( + {isMobile ? ( + + ) : (filterCollapsed && ( - )} + ))} +
+ + ) : ( + routeCard + ); })} {filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && ( diff --git a/src/web/pages/Tokens.tsx b/src/web/pages/Tokens.tsx index 7c699b4..c48dd14 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: '', @@ -726,7 +729,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token ) : null} - {selectedTokenIds.length > 0 && ( + {!isMobile && selectedTokenIds.length > 0 && (
已选 {selectedTokenIds.length} 项
)} + {isMobile && selectedTokenIds.length > 0 && ( +
+ 已选 {selectedTokenIds.length} 项 +
+ + + +
+
+ )} +
) : 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 +1128,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token })}
+ ) ) : (
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="提示"'); + }); +}); 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'); + }); +}); diff --git a/src/web/pages/mobile-actions.test.tsx b/src/web/pages/mobile-actions.test.tsx new file mode 100644 index 0000000..fe5f8a7 --- /dev/null +++ b/src/web/pages/mobile-actions.test.tsx @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('Mobile actions bar styles', () => { + 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'); + }); +}); 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'); + }); +});