refactor: share responsive batch actions across admin pages

This commit is contained in:
Cita
2026-03-23 13:12:06 +08:00
parent b82d7c31d9
commit f7a7084278
7 changed files with 124 additions and 96 deletions
@@ -0,0 +1,16 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
function readPageSource(relativePath: string): string {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n');
}
describe('ResponsiveBatchActionBar page adoption', () => {
it('is used by the repeated admin list pages instead of open-coded mobile and desktop batch wrappers', () => {
expect(readPageSource('src/web/pages/Accounts.tsx')).toContain('ResponsiveBatchActionBar');
expect(readPageSource('src/web/pages/Sites.tsx')).toContain('ResponsiveBatchActionBar');
expect(readPageSource('src/web/pages/Tokens.tsx')).toContain('ResponsiveBatchActionBar');
expect(readPageSource('src/web/pages/DownstreamKeys.tsx')).toContain('ResponsiveBatchActionBar');
});
});
@@ -0,0 +1,30 @@
import React from 'react';
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import ResponsiveBatchActionBar from './ResponsiveBatchActionBar.js';
describe('ResponsiveBatchActionBar', () => {
it('renders the shared mobile batch bar on mobile', () => {
const root = create(
<ResponsiveBatchActionBar isMobile info="已选 2 项">
<button type="button">批量启用</button>
</ResponsiveBatchActionBar>,
);
const bar = root.root.find((node) => node.props.className === 'mobile-actions-bar mobile-batch-bar');
expect(bar).toBeTruthy();
expect(root.root.findByType('button').children).toContain('批量启用');
});
it('renders the desktop card wrapper on desktop', () => {
const root = create(
<ResponsiveBatchActionBar isMobile={false} info="已选 3 项">
<button type="button">批量删除</button>
</ResponsiveBatchActionBar>,
);
const card = root.root.find((node) => node.props.className === 'card');
expect(card).toBeTruthy();
expect(root.root.findAllByType('span').some((node) => node.children.includes('已选 3 项'))).toBe(true);
});
});
@@ -0,0 +1,42 @@
import React from 'react';
import MobileBatchBar from './MobileBatchBar.js';
type ResponsiveBatchActionBarProps = {
isMobile: boolean;
info: React.ReactNode;
children: React.ReactNode;
desktopStyle?: React.CSSProperties;
infoStyle?: React.CSSProperties;
};
const DEFAULT_DESKTOP_STYLE: React.CSSProperties = {
padding: 12,
display: 'flex',
gap: 8,
flexWrap: 'wrap',
alignItems: 'center',
};
const DEFAULT_INFO_STYLE: React.CSSProperties = {
fontSize: 13,
fontWeight: 600,
};
export default function ResponsiveBatchActionBar({
isMobile,
info,
children,
desktopStyle,
infoStyle,
}: ResponsiveBatchActionBarProps) {
if (isMobile) {
return <MobileBatchBar info={info}>{children}</MobileBatchBar>;
}
return (
<div className="card" style={{ ...DEFAULT_DESKTOP_STYLE, ...desktopStyle }}>
<span style={{ ...DEFAULT_INFO_STYLE, ...infoStyle }}>{info}</span>
{children}
</div>
);
}
+8 -22
View File
@@ -2,9 +2,9 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js';
import MobileBatchBar from '../components/MobileBatchBar.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js';
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
@@ -1066,9 +1066,12 @@ export default function Accounts() {
: <>确定要删除选中的 <strong>{deleteConfirm?.count || 0}</strong> 个连接吗?</>}
/>
{!isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedAccountIds.length} 项</span>
{activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
<ResponsiveBatchActionBar
isMobile={isMobile}
info={`已选 ${selectedAccountIds.length} 项`}
desktopStyle={{ marginBottom: 12 }}
>
<button data-testid="accounts-batch-refresh-balance" onClick={() => runBatchAccountAction('refreshBalance')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量刷新余额
</button>
@@ -1081,24 +1084,7 @@ export default function Accounts() {
<button onClick={() => runBatchAccountAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
批量删除
</button>
</div>
)}
{isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && (
<MobileBatchBar info={`已选 ${selectedAccountIds.length} 项`}>
<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>
</MobileBatchBar>
</ResponsiveBatchActionBar>
)}
{activeSegment === 'tokens' ? (
+12 -19
View File
@@ -3,9 +3,9 @@ import { createPortal } from 'react-dom';
import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
import MobileBatchBar from '../components/MobileBatchBar.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js';
import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
@@ -1687,24 +1687,17 @@ export default function DownstreamKeys() {
</div>
{selectedIds.length > 0 ? (
isMobile ? (
<MobileBatchBar info={`已选 ${selectedIds.length} 个密钥`}>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={openBatchMetadata} disabled={batchActionLoading}>归类/标签</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量启用', selectedIds)} disabled={batchActionLoading}>启用</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量禁用', selectedIds)} disabled={batchActionLoading}>禁用</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量清零用量', selectedIds)} disabled={batchActionLoading}>清零</button>
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'batch', ids: [...selectedIds] })} disabled={batchActionLoading}>删除</button>
</MobileBatchBar>
) : (
<div className="card" style={{ padding: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>已选 {selectedIds.length} 个密钥</span>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={openBatchMetadata} disabled={batchActionLoading}>批量归类/标签</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量启用', selectedIds)} disabled={batchActionLoading}>批量启用</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量禁用', selectedIds)} disabled={batchActionLoading}>批量禁用</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量清零用量', selectedIds)} disabled={batchActionLoading}>批量清零用量</button>
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'batch', ids: [...selectedIds] })} disabled={batchActionLoading}>批量删除</button>
</div>
)
<ResponsiveBatchActionBar
isMobile={isMobile}
info={`已选 ${selectedIds.length} 个密钥`}
infoStyle={{ color: 'var(--color-text-primary)' }}
>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={openBatchMetadata} disabled={batchActionLoading}>{isMobile ? '归类/标签' : '批量归类/标签'}</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量启用', selectedIds)} disabled={batchActionLoading}>{isMobile ? '启用' : '批量启用'}</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量禁用', selectedIds)} disabled={batchActionLoading}>{isMobile ? '禁用' : '批量禁用'}</button>
<button className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => void batchRun('批量清零用量', selectedIds)} disabled={batchActionLoading}>{isMobile ? '清零' : '批量清零用量'}</button>
<button className="btn btn-link btn-link-danger" onClick={() => setDeleteConfirm({ mode: 'batch', ids: [...selectedIds] })} disabled={batchActionLoading}>{isMobile ? '删除' : '批量删除'}</button>
</ResponsiveBatchActionBar>
) : null}
<div className="card" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
+8 -36
View File
@@ -2,8 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js';
import MobileBatchBar from '../components/MobileBatchBar.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js';
import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
@@ -689,9 +689,12 @@ export default function Sites() {
</div>
</MobileFilterSheet>
{!isMobile && selectedSiteIds.length > 0 && (
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedSiteIds.length} 项</span>
{selectedSiteIds.length > 0 && (
<ResponsiveBatchActionBar
isMobile={isMobile}
info={`已选 ${selectedSiteIds.length} 项`}
desktopStyle={{ marginBottom: 12 }}
>
<button
data-testid="sites-batch-enable-system-proxy"
onClick={() => runBatchAction('enableSystemProxy')}
@@ -718,38 +721,7 @@ export default function Sites() {
<button onClick={() => runBatchAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
批量删除
</button>
</div>
)}
{isMobile && selectedSiteIds.length > 0 && (
<MobileBatchBar info={`已选 ${selectedSiteIds.length} 项`}>
<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>
</MobileBatchBar>
</ResponsiveBatchActionBar>
)}
<div className="info-tip" style={{ marginBottom: 12 }}>
+8 -19
View File
@@ -2,9 +2,9 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js';
import MobileBatchBar from '../components/MobileBatchBar.js';
import MobileFilterSheet from '../components/MobileFilterSheet.js';
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js';
import { useToast } from '../components/Toast.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import {
@@ -996,9 +996,12 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
) : null}
</CenteredModal>
{!isMobile && selectedTokenIds.length > 0 && (
<div className="card" style={{ padding: 12, marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ fontSize: 13, fontWeight: 600 }}>已选 {selectedTokenIds.length} 项</span>
{selectedTokenIds.length > 0 && (
<ResponsiveBatchActionBar
isMobile={isMobile}
info={`已选 ${selectedTokenIds.length} 项`}
desktopStyle={{ marginBottom: 12 }}
>
<button onClick={() => runBatchTokenAction('enable')} disabled={batchActionLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
批量启用
</button>
@@ -1008,21 +1011,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<button data-testid="tokens-batch-delete" onClick={() => runBatchTokenAction('delete')} disabled={batchActionLoading} className="btn btn-link btn-link-danger">
批量删除
</button>
</div>
)}
{isMobile && selectedTokenIds.length > 0 && (
<MobileBatchBar info={`已选 ${selectedTokenIds.length} 项`}>
<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>
</MobileBatchBar>
</ResponsiveBatchActionBar>
)}
<CenteredModal