forked from boos/BoosAPI
refactor: share responsive filter panel scaffold
This commit is contained in:
@@ -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
@@ -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={{
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,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', () => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user