unify Semi UI filter workspaces and pagination
This commit is contained in:
@@ -7,10 +7,10 @@ import { api } from '../../api/client';
|
||||
import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page } from '../../api/types';
|
||||
import { accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access';
|
||||
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
|
||||
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||
import { MetricActionButton } from '../shared/MetricActionButton';
|
||||
import { PageHeader } from '../shared/PageHeader';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { detailTriggerRow } from '../shared/detailTriggerRow';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
@@ -208,14 +208,23 @@ export default function AccessPage() {
|
||||
meta={<Typography.Text type="tertiary">数据时间 {summary?.asOf ? formatAccessTime(summary.asOf) : '—'}</Typography.Text>}
|
||||
actions={<>{editable ? <Button theme="light" icon={<IconSetting />} aria-haspopup="dialog" aria-controls="v2-access-governance" aria-expanded={governanceOpen} onClick={() => setGovernanceOpen(true)}>接入治理{unresolvedQuery.data?.total ? ` · ${unresolvedQuery.data.total}` : ''}</Button> : null}<Button theme="light" icon={<IconRefresh />} onClick={() => void refresh()}>刷新</Button></>}
|
||||
/>
|
||||
<MobileFilterToggle summary={Object.values(criteria).filter(Boolean).length ? `已启用 ${Object.values(criteria).filter(Boolean).length} 项` : '全部主车辆'} expanded={!filtersCollapsed} onToggle={() => setFiltersCollapsed((value) => !value)} />
|
||||
<Card className={`v2-access-filter-card-v3${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} bodyStyle={{ padding: 0 }}><form className="v2-access-filter-v3" onSubmit={submit}>
|
||||
<WorkspaceFilterPanel
|
||||
className="v2-access-filter-card-v3"
|
||||
title="接入筛选"
|
||||
description="车辆、接入状态、真实协议与品牌"
|
||||
mobileSummary={Object.values(criteria).filter(Boolean).length ? `已启用 ${Object.values(criteria).filter(Boolean).length} 项` : '全部主车辆'}
|
||||
expanded={!filtersCollapsed}
|
||||
status={Object.values(criteria).filter(Boolean).length ? `${Object.values(criteria).filter(Boolean).length} 项已启用` : '全部主车辆'}
|
||||
statusColor={Object.values(criteria).filter(Boolean).length ? 'blue' : 'grey'}
|
||||
onToggle={() => setFiltersCollapsed((value) => !value)}
|
||||
>
|
||||
<form className="v2-access-filter-v3" onSubmit={submit}>
|
||||
<label className="is-search"><span>车辆</span><Input aria-label="车辆" prefix={<IconSearch />} value={draft.keyword} onChange={(value) => setDraft({ ...draft, keyword: value })} placeholder="车牌 / VIN" /></label>
|
||||
<label><span id="access-connection-label">接入状态</span><Select aria-labelledby="access-connection-label" value={draft.connectionState} onChange={(value) => setDraft({ ...draft, connectionState: String(value) })} optionList={[{ value: '', label: '全部状态' }, { value: 'attention', label: '需关注' }, { value: 'healthy', label: '已接来源正常' }, { value: 'master_data', label: '资料待维护' }, { value: 'degraded', label: '部分来源异常' }, { value: 'offline', label: '已接来源离线' }, { value: 'not_connected', label: '尚无来源' }]} /></label>
|
||||
<label><span id="access-protocol-label">真实协议</span><Select aria-labelledby="access-protocol-label" value={draft.protocol} onChange={(value) => setDraft({ ...draft, protocol: String(value) })} optionList={[{ value: '', label: '全部协议' }, ...PROTOCOLS.map((item) => ({ value: item, label: item }))]} /></label>
|
||||
<label><span id="access-oem-label">车辆品牌</span><Select aria-labelledby="access-oem-label" value={draft.oem} onChange={(value) => setDraft({ ...draft, oem: String(value) })} optionList={[{ value: '', label: '全部品牌' }, ...(summary?.oems.map((item) => ({ value: item.name, label: item.name })) ?? [])]} /></label>
|
||||
<Button className="v2-primary-button" theme="solid" htmlType="submit">查询</Button><Button className="v2-secondary-button" theme="light" htmlType="button" onClick={() => apply(EMPTY_FILTERS)}>重置</Button>
|
||||
</form></Card>
|
||||
</form></WorkspaceFilterPanel>
|
||||
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
|
||||
<Card className="v2-access-kpis-card-v3" bodyStyle={{ padding: 0 }}><section className="v2-access-kpis-v3">{[
|
||||
{ label: '主车辆', value: summary?.totalVehicles ?? 0, tone: 'all', connectionState: '', hint: '车辆主档' },
|
||||
|
||||
@@ -7,12 +7,12 @@ import { api } from '../../api/client';
|
||||
import type { AlertEvent, AlertNotification, AlertQuery, AlertRule, AlertRuleInput, AlertStatus, MetricDefinition, Page } from '../../api/types';
|
||||
import { actionLabels, alertValue, canAct, formatAlertTime, operatorLabels, ruleCondition, severityLabels, statusLabels, thresholdText } from '../domain/alert';
|
||||
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
|
||||
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||
import { MetricActionButton } from '../shared/MetricActionButton';
|
||||
import { PageHeader } from '../shared/PageHeader';
|
||||
import { SegmentedTabs } from '../shared/SegmentedTabs';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
|
||||
import { detailTriggerRow } from '../shared/detailTriggerRow';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister, canOperate } from '../auth/session';
|
||||
@@ -177,8 +177,16 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
|
||||
const advancedFilterCount = [filters.ruleId, filters.protocol, filters.dateFrom, filters.dateTo].filter(Boolean).length;
|
||||
const inspector = <EventInspector event={selectedEvent} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} onClose={closeDetail} />;
|
||||
return <>
|
||||
<MobileFilterToggle summary={activeFilterCount ? `已启用 ${activeFilterCount} 项` : '全部告警'} expanded={!filtersCollapsed} onToggle={() => setFiltersCollapsed((value) => !value)} />
|
||||
<Card className={`v2-alert-filter-card${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} bodyStyle={{ padding: 0 }}>
|
||||
<WorkspaceFilterPanel
|
||||
className="v2-alert-filter-card"
|
||||
title="事件筛选"
|
||||
description="关键词、严重程度、处理状态与高级范围"
|
||||
mobileSummary={activeFilterCount ? `已启用 ${activeFilterCount} 项` : '全部告警'}
|
||||
expanded={!filtersCollapsed}
|
||||
status={activeFilterCount ? `${activeFilterCount} 项已启用` : '全部告警'}
|
||||
statusColor={activeFilterCount ? 'blue' : 'grey'}
|
||||
onToggle={() => setFiltersCollapsed((value) => !value)}
|
||||
>
|
||||
<form className="v2-alert-filter v2-alert-filter-primary" onSubmit={submit}>
|
||||
<label><span>关键词</span><Input prefix={<IconSearch />} value={draft.keyword} onChange={(value) => setDraft({ ...draft, keyword: value })} placeholder="车牌 / VIN / 规则名称" /></label>
|
||||
<label><span id="alert-severity-label">严重程度</span><Select aria-labelledby="alert-severity-label" value={draft.severity} onChange={(value) => setDraft({ ...draft, severity: String(value) })} optionList={[{ value: '', label: '全部' }, { value: 'critical', label: '紧急' }, { value: 'major', label: '重要' }, { value: 'minor', label: '一般' }]} /></label>
|
||||
@@ -187,7 +195,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, e
|
||||
<Button className="v2-primary-button" theme="solid" htmlType="submit">查询</Button>
|
||||
<Button className="v2-secondary-button" theme="light" htmlType="button" onClick={resetFilters}>重置</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</WorkspaceFilterPanel>
|
||||
{summary.isError ? <InlineError message={summary.error instanceof Error ? summary.error.message : '告警汇总读取失败'} onRetry={() => summary.refetch()} /> : null}
|
||||
<Card className="v2-alert-kpis-card" bodyStyle={{ padding: 0 }}>
|
||||
<section className="v2-alert-kpis">{[
|
||||
|
||||
@@ -9,9 +9,9 @@ import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, for
|
||||
import { downloadBlob } from '../domain/download';
|
||||
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
|
||||
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
|
||||
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||
import { PageHeader } from '../shared/PageHeader';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { detailTriggerRow } from '../shared/detailTriggerRow';
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
@@ -337,8 +337,16 @@ export default function HistoryPage() {
|
||||
status="证据可追溯"
|
||||
meta={<Typography.Text type="tertiary">{keywords.length ? `${keywords.length} 辆车辆` : '等待选择车辆'}</Typography.Text>}
|
||||
/>
|
||||
<MobileFilterToggle title="查询条件" summary={keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'} expanded={!filtersCollapsed} collapsedLabel="修改" onToggle={() => setFiltersCollapsed((value) => !value)} />
|
||||
<Card className={`v2-history-filter-card${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} bodyStyle={{ padding: 0 }}>
|
||||
<WorkspaceFilterPanel
|
||||
className="v2-history-filter-card"
|
||||
title="查询范围"
|
||||
description="车辆、时间、数据类型与协议来源"
|
||||
mobileSummary={keywords.length ? `${keywords.length} 辆 · ${criteria.category === 'location' ? '位置数据' : criteria.category === 'raw' ? '原始报文' : '日里程'}` : '请选择车辆'}
|
||||
expanded={!filtersCollapsed}
|
||||
status={keywords.length ? `${keywords.length} 辆已选` : '待选择车辆'}
|
||||
statusColor={keywords.length ? 'blue' : 'grey'}
|
||||
onToggle={() => setFiltersCollapsed((value) => !value)}
|
||||
>
|
||||
<form className={`v2-history-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
|
||||
<label className="v2-history-vehicles"><span>车辆(最多 5 台)</span><Input prefix={<IconSearch />} value={draft.keywords} onChange={(value) => setDraft((current) => ({ ...current, keywords: value }))} placeholder="车牌 / VIN,多台用逗号分隔" /></label>
|
||||
<fieldset className="v2-history-range"><legend>时间范围</legend><div><Input aria-label="开始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => setDraft((current) => ({ ...current, dateFrom: value }))} /><span>至</span><Input aria-label="结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => setDraft((current) => ({ ...current, dateTo: value }))} /></div></fieldset>
|
||||
@@ -346,7 +354,7 @@ export default function HistoryPage() {
|
||||
<label><span id="history-protocol-label">数据来源</span><Select aria-labelledby="history-protocol-label" value={draft.protocol} onChange={(value) => setDraft((current) => ({ ...current, protocol: String(value) }))} optionList={[{ value: '', label: '全部来源' }, { value: 'GB32960', label: 'GB32960' }, { value: 'JT808', label: 'JT808' }, { value: 'YUTONG_MQTT', label: 'YUTONG_MQTT' }]} /></label>
|
||||
<div className="v2-history-toolbar-actions"><Button className="v2-primary-button" theme="solid" htmlType="submit" disabled={!parseHistoryKeywords(draft.keywords).length}>查询</Button><Button className="v2-secondary-button" theme="light" onClick={reset}>重置</Button>{exportAllowed ? <CreateExportButton disabled={!resultRows.length} request={{ keywords, category: criteria.category, protocol: criteria.protocol || undefined, dateFrom: criteria.dateFrom, dateTo: criteria.dateTo, metrics: visibleKeys, format: 'csv' }} /> : <Tag className="v2-history-permission-tag" color="grey" type="light" size="large">只读</Tag>}</div>
|
||||
</form>
|
||||
</Card>
|
||||
</WorkspaceFilterPanel>
|
||||
<Card className="v2-history-metrics-card" bodyStyle={{ padding: 0 }}>
|
||||
<div className="v2-history-metrics">
|
||||
<strong>已选字段</strong>
|
||||
|
||||
@@ -265,15 +265,15 @@ test('paginates all unique vehicles when no license plate is selected', async ()
|
||||
|
||||
expect((await screen.findAllByText('粤A00001')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('共 32 辆 · 每页 20 辆')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-table-pagination-current')).toHaveTextContent('1/2');
|
||||
expect(document.querySelector('.v2-table-pagination .semi-page-item-small')).toHaveTextContent('1/2');
|
||||
expect(screen.getByText('档案口径 · 1 辆有里程')).toBeInTheDocument();
|
||||
expect(mocks.vehicleCoverage.mock.calls[0][0].get('bindingStatus')).toBe('bound');
|
||||
await waitFor(() => expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1]?.[0].get('vins')).toContain('VIN00000000000001'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect((await screen.findAllByText('粤A00021')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('共 32 辆 · 每页 20 辆')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-table-pagination-current')).toHaveTextContent('2/2');
|
||||
expect(document.querySelector('.v2-table-pagination .semi-page-item-small')).toHaveTextContent('2/2');
|
||||
await waitFor(() => expect(mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1]?.[0].get('offset')).toBe('20'));
|
||||
});
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ test('pages and filters large vehicle grants without nesting a vehicle-list scro
|
||||
expect(screen.getAllByRole('button', { name: /^移除 / })).toHaveLength(10);
|
||||
expect(screen.getByText('第 1–10 条,共 12 辆')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-assigned-pagination')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(await screen.findByRole('button', { name: '移除 粤A00012' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: /^移除 / })).toHaveLength(2);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const reconciliationSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/R
|
||||
const profileSyncPanelSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleProfileSyncPanel.tsx'), 'utf8');
|
||||
const sourceEvidenceSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/VehicleSourceEvidencePanel.tsx'), 'utf8');
|
||||
const mobileFilterToggleSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/MobileFilterToggle.tsx'), 'utf8');
|
||||
const workspaceFilterPanelSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceFilterPanel.tsx'), 'utf8');
|
||||
const segmentedTabsSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/SegmentedTabs.tsx'), 'utf8');
|
||||
const metricActionSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/MetricActionButton.tsx'), 'utf8');
|
||||
const tablePaginationSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/TablePagination.tsx'), 'utf8');
|
||||
@@ -122,7 +123,8 @@ describe('V2 production entry', () => {
|
||||
for (const surface of ['v2-export-jobs', 'v2-history-evidence', 'v2-history-metrics-card', 'v2-history-summary']) {
|
||||
expect(corePageSources.HistoryPage).toContain(`<Card className="${surface}"`);
|
||||
}
|
||||
expect(corePageSources.HistoryPage).toContain('<Card className={`v2-history-filter-card');
|
||||
expect(corePageSources.HistoryPage).toContain('<WorkspaceFilterPanel');
|
||||
expect(corePageSources.HistoryPage).toContain('className="v2-history-filter-card"');
|
||||
expect(corePageSources.HistoryPage).toContain('<Card className={`v2-history-table-card');
|
||||
expect(corePageSources.HistoryPage).toContain('<Descriptions className="v2-history-evidence-descriptions"');
|
||||
expect(corePageSources.HistoryPage).toContain('<Descriptions className="v2-evidence-values"');
|
||||
@@ -153,7 +155,7 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-event-table"');
|
||||
expect(corePageSources.AlertsPage).toContain('detailTriggerRow({');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<table><thead><tr><th />');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className={`v2-alert-filter-card');
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-filter-card"');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<section className="v2-alert-table-card"');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<aside className="v2-alert-inspector"');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<select');
|
||||
@@ -189,7 +191,7 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.AccessPage).toContain('<Card className={`v2-access-inspector-v3');
|
||||
expect(corePageSources.AccessPage).toContain('className="v2-access-detail-sidesheet"');
|
||||
expect(corePageSources.AccessPage).toContain('className="v2-access-governance-sidesheet"');
|
||||
expect(corePageSources.AccessPage).toContain('<Card className={`v2-access-filter-card-v3');
|
||||
expect(corePageSources.AccessPage).toContain('className="v2-access-filter-card-v3"');
|
||||
expect(corePageSources.AccessPage).not.toContain('<section className="v2-access-table-v3"');
|
||||
expect(corePageSources.AccessPage).not.toContain('<aside className="v2-access-inspector-v3"');
|
||||
expect(corePageSources.AccessPage).toContain('<Card key={row.vin} className={`v2-access-mobile-card');
|
||||
@@ -384,8 +386,9 @@ describe('V2 production entry', () => {
|
||||
expect(metricActionSource).toContain("from '@douyinfe/semi-ui'");
|
||||
expect(metricActionSource).toContain('aria-pressed={active}');
|
||||
expect(tablePaginationSource).toContain("from '@douyinfe/semi-ui'");
|
||||
expect(tablePaginationSource).toContain('aria-label="上一页"');
|
||||
expect(tablePaginationSource).toContain('aria-label="下一页"');
|
||||
expect(tablePaginationSource).toContain('<Pagination');
|
||||
expect(tablePaginationSource).toContain('currentPage={safePage}');
|
||||
expect(tablePaginationSource).toContain('onPageChange={onPageChange}');
|
||||
for (const name of ['MonitorPage', 'AlertsPage', 'HistoryPage', 'AccessPage', 'StatisticsPage']) {
|
||||
expect(corePageSources[name]).toContain("from '../shared/TablePagination'");
|
||||
expect(corePageSources[name]).toContain('<TablePagination');
|
||||
@@ -435,11 +438,18 @@ describe('V2 production entry', () => {
|
||||
expect(workspaceStyles).toContain('color: #f8fbff;');
|
||||
expect(v2Styles).toContain('.v2-track-current-card > .semi-card-body > div strong { margin-top: 4px; color: var(--track-overlay-text);');
|
||||
expect(v2Styles).toContain('.v2-track-current-card.semi-card { top: auto; right: 8px; bottom: 126px; left: 8px; width: auto; height: auto;');
|
||||
for (const name of ['HistoryPage', 'StatisticsPage', 'AlertsPage', 'AccessPage']) {
|
||||
expect(workspaceFilterPanelSource).toContain("from './MobileFilterToggle'");
|
||||
expect(workspaceFilterPanelSource).toContain('<MobileFilterToggle');
|
||||
for (const name of ['HistoryPage', 'AlertsPage', 'AccessPage']) {
|
||||
expect(corePageSources[name]).toContain("from '../shared/WorkspaceFilterPanel'");
|
||||
expect(corePageSources[name]).toContain('<WorkspaceFilterPanel');
|
||||
}
|
||||
for (const name of ['StatisticsPage']) {
|
||||
expect(corePageSources[name]).toContain("from '../shared/MobileFilterToggle'");
|
||||
expect(corePageSources[name]).toContain('<MobileFilterToggle');
|
||||
expect(corePageSources[name]).not.toContain('<button type="button" className="v2-mobile-filter-toggle"');
|
||||
}
|
||||
expect(tablePaginationSource).toContain('<Pagination');
|
||||
});
|
||||
|
||||
test('keeps the shared desktop and mobile application shell on Semi UI navigation primitives', () => {
|
||||
|
||||
@@ -7,14 +7,13 @@ describe('TablePagination', () => {
|
||||
|
||||
test('renders one shared Semi pagination pattern and changes pages', () => {
|
||||
const onPageChange = vi.fn();
|
||||
render(<footer><TablePagination page={2} totalPages={4} info="共 80 条" onPageChange={onPageChange} /></footer>);
|
||||
const { container } = render(<footer><TablePagination page={2} totalPages={4} info="共 80 条" onPageChange={onPageChange} /></footer>);
|
||||
|
||||
expect(screen.getByText('共 80 条')).toHaveClass('v2-table-pagination-info');
|
||||
expect(screen.getByText('2').closest('.v2-table-pagination-current')).toHaveTextContent('2/4');
|
||||
const previous = screen.getByRole('button', { name: '上一页' });
|
||||
const next = screen.getByRole('button', { name: '下一页' });
|
||||
expect(previous).toHaveClass('semi-button');
|
||||
expect(next).toHaveClass('semi-button');
|
||||
expect(container.querySelector('.v2-table-pagination')).toHaveClass('semi-page');
|
||||
expect(container.querySelector('.semi-page-item-small')).toHaveTextContent('2/4');
|
||||
const previous = screen.getByRole('button', { name: 'Previous' });
|
||||
const next = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(previous);
|
||||
fireEvent.click(next);
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(1, 1);
|
||||
@@ -23,8 +22,8 @@ describe('TablePagination', () => {
|
||||
|
||||
test('clamps invalid pages and disables both boundaries for a single page', () => {
|
||||
render(<footer><TablePagination page={0} totalPages={0} info="暂无数据" onPageChange={vi.fn()} /></footer>);
|
||||
expect(screen.getByRole('button', { name: '上一页' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '下一页' })).toBeDisabled();
|
||||
expect(document.querySelector('.v2-table-pagination-current')).toHaveTextContent('1/1');
|
||||
expect(screen.getByRole('button', { name: 'Previous' })).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(document.querySelector('.semi-page-item-small')).toHaveTextContent('1/1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IconChevronLeft, IconChevronRight } from '@douyinfe/semi-icons';
|
||||
import { Button, Select } from '@douyinfe/semi-ui';
|
||||
import { Pagination, Select } from '@douyinfe/semi-ui';
|
||||
import { useId, type ReactNode } from 'react';
|
||||
|
||||
type PageSizeOption = {
|
||||
@@ -37,30 +36,17 @@ export function TablePagination({
|
||||
return <>
|
||||
<span className="v2-table-pagination-info">{info}</span>
|
||||
<div className="v2-table-pagination-controls">
|
||||
<Button
|
||||
className="v2-table-pagination-button"
|
||||
theme="light"
|
||||
icon={<IconChevronLeft />}
|
||||
disabled={disabled || safePage <= 1}
|
||||
aria-label="上一页"
|
||||
onClick={() => onPageChange(safePage - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="v2-table-pagination-current" aria-live="polite" aria-atomic="true">
|
||||
<b>{safePage}</b><i>/</i><span>{safeTotalPages}</span>
|
||||
</span>
|
||||
<Button
|
||||
className="v2-table-pagination-button"
|
||||
theme="light"
|
||||
icon={<IconChevronRight />}
|
||||
iconPosition="right"
|
||||
disabled={disabled || safePage >= safeTotalPages}
|
||||
aria-label="下一页"
|
||||
onClick={() => onPageChange(safePage + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<Pagination
|
||||
className="v2-table-pagination"
|
||||
size="small"
|
||||
currentPage={safePage}
|
||||
total={safeTotalPages}
|
||||
pageSize={1}
|
||||
disabled={disabled}
|
||||
hideOnSinglePage={false}
|
||||
hoverShowPageSelect={!disabled}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
{pageSize != null && pageSizeOptions?.length && onPageSizeChange ? <>
|
||||
<span className="v2-sr-only" id={pageSizeID}>{pageSizeLabel}</span>
|
||||
<Select
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { WorkspaceFilterPanel } from './WorkspaceFilterPanel';
|
||||
|
||||
describe('WorkspaceFilterPanel', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
test('keeps desktop context and mobile disclosure in one shared component', () => {
|
||||
const onToggle = vi.fn();
|
||||
const { container } = render(
|
||||
<WorkspaceFilterPanel
|
||||
title="事件筛选"
|
||||
description="按关键词与状态收窄范围"
|
||||
mobileSummary="已启用 2 项"
|
||||
expanded={false}
|
||||
status="2 项已启用"
|
||||
statusColor="blue"
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<form aria-label="事件筛选表单"><input aria-label="关键词" /></form>
|
||||
</WorkspaceFilterPanel>
|
||||
);
|
||||
|
||||
expect(screen.getByText('按关键词与状态收窄范围')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 项已启用')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '修改事件筛选:已启用 2 项' })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(container.querySelector('.v2-workspace-filter-panel')).toHaveClass('semi-card', 'is-mobile-collapsed');
|
||||
expect(screen.getByRole('form', { name: '事件筛选表单' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改事件筛选:已启用 2 项' }));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { IconFilter } from '@douyinfe/semi-icons';
|
||||
import { Card, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { MobileFilterToggle } from './MobileFilterToggle';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type WorkspaceFilterPanelProps = {
|
||||
title?: string;
|
||||
description: string;
|
||||
mobileSummary: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
status?: ReactNode;
|
||||
statusColor?: ComponentProps<typeof Tag>['color'];
|
||||
collapsedLabel?: string;
|
||||
};
|
||||
|
||||
export function WorkspaceFilterPanel({
|
||||
title = '查询条件',
|
||||
description,
|
||||
mobileSummary,
|
||||
expanded,
|
||||
onToggle,
|
||||
children,
|
||||
className = '',
|
||||
status = '默认范围',
|
||||
statusColor = 'grey',
|
||||
collapsedLabel = '修改'
|
||||
}: WorkspaceFilterPanelProps) {
|
||||
const panelClassName = [
|
||||
'v2-workspace-filter-panel',
|
||||
className,
|
||||
expanded ? '' : 'is-mobile-collapsed'
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return <>
|
||||
<MobileFilterToggle
|
||||
title={title}
|
||||
summary={mobileSummary}
|
||||
expanded={expanded}
|
||||
collapsedLabel={collapsedLabel}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
<Card className={panelClassName} bodyStyle={{ padding: 0 }}>
|
||||
<header className="v2-workspace-filter-heading">
|
||||
<span className="v2-workspace-filter-icon" aria-hidden="true"><IconFilter /></span>
|
||||
<span className="v2-workspace-filter-copy">
|
||||
<Text strong>{title}</Text>
|
||||
<Text type="tertiary" size="small">{description}</Text>
|
||||
</span>
|
||||
<Tag color={statusColor} type="light" size="small">{status}</Tag>
|
||||
</header>
|
||||
{children}
|
||||
</Card>
|
||||
</>;
|
||||
}
|
||||
@@ -3674,3 +3674,152 @@
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Semi UI data workspace controls: one filter anatomy and one compact pager. */
|
||||
.v2-workspace-filter-panel.semi-card {
|
||||
overflow: visible;
|
||||
border-color: #dce5ef;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 6px 20px rgba(31, 47, 70, .05);
|
||||
}
|
||||
|
||||
.v2-workspace-filter-panel > .semi-card-body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.v2-workspace-filter-heading {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border-bottom: 1px solid #e6ebf2;
|
||||
border-radius: 12px 12px 0 0;
|
||||
background: linear-gradient(100deg, #f8fbff 0%, #f5f8fc 58%, #fbfcfe 100%);
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.v2-workspace-filter-icon {
|
||||
display: inline-grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: #eaf2ff;
|
||||
color: var(--v2-blue);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.v2-workspace-filter-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.v2-workspace-filter-copy > .semi-typography:first-child {
|
||||
flex: 0 0 auto;
|
||||
color: #30445d;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.v2-workspace-filter-copy > .semi-typography:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #7d8b9d;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v2-workspace-filter-heading > .semi-tag {
|
||||
min-height: 23px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
padding-inline: 8px;
|
||||
font-size: 9px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.v2-table-pagination.semi-page {
|
||||
min-width: 0;
|
||||
color: #607086;
|
||||
}
|
||||
|
||||
.v2-table-pagination.semi-page .semi-page-item {
|
||||
width: 30px;
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
margin: 0 1px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.v2-table-pagination.semi-page .semi-page-item-small {
|
||||
width: auto;
|
||||
min-width: 56px;
|
||||
border-color: #dce5f0;
|
||||
background: #f7faff;
|
||||
color: #52647a;
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.v2-table-pagination.semi-page .semi-page-prev:not(.semi-page-item-disabled):hover,
|
||||
.v2-table-pagination.semi-page .semi-page-next:not(.semi-page-item-disabled):hover {
|
||||
border-color: #b9cee9;
|
||||
background: #f2f7ff;
|
||||
color: var(--v2-blue);
|
||||
}
|
||||
|
||||
.v2-table-pagination.semi-page .semi-page-item-disabled {
|
||||
background: #f8fafc;
|
||||
color: #b1bbc8;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.v2-workspace-filter-heading {
|
||||
min-height: 42px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.v2-workspace-filter-copy > .semi-typography:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.v2-workspace-filter-heading {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.v2-workspace-filter-panel.semi-card {
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.v2-table-pagination-controls {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.v2-table-pagination.semi-page {
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.v2-table-pagination.semi-page .semi-page-item {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.v2-table-pagination.semi-page .semi-page-item-small {
|
||||
width: auto;
|
||||
min-width: 62px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user