UserListPage.tsx 10 KB
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams, useLocation } from 'react-router-dom'
import { useAppDispatch } from '../../store/hooks'
import { openTab, updateTabPath } from '../../store/slices/tabsSlice'
import { PermButton } from '../../components/PermButton'
import { getUserList } from '../../api/usr'
import type { PageVO, UserListItemVO } from '../../api/usr'

const QUERY_FIELDS = [
  { value: 'username', label: '用户名' },
  { value: 'staffName', label: '员工名' },
  { value: 'userCode', label: '用户号' },
  { value: 'department', label: '部门' },
  { value: 'userType', label: '用户类型' },
  { value: 'disabled', label: '作废' },
  { value: 'lastLoginDate', label: '登录日期' },
  { value: 'creator', label: '制单人' },
]

const MATCH_TYPES = [
  { value: 'contains', label: '包含' },
  { value: 'notContains', label: '不包含' },
  { value: 'equals', label: '等于' },
]

// REQ-USR-003: 查询用户
// REQ-USR-001: 新增入口 (navigate to /usr/users/new)
// REQ-USR-002: 修改入口 (double-click row → /usr/users/:id)
export default function UserListPage() {
  const navigate = useNavigate()
  const dispatch = useAppDispatch()
  const location = useLocation()
  const [searchParams, setSearchParams] = useSearchParams()

  const [queryField, setQueryField] = useState(searchParams.get('queryField') ?? 'username')
  const [matchType, setMatchType] = useState(searchParams.get('matchType') ?? 'contains')
  const [queryValue, setQueryValue] = useState(searchParams.get('queryValue') ?? '')
  const [currentPage, setCurrentPage] = useState(Number(searchParams.get('page') ?? '1'))
  const [data, setData] = useState<PageVO<UserListItemVO> | null>(null)
  const [selectedId, setSelectedId] = useState<string | null>(null)

  useEffect(() => {
    dispatch(openTab({ id: 'userlist', title: '用户列表', path: location.pathname + location.search, closable: true }))
    doLoad(currentPage, queryField, matchType, queryValue)
  }, [])

  function doLoad(pg: number, field: string, match: string, val: string) {
    setCurrentPage(pg)
    getUserList({ queryField: field, matchType: match, queryValue: val, page: pg, pageSize: 20 }).then(setData)
  }

  function handleSearch() {
    const newPath = `/usr/users?queryField=${queryField}&matchType=${matchType}&queryValue=${encodeURIComponent(queryValue)}`
    setSearchParams({ queryField, matchType, queryValue, page: '1' })
    dispatch(updateTabPath({ id: 'userlist', path: newPath }))
    doLoad(1, queryField, matchType, queryValue)
  }

  function handleClear() {
    setQueryField('username'); setMatchType('contains'); setQueryValue('')
    setSearchParams({})
    dispatch(updateTabPath({ id: 'userlist', path: '/usr/users' }))
    doLoad(1, 'username', 'contains', '')
  }

  const totalPages = data ? Math.ceil(data.total / 20) : 1

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
      {/* Toolbar */}
      <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 }}>
        <button onClick={() => doLoad(currentPage, queryField, matchType, queryValue)} style={tbBtn}>
          <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>
          刷新
        </button>
        <PermButton permission="usr:create" onClick={() => navigate('/usr/users/new')} style={tbBtn}>
          <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>
          新增
        </PermButton>
        <button style={tbBtn}>
          <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>
          导出Excel
        </button>
        <span style={{ flex: 1 }} />
        <span style={{ padding: '6px 8px', cursor: 'pointer', color: '#cfd2d8' }}>⚙</span>
      </div>
      {/* Filter bar */}
      <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 }}>
        <select value={queryField} onChange={e => setQueryField(e.target.value)} style={fSel}>
          {QUERY_FIELDS.map(f => <option key={f.value} value={f.value}>{f.label}</option>)}
        </select>
        <select value={matchType} onChange={e => setMatchType(e.target.value)} style={fSel}>
          {MATCH_TYPES.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
        </select>
        <input value={queryValue} onChange={e => setQueryValue(e.target.value)} style={fIn} />
        <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>
        <button onClick={handleSearch} style={fBtn}>
          <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>
          搜索
        </button>
        <button onClick={handleClear} style={{ ...fBtn, background: '#fff', color: '#444', borderColor: '#cfd3da' }}>⊗ 清空</button>
      </div>
      {/* Table */}
      <div style={{ flex: 1, overflow: 'auto', background: '#fff', border: '1px solid var(--color-table-border)', borderTop: 'none' }}>
        <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
          <thead>
            <tr>
              {['', '序号', '用户名', '员工名', '用户号', '部门', '用户类型', '语言', '作废', '登录日期', '制单人', '制单日期'].map((h, i) => (
                <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 }}>
                  {h}{i > 1 && <span style={{ color: '#aaa', marginLeft: 4 }}>⇅ ⌕</span>}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {(data?.list ?? []).map((row, idx) => (
              <tr
                key={row.sId}
                onClick={() => setSelectedId(row.sId)}
                onDoubleClick={() => navigate(`/usr/users/${row.sId}`, { state: row })}
                style={{ background: row.sId === selectedId ? 'var(--color-table-row-bg-selected)' : idx % 2 === 1 ? 'var(--color-table-row-alt)' : '#fff', cursor: 'pointer' }}
              >
                <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>
                <td style={td}>{idx + 1}</td>
                <td style={td}>{row.sUsername}</td>
                <td style={td}>{row.sStaffName ?? ''}</td>
                <td style={td}>{row.sUserCode}</td>
                <td style={td}>{row.sDepartment ?? ''}</td>
                <td style={td}>{row.sUserType}</td>
                <td style={td}>{row.sLanguage}</td>
                <td style={td}>{row.bIsDisabled ? '是' : ''}</td>
                <td style={td}>{row.tLastLoginDate ?? ''}</td>
                <td style={td}>{row.sCreatorUsername ?? ''}</td>
                <td style={td}>{row.tCreateDate}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      {/* Pager */}
      <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 }}>
        <span>当前显示 共{data?.total ?? 0}条记录</span>
        <button onClick={() => currentPage > 1 && doLoad(currentPage - 1, queryField, matchType, queryValue)} style={pgBtn}>‹</button>
        <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>
        <button onClick={() => currentPage < totalPages && doLoad(currentPage + 1, queryField, matchType, queryValue)} style={pgBtn}>›</button>
        <select defaultValue="10000" style={{ height: 28, border: '1px solid #d5d8de', borderRadius: 2, padding: '0 8px' }}>
          <option value="10000">10000 条/页</option>
        </select>
      </div>
    </div>
  )
}

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' }
const fSel: React.CSSProperties = { height: 30, border: '1px solid #d5d8de', borderRadius: 2, padding: '0 28px 0 10px', background: '#fff', minWidth: 100 }
const fIn: React.CSSProperties = { height: 30, border: '1px solid #d5d8de', borderRadius: 2, padding: '0 10px', minWidth: 140, fontSize: 13, fontFamily: 'inherit' }
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' }
const td: React.CSSProperties = { padding: '7px 10px', textAlign: 'left', whiteSpace: 'nowrap', border: '1px solid var(--color-table-border)' }
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' }