forked from boos/BoosAPI
feat: add mobile actions and CSS
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user