Commit a353c5a4d8af8a9e37f5338c37e13fe59f19cb00
1 parent
7241d98b
feat(usr): 用户列表筛选栏 UserFilterBar REQ-USR-003
Showing
3 changed files
with
311 additions
and
0 deletions
frontend/src/pages/usr/UserList/UserFilterBar.tsx
0 → 100644
| 1 | +// REQ-USR-003: 用户列表筛选栏(范围/查询字段/匹配方式/查询值/更多/搜索/清空) | ||
| 2 | +import { Button, Input, Select } from 'antd'; | ||
| 3 | +import { SearchOutlined } from '@ant-design/icons'; | ||
| 4 | +import type { UserListQuery } from '../../../api/types'; | ||
| 5 | +import { | ||
| 6 | + MATCH_TYPE_OPTIONS, | ||
| 7 | + QUERY_FIELD_OPTIONS, | ||
| 8 | + SCOPE_OPTIONS, | ||
| 9 | + TEXT_CLEAR, | ||
| 10 | + TEXT_SEARCH, | ||
| 11 | +} from './constants'; | ||
| 12 | +import styles from './UserList.module.css'; | ||
| 13 | + | ||
| 14 | +export interface UserFilterBarProps { | ||
| 15 | + query: UserListQuery; | ||
| 16 | + onChangeQueryField(v: string): void; | ||
| 17 | + onChangeMatchType(v: string): void; | ||
| 18 | + onChangeQueryValue(v: string): void; | ||
| 19 | + onSearch(): void; | ||
| 20 | + onClear(): void; | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +const toOptions = (arr: readonly string[]) => arr.map((v) => ({ value: v, label: v })); | ||
| 24 | + | ||
| 25 | +export default function UserFilterBar({ | ||
| 26 | + query, | ||
| 27 | + onChangeQueryField, | ||
| 28 | + onChangeMatchType, | ||
| 29 | + onChangeQueryValue, | ||
| 30 | + onSearch, | ||
| 31 | + onClear, | ||
| 32 | +}: UserFilterBarProps) { | ||
| 33 | + return ( | ||
| 34 | + <div className={styles.filterbar}> | ||
| 35 | + {/* 用户范围下拉(占位 demo,D2:不向后端传额外参数) */} | ||
| 36 | + <div data-testid="filter-scope"> | ||
| 37 | + <Select | ||
| 38 | + value={SCOPE_OPTIONS[0]} | ||
| 39 | + options={toOptions(SCOPE_OPTIONS)} | ||
| 40 | + style={{ width: 120 }} | ||
| 41 | + /> | ||
| 42 | + </div> | ||
| 43 | + | ||
| 44 | + {/* 查询字段下拉(默认用户名,BR2/BR4) */} | ||
| 45 | + <div data-testid="filter-query-field"> | ||
| 46 | + <Select | ||
| 47 | + value={query.queryField} | ||
| 48 | + options={toOptions(QUERY_FIELD_OPTIONS)} | ||
| 49 | + onChange={onChangeQueryField} | ||
| 50 | + virtual={false} | ||
| 51 | + style={{ width: 120 }} | ||
| 52 | + /> | ||
| 53 | + </div> | ||
| 54 | + | ||
| 55 | + {/* 匹配方式下拉(默认包含,BR2/BR4) */} | ||
| 56 | + <div data-testid="filter-match-type"> | ||
| 57 | + <Select | ||
| 58 | + value={query.matchType} | ||
| 59 | + options={toOptions(MATCH_TYPE_OPTIONS)} | ||
| 60 | + onChange={onChangeMatchType} | ||
| 61 | + virtual={false} | ||
| 62 | + style={{ width: 100 }} | ||
| 63 | + /> | ||
| 64 | + </div> | ||
| 65 | + | ||
| 66 | + {/* 查询值输入(空为全部 BR3;回车触发搜索 BR7) */} | ||
| 67 | + <div data-testid="filter-query-value"> | ||
| 68 | + <Input | ||
| 69 | + value={query.queryValue ?? ''} | ||
| 70 | + onChange={(e) => onChangeQueryValue(e.target.value)} | ||
| 71 | + onPressEnter={() => onSearch()} | ||
| 72 | + allowClear | ||
| 73 | + style={{ width: 200 }} | ||
| 74 | + /> | ||
| 75 | + </div> | ||
| 76 | + | ||
| 77 | + {/* 更多条件占位(D3:点击无业务回调) */} | ||
| 78 | + <span className={styles.moreToggle} data-testid="filter-more" role="presentation"> | ||
| 79 | + ▾ | ||
| 80 | + </span> | ||
| 81 | + | ||
| 82 | + <Button | ||
| 83 | + type="primary" | ||
| 84 | + icon={<SearchOutlined />} | ||
| 85 | + onClick={() => onSearch()} | ||
| 86 | + data-testid="btn-search" | ||
| 87 | + > | ||
| 88 | + {TEXT_SEARCH} | ||
| 89 | + </Button> | ||
| 90 | + <Button onClick={() => onClear()} data-testid="btn-clear"> | ||
| 91 | + ⊗ {TEXT_CLEAR} | ||
| 92 | + </Button> | ||
| 93 | + </div> | ||
| 94 | + ); | ||
| 95 | +} |
frontend/src/pages/usr/UserList/UserList.module.css
0 → 100644
| 1 | +/* REQ-USR-003: 用户列表页 scoped 样式。语义色只用 var(--color-*);工具栏深色底为局部装饰(D10) */ | ||
| 2 | + | ||
| 3 | +.page { | ||
| 4 | + display: flex; | ||
| 5 | + flex-direction: column; | ||
| 6 | + height: 100%; | ||
| 7 | + background: var(--color-bg-base); | ||
| 8 | +} | ||
| 9 | + | ||
| 10 | +/* === 工具栏:深色底为页面局部装饰(非语义 token,scoped,D10,与 FE-02 顶栏处理一致) === */ | ||
| 11 | +.toolbar { | ||
| 12 | + display: flex; | ||
| 13 | + align-items: center; | ||
| 14 | + gap: 4px; | ||
| 15 | + padding: 6px 12px; | ||
| 16 | + background: #2c2f36; | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +.toolbarSpacer { | ||
| 20 | + flex: 1 1 auto; | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +.toolbar :global(.ant-btn) { | ||
| 24 | + color: #ffffff; | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +.gear { | ||
| 28 | + color: #ffffff; | ||
| 29 | + font-size: 16px; | ||
| 30 | + cursor: default; | ||
| 31 | + padding: 0 8px; | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +/* === 筛选栏:白底 + 下边线 === */ | ||
| 35 | +.filterbar { | ||
| 36 | + display: flex; | ||
| 37 | + align-items: center; | ||
| 38 | + gap: 8px; | ||
| 39 | + flex-wrap: wrap; | ||
| 40 | + padding: 10px 12px; | ||
| 41 | + background: var(--color-form-bg-edit); | ||
| 42 | + border-bottom: 1px solid var(--color-border); | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +.moreToggle { | ||
| 46 | + color: var(--color-text-secondary); | ||
| 47 | + cursor: pointer; | ||
| 48 | + user-select: none; | ||
| 49 | + padding: 0 4px; | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +/* === 表格壳:白底,可横向滚动 === */ | ||
| 53 | +.tableShell { | ||
| 54 | + flex: 1 1 auto; | ||
| 55 | + overflow: auto; | ||
| 56 | + padding: 12px; | ||
| 57 | + background: var(--color-bg-base); | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +.tableShell :global(.ant-table-thead > tr > th) { | ||
| 61 | + background: var(--color-table-header-bg); | ||
| 62 | + color: var(--color-table-header-fg); | ||
| 63 | + border-color: var(--color-border); | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +.tableShell :global(.ant-table-tbody > tr > td) { | ||
| 67 | + color: var(--color-table-row-fg); | ||
| 68 | + border-color: var(--color-border); | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +.tableShell :global(.ant-table-tbody > tr:hover > td) { | ||
| 72 | + background: var(--color-table-row-bg-hover); | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +.tableShell :global(.ant-table-tbody > tr.ant-table-row-selected > td) { | ||
| 76 | + background: var(--color-table-row-bg-selected); | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +/* === 错误占位 === */ | ||
| 80 | +.errorBox { | ||
| 81 | + display: flex; | ||
| 82 | + flex-direction: column; | ||
| 83 | + align-items: center; | ||
| 84 | + justify-content: center; | ||
| 85 | + gap: 12px; | ||
| 86 | + padding: 48px 0; | ||
| 87 | + color: var(--color-text-secondary); | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +.errorText { | ||
| 91 | + color: var(--color-error); | ||
| 92 | +} |
frontend/tests/unit/UserFilterBar.test.tsx
0 → 100644
| 1 | +// REQ-USR-003: UserFilterBar 筛选栏单测(BR2/BR3/BR4/BR7/BR10/D2/D3) | ||
| 2 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| 3 | +import { screen, within } from '@testing-library/react'; | ||
| 4 | +import userEvent from '@testing-library/user-event'; | ||
| 5 | +import { renderShell } from './renderShell'; | ||
| 6 | +import UserFilterBar from '../../src/pages/usr/UserList/UserFilterBar'; | ||
| 7 | +import { DEFAULT_QUERY } from '../../src/pages/usr/UserList/constants'; | ||
| 8 | +import type { UserListQuery } from '../../src/api/types'; | ||
| 9 | + | ||
| 10 | +function setup(overrides?: Partial<UserListQuery>) { | ||
| 11 | + const onChangeQueryField = vi.fn(); | ||
| 12 | + const onChangeMatchType = vi.fn(); | ||
| 13 | + const onChangeQueryValue = vi.fn(); | ||
| 14 | + const onSearch = vi.fn(); | ||
| 15 | + const onClear = vi.fn(); | ||
| 16 | + const query: UserListQuery = { ...DEFAULT_QUERY, ...overrides }; | ||
| 17 | + renderShell( | ||
| 18 | + <UserFilterBar | ||
| 19 | + query={query} | ||
| 20 | + onChangeQueryField={onChangeQueryField} | ||
| 21 | + onChangeMatchType={onChangeMatchType} | ||
| 22 | + onChangeQueryValue={onChangeQueryValue} | ||
| 23 | + onSearch={onSearch} | ||
| 24 | + onClear={onClear} | ||
| 25 | + />, | ||
| 26 | + { preloadedAuth: { token: 't', user: { id: 1, sUserName: 'a', sUserType: 'x', sLanguage: '中文' } } }, | ||
| 27 | + ); | ||
| 28 | + return { onChangeQueryField, onChangeMatchType, onChangeQueryValue, onSearch, onClear }; | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +describe('UserFilterBar', () => { | ||
| 32 | + beforeEach(() => { | ||
| 33 | + vi.clearAllMocks(); | ||
| 34 | + }); | ||
| 35 | + | ||
| 36 | + it('renders defaults 用户名 / 包含 and empty value', () => { | ||
| 37 | + setup(); | ||
| 38 | + const fieldSel = within(screen.getByTestId('filter-query-field')); | ||
| 39 | + expect(fieldSel.getByText('用户名')).toBeInTheDocument(); | ||
| 40 | + const matchSel = within(screen.getByTestId('filter-match-type')); | ||
| 41 | + expect(matchSel.getByText('包含')).toBeInTheDocument(); | ||
| 42 | + const input = screen.getByTestId('filter-query-value').querySelector('input')!; | ||
| 43 | + expect(input.value).toBe(''); | ||
| 44 | + }); | ||
| 45 | + | ||
| 46 | + it('query field options match enum (BR4)', async () => { | ||
| 47 | + const user = userEvent.setup(); | ||
| 48 | + setup(); | ||
| 49 | + // 展开查询字段下拉 | ||
| 50 | + const combobox = within(screen.getByTestId('filter-query-field')).getByRole('combobox'); | ||
| 51 | + await user.click(combobox); | ||
| 52 | + // AntD options 渲染为 role=option(在 document.body 的下拉层) | ||
| 53 | + const options = await screen.findAllByRole('option'); | ||
| 54 | + const labels = options.map((o) => o.textContent); | ||
| 55 | + for (const label of ['用户名', '员工名', '用户号', '部门', '用户类型', '作废', '登录日期', '制单人']) { | ||
| 56 | + expect(labels).toContain(label); | ||
| 57 | + } | ||
| 58 | + expect(options).toHaveLength(8); | ||
| 59 | + }); | ||
| 60 | + | ||
| 61 | + it('match type options are 包含/不包含/等于 (BR4)', async () => { | ||
| 62 | + const user = userEvent.setup(); | ||
| 63 | + setup(); | ||
| 64 | + const combobox = within(screen.getByTestId('filter-match-type')).getByRole('combobox'); | ||
| 65 | + await user.click(combobox); | ||
| 66 | + const options = await screen.findAllByRole('option'); | ||
| 67 | + const labels = options.map((o) => o.textContent); | ||
| 68 | + expect(labels).toEqual(['包含', '不包含', '等于']); | ||
| 69 | + }); | ||
| 70 | + | ||
| 71 | + it('scope select shows 全部用户 only (D2)', () => { | ||
| 72 | + setup(); | ||
| 73 | + const scope = within(screen.getByTestId('filter-scope')); | ||
| 74 | + expect(scope.getByText('全部用户')).toBeInTheDocument(); | ||
| 75 | + }); | ||
| 76 | + | ||
| 77 | + it('Enter in value triggers onSearch (BR7)', async () => { | ||
| 78 | + const user = userEvent.setup(); | ||
| 79 | + const { onSearch } = setup(); | ||
| 80 | + const input = screen.getByTestId('filter-query-value').querySelector('input')!; | ||
| 81 | + input.focus(); | ||
| 82 | + await user.keyboard('{Enter}'); | ||
| 83 | + expect(onSearch).toHaveBeenCalledTimes(1); | ||
| 84 | + }); | ||
| 85 | + | ||
| 86 | + it('click 搜索 calls onSearch / click 清空 calls onClear (BR7/BR10)', async () => { | ||
| 87 | + const user = userEvent.setup(); | ||
| 88 | + const { onSearch, onClear } = setup(); | ||
| 89 | + await user.click(screen.getByTestId('btn-search')); | ||
| 90 | + expect(onSearch).toHaveBeenCalledTimes(1); | ||
| 91 | + await user.click(screen.getByTestId('btn-clear')); | ||
| 92 | + expect(onClear).toHaveBeenCalledTimes(1); | ||
| 93 | + }); | ||
| 94 | + | ||
| 95 | + it('typing value calls onChangeQueryValue', async () => { | ||
| 96 | + const user = userEvent.setup(); | ||
| 97 | + const { onChangeQueryValue } = setup(); | ||
| 98 | + const input = screen.getByTestId('filter-query-value').querySelector('input')!; | ||
| 99 | + await user.type(input, 'x'); | ||
| 100 | + expect(onChangeQueryValue).toHaveBeenCalled(); | ||
| 101 | + expect(onChangeQueryValue).toHaveBeenLastCalledWith('x'); | ||
| 102 | + }); | ||
| 103 | + | ||
| 104 | + it('changing query field select calls onChangeQueryField', async () => { | ||
| 105 | + const user = userEvent.setup(); | ||
| 106 | + const { onChangeQueryField } = setup(); | ||
| 107 | + const combobox = within(screen.getByTestId('filter-query-field')).getByRole('combobox'); | ||
| 108 | + await user.click(combobox); | ||
| 109 | + const options = await screen.findAllByRole('option'); | ||
| 110 | + const target = options.find((o) => o.textContent === '员工名')!; | ||
| 111 | + await user.click(target); | ||
| 112 | + // AntD Select.onChange 透传 (value, option),回调首参即选中值 | ||
| 113 | + expect(onChangeQueryField).toHaveBeenCalled(); | ||
| 114 | + expect(onChangeQueryField.mock.calls[0][0]).toBe('员工名'); | ||
| 115 | + }); | ||
| 116 | + | ||
| 117 | + it('more toggle ▾ is placeholder (no extra callback) (D3)', async () => { | ||
| 118 | + const user = userEvent.setup(); | ||
| 119 | + const { onSearch, onChangeQueryField } = setup(); | ||
| 120 | + await user.click(screen.getByTestId('filter-more')); | ||
| 121 | + expect(onSearch).not.toHaveBeenCalled(); | ||
| 122 | + expect(onChangeQueryField).not.toHaveBeenCalled(); | ||
| 123 | + }); | ||
| 124 | +}); |