refactor: share responsive filter panel scaffold

This commit is contained in:
Cita
2026-03-23 14:29:11 +08:00
parent dd914b327c
commit 7cc0a04b8b
13 changed files with 375 additions and 307 deletions
@@ -0,0 +1,58 @@
import React from 'react';
import MobileFilterSheet from './MobileFilterSheet.js';
type ResponsiveFilterPanelProps = {
isMobile: boolean;
mobileOpen: boolean;
onMobileOpen?: () => void;
onMobileClose: () => void;
mobileTitle: string;
mobileContent: React.ReactNode;
desktopContent?: React.ReactNode;
mobileTrigger?: React.ReactNode;
mobileTriggerLabel?: string;
mobileTriggerWrapperClassName?: string;
mobileTriggerWrapperStyle?: React.CSSProperties;
mobileTriggerButtonClassName?: string;
mobileTriggerButtonStyle?: React.CSSProperties;
};
export default function ResponsiveFilterPanel({
isMobile,
mobileOpen,
onMobileOpen,
onMobileClose,
mobileTitle,
mobileContent,
desktopContent = null,
mobileTrigger,
mobileTriggerLabel = '筛选',
mobileTriggerWrapperClassName = 'mobile-filter-row',
mobileTriggerWrapperStyle,
mobileTriggerButtonClassName = 'btn btn-ghost',
mobileTriggerButtonStyle = { border: '1px solid var(--color-border)' },
}: ResponsiveFilterPanelProps) {
if (!isMobile) {
return <>{desktopContent}</>;
}
return (
<>
{mobileTrigger ?? (onMobileOpen ? (
<div className={mobileTriggerWrapperClassName} style={mobileTriggerWrapperStyle}>
<button
type="button"
className={mobileTriggerButtonClassName}
style={mobileTriggerButtonStyle}
onClick={onMobileOpen}
>
{mobileTriggerLabel}
</button>
</div>
) : null)}
<MobileFilterSheet open={mobileOpen} onClose={onMobileClose} title={mobileTitle}>
{mobileContent}
</MobileFilterSheet>
</>
);
}
+40 -34
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js'; import CenteredModal from '../components/CenteredModal.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js'; import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js'; import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js'; import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
@@ -976,47 +976,53 @@ export default function Accounts() {
{activeSegment === 'tokens' && embeddedTokenActions} {activeSegment === 'tokens' && embeddedTokenActions}
</div> </div>
<MobileFilterSheet open={showMobileTools} onClose={() => setShowMobileTools(false)} title="连接排序与操作"> <ResponsiveFilterPanel
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> isMobile={isMobile}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> mobileOpen={showMobileTools}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>排序方式</div> onMobileClose={() => setShowMobileTools(false)}
<ModernSelect mobileTitle="连接排序与操作"
value={sortMode} mobileContent={(
onChange={(nextValue) => setSortMode(nextValue as SortMode)} <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
options={[ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{ value: 'custom', label: '自定义排序' }, <div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>排序方式</div>
{ value: 'balance-desc', label: '余额高到低' }, <ModernSelect
{ value: 'balance-asc', label: '余额低到高' }, value={sortMode}
]} onChange={(nextValue) => setSortMode(nextValue as SortMode)}
placeholder="自定义排序" options={[
/> { value: 'custom', label: '自定义排序' },
</div> { value: 'balance-desc', label: '余额高到低' },
{activeSegment === 'session' && ( { value: 'balance-asc', label: '余额低到高' },
]}
placeholder="自定义排序"
/>
</div>
{activeSegment === 'session' && (
<button
onClick={async () => {
setShowMobileTools(false);
await withLoading('checkin-all', () => api.triggerCheckinAll(), '已触发全部签到');
}}
disabled={actionLoading['checkin-all']}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{actionLoading['checkin-all'] ? <><span className="spinner spinner-sm" />{tr('签到中...')}</> : tr('全部签到')}
</button>
)}
<button <button
onClick={async () => { onClick={async () => {
setShowMobileTools(false); setShowMobileTools(false);
await withLoading('checkin-all', () => api.triggerCheckinAll(), '已触发全部签到'); await handleRefreshRuntimeHealth();
}} }}
disabled={actionLoading['checkin-all']} disabled={actionLoading['health-refresh']}
className="btn btn-ghost" className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }} style={{ border: '1px solid var(--color-border)' }}
> >
{actionLoading['checkin-all'] ? <><span className="spinner spinner-sm" />{tr('签到中...')}</> : tr('全部签到')} {actionLoading['health-refresh'] ? <><span className="spinner spinner-sm" />{tr('刷新状态中...')}</> : tr('刷新账户状态')}
</button> </button>
)} </div>
<button )}
onClick={async () => { />
setShowMobileTools(false);
await handleRefreshRuntimeHealth();
}}
disabled={actionLoading['health-refresh']}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{actionLoading['health-refresh'] ? <><span className="spinner spinner-sm" />{tr('刷新状态中...')}</> : tr('刷新账户状态')}
</button>
</div>
</MobileFilterSheet>
<div <div
style={{ style={{
+36 -46
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, 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 MobileFilterSheet from "../components/MobileFilterSheet.js"; import ResponsiveFilterPanel from "../components/ResponsiveFilterPanel.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 { import {
@@ -268,54 +268,44 @@ export default function CheckinLog() {
</button> </button>
</div> </div>
{isMobile ? ( <ResponsiveFilterPanel
<> isMobile={isMobile}
<div className="mobile-filter-row"> mobileOpen={showFilters}
<button onMobileOpen={() => setShowFilters(true)}
type="button" onMobileClose={() => setShowFilters(false)}
className="btn btn-ghost" mobileTitle="筛选签到记录"
style={{ border: "1px solid var(--color-border)" }} mobileContent={(
onClick={() => setShowFilters(true)} <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
>
筛选
</button>
</div>
<MobileFilterSheet
open={showFilters}
onClose={() => setShowFilters(false)}
title="筛选签到记录"
>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{timeRangeControls}
{hasInvalidTimeRange && (
<div className="alert alert-error">
结束时间必须晚于开始时间
</div>
)}
{filterTabs}
</div>
</MobileFilterSheet>
</>
) : (
<div className="toolbar" style={{ marginBottom: "12px" }}>
<div style={{ minWidth: 280 }}>{filterTabs}</div>
<div
style={{
flex: "0 0 auto",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
{timeRangeControls} {timeRangeControls}
{hasInvalidTimeRange && (
<div className="alert alert-error">
结束时间必须晚于开始时间
</div>
)}
{filterTabs}
</div> </div>
{hasInvalidTimeRange && ( )}
<div className="alert alert-error" style={{ width: "100%" }}> desktopContent={(
结束时间必须晚于开始时间 <div className="toolbar" style={{ marginBottom: "12px" }}>
<div style={{ minWidth: 280 }}>{filterTabs}</div>
<div
style={{
flex: "0 0 auto",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
{timeRangeControls}
</div> </div>
)} {hasInvalidTimeRange && (
</div> <div className="alert alert-error" style={{ width: "100%" }}>
)} 结束时间必须晚于开始时间
</div>
)}
</div>
)}
/>
<div <div
className="card" className="card"
+15 -19
View File
@@ -5,7 +5,7 @@ import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../c
import SiteBadgeLink from '../components/SiteBadgeLink.js'; import SiteBadgeLink from '../components/SiteBadgeLink.js';
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 MobileFilterSheet from '../components/MobileFilterSheet.js'; import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.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 { mergeMarketplaceMetadata, shouldHydrateMarketplaceMetadata } from './helpers/modelsMarketplaceMetadata.js'; import { mergeMarketplaceMetadata, shouldHydrateMarketplaceMetadata } from './helpers/modelsMarketplaceMetadata.js';
@@ -522,15 +522,13 @@ export default function Models() {
)} )}
</div> </div>
</div> </div>
{isMobile && ( <ResponsiveFilterPanel
<MobileFilterSheet isMobile={isMobile}
open={showFilters} mobileOpen={showFilters}
onClose={() => setShowFilters(false)} onMobileClose={() => setShowFilters(false)}
title={tr('筛选模型')} mobileTitle={tr('筛选模型')}
> mobileContent={filterControls}
{filterControls} />
</MobileFilterSheet>
)}
{[...Array(4)].map((_, i) => <div key={i} className="skeleton" style={{ height: 100, marginBottom: 12, borderRadius: 12 }} />)} {[...Array(4)].map((_, i) => <div key={i} className="skeleton" style={{ height: 100, marginBottom: 12, borderRadius: 12 }} />)}
</div> </div>
</div> </div>
@@ -607,15 +605,13 @@ export default function Models() {
</div> </div>
</div> </div>
{isMobile && ( <ResponsiveFilterPanel
<MobileFilterSheet isMobile={isMobile}
open={showFilters} mobileOpen={showFilters}
onClose={() => setShowFilters(false)} onMobileClose={() => setShowFilters(false)}
title={tr('筛选模型')} mobileTitle={tr('筛选模型')}
> mobileContent={filterControls}
{filterControls} />
</MobileFilterSheet>
)}
{/* Toolbar */} {/* Toolbar */}
<div className="toolbar"> <div className="toolbar">
+56 -66
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, 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 MobileFilterSheet from '../components/MobileFilterSheet.js'; import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.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 { formatDateTimeLocal } from './helpers/checkinLogTime.js'; import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
@@ -213,55 +213,14 @@ export default function ProgramLogs() {
</div> </div>
</div> </div>
{isMobile ? ( <ResponsiveFilterPanel
<> isMobile={isMobile}
<div className="mobile-filter-row"> mobileOpen={showFilters}
<button onMobileOpen={() => setShowFilters(true)}
type="button" onMobileClose={() => setShowFilters(false)}
className="btn btn-ghost" mobileTitle="筛选程序日志"
style={{ border: '1px solid var(--color-border)' }} mobileContent={(
onClick={() => setShowFilters(true)} <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
>
筛选
</button>
</div>
<MobileFilterSheet
open={showFilters}
onClose={() => setShowFilters(false)}
title="筛选程序日志"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<ModernSelect
size="sm"
value={filterType}
onChange={(nextValue) => setFilterType(nextValue)}
options={TYPE_OPTIONS.map((item) => ({
value: item.value,
label: item.label,
}))}
placeholder="全部类型"
/>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={onlyUnread}
onChange={(e) => {
setOffset(0);
setHasMore(true);
setOnlyUnread(e.target.checked);
}}
/>
仅看未读
</label>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
共 {visibleRows.length} 条
</div>
</div>
</MobileFilterSheet>
</>
) : (
<div className="card" style={{ padding: 14, marginBottom: 12, display: 'flex', gap: 10, alignItems: 'center' }}>
<div style={{ minWidth: 170 }}>
<ModernSelect <ModernSelect
size="sm" size="sm"
value={filterType} value={filterType}
@@ -272,26 +231,57 @@ export default function ProgramLogs() {
}))} }))}
placeholder="全部类型" placeholder="全部类型"
/> />
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={onlyUnread}
onChange={(e) => {
setOffset(0);
setHasMore(true);
setOnlyUnread(e.target.checked);
}}
/>
仅看未读
</label>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
共 {visibleRows.length} 条
</div>
</div> </div>
)}
desktopContent={(
<div className="card" style={{ padding: 14, marginBottom: 12, display: 'flex', gap: 10, alignItems: 'center' }}>
<div style={{ minWidth: 170 }}>
<ModernSelect
size="sm"
value={filterType}
onChange={(nextValue) => setFilterType(nextValue)}
options={TYPE_OPTIONS.map((item) => ({
value: item.value,
label: item.label,
}))}
placeholder="全部类型"
/>
</div>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}> <label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input <input
type="checkbox" type="checkbox"
checked={onlyUnread} checked={onlyUnread}
onChange={(e) => { onChange={(e) => {
setOffset(0); setOffset(0);
setHasMore(true); setHasMore(true);
setOnlyUnread(e.target.checked); setOnlyUnread(e.target.checked);
}} }}
/> />
仅看未读 仅看未读
</label> </label>
<div style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--color-text-muted)' }}> <div style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--color-text-muted)' }}>
共 {visibleRows.length} 条 共 {visibleRows.length} 条
</div>
</div> </div>
</div> )}
)} />
<div className="card" style={{ overflowX: 'auto' }}> <div className="card" style={{ overflowX: 'auto' }}>
{loading ? ( {loading ? (
+13 -25
View File
@@ -13,7 +13,7 @@ import { useToast } from '../components/Toast.js';
import { ModelBadge } from '../components/BrandIcon.js'; import { ModelBadge } from '../components/BrandIcon.js';
import SiteBadgeLink from '../components/SiteBadgeLink.js'; import SiteBadgeLink from '../components/SiteBadgeLink.js';
import { MobileCard, MobileField } from '../components/MobileCard.js'; import { MobileCard, MobileField } from '../components/MobileCard.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js'; import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.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';
@@ -661,31 +661,19 @@ export default function ProxyLogs() {
</div> </div>
</div> </div>
{isMobile ? ( <ResponsiveFilterPanel
<> isMobile={isMobile}
<div className="mobile-filter-row"> mobileOpen={showFilters}
<button onMobileOpen={() => setShowFilters(true)}
type="button" onMobileClose={() => setShowFilters(false)}
className="btn btn-ghost" mobileTitle={tr('筛选日志')}
style={{ border: '1px solid var(--color-border)' }} mobileContent={filterControls}
onClick={() => setShowFilters(true)} desktopContent={(
> <div className="toolbar" style={{ marginBottom: 12 }}>
筛选
</button>
</div>
<MobileFilterSheet
open={showFilters}
onClose={() => setShowFilters(false)}
title={tr('筛选日志')}
>
{filterControls} {filterControls}
</MobileFilterSheet> </div>
</> )}
) : ( />
<div className="toolbar" style={{ marginBottom: 12 }}>
{filterControls}
</div>
)}
{hasInvalidTimeRange && ( {hasInvalidTimeRange && (
<div className="alert alert-error" style={{ marginBottom: 12 }}> <div className="alert alert-error" style={{ marginBottom: 12 }}>
+34 -28
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js'; import CenteredModal from '../components/CenteredModal.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js'; import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js'; import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js'; import ModernSelect from '../components/ModernSelect.js';
@@ -660,34 +660,40 @@ export default function Sites() {
</div> </div>
</div> </div>
<MobileFilterSheet open={showMobileTools} onClose={() => setShowMobileTools(false)} title="站点排序与操作"> <ResponsiveFilterPanel
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> isMobile={isMobile}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> mobileOpen={showMobileTools}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>排序方式</div> onMobileClose={() => setShowMobileTools(false)}
<ModernSelect mobileTitle="站点排序与操作"
value={sortMode} mobileContent={(
onChange={(nextValue) => setSortMode(nextValue as SortMode)} <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
options={[ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{ value: 'custom', label: '自定义排序' }, <div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>排序方式</div>
{ value: 'balance-desc', label: '余额高到低' }, <ModernSelect
{ value: 'balance-asc', label: '余额低到高' }, value={sortMode}
]} onChange={(nextValue) => setSortMode(nextValue as SortMode)}
placeholder="自定义排序" options={[
/> { value: 'custom', label: '自定义排序' },
{ value: 'balance-desc', label: '余额高到低' },
{ value: 'balance-asc', label: '余额低到高' },
]}
placeholder="自定义排序"
/>
</div>
<button
type="button"
onClick={() => {
toggleSelectAllVisible(!allVisibleSitesSelected);
setShowMobileTools(false);
}}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{allVisibleSitesSelected ? '取消全选可见项' : '全选可见项'}
</button>
</div> </div>
<button )}
type="button" />
onClick={() => {
toggleSelectAllVisible(!allVisibleSitesSelected);
setShowMobileTools(false);
}}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{allVisibleSitesSelected ? '取消全选可见项' : '全选可见项'}
</button>
</div>
</MobileFilterSheet>
{selectedSiteIds.length > 0 && ( {selectedSiteIds.length > 0 && (
<ResponsiveBatchActionBar <ResponsiveBatchActionBar
+48 -49
View File
@@ -7,7 +7,7 @@ import { BrandGlyph, getBrand, InlineBrandIcon, type BrandInfo } from '../compon
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 MobileFilterSheet from '../components/MobileFilterSheet.js'; import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
import { useIsMobile } from '../components/useIsMobile.js'; import { useIsMobile } from '../components/useIsMobile.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
import { import {
@@ -1113,54 +1113,53 @@ export default function TokenRoutes() {
</div> </div>
{/* Collapsible filter panel */} {/* Collapsible filter panel */}
{isMobile ? ( <ResponsiveFilterPanel
<> isMobile={isMobile}
<button mobileOpen={showFilters}
className="btn btn-ghost" onMobileOpen={() => setShowFilters(true)}
style={{ border: '1px solid var(--color-border)', padding: '8px 14px', marginBottom: 12 }} onMobileClose={() => setShowFilters(false)}
onClick={() => setShowFilters(true)} mobileTitle={tr('筛选路由')}
> mobileTriggerWrapperClassName=""
{tr('筛选')} mobileTriggerButtonStyle={{ border: '1px solid var(--color-border)', padding: '8px 14px', marginBottom: 12 }}
</button> mobileContent={(
<MobileFilterSheet open={showFilters} onClose={() => setShowFilters(false)} title={tr('筛选路由')}> <RouteFilterBar
<RouteFilterBar totalRouteCount={listVisibleRoutes.length}
totalRouteCount={listVisibleRoutes.length} activeBrand={activeBrand}
activeBrand={activeBrand} setActiveBrand={setActiveBrand}
setActiveBrand={setActiveBrand} activeSite={activeSite}
activeSite={activeSite} setActiveSite={setActiveSite}
setActiveSite={setActiveSite} activeEndpointType={activeEndpointType}
activeEndpointType={activeEndpointType} setActiveEndpointType={setActiveEndpointType}
setActiveEndpointType={setActiveEndpointType} activeGroupFilter={activeGroupFilter}
activeGroupFilter={activeGroupFilter} setActiveGroupFilter={setActiveGroupFilter}
setActiveGroupFilter={setActiveGroupFilter} brandList={brandList}
brandList={brandList} siteList={siteList}
siteList={siteList} endpointTypeList={endpointTypeList}
endpointTypeList={endpointTypeList} groupRouteList={groupRouteList}
groupRouteList={groupRouteList} collapsed={false}
collapsed={false} onToggle={() => setShowFilters(false)}
onToggle={() => setShowFilters(false)} />
/> )}
</MobileFilterSheet> desktopContent={(
</> <RouteFilterBar
) : ( totalRouteCount={listVisibleRoutes.length}
<RouteFilterBar activeBrand={activeBrand}
totalRouteCount={listVisibleRoutes.length} setActiveBrand={setActiveBrand}
activeBrand={activeBrand} activeSite={activeSite}
setActiveBrand={setActiveBrand} setActiveSite={setActiveSite}
activeSite={activeSite} activeEndpointType={activeEndpointType}
setActiveSite={setActiveSite} setActiveEndpointType={setActiveEndpointType}
activeEndpointType={activeEndpointType} activeGroupFilter={activeGroupFilter}
setActiveEndpointType={setActiveEndpointType} setActiveGroupFilter={setActiveGroupFilter}
activeGroupFilter={activeGroupFilter} brandList={brandList}
setActiveGroupFilter={setActiveGroupFilter} siteList={siteList}
brandList={brandList} endpointTypeList={endpointTypeList}
siteList={siteList} groupRouteList={groupRouteList}
endpointTypeList={endpointTypeList} collapsed={filterCollapsed}
groupRouteList={groupRouteList} onToggle={() => setFilterCollapsed((prev) => !prev)}
collapsed={filterCollapsed} />
onToggle={() => setFilterCollapsed((prev) => !prev)} )}
/> />
)}
{/* Info tip */} {/* Info tip */}
<div className="info-tip" style={{ marginBottom: 12 }}> <div className="info-tip" style={{ marginBottom: 12 }}>
+41 -35
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Navigate, useLocation, useNavigate } from 'react-router-dom'; import { Navigate, useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js'; import CenteredModal from '../components/CenteredModal.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js'; import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js'; import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js'; import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
@@ -825,41 +825,47 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
</div> </div>
)} )}
<MobileFilterSheet open={showMobileTools} onClose={() => setShowMobileTools(false)} title="令牌同步与筛选"> <ResponsiveFilterPanel
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> isMobile={isMobile}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> mobileOpen={showMobileTools}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>同步账号</div> onMobileClose={() => setShowMobileTools(false)}
<ModernSelect mobileTitle="令牌同步与筛选"
value={String(syncingAccountId || 0)} mobileContent={(
onChange={(nextValue) => setSyncingAccountId(Number.parseInt(nextValue, 10) || 0)} <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
options={[ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{ value: '0', label: '选择账号后同步站点令牌' }, <div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>同步账号</div>
...activeAccounts.map((account) => ({ <ModernSelect
value: String(account.id), value={String(syncingAccountId || 0)}
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`, onChange={(nextValue) => setSyncingAccountId(Number.parseInt(nextValue, 10) || 0)}
})), options={[
]} { value: '0', label: '选择账号后同步站点令牌' },
placeholder="选择账号后同步站点令牌" ...activeAccounts.map((account) => ({
/> value: String(account.id),
label: `${account.username || `account-${account.id}`} @ ${account.site?.name || '-'}`,
})),
]}
placeholder="选择账号后同步站点令牌"
/>
</div>
<button
onClick={handleSync}
disabled={syncing || syncingAll || !syncingAccountId}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{syncing ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步站点令牌'}
</button>
<button
onClick={handleSyncAll}
disabled={syncing || syncingAll || activeAccounts.length === 0}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{syncingAll ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步全部账号'}
</button>
</div> </div>
<button )}
onClick={handleSync} />
disabled={syncing || syncingAll || !syncingAccountId}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{syncing ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步站点令牌'}
</button>
<button
onClick={handleSyncAll}
disabled={syncing || syncingAll || activeAccounts.length === 0}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{syncingAll ? <><span className="spinner spinner-sm" /> 同步中...</> : '同步全部账号'}
</button>
</div>
</MobileFilterSheet>
<div className="info-tip" style={{ marginBottom: 12 }}> <div className="info-tip" style={{ marginBottom: 12 }}>
新增令牌会调用站点 API 创建新密钥,再自动同步到本地。支持设置分组、额度、过期时间和 IP 白名单;已存在密钥可直接用“同步站点令牌”读取。 新增令牌会调用站点 API 创建新密钥,再自动同步到本地。支持设置分组、额度、过期时间和 IP 白名单;已存在密钥可直接用“同步站点令牌”读取。
+3 -2
View File
@@ -3,9 +3,10 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
describe('CheckinLog mobile layout', () => { describe('CheckinLog mobile layout', () => {
it('uses the shared mobile filter sheet for mobile filters', () => { it('uses the shared responsive filter scaffold for mobile filters', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/CheckinLog.tsx'), 'utf8'); const source = readFileSync(resolve(process.cwd(), 'src/web/pages/CheckinLog.tsx'), 'utf8');
expect(source).toContain('MobileFilterSheet'); expect(source).toMatch(/import\s+ResponsiveFilterPanel\s+from\s+['"]\.\.\/components\/ResponsiveFilterPanel\.js['"]/);
expect(source).toContain('<ResponsiveFilterPanel');
}); });
it('uses the shared mobile card header and footer action slots', () => { it('uses the shared mobile card header and footer action slots', () => {
+2 -1
View File
@@ -6,7 +6,8 @@ describe('ProxyLogs mobile layout', () => {
it('renders compact mobile summary cards for proxy logs', () => { it('renders compact mobile summary cards for proxy logs', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/ProxyLogs.tsx'), 'utf8'); const source = readFileSync(resolve(process.cwd(), 'src/web/pages/ProxyLogs.tsx'), 'utf8');
expect(source).toContain('MobileCard'); expect(source).toContain('MobileCard');
expect(source).toContain('MobileFilterSheet'); expect(source).toContain("import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js'");
expect(source).toContain('<ResponsiveFilterPanel');
expect(source).toContain('compact'); expect(source).toContain('compact');
expect(source).toContain('mobile-summary-grid'); expect(source).toContain('mobile-summary-grid');
expect(source).toContain("subtitle={formatDateTimeLocal(log.createdAt)}"); expect(source).toContain("subtitle={formatDateTimeLocal(log.createdAt)}");
@@ -6,11 +6,11 @@ describe('ProgramLogs mobile layout', () => {
it('uses the shared mobile primitives for filters and list cards', () => { it('uses the shared mobile primitives for filters and list cards', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/ProgramLogs.tsx'), 'utf8'); const source = readFileSync(resolve(process.cwd(), 'src/web/pages/ProgramLogs.tsx'), 'utf8');
expect(source).toContain("import MobileFilterSheet from '../components/MobileFilterSheet.js'"); expect(source).toContain("import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js'");
expect(source).toContain("import { MobileCard, MobileField } from '../components/MobileCard.js'"); expect(source).toContain("import { MobileCard, MobileField } from '../components/MobileCard.js'");
expect(source).toContain("import { useIsMobile } from '../components/useIsMobile.js'"); expect(source).toContain("import { useIsMobile } from '../components/useIsMobile.js'");
expect(source).toContain('const isMobile = useIsMobile()'); expect(source).toContain('const isMobile = useIsMobile()');
expect(source).toContain('mobile-card-list'); expect(source).toContain('mobile-card-list');
expect(source).toContain('<MobileFilterSheet'); expect(source).toContain('<ResponsiveFilterPanel');
}); });
}); });
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const pages = [
'src/web/pages/Accounts.tsx',
'src/web/pages/CheckinLog.tsx',
'src/web/pages/DownstreamKeys.tsx',
'src/web/pages/Models.tsx',
'src/web/pages/ProgramLogs.tsx',
'src/web/pages/ProxyLogs.tsx',
'src/web/pages/Sites.tsx',
'src/web/pages/TokenRoutes.tsx',
'src/web/pages/Tokens.tsx',
];
describe('ResponsiveFilterPanel adoption', () => {
it('routes page-level filter sheets through the shared scaffold component', () => {
for (const page of pages) {
const source = readFileSync(resolve(process.cwd(), page), 'utf8').replace(/\r\n/g, '\n');
expect(source, page).toMatch(/import\s+ResponsiveFilterPanel\s+from\s+['"]\.\.\/components\/ResponsiveFilterPanel\.js['"]/);
expect(source, page).not.toContain("import MobileFilterSheet from '../components/MobileFilterSheet.js'");
expect(source, page).not.toContain('<MobileFilterSheet');
}
});
});