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'); + }); +});