1
0
forked from boos/BoosAPI

feat: add mobile actions and CSS

This commit is contained in:
cita
2026-03-10 16:05:24 +08:00
parent f01379f7a3
commit 715a2dba9b
8 changed files with 503 additions and 235 deletions
+104
View File
@@ -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 {
+21 -1
View File
@@ -795,7 +795,7 @@ export default function Accounts() {
))}
</div>
{activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
{!isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedAccountIds.length} 项</span>
<button data-testid="accounts-batch-refresh-balance" onClick={() => runBatchAccountAction('refreshBalance')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
@@ -813,6 +813,26 @@ export default function Accounts() {
</div>
)}
{isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
<div className="mobile-actions-bar">
<span className="mobile-actions-info">已选 {selectedAccountIds.length} 项</span>
<div className="mobile-actions-row">
<button data-testid="accounts-batch-refresh-balance" onClick={() => runBatchAccountAction('refreshBalance')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量刷新余额
</button>
<button onClick={() => runBatchAccountAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量启用
</button>
<button onClick={() => runBatchAccountAction('disable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量禁用
</button>
<button onClick={() => runBatchAccountAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
批量删除
</button>
</div>
</div>
)}
{activeSegment === 'tokens' ? (
<TokensPanel embedded onEmbeddedActionsChange={setEmbeddedTokenActions} />
) : (
+36 -10
View File
@@ -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<LogFilter>('all');
const [expandedLogId, setExpandedLogId] = useState<number | null>(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 = (
<div className="tabs" style={{ marginBottom: 0 }}>
{(['all', 'success', 'failed', 'skipped'] as const).map((tab) => (
<button key={tab} className={`tab ${filter === tab ? 'active' : ''}`} onClick={() => setFilter(tab)}>
{tab === 'all' && `全部 (${logs.length})`}
{tab === 'success' && `成功 (${countBy('success')})`}
{tab === 'failed' && `失败 (${countBy('failed')})`}
{tab === 'skipped' && `跳过 (${countBy('skipped')})`}
</button>
))}
</div>
);
return (
<div className="animate-fade-in">
<div className="page-header">
@@ -106,16 +121,27 @@ export default function CheckinLog() {
</button>
</div>
<div className="tabs" style={{ marginBottom: 0 }}>
{(['all', 'success', 'failed', 'skipped'] as const).map((tab) => (
<button key={tab} className={`tab ${filter === tab ? 'active' : ''}`} onClick={() => setFilter(tab)}>
{tab === 'all' && `全部 (${logs.length})`}
{tab === 'success' && `成功 (${countBy('success')})`}
{tab === 'failed' && `失败 (${countBy('failed')})`}
{tab === 'skipped' && `跳过 (${countBy('skipped')})`}
</button>
))}
</div>
{isMobile ? (
<>
<div className="mobile-filter-row">
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => setShowFilters(true)}
>
筛选
</button>
</div>
<MobileDrawer open={showFilters} onClose={() => setShowFilters(false)}>
<div className="mobile-filter-panel">
{filterTabs}
</div>
</MobileDrawer>
</>
) : (
filterTabs
)}
<div className="card" style={{ overflowX: 'auto', borderTopLeftRadius: 0, borderTopRightRadius: 0 }}>
{loading ? (
+50 -22
View File
@@ -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<Record<number, ProxyLogDetailState>>({});
const [showFilters, setShowFilters] = useState(false);
const isMobile = useIsMobile(768);
const toast = useToast();
@@ -217,6 +219,35 @@ export default function ProxyLogs() {
}
}, [expanded, loadDetail]);
const filterControls = (
<>
<div className="pill-tabs">
{([
{ 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) => (
<button
key={tab.key}
className={`pill-tab ${statusFilter === tab.key ? 'active' : ''}`}
onClick={() => {
setStatusFilter(tab.key);
setPage(1);
}}
>
{tab.label} <span style={{ fontVariantNumeric: 'tabular-nums', opacity: 0.7 }}>{tab.count}</span>
</button>
))}
</div>
<div className="toolbar-search" style={{ maxWidth: 280 }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="搜索模型名称..." />
</div>
</>
);
return (
<div className="animate-fade-in">
<div className="page-header" style={{ marginBottom: 16 }}>
@@ -237,32 +268,29 @@ export default function ProxyLogs() {
</button>
</div>
<div className="toolbar" style={{ marginBottom: 12 }}>
<div className="pill-tabs">
{([
{ 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 ? (
<>
<div className="mobile-filter-row">
<button
key={tab.key}
className={`pill-tab ${statusFilter === tab.key ? 'active' : ''}`}
onClick={() => {
setStatusFilter(tab.key);
setPage(1);
}}
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => setShowFilters(true)}
>
{tab.label} <span style={{ fontVariantNumeric: 'tabular-nums', opacity: 0.7 }}>{tab.count}</span>
筛选
</button>
))}
</div>
<MobileDrawer open={showFilters} onClose={() => setShowFilters(false)}>
<div className="mobile-filter-panel">
{filterControls}
</div>
</MobileDrawer>
</>
) : (
<div className="toolbar" style={{ marginBottom: 12 }}>
{filterControls}
</div>
<div className="toolbar-search" style={{ maxWidth: 280 }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="搜索模型名称..." />
</div>
</div>
)}
<div className="card" style={{ overflowX: 'auto' }}>
{loading ? (
+35 -1
View File
@@ -395,7 +395,7 @@ export default function Sites() {
</div>
</div>
{selectedSiteIds.length > 0 && (
{!isMobile && selectedSiteIds.length > 0 && (
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedSiteIds.length} 项</span>
<button
@@ -427,6 +427,40 @@ export default function Sites() {
</div>
)}
{isMobile && selectedSiteIds.length > 0 && (
<div className="mobile-actions-bar">
<span className="mobile-actions-info">已选 {selectedSiteIds.length} 项</span>
<div className="mobile-actions-row">
<button
data-testid="sites-batch-enable-system-proxy"
onClick={() => runBatchAction('enableSystemProxy')}
disabled={batchActionLoading}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
批量开启系统代理
</button>
<button
onClick={() => runBatchAction('disableSystemProxy')}
disabled={batchActionLoading}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
批量关闭系统代理
</button>
<button onClick={() => runBatchAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量启用
</button>
<button onClick={() => runBatchAction('disable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量禁用
</button>
<button onClick={() => runBatchAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
批量删除
</button>
</div>
</div>
)}
<div className="info-tip" style={{ marginBottom: 12 }}>
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
</div>
+229 -200
View File
@@ -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<string | null>(null);
const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null);
const [filterCollapsed, setFilterCollapsed] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const [sortBy, setSortBy] = useState<RouteSortBy>('channelCount');
const [sortDir, setSortDir] = useState<RouteSortDir>('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 = (
<div className={`filter-panel filter-collapsible ${filterPanelPresence.isVisible ? '' : 'is-closing'}`.trim()}>
<div className="filter-panel-section">
<div className="filter-panel-title">
品牌
{activeBrand && <button onClick={() => setActiveBrand(null)}>重置</button>}
</div>
<div className={`filter-item ${!activeBrand ? 'active' : ''}`} onClick={() => setActiveBrand(null)}>
<span
className="filter-item-icon"
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
>
✦
</span>
全部品牌
<span className="filter-item-count">{routes.length}</span>
</div>
{brandList.list.map(([brandName, { count, brand }]) => (
<div
key={brandName}
className={`filter-item ${activeBrand === brandName ? 'active' : ''}`}
onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
>
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<BrandGlyph brand={brand} size={14} fallbackText={brandName} />
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{brandName}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
{brandList.otherCount > 0 && (
<div
className={`filter-item ${activeBrand === '__other__' ? 'active' : ''}`}
onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')}
>
<span
className="filter-item-icon"
style={{ background: 'var(--color-bg)', color: 'var(--color-text-muted)', fontSize: 10 }}
>
?
</span>
其他
<span className="filter-item-count">{brandList.otherCount}</span>
</div>
)}
</div>
<div className="filter-panel-section">
<div className="filter-panel-title">
{tr('群组')}
{activeGroupFilter !== null && <button onClick={() => setActiveGroupFilter(null)}>重置</button>}
</div>
<div
className={`filter-item ${activeGroupFilter === '__all__' ? 'active' : ''}`}
onClick={() => setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')}
>
<span
className="filter-item-icon"
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
>
◎
</span>
{tr('全部群组')}
<span className="filter-item-count">{groupRouteList.length}</span>
</div>
{groupRouteList.map((groupRoute) => (
<div
key={groupRoute.id}
className={`filter-item ${activeGroupFilter === groupRoute.id ? 'active' : ''}`}
onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)}
>
<span
className="filter-item-icon"
style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
{groupRoute.icon.kind === 'brand' ? (
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
) : groupRoute.icon.kind === 'text' ? (
<span style={{ fontSize: 12, lineHeight: 1 }}>{groupRoute.icon.value}</span>
) : groupRoute.brand ? (
<BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
) : (
<InlineBrandIcon model={groupRoute.modelPattern} size={14} />
)}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{groupRoute.title}</span>
<span className="filter-item-count">{groupRoute.channelCount}</span>
</div>
))}
</div>
{siteList.length > 0 && (
<div className="filter-panel-section">
<div className="filter-panel-title">
站点
{activeSite && <button onClick={() => setActiveSite(null)}>重置</button>}
</div>
<div className={`filter-item ${!activeSite ? 'active' : ''}`} onClick={() => setActiveSite(null)}>
<span
className="filter-item-icon"
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
>
⚡
</span>
全部站点
<span className="filter-item-count">{routes.length}</span>
</div>
{siteList.map(([siteName, { count }]) => (
<div
key={siteName}
className={`filter-item ${activeSite === siteName ? 'active' : ''}`}
onClick={() => setActiveSite(activeSite === siteName ? null : siteName)}
>
<span
className="filter-item-icon"
style={{ background: hashColor(siteName), color: 'white', fontSize: 9, borderRadius: 4 }}
>
{siteAvatarLetters(siteName)}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{siteName}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
</div>
)}
<div className="filter-panel-section">
<div className="filter-panel-title">
接口能力
{activeEndpointType && <button onClick={() => setActiveEndpointType(null)}>重置</button>}
</div>
<div className={`filter-item ${!activeEndpointType ? 'active' : ''}`} onClick={() => setActiveEndpointType(null)}>
<span
className="filter-item-icon"
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
>
⚙
</span>
全部能力
<span className="filter-item-count">{routes.length}</span>
</div>
{endpointTypeList.map(([endpointType, count]) => (
<div
key={endpointType}
className={`filter-item ${activeEndpointType === endpointType ? 'active' : ''}`}
onClick={() => setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)}
>
<span
className="filter-item-icon"
style={{
background: 'var(--color-bg)',
color: 'var(--color-text-muted)',
fontSize: 10,
borderRadius: 4,
overflow: 'hidden',
}}
>
{(() => {
const iconModel = resolveEndpointTypeIconModel(endpointType);
if (!iconModel) return <span style={{ fontSize: 10 }}>⚙</span>;
return <InlineBrandIcon model={iconModel} size={14} />;
})()}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{endpointType}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
{endpointTypeList.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', padding: '4px 10px 0' }}>
暂无接口能力数据
</div>
)}
</div>
<button
className="btn btn-ghost"
style={{
width: '100%',
fontSize: 12,
padding: '6px 10px',
marginTop: 8,
justifyContent: 'center',
border: '1px solid var(--color-border)',
}}
onClick={closeFilters}
>
{tr('收起')}
</button>
</div>
);
const loadRouteDecisions = async (
routeRows: RouteRow[],
options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean },
@@ -1405,205 +1618,13 @@ export default function TokenRoutes() {
return (
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
{filterPanelPresence.shouldRender && (
<div className={`filter-panel filter-collapsible ${filterPanelPresence.isVisible ? '' : 'is-closing'}`.trim()}>
<div className="filter-panel-section">
<div className="filter-panel-title">
品牌
{activeBrand && <button onClick={() => setActiveBrand(null)}>重置</button>}
</div>
<div className={`filter-item ${!activeBrand ? 'active' : ''}`} onClick={() => setActiveBrand(null)}>
<span
className="filter-item-icon"
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
>
✦
</span>
全部品牌
<span className="filter-item-count">{routes.length}</span>
</div>
{brandList.list.map(([brandName, { count, brand }]) => (
<div
key={brandName}
className={`filter-item ${activeBrand === brandName ? 'active' : ''}`}
onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
>
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<BrandGlyph brand={brand} size={14} fallbackText={brandName} />
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{brandName}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
{brandList.otherCount > 0 && (
<div
className={`filter-item ${activeBrand === '__other__' ? 'active' : ''}`}
onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')}
>
<span
className="filter-item-icon"
style={{ background: 'var(--color-bg)', color: 'var(--color-text-muted)', fontSize: 10 }}
>
?
</span>
其他
<span className="filter-item-count">{brandList.otherCount}</span>
</div>
)}
{!isMobile && filterPanelPresence.shouldRender && filterPanelContent}
{isMobile && (
<MobileDrawer open={showFilters} onClose={closeFilters}>
<div className="mobile-filter-panel">
{filterPanelPresence.shouldRender && filterPanelContent}
</div>
<div className="filter-panel-section">
<div className="filter-panel-title">
{tr('群组')}
{activeGroupFilter !== null && <button onClick={() => setActiveGroupFilter(null)}>重置</button>}
</div>
<div
className={`filter-item ${activeGroupFilter === '__all__' ? 'active' : ''}`}
onClick={() => setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')}
>
<span
className="filter-item-icon"
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
>
◎
</span>
{tr('全部群组')}
<span className="filter-item-count">{groupRouteList.length}</span>
</div>
{groupRouteList.map((groupRoute) => (
<div
key={groupRoute.id}
className={`filter-item ${activeGroupFilter === groupRoute.id ? 'active' : ''}`}
onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)}
>
<span
className="filter-item-icon"
style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
{groupRoute.icon.kind === 'brand' ? (
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
) : groupRoute.icon.kind === 'text' ? (
<span style={{ fontSize: 12, lineHeight: 1 }}>{groupRoute.icon.value}</span>
) : groupRoute.brand ? (
<BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
) : (
<InlineBrandIcon model={groupRoute.modelPattern} size={14} />
)}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{groupRoute.title}</span>
<span className="filter-item-count">{groupRoute.channelCount}</span>
</div>
))}
</div>
{siteList.length > 0 && (
<div className="filter-panel-section">
<div className="filter-panel-title">
站点
{activeSite && <button onClick={() => setActiveSite(null)}>重置</button>}
</div>
<div className={`filter-item ${!activeSite ? 'active' : ''}`} onClick={() => setActiveSite(null)}>
<span
className="filter-item-icon"
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
>
⚡
</span>
全部站点
<span className="filter-item-count">{routes.length}</span>
</div>
{siteList.map(([siteName, { count }]) => (
<div
key={siteName}
className={`filter-item ${activeSite === siteName ? 'active' : ''}`}
onClick={() => setActiveSite(activeSite === siteName ? null : siteName)}
>
<span
className="filter-item-icon"
style={{ background: hashColor(siteName), color: 'white', fontSize: 9, borderRadius: 4 }}
>
{siteAvatarLetters(siteName)}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{siteName}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
</div>
)}
<div className="filter-panel-section">
<div className="filter-panel-title">
接口能力
{activeEndpointType && <button onClick={() => setActiveEndpointType(null)}>重置</button>}
</div>
<div className={`filter-item ${!activeEndpointType ? 'active' : ''}`} onClick={() => setActiveEndpointType(null)}>
<span
className="filter-item-icon"
style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}
>
⚙
</span>
全部能力
<span className="filter-item-count">{routes.length}</span>
</div>
{endpointTypeList.map(([endpointType, count]) => (
<div
key={endpointType}
className={`filter-item ${activeEndpointType === endpointType ? 'active' : ''}`}
onClick={() => setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)}
>
<span
className="filter-item-icon"
style={{
background: 'var(--color-bg)',
color: 'var(--color-text-muted)',
fontSize: 10,
borderRadius: 4,
overflow: 'hidden',
}}
>
{(() => {
const iconModel = resolveEndpointTypeIconModel(endpointType);
if (!iconModel) return <span style={{ fontSize: 10 }}>⚙</span>;
return <InlineBrandIcon model={iconModel} size={14} />;
})()}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{endpointType}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
{endpointTypeList.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', padding: '4px 10px 0' }}>
暂无接口能力数据
</div>
)}
</div>
<button
className="btn btn-ghost"
style={{
width: '100%',
fontSize: 12,
padding: '6px 10px',
marginTop: 8,
justifyContent: 'center',
border: '1px solid var(--color-border)',
}}
onClick={() => setFilterCollapsed(true)}
>
{tr('收起')}
</button>
</div>
</MobileDrawer>
)}
<div style={{ flex: 1, minWidth: 0 }}>
@@ -1631,7 +1652,15 @@ export default function TokenRoutes() {
</div>
<div className="page-actions" style={{ flexWrap: 'wrap' }}>
{filterCollapsed && (
{isMobile ? (
<button
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
onClick={openFilters}
>
{tr('筛选')}
</button>
) : (filterCollapsed && (
<button
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
@@ -1639,7 +1668,7 @@ export default function TokenRoutes() {
>
{tr('筛选')}
</button>
)}
))}
<button
onClick={handleRefreshRouteDecisions}
+18 -1
View File
@@ -729,7 +729,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
) : null}
</CenteredModal>
{selectedTokenIds.length > 0 && (
{!isMobile && selectedTokenIds.length > 0 && (
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedTokenIds.length} 项</span>
<button onClick={() => runBatchTokenAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
@@ -744,6 +744,23 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
</div>
)}
{isMobile && selectedTokenIds.length > 0 && (
<div className="mobile-actions-bar">
<span className="mobile-actions-info">已选 {selectedTokenIds.length} 项</span>
<div className="mobile-actions-row">
<button onClick={() => runBatchTokenAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量启用
</button>
<button onClick={() => runBatchTokenAction('disable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量禁用
</button>
<button data-testid="tokens-batch-delete" onClick={() => runBatchTokenAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
批量删除
</button>
</div>
</div>
)}
<CenteredModal
open={showAdd}
onClose={handleToggleAdd}
+10
View File
@@ -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');
});
});