Per guide ch.7 page split pattern (skeleton orchestrates, private components live in features/<domain>/): - InstancePage 1716->328, UsersPage 1309->240, TicketsPage 1076->195, TicketDetailPage 911->287, QueriesPage 883->479, LdapConfigPage 860->393, WorkflowPage 852->194 - new feature modules: datasource/ rbac/ ticket/ query/ system/ldap/ governance/workflow/ (components + hooks + types + utils) - stable keys replace index keys in dynamic lists; polling intervals and toast call sites preserved 1:1 (verified by agents) - filter/pagination state moved to URL query via new useQueryState hook (Instance/Users/Tickets/AuditLogs pages): refresh/share/back-button restore state; composite updates applied atomically - permission pure functions extracted to lib/permissions.ts (used by useAuth + useLogin); page-size audit: 0 pages over 700-line budget
223 lines
11 KiB
TypeScript
223 lines
11 KiB
TypeScript
/**
|
|
* 用途:用户列表表格(多选、行内启用/禁用开关、详情/编辑/重置密码/删除入口)+ 分页底栏
|
|
* 必需 props:users / loading / total / page / pageSize / selectedIds / allSelected 及各事件回调
|
|
* 已知限制:仅 loading 且列表为空时显示加载态;空态「清除筛选条件」由 showClearFilters 控制显隐
|
|
*/
|
|
|
|
import {
|
|
CheckSquare, Square, Search, Eye, Edit2, Trash2, KeyRound,
|
|
ChevronLeft, ChevronRight,
|
|
} from 'lucide-react';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { roleNameMap, roleTagStyle } from '../constants';
|
|
import type { UserItem } from '../types';
|
|
|
|
interface UserTableProps {
|
|
users: UserItem[];
|
|
loading: boolean;
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
selectedIds: Set<number>;
|
|
allSelected: boolean;
|
|
showClearFilters: boolean;
|
|
onToggleSelectAll: () => void;
|
|
onToggleSelect: (id: number) => void;
|
|
onShowDetail: (user: UserItem) => void;
|
|
onShowEdit: (user: UserItem) => void;
|
|
onShowResetPassword: (user: UserItem) => void;
|
|
onToggleActive: (user: UserItem) => void;
|
|
onDelete: (id: number) => void;
|
|
onClearFilters: () => void;
|
|
onPageChange: (page: number) => void;
|
|
}
|
|
|
|
export function UserTable({
|
|
users,
|
|
loading,
|
|
total,
|
|
page,
|
|
pageSize,
|
|
selectedIds,
|
|
allSelected,
|
|
showClearFilters,
|
|
onToggleSelectAll,
|
|
onToggleSelect,
|
|
onShowDetail,
|
|
onShowEdit,
|
|
onShowResetPassword,
|
|
onToggleActive,
|
|
onDelete,
|
|
onClearFilters,
|
|
onPageChange,
|
|
}: UserTableProps) {
|
|
return (
|
|
<Card className="dg-card">
|
|
<CardContent className="p-0">
|
|
{loading && users.length === 0 ? (
|
|
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
|
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
|
加载中...
|
|
</div>
|
|
) : users.length === 0 ? (
|
|
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
|
<Search className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
|
<p className="text-sm">没有找到匹配的用户</p>
|
|
{showClearFilters && (
|
|
<button
|
|
onClick={onClearFilters}
|
|
className="text-xs text-blue-400 hover:text-blue-300 mt-2 transition-colors"
|
|
>
|
|
清除筛选条件
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
|
<th className="text-left px-3 py-3 w-10">
|
|
<button onClick={onToggleSelectAll} className="hover:opacity-70 transition-opacity">
|
|
{allSelected ? <CheckSquare className="w-4 h-4 text-blue-400" /> : <Square className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
|
</button>
|
|
</th>
|
|
<th className="text-left px-4 py-3 font-semibold">用户</th>
|
|
<th className="text-left px-4 py-3 font-semibold">角色</th>
|
|
<th className="text-left px-4 py-3 font-semibold">邮箱</th>
|
|
<th className="text-left px-4 py-3 font-semibold">最后登录</th>
|
|
<th className="text-left px-4 py-3 font-semibold">状态</th>
|
|
<th className="text-left px-4 py-3 font-semibold">来源</th>
|
|
<th className="text-left px-4 py-3 font-semibold">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map(user => {
|
|
const isSelected = selectedIds.has(user.id);
|
|
return (
|
|
<tr
|
|
key={user.id}
|
|
className={`border-b border-[var(--dg-border-light)] transition-colors ${
|
|
isSelected ? 'bg-blue-500/5' : 'hover:bg-[var(--dg-bg-hover)]'
|
|
}`}
|
|
>
|
|
<td className="px-3 py-3.5">
|
|
<button onClick={() => onToggleSelect(user.id)} className="hover:opacity-70 transition-opacity">
|
|
{isSelected ? <CheckSquare className="w-4 h-4 text-blue-400" /> : <Square className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
|
</button>
|
|
</td>
|
|
<td className="px-4 py-3.5">
|
|
<div className="flex items-center gap-3 cursor-pointer" onClick={() => onShowDetail(user)}>
|
|
<div>
|
|
<div className="font-medium hover:text-blue-400 transition-colors">{user.display_name || user.username}</div>
|
|
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3.5">
|
|
<span className={`inline-flex px-2.5 py-1 rounded-md text-xs font-medium ${roleTagStyle[user.role] || 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]'}`}>
|
|
{roleNameMap[user.role] || user.role}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3.5 text-[var(--dg-text-secondary)]">{user.email || '-'}</td>
|
|
<td className="px-4 py-3.5 text-[var(--dg-text-secondary)] text-xs">
|
|
{user.last_login
|
|
? new Date(user.last_login).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
: '-'}
|
|
</td>
|
|
<td className="px-4 py-3.5">
|
|
<button
|
|
onClick={() => onToggleActive(user)}
|
|
className={`relative w-11 h-6 rounded-full transition-colors ${user.is_active ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}
|
|
title={user.is_active ? '点击禁用' : '点击启用'}
|
|
aria-label={user.is_active ? '禁用用户' : '启用用户'}
|
|
>
|
|
<div
|
|
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all"
|
|
style={{ transform: user.is_active ? 'translateX(20px)' : 'translateX(2px)' }}
|
|
/>
|
|
</button>
|
|
</td>
|
|
<td className="px-4 py-3.5">
|
|
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${
|
|
user.auth_source === 'ldap'
|
|
? 'bg-blue-500/15 text-blue-400'
|
|
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'
|
|
}`}>
|
|
{user.auth_source === 'ldap' ? 'LDAP' : '本地'}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3.5">
|
|
<div className="flex gap-1.5">
|
|
<button
|
|
onClick={() => onShowDetail(user)}
|
|
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
|
title="查看详情"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => onShowEdit(user)}
|
|
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
|
title="编辑"
|
|
>
|
|
<Edit2 className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => user.auth_source !== 'ldap' && onShowResetPassword(user)}
|
|
className={`p-1.5 rounded-md transition-colors ${user.auth_source === 'ldap' ? 'opacity-30 cursor-not-allowed' : 'hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-yellow-400'}`}
|
|
title={user.auth_source === 'ldap' ? 'LDAP 用户密码由 LDAP 服务器管理' : '重置密码'}
|
|
disabled={user.auth_source === 'ldap'}
|
|
>
|
|
<KeyRound className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
className="p-1.5 rounded-md hover:bg-red-500/10 text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"
|
|
onClick={() => onDelete(user.id)}
|
|
title="删除"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
|
|
<span className="text-sm text-[var(--dg-text-muted)]">
|
|
共 <strong className="text-[var(--dg-text-primary)]">{total}</strong> 条
|
|
{selectedIds.size > 0 && `,已选择 ${selectedIds.size} 项`}
|
|
</span>
|
|
{total > pageSize && (
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
|
disabled={page <= 1}
|
|
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
<span className="text-sm text-[var(--dg-text-secondary)] px-2">
|
|
{page} / {Math.ceil(total / pageSize)}
|
|
</span>
|
|
<button
|
|
onClick={() => onPageChange(Math.min(Math.ceil(total / pageSize), page + 1))}
|
|
disabled={page >= Math.ceil(total / pageSize)}
|
|
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|