refactor(frontend): split 7 oversized pages into feature co-location
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
This commit is contained in:
1 parent
ac563d1c5e
commit
ac498c7ba2
92 files changed
+8224
-6146
No files matched your search
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 用途:用户列表表格(多选、行内启用/禁用开关、详情/编辑/重置密码/删除入口)+ 分页底栏
|
||||
* 必需 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user