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) */}
+
+
+
+
+ {/* 匹配方式下拉(默认包含,BR2/BR4) */}
+
+
+
+
+ {/* 查询值输入(空为全部 BR3;回车触发搜索 BR7) */}
+
+ onChangeQueryValue(e.target.value)}
+ onPressEnter={() => onSearch()}
+ allowClear
+ style={{ width: 200 }}
+ />
+
+
+ {/* 更多条件占位(D3:点击无业务回调) */}
+
+ ▾
+
+
+
}
+ onClick={() => onSearch()}
+ data-testid="btn-search"
+ >
+ {TEXT_SEARCH}
+
+
+
+ );
+}
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();
+ });
+});