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:
Logan.Li committed 2026-10-26 17:54:15 +08:00
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>
);
}