feat: add mobile actions and CSS
This commit is contained in:
@@ -1023,6 +1023,83 @@ body {
|
|||||||
text-align: right;
|
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 (New API style) ===== */
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--color-bg-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 ===== */
|
/* ===== Responsive Adjustments ===== */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.topbar-search-trigger {
|
.topbar-search-trigger {
|
||||||
|
|||||||
@@ -795,7 +795,7 @@ export default function Accounts() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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' }}>
|
<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>
|
<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)' }}>
|
<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>
|
</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' ? (
|
{activeSegment === 'tokens' ? (
|
||||||
<TokensPanel embedded onEmbeddedActionsChange={setEmbeddedTokenActions} />
|
<TokensPanel embedded onEmbeddedActionsChange={setEmbeddedTokenActions} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||||
|
import { MobileDrawer } from '../components/MobileDrawer.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import { useIsMobile } from '../components/useIsMobile.js';
|
import { useIsMobile } from '../components/useIsMobile.js';
|
||||||
import { formatCheckinLogTime } from './helpers/checkinLogTime.js';
|
import { formatCheckinLogTime } from './helpers/checkinLogTime.js';
|
||||||
@@ -22,6 +23,7 @@ export default function CheckinLog() {
|
|||||||
const [triggering, setTriggering] = useState(false);
|
const [triggering, setTriggering] = useState(false);
|
||||||
const [filter, setFilter] = useState<LogFilter>('all');
|
const [filter, setFilter] = useState<LogFilter>('all');
|
||||||
const [expandedLogId, setExpandedLogId] = useState<number | null>(null);
|
const [expandedLogId, setExpandedLogId] = useState<number | null>(null);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const isMobile = useIsMobile(768);
|
const isMobile = useIsMobile(768);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
@@ -86,6 +88,19 @@ export default function CheckinLog() {
|
|||||||
return reason;
|
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 (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -106,16 +121,27 @@ export default function CheckinLog() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tabs" style={{ marginBottom: 0 }}>
|
{isMobile ? (
|
||||||
{(['all', 'success', 'failed', 'skipped'] as const).map((tab) => (
|
<>
|
||||||
<button key={tab} className={`tab ${filter === tab ? 'active' : ''}`} onClick={() => setFilter(tab)}>
|
<div className="mobile-filter-row">
|
||||||
{tab === 'all' && `全部 (${logs.length})`}
|
<button
|
||||||
{tab === 'success' && `成功 (${countBy('success')})`}
|
type="button"
|
||||||
{tab === 'failed' && `失败 (${countBy('failed')})`}
|
className="btn btn-ghost"
|
||||||
{tab === 'skipped' && `跳过 (${countBy('skipped')})`}
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
onClick={() => setShowFilters(true)}
|
||||||
|
>
|
||||||
|
筛选
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
</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 }}>
|
<div className="card" style={{ overflowX: 'auto', borderTopLeftRadius: 0, borderTopRightRadius: 0 }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
+49
-21
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import { ModelBadge } from '../components/BrandIcon.js';
|
import { ModelBadge } from '../components/BrandIcon.js';
|
||||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||||
|
import { MobileDrawer } from '../components/MobileDrawer.js';
|
||||||
import { useIsMobile } from '../components/useIsMobile.js';
|
import { useIsMobile } from '../components/useIsMobile.js';
|
||||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
@@ -125,6 +126,7 @@ export default function ProxyLogs() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(50);
|
const [pageSize, setPageSize] = useState(50);
|
||||||
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
|
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const isMobile = useIsMobile(768);
|
const isMobile = useIsMobile(768);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
@@ -217,27 +219,8 @@ export default function ProxyLogs() {
|
|||||||
}
|
}
|
||||||
}, [expanded, loadDetail]);
|
}, [expanded, loadDetail]);
|
||||||
|
|
||||||
return (
|
const filterControls = (
|
||||||
<div className="animate-fade-in">
|
<>
|
||||||
<div className="page-header" style={{ marginBottom: 16 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
||||||
<h2 className="page-title">{tr('使用日志')}</h2>
|
|
||||||
<span className="kpi-chip kpi-chip-success">
|
|
||||||
消耗总额 ${summary.totalCost.toFixed(4)}
|
|
||||||
</span>
|
|
||||||
<span className="kpi-chip kpi-chip-warning">
|
|
||||||
{summary.totalTokensAll.toLocaleString()} tokens
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button onClick={load} disabled={loading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '6px 14px' }}>
|
|
||||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ animation: loading ? 'spin 1s linear infinite' : 'none' }}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
{loading ? '加载中...' : '刷新'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar" style={{ marginBottom: 12 }}>
|
|
||||||
<div className="pill-tabs">
|
<div className="pill-tabs">
|
||||||
{([
|
{([
|
||||||
{ key: 'all' as ProxyLogStatusFilter, label: '全部', count: summary.totalCount },
|
{ key: 'all' as ProxyLogStatusFilter, label: '全部', count: summary.totalCount },
|
||||||
@@ -262,7 +245,52 @@ export default function ProxyLogs() {
|
|||||||
</svg>
|
</svg>
|
||||||
<input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="搜索模型名称..." />
|
<input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="搜索模型名称..." />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<div className="page-header" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<h2 className="page-title">{tr('使用日志')}</h2>
|
||||||
|
<span className="kpi-chip kpi-chip-success">
|
||||||
|
消耗总额 ${summary.totalCost.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
<span className="kpi-chip kpi-chip-warning">
|
||||||
|
{summary.totalTokensAll.toLocaleString()} tokens
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button onClick={load} disabled={loading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '6px 14px' }}>
|
||||||
|
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ animation: loading ? 'spin 1s linear infinite' : 'none' }}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
{loading ? '加载中...' : '刷新'}
|
||||||
|
</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">
|
||||||
|
{filterControls}
|
||||||
|
</div>
|
||||||
|
</MobileDrawer>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="toolbar" style={{ marginBottom: 12 }}>
|
||||||
|
{filterControls}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card" style={{ overflowX: 'auto' }}>
|
<div className="card" style={{ overflowX: 'auto' }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
+35
-1
@@ -395,7 +395,7 @@ export default function Sites() {
|
|||||||
</div>
|
</div>
|
||||||
</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' }}>
|
<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>
|
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedSiteIds.length} 项</span>
|
||||||
<button
|
<button
|
||||||
@@ -427,6 +427,40 @@ export default function Sites() {
|
|||||||
</div>
|
</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 }}>
|
<div className="info-tip" style={{ marginBottom: 12 }}>
|
||||||
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
|
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+229
-200
@@ -22,6 +22,7 @@ import { BrandGlyph, InlineBrandIcon, getBrand, hashColor, normalizeBrandIconKey
|
|||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||||
|
import { MobileDrawer } from '../components/MobileDrawer.js';
|
||||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||||
import { useIsMobile } from '../components/useIsMobile.js';
|
import { useIsMobile } from '../components/useIsMobile.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
@@ -710,6 +711,7 @@ export default function TokenRoutes() {
|
|||||||
const [activeEndpointType, setActiveEndpointType] = useState<string | null>(null);
|
const [activeEndpointType, setActiveEndpointType] = useState<string | null>(null);
|
||||||
const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null);
|
const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null);
|
||||||
const [filterCollapsed, setFilterCollapsed] = useState(false);
|
const [filterCollapsed, setFilterCollapsed] = useState(false);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [sortBy, setSortBy] = useState<RouteSortBy>('channelCount');
|
const [sortBy, setSortBy] = useState<RouteSortBy>('channelCount');
|
||||||
const [sortDir, setSortDir] = useState<RouteSortDir>('desc');
|
const [sortDir, setSortDir] = useState<RouteSortDir>('desc');
|
||||||
|
|
||||||
@@ -747,6 +749,217 @@ export default function TokenRoutes() {
|
|||||||
|
|
||||||
const toast = useToast();
|
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 (
|
const loadRouteDecisions = async (
|
||||||
routeRows: RouteRow[],
|
routeRows: RouteRow[],
|
||||||
options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean },
|
options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean },
|
||||||
@@ -1405,205 +1618,13 @@ export default function TokenRoutes() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
|
<div className="animate-fade-in" style={{ display: 'flex', gap: 24, minHeight: 400 }}>
|
||||||
{filterPanelPresence.shouldRender && (
|
{!isMobile && filterPanelPresence.shouldRender && filterPanelContent}
|
||||||
<div className={`filter-panel filter-collapsible ${filterPanelPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
{isMobile && (
|
||||||
<div className="filter-panel-section">
|
<MobileDrawer open={showFilters} onClose={closeFilters}>
|
||||||
<div className="filter-panel-title">
|
<div className="mobile-filter-panel">
|
||||||
品牌
|
{filterPanelPresence.shouldRender && filterPanelContent}
|
||||||
{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={() => setFilterCollapsed(true)}
|
|
||||||
>
|
|
||||||
{tr('收起')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</MobileDrawer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
@@ -1631,7 +1652,15 @@ export default function TokenRoutes() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-actions" style={{ flexWrap: 'wrap' }}>
|
<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
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||||
@@ -1639,7 +1668,7 @@ export default function TokenRoutes() {
|
|||||||
>
|
>
|
||||||
{tr('筛选')}
|
{tr('筛选')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleRefreshRouteDecisions}
|
onClick={handleRefreshRouteDecisions}
|
||||||
|
|||||||
@@ -729,7 +729,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
|||||||
) : null}
|
) : null}
|
||||||
</CenteredModal>
|
</CenteredModal>
|
||||||
|
|
||||||
{selectedTokenIds.length > 0 && (
|
{!isMobile && selectedTokenIds.length > 0 && (
|
||||||
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
<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>
|
<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)' }}>
|
<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>
|
</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
|
<CenteredModal
|
||||||
open={showAdd}
|
open={showAdd}
|
||||||
onClose={handleToggleAdd}
|
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