Commit 11778b8c72a92be15810a3af9f79c1758771e137

Authored by zichun
1 parent 4a9044d7

feat(usr): rewrite UserListPage visual + URL filter sync REQ-USR-003

frontend/src/pages/usr/UserListPage.tsx
1   -import { useState, useEffect } from 'react'
2   -import { Table, Select, Input, Button, Space } from 'antd'
3   -import type { ColumnsType } from 'antd/es/table'
  1 +import { useEffect, useState } from 'react'
  2 +import { useNavigate, useSearchParams, useLocation } from 'react-router-dom'
  3 +import { useAppDispatch } from '../../store/hooks'
  4 +import { openTab, updateTabPath } from '../../store/slices/tabsSlice'
4 5 import { PermButton } from '../../components/PermButton'
5   -import UserFormDrawer from './UserFormDrawer'
6 6 import { getUserList } from '../../api/usr'
7 7 import type { PageVO, UserListItemVO } from '../../api/usr'
8 8  
... ... @@ -24,110 +24,136 @@ const MATCH_TYPES = [
24 24 ]
25 25  
26 26 // REQ-USR-003: 查询用户
27   -// REQ-USR-002: 修改用户
  27 +// REQ-USR-001: 新增入口 (navigate to /usr/users/new)
  28 +// REQ-USR-002: 修改入口 (double-click row → /usr/users/:id)
28 29 export default function UserListPage() {
29   - const [drawerOpen, setDrawerOpen] = useState(false)
30   - const [editingUser, setEditingUser] = useState<UserListItemVO | null>(null)
  30 + const navigate = useNavigate()
  31 + const dispatch = useAppDispatch()
  32 + const location = useLocation()
  33 + const [searchParams, setSearchParams] = useSearchParams()
  34 +
  35 + const [queryField, setQueryField] = useState(searchParams.get('queryField') ?? 'username')
  36 + const [matchType, setMatchType] = useState(searchParams.get('matchType') ?? 'contains')
  37 + const [queryValue, setQueryValue] = useState(searchParams.get('queryValue') ?? '')
  38 + const [currentPage, setCurrentPage] = useState(Number(searchParams.get('page') ?? '1'))
31 39 const [data, setData] = useState<PageVO<UserListItemVO> | null>(null)
32   - const [queryField, setQueryField] = useState('username')
33   - const [matchType, setMatchType] = useState('contains')
34   - const [queryValue, setQueryValue] = useState('')
35   - const [currentPage, setCurrentPage] = useState(1)
  40 + const [selectedId, setSelectedId] = useState<string | null>(null)
  41 +
  42 + useEffect(() => {
  43 + dispatch(openTab({ id: 'userlist', title: '用户列表', path: location.pathname + location.search, closable: true }))
  44 + doLoad(currentPage, queryField, matchType, queryValue)
  45 + }, [])
36 46  
37   - const load = (pg = 1) => {
  47 + function doLoad(pg: number, field: string, match: string, val: string) {
38 48 setCurrentPage(pg)
39   - getUserList({ queryField, matchType, queryValue, page: pg, pageSize: 20 }).then(setData)
  49 + getUserList({ queryField: field, matchType: match, queryValue: val, page: pg, pageSize: 20 }).then(setData)
40 50 }
41 51  
42   - useEffect(() => {
43   - load()
44   - }, [])
  52 + function handleSearch() {
  53 + const newPath = `/usr/users?queryField=${queryField}&matchType=${matchType}&queryValue=${encodeURIComponent(queryValue)}`
  54 + setSearchParams({ queryField, matchType, queryValue, page: '1' })
  55 + dispatch(updateTabPath({ id: 'userlist', path: newPath }))
  56 + doLoad(1, queryField, matchType, queryValue)
  57 + }
45 58  
46   - const columns: ColumnsType<UserListItemVO> = [
47   - { title: '用户名', dataIndex: 'sUsername' },
48   - { title: '员工名', dataIndex: 'sStaffName' },
49   - { title: '用户号', dataIndex: 'sUserCode' },
50   - { title: '部门', dataIndex: 'sDepartment' },
51   - { title: '用户类型', dataIndex: 'sUserType' },
52   - { title: '语言', dataIndex: 'sLanguage' },
53   - { title: '作废', dataIndex: 'bIsDisabled', render: (v: number) => v ? '是' : '否' },
54   - { title: '登录日期', dataIndex: 'tLastLoginDate' },
55   - { title: '制单人', dataIndex: 'sCreatorUsername' },
56   - { title: '制单日期', dataIndex: 'tCreateDate' },
57   - {
58   - title: '操作',
59   - key: 'action',
60   - render: (_, record) => (
61   - <PermButton
62   - permission="usr:edit"
63   - type="link"
64   - onClick={() => setEditingUser(record)}
65   - >
66   - 修改
67   - </PermButton>
68   - )
69   - },
70   - ]
  59 + function handleClear() {
  60 + setQueryField('username'); setMatchType('contains'); setQueryValue('')
  61 + setSearchParams({})
  62 + dispatch(updateTabPath({ id: 'userlist', path: '/usr/users' }))
  63 + doLoad(1, 'username', 'contains', '')
  64 + }
  65 +
  66 + const totalPages = data ? Math.ceil(data.total / 20) : 1
71 67  
72 68 return (
73   - <div>
74   - <Space style={{ marginBottom: 16 }} wrap>
75   - <Select
76   - value={queryField}
77   - onChange={setQueryField}
78   - options={QUERY_FIELDS}
79   - style={{ width: 120 }}
80   - />
81   - <Select
82   - value={matchType}
83   - onChange={setMatchType}
84   - options={MATCH_TYPES}
85   - style={{ width: 100 }}
86   - />
87   - <Input
88   - value={queryValue}
89   - onChange={e => setQueryValue(e.target.value)}
90   - placeholder="查询值"
91   - style={{ width: 160 }}
92   - />
93   - <Button type="primary" onClick={() => load(1)}>搜索</Button>
94   - <PermButton
95   - permission="usr:create"
96   - type="primary"
97   - onClick={() => setDrawerOpen(true)}
98   - >
  69 + <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
  70 + {/* Toolbar */}
  71 + <div style={{ background: 'var(--color-toolbar-bg)', color: 'var(--color-toolbar-text)', display: 'flex', alignItems: 'center', gap: 6, padding: '0 8px', height: 38, flexShrink: 0 }}>
  72 + <button onClick={() => doLoad(currentPage, queryField, matchType, queryValue)} style={tbBtn}>
  73 + <svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/></svg>
  74 + 刷新
  75 + </button>
  76 + <PermButton permission="usr:create" onClick={() => navigate('/usr/users/new')} style={tbBtn}>
  77 + <svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><circle cx={12} cy={12} r={9}/><path d="M12 8v8M8 12h8"/></svg>
99 78 新增
100 79 </PermButton>
101   - </Space>
102   - <Table
103   - dataSource={data?.list ?? []}
104   - columns={columns}
105   - rowKey="sId"
106   - pagination={{
107   - total: data?.total ?? 0,
108   - pageSize: 20,
109   - current: currentPage,
110   - onChange: load,
111   - }}
112   - />
113   - <UserFormDrawer
114   - open={drawerOpen}
115   - onClose={() => setDrawerOpen(false)}
116   - onSuccess={() => { setDrawerOpen(false); load(1) }}
117   - />
118   - <UserFormDrawer
119   - open={editingUser !== null}
120   - userId={editingUser?.sId}
121   - initialData={editingUser ? {
122   - userType: editingUser.sUserType,
123   - language: editingUser.sLanguage,
124   - canEditDoc: editingUser.bCanEditDoc === 1,
125   - isDisabled: editingUser.bIsDisabled === 1,
126   - employeeId: null,
127   - } : undefined}
128   - onClose={() => setEditingUser(null)}
129   - onSuccess={() => { setEditingUser(null); load(1) }}
130   - />
  80 + <button style={tbBtn}>
  81 + <svg width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path d="M4 4h12l4 4v12H4z"/><path d="M16 4v4h4M8 12h8M8 16h8"/></svg>
  82 + 导出Excel
  83 + </button>
  84 + <span style={{ flex: 1 }} />
  85 + <span style={{ padding: '6px 8px', cursor: 'pointer', color: '#cfd2d8' }}>⚙</span>
  86 + </div>
  87 + {/* Filter bar */}
  88 + <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', background: 'var(--color-filterbar-bg)', borderBottom: '1px solid var(--color-table-border)', flexShrink: 0 }}>
  89 + <select value={queryField} onChange={e => setQueryField(e.target.value)} style={fSel}>
  90 + {QUERY_FIELDS.map(f => <option key={f.value} value={f.value}>{f.label}</option>)}
  91 + </select>
  92 + <select value={matchType} onChange={e => setMatchType(e.target.value)} style={fSel}>
  93 + {MATCH_TYPES.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
  94 + </select>
  95 + <input value={queryValue} onChange={e => setQueryValue(e.target.value)} style={fIn} />
  96 + <span style={{ width: 34, height: 30, background: '#dfe5ee', border: '1px solid #d5d8de', display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 2, cursor: 'pointer', color: '#3776c8' }}>▾</span>
  97 + <button onClick={handleSearch} style={fBtn}>
  98 + <svg width={13} height={13} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><circle cx={11} cy={11} r={7}/><line x1={21} y1={21} x2={16.5} y2={16.5}/></svg>
  99 + 搜索
  100 + </button>
  101 + <button onClick={handleClear} style={{ ...fBtn, background: '#fff', color: '#444', borderColor: '#cfd3da' }}>⊗ 清空</button>
  102 + </div>
  103 + {/* Table */}
  104 + <div style={{ flex: 1, overflow: 'auto', background: '#fff', border: '1px solid var(--color-table-border)', borderTop: 'none' }}>
  105 + <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
  106 + <thead>
  107 + <tr>
  108 + {['', '序号', '用户名', '员工名', '用户号', '部门', '用户类型', '语言', '作废', '登录日期', '制单人', '制单日期'].map((h, i) => (
  109 + <th key={i} style={{ background: 'var(--color-table-header-bg)', fontWeight: 500, color: '#333', padding: '7px 10px', textAlign: 'left', whiteSpace: 'nowrap', border: '1px solid var(--color-table-border)', position: 'sticky', top: 0, zIndex: 1, width: i === 0 ? 32 : i === 1 ? 60 : undefined }}>
  110 + {h}{i > 1 && <span style={{ color: '#aaa', marginLeft: 4 }}>⇅ ⌕</span>}
  111 + </th>
  112 + ))}
  113 + </tr>
  114 + </thead>
  115 + <tbody>
  116 + {(data?.list ?? []).map((row, idx) => (
  117 + <tr
  118 + key={row.sId}
  119 + onClick={() => setSelectedId(row.sId)}
  120 + onDoubleClick={() => navigate(`/usr/users/${row.sId}`, { state: row })}
  121 + style={{ background: row.sId === selectedId ? 'var(--color-table-row-bg-selected)' : idx % 2 === 1 ? 'var(--color-table-row-alt)' : '#fff', cursor: 'pointer' }}
  122 + >
  123 + <td style={td}><span style={{ width: 14, height: 14, border: '1px solid #b8bcc3', borderRadius: '50%', display: 'inline-block', background: row.sId === selectedId ? 'var(--color-primary)' : '#fff' }} /></td>
  124 + <td style={td}>{idx + 1}</td>
  125 + <td style={td}>{row.sUsername}</td>
  126 + <td style={td}>{row.sStaffName ?? ''}</td>
  127 + <td style={td}>{row.sUserCode}</td>
  128 + <td style={td}>{row.sDepartment ?? ''}</td>
  129 + <td style={td}>{row.sUserType}</td>
  130 + <td style={td}>{row.sLanguage}</td>
  131 + <td style={td}>{row.bIsDisabled ? '是' : ''}</td>
  132 + <td style={td}>{row.tLastLoginDate ?? ''}</td>
  133 + <td style={td}>{row.sCreatorUsername ?? ''}</td>
  134 + <td style={td}>{row.tCreateDate}</td>
  135 + </tr>
  136 + ))}
  137 + </tbody>
  138 + </table>
  139 + </div>
  140 + {/* Pager */}
  141 + <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px', background: '#fff', borderTop: '1px solid var(--color-table-border)', justifyContent: 'flex-end', fontSize: 13, color: '#555', flexShrink: 0 }}>
  142 + <span>当前显示 共{data?.total ?? 0}条记录</span>
  143 + <button onClick={() => currentPage > 1 && doLoad(currentPage - 1, queryField, matchType, queryValue)} style={pgBtn}>‹</button>
  144 + <span style={{ width: 28, height: 28, border: '1px solid var(--color-primary)', color: 'var(--color-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', borderRadius: 2 }}>{currentPage}</span>
  145 + <button onClick={() => currentPage < totalPages && doLoad(currentPage + 1, queryField, matchType, queryValue)} style={pgBtn}>›</button>
  146 + <select defaultValue="10000" style={{ height: 28, border: '1px solid #d5d8de', borderRadius: 2, padding: '0 8px' }}>
  147 + <option value="10000">10000 条/页</option>
  148 + </select>
  149 + </div>
131 150 </div>
132 151 )
133 152 }
  153 +
  154 +const tbBtn: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 12px', color: 'var(--color-toolbar-text)', cursor: 'pointer', fontSize: 13, borderRadius: 2, border: 'none', background: 'transparent', fontFamily: 'inherit' }
  155 +const fSel: React.CSSProperties = { height: 30, border: '1px solid #d5d8de', borderRadius: 2, padding: '0 28px 0 10px', background: '#fff', minWidth: 100 }
  156 +const fIn: React.CSSProperties = { height: 30, border: '1px solid #d5d8de', borderRadius: 2, padding: '0 10px', minWidth: 140, fontSize: 13, fontFamily: 'inherit' }
  157 +const fBtn: React.CSSProperties = { height: 30, padding: '0 14px', borderRadius: 2, border: '1px solid var(--color-primary)', background: 'var(--color-primary)', color: '#fff', display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }
  158 +const td: React.CSSProperties = { padding: '7px 10px', textAlign: 'left', whiteSpace: 'nowrap', border: '1px solid var(--color-table-border)' }
  159 +const pgBtn: React.CSSProperties = { width: 28, height: 28, border: '1px solid #d5d8de', background: '#fff', borderRadius: 2, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: '#666', fontFamily: 'inherit' }
... ...
frontend/src/test/UserListPage.test.tsx
... ... @@ -2,9 +2,10 @@ import { describe, it, expect, vi, beforeEach } from &#39;vitest&#39;
2 2 import { render, screen, waitFor } from '@testing-library/react'
3 3 import userEvent from '@testing-library/user-event'
4 4 import { Provider } from 'react-redux'
5   -import { MemoryRouter } from 'react-router-dom'
  5 +import { MemoryRouter, Routes, Route } from 'react-router-dom'
6 6 import { configureStore } from '@reduxjs/toolkit'
7 7 import authReducer from '../store/slices/authSlice'
  8 +import tabsReducer from '../store/slices/tabsSlice'
8 9 import UserListPage from '../pages/usr/UserListPage'
9 10  
10 11 vi.mock('../api/usr', () => ({
... ... @@ -12,41 +13,50 @@ vi.mock(&#39;../api/usr&#39;, () =&gt; ({
12 13 getPermissionGroups: vi.fn().mockResolvedValue([]),
13 14 createUser: vi.fn(),
14 15 getUserList: vi.fn().mockResolvedValue({ total: 0, page: 1, pageSize: 20, list: [] }),
15   - updateUser: vi.fn().mockResolvedValue({ userId: 'u1', username: 'alice', updatedAt: '2026-05-08T10:00:00' })
  16 + updateUser: vi.fn().mockResolvedValue({ userId: 'u1', username: 'alice', updatedAt: '2026-05-08T10:00:00' }),
16 17 }))
17 18  
18 19 vi.mock('../api/request', () => ({
19   - default: { get: vi.fn(), post: vi.fn() }
  20 + default: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
20 21 }))
21 22  
22 23 function makeStore(userType: string) {
23   - const store = configureStore({ reducer: { auth: authReducer } })
  24 + const store = configureStore({ reducer: { auth: authReducer, tabs: tabsReducer } })
24 25 store.dispatch({
25 26 type: 'auth/setCredentials',
26   - payload: {
27   - accessToken: 'test-token',
28   - refreshToken: 'test-refresh',
29   - userInfo: { userId: 'u1', username: 'admin', userType, language: '中文', brandId: 'b1' }
30   - }
  27 + payload: { accessToken: 'test-token', refreshToken: 'test-refresh', userInfo: { userId: 'u1', username: 'admin', userType, language: '中文', brandId: 'b1' } },
31 28 })
32 29 return store
33 30 }
34 31  
35   -function renderPage(userType: string) {
  32 +const sampleRow = {
  33 + sId: 'u1', sUsername: 'alice', sUserCode: 'UC001', sUserType: '普通用户',
  34 + sLanguage: '中文', bCanEditDoc: 0, bIsDisabled: 0, tLastLoginDate: null,
  35 + sCreatorUsername: 'admin', tCreateDate: '2026-01-01T00:00:00',
  36 + sStaffName: '张三', sDepartment: '研发部',
  37 +}
  38 +
  39 +function renderPage(userType: string, extraRoutes = false) {
36 40 const store = makeStore(userType)
37 41 return render(
38 42 <Provider store={store}>
39   - <MemoryRouter>
40   - <UserListPage />
  43 + <MemoryRouter initialEntries={['/usr/users']}>
  44 + {extraRoutes ? (
  45 + <Routes>
  46 + <Route path="/usr/users" element={<UserListPage />} />
  47 + <Route path="/usr/users/new" element={<div>NewUserPage</div>} />
  48 + <Route path="/usr/users/:id" element={<div>DetailPage</div>} />
  49 + </Routes>
  50 + ) : (
  51 + <UserListPage />
  52 + )}
41 53 </MemoryRouter>
42 54 </Provider>
43 55 )
44 56 }
45 57  
46 58 describe('UserListPage', () => {
47   - beforeEach(() => {
48   - vi.clearAllMocks()
49   - })
  59 + beforeEach(() => { vi.clearAllMocks() })
50 60  
51 61 it('superAdmin_seesNewButton', () => {
52 62 renderPage('超级管理员')
... ... @@ -58,23 +68,15 @@ describe(&#39;UserListPage&#39;, () =&gt; {
58 68 expect(screen.queryByRole('button', { name: /新\s*增/ })).not.toBeInTheDocument()
59 69 })
60 70  
61   - it('clickNewButton_opensDrawer', async () => {
62   - renderPage('超级管理员')
  71 + it('clickNewButton_navigatesToNewPage', async () => {
  72 + renderPage('超级管理员', true)
63 73 await userEvent.click(screen.getByRole('button', { name: /新\s*增/ }))
64   - await waitFor(() => expect(screen.getByText('新增用户')).toBeInTheDocument())
  74 + await waitFor(() => expect(screen.getByText('NewUserPage')).toBeInTheDocument())
65 75 })
66 76  
67 77 it('initialLoad_rendersTableRows', async () => {
68 78 const { getUserList } = await import('../api/usr')
69   - vi.mocked(getUserList).mockResolvedValueOnce({
70   - total: 1, page: 1, pageSize: 20,
71   - list: [{
72   - sId: 'u1', sUsername: 'alice', sUserCode: 'UC001', sUserType: '普通用户',
73   - sLanguage: '中文', bCanEditDoc: 0, bIsDisabled: 0, tLastLoginDate: null,
74   - sCreatorUsername: 'admin', tCreateDate: '2026-01-01T00:00:00',
75   - sStaffName: '张三', sDepartment: '研发部'
76   - }]
77   - })
  79 + vi.mocked(getUserList).mockResolvedValueOnce({ total: 1, page: 1, pageSize: 20, list: [sampleRow] })
78 80 renderPage('超级管理员')
79 81 await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument())
80 82 expect(screen.getByText('张三')).toBeInTheDocument()
... ... @@ -87,22 +89,12 @@ describe(&#39;UserListPage&#39;, () =&gt; {
87 89 await waitFor(() => expect(vi.mocked(getUserList)).toHaveBeenCalledTimes(2))
88 90 })
89 91  
90   - it('editMode_submit_callsUpdateUser', async () => {
91   - const { getUserList, updateUser } = await import('../api/usr')
92   - vi.mocked(getUserList).mockResolvedValue({
93   - total: 1, page: 1, pageSize: 20,
94   - list: [{
95   - sId: 'u1', sUsername: 'alice', sUserCode: 'UC001', sUserType: '普通用户',
96   - sLanguage: '中文', bCanEditDoc: 0, bIsDisabled: 0, tLastLoginDate: null,
97   - sCreatorUsername: 'admin', tCreateDate: '2026-01-01T00:00:00',
98   - sStaffName: null, sDepartment: null
99   - }]
100   - })
101   - renderPage('超级管理员')
  92 + it('doubleClickRow_navigatesToDetailPage', async () => {
  93 + const { getUserList } = await import('../api/usr')
  94 + vi.mocked(getUserList).mockResolvedValueOnce({ total: 1, page: 1, pageSize: 20, list: [sampleRow] })
  95 + renderPage('超级管理员', true)
102 96 await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument())
103   - await userEvent.click(screen.getByRole('button', { name: /修改/ }))
104   - await waitFor(() => expect(screen.getByText('修改用户')).toBeInTheDocument())
105   - await userEvent.click(screen.getByRole('button', { name: /确\s*认/ }))
106   - await waitFor(() => expect(vi.mocked(updateUser)).toHaveBeenCalledTimes(1))
  97 + await userEvent.dblClick(screen.getByText('alice').closest('tr')!)
  98 + await waitFor(() => expect(screen.getByText('DetailPage')).toBeInTheDocument())
107 99 })
108 100 })
... ...