Commit a353c5a4d8af8a9e37f5338c37e13fe59f19cb00

Authored by zichun
1 parent 7241d98b

feat(usr): 用户列表筛选栏 UserFilterBar REQ-USR-003

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 +});