From a353c5a4d8af8a9e37f5338c37e13fe59f19cb00 Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 17:40:40 +0800 Subject: [PATCH] feat(usr): 用户列表筛选栏 UserFilterBar REQ-USR-003 --- frontend/src/pages/usr/UserList/UserFilterBar.tsx | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/src/pages/usr/UserList/UserList.module.css | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/tests/unit/UserFilterBar.test.tsx | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+), 0 deletions(-) create mode 100644 frontend/src/pages/usr/UserList/UserFilterBar.tsx create mode 100644 frontend/src/pages/usr/UserList/UserList.module.css create mode 100644 frontend/tests/unit/UserFilterBar.test.tsx diff --git a/frontend/src/pages/usr/UserList/UserFilterBar.tsx b/frontend/src/pages/usr/UserList/UserFilterBar.tsx new file mode 100644 index 0000000..5e0349f --- /dev/null +++ b/frontend/src/pages/usr/UserList/UserFilterBar.tsx @@ -0,0 +1,95 @@ +// REQ-USR-003: 用户列表筛选栏(范围/查询字段/匹配方式/查询值/更多/搜索/清空) +import { Button, Input, Select } from 'antd'; +import { SearchOutlined } from '@ant-design/icons'; +import type { UserListQuery } from '../../../api/types'; +import { + MATCH_TYPE_OPTIONS, + QUERY_FIELD_OPTIONS, + SCOPE_OPTIONS, + TEXT_CLEAR, + TEXT_SEARCH, +} from './constants'; +import styles from './UserList.module.css'; + +export interface UserFilterBarProps { + query: UserListQuery; + onChangeQueryField(v: string): void; + onChangeMatchType(v: string): void; + onChangeQueryValue(v: string): void; + onSearch(): void; + onClear(): void; +} + +const toOptions = (arr: readonly string[]) => arr.map((v) => ({ value: v, label: v })); + +export default function UserFilterBar({ + query, + onChangeQueryField, + onChangeMatchType, + onChangeQueryValue, + onSearch, + onClear, +}: UserFilterBarProps) { + return ( +
+ {/* 用户范围下拉(占位 demo,D2:不向后端传额外参数) */} +
+ +
+ + {/* 匹配方式下拉(默认包含,BR2/BR4) */} +
+ onChangeQueryValue(e.target.value)} + onPressEnter={() => onSearch()} + allowClear + style={{ width: 200 }} + /> +
+ + {/* 更多条件占位(D3:点击无业务回调) */} + + ▾ + + + + +
+ ); +} diff --git a/frontend/src/pages/usr/UserList/UserList.module.css b/frontend/src/pages/usr/UserList/UserList.module.css new file mode 100644 index 0000000..287b2cf --- /dev/null +++ b/frontend/src/pages/usr/UserList/UserList.module.css @@ -0,0 +1,92 @@ +/* REQ-USR-003: 用户列表页 scoped 样式。语义色只用 var(--color-*);工具栏深色底为局部装饰(D10) */ + +.page { + display: flex; + flex-direction: column; + height: 100%; + background: var(--color-bg-base); +} + +/* === 工具栏:深色底为页面局部装饰(非语义 token,scoped,D10,与 FE-02 顶栏处理一致) === */ +.toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + background: #2c2f36; +} + +.toolbarSpacer { + flex: 1 1 auto; +} + +.toolbar :global(.ant-btn) { + color: #ffffff; +} + +.gear { + color: #ffffff; + font-size: 16px; + cursor: default; + padding: 0 8px; +} + +/* === 筛选栏:白底 + 下边线 === */ +.filterbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 10px 12px; + background: var(--color-form-bg-edit); + border-bottom: 1px solid var(--color-border); +} + +.moreToggle { + color: var(--color-text-secondary); + cursor: pointer; + user-select: none; + padding: 0 4px; +} + +/* === 表格壳:白底,可横向滚动 === */ +.tableShell { + flex: 1 1 auto; + overflow: auto; + padding: 12px; + background: var(--color-bg-base); +} + +.tableShell :global(.ant-table-thead > tr > th) { + background: var(--color-table-header-bg); + color: var(--color-table-header-fg); + border-color: var(--color-border); +} + +.tableShell :global(.ant-table-tbody > tr > td) { + color: var(--color-table-row-fg); + border-color: var(--color-border); +} + +.tableShell :global(.ant-table-tbody > tr:hover > td) { + background: var(--color-table-row-bg-hover); +} + +.tableShell :global(.ant-table-tbody > tr.ant-table-row-selected > td) { + background: var(--color-table-row-bg-selected); +} + +/* === 错误占位 === */ +.errorBox { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 48px 0; + color: var(--color-text-secondary); +} + +.errorText { + color: var(--color-error); +} diff --git a/frontend/tests/unit/UserFilterBar.test.tsx b/frontend/tests/unit/UserFilterBar.test.tsx new file mode 100644 index 0000000..c508089 --- /dev/null +++ b/frontend/tests/unit/UserFilterBar.test.tsx @@ -0,0 +1,124 @@ +// REQ-USR-003: UserFilterBar 筛选栏单测(BR2/BR3/BR4/BR7/BR10/D2/D3) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderShell } from './renderShell'; +import UserFilterBar from '../../src/pages/usr/UserList/UserFilterBar'; +import { DEFAULT_QUERY } from '../../src/pages/usr/UserList/constants'; +import type { UserListQuery } from '../../src/api/types'; + +function setup(overrides?: Partial) { + const onChangeQueryField = vi.fn(); + const onChangeMatchType = vi.fn(); + const onChangeQueryValue = vi.fn(); + const onSearch = vi.fn(); + const onClear = vi.fn(); + const query: UserListQuery = { ...DEFAULT_QUERY, ...overrides }; + renderShell( + , + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, + ); + return { onChangeQueryField, onChangeMatchType, onChangeQueryValue, onSearch, onClear }; +} + +describe('UserFilterBar', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders defaults 用户名 / 包含 and empty value', () => { + setup(); + const fieldSel = within(screen.getByTestId('filter-query-field')); + expect(fieldSel.getByText('用户名')).toBeInTheDocument(); + const matchSel = within(screen.getByTestId('filter-match-type')); + expect(matchSel.getByText('包含')).toBeInTheDocument(); + const input = screen.getByTestId('filter-query-value').querySelector('input')!; + expect(input.value).toBe(''); + }); + + it('query field options match enum (BR4)', async () => { + const user = userEvent.setup(); + setup(); + // 展开查询字段下拉 + const combobox = within(screen.getByTestId('filter-query-field')).getByRole('combobox'); + await user.click(combobox); + // AntD options 渲染为 role=option(在 document.body 的下拉层) + const options = await screen.findAllByRole('option'); + const labels = options.map((o) => o.textContent); + for (const label of ['用户名', '员工名', '用户号', '部门', '用户类型', '作废', '登录日期', '制单人']) { + expect(labels).toContain(label); + } + expect(options).toHaveLength(8); + }); + + it('match type options are 包含/不包含/等于 (BR4)', async () => { + const user = userEvent.setup(); + setup(); + const combobox = within(screen.getByTestId('filter-match-type')).getByRole('combobox'); + await user.click(combobox); + const options = await screen.findAllByRole('option'); + const labels = options.map((o) => o.textContent); + expect(labels).toEqual(['包含', '不包含', '等于']); + }); + + it('scope select shows 全部用户 only (D2)', () => { + setup(); + const scope = within(screen.getByTestId('filter-scope')); + expect(scope.getByText('全部用户')).toBeInTheDocument(); + }); + + it('Enter in value triggers onSearch (BR7)', async () => { + const user = userEvent.setup(); + const { onSearch } = setup(); + const input = screen.getByTestId('filter-query-value').querySelector('input')!; + input.focus(); + await user.keyboard('{Enter}'); + expect(onSearch).toHaveBeenCalledTimes(1); + }); + + it('click 搜索 calls onSearch / click 清空 calls onClear (BR7/BR10)', async () => { + const user = userEvent.setup(); + const { onSearch, onClear } = setup(); + await user.click(screen.getByTestId('btn-search')); + expect(onSearch).toHaveBeenCalledTimes(1); + await user.click(screen.getByTestId('btn-clear')); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it('typing value calls onChangeQueryValue', async () => { + const user = userEvent.setup(); + const { onChangeQueryValue } = setup(); + const input = screen.getByTestId('filter-query-value').querySelector('input')!; + await user.type(input, 'x'); + expect(onChangeQueryValue).toHaveBeenCalled(); + expect(onChangeQueryValue).toHaveBeenLastCalledWith('x'); + }); + + it('changing query field select calls onChangeQueryField', async () => { + const user = userEvent.setup(); + const { onChangeQueryField } = setup(); + const combobox = within(screen.getByTestId('filter-query-field')).getByRole('combobox'); + await user.click(combobox); + const options = await screen.findAllByRole('option'); + const target = options.find((o) => o.textContent === '员工名')!; + await user.click(target); + // AntD Select.onChange 透传 (value, option),回调首参即选中值 + expect(onChangeQueryField).toHaveBeenCalled(); + expect(onChangeQueryField.mock.calls[0][0]).toBe('员工名'); + }); + + it('more toggle ▾ is placeholder (no extra callback) (D3)', async () => { + const user = userEvent.setup(); + const { onSearch, onChangeQueryField } = setup(); + await user.click(screen.getByTestId('filter-more')); + expect(onSearch).not.toHaveBeenCalled(); + expect(onChangeQueryField).not.toHaveBeenCalled(); + }); +}); -- libgit2 0.22.2