Files
opsxsql/frontend/src/pages/UsersPage.tsx
T
Logan.Li ac498c7ba2 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
2026-10-26 17:54:15 +08:00

240 lines
10 KiB
TypeScript

import { useState } from 'react';
import { Plus, Upload, Download, FileText } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { userApi } from '@/api/user';
import { toast } from '@/hooks/useToast';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { useQueryState, asString, asNumber } from '@/hooks/useQueryState';
import { useUsersList } from '@/features/rbac/hooks/useUsersList';
import { useUserSelection } from '@/features/rbac/hooks/useUserSelection';
import { exportUsersToCSV } from '@/features/rbac/utils';
import type { BatchActionKey } from '@/features/rbac/constants';
import type { UserItem } from '@/features/rbac/types';
import { UserFilters } from '@/features/rbac/components/UserFilters';
import { BatchActionBar } from '@/features/rbac/components/BatchActionBar';
import { UserTable } from '@/features/rbac/components/UserTable';
import { CreateUserModal } from '@/features/rbac/components/CreateUserModal';
import { EditUserModal } from '@/features/rbac/components/EditUserModal';
import { ResetPasswordModal } from '@/features/rbac/components/ResetPasswordModal';
import { UserDetailModal } from '@/features/rbac/components/UserDetailModal';
import { BatchImportModal } from '@/features/rbac/components/BatchImportModal';
/* ───────── 用户管理页(编排:筛选/表格/弹窗均拆分至 features/rbac) ───────── */
const PAGE_SIZE = 20;
/** 列表接口参与的筛选键(空态「清除筛选条件」仅清这三项,保留来源与页码) */
const LIST_FILTER_KEYS = ['q', 'role', 'status'] as const;
export default function UsersPage() {
// 筛选/分页状态迁移到 URL query:刷新、分享链接、回退均可恢复
const [search, setSearch] = useQueryState('q', '', asString);
const [roleFilter, setRoleFilter] = useQueryState('role', '', asString);
const [statusFilter, setStatusFilter] = useQueryState('status', '', asString);
const [sourceFilter, setSourceFilter] = useQueryState('source', '', asString);
const [page, setPage] = useQueryState('page', 1, asNumber);
// 注意:react-router 的 setSearchParams 函数式更新基于渲染期快照,
// 同一事件内多次调用会互相覆盖,批量重置必须合并为一次导航
const [, setSearchParams] = useSearchParams();
const [showCreate, setShowCreate] = useState(false);
const [showDetail, setShowDetail] = useState<UserItem | null>(null);
const [showEdit, setShowEdit] = useState<UserItem | null>(null);
const [showResetPwd, setShowResetPwd] = useState<UserItem | null>(null);
const [showImport, setShowImport] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<{msg: string; onConfirm: () => void} | null>(null);
const { users, total, loading, fetchUsers } = useUsersList({
page, pageSize: PAGE_SIZE, search, roleFilter, statusFilter,
});
// 来源筛选在前端执行(不参与列表请求,保持原行为)
const filtered = users.filter(u => {
if (sourceFilter && u.auth_source !== sourceFilter) return false;
return true;
});
const { selectedIds, setSelectedIds, allSelected, toggleSelectAll, toggleSelect } = useUserSelection(filtered);
/* ───────── 筛选重置(单次导航,等价于原「清空全部筛选并回到第 1 页」) ───────── */
const resetFilters = () => setSearchParams({}, { replace: true });
/* ───────── 空态「清除筛选条件」:仅清关键字/角色/状态,保留来源与页码 ───────── */
const clearListFilters = () => setSearchParams(prev => {
const params = new URLSearchParams(prev);
LIST_FILTER_KEYS.forEach(key => params.delete(key));
return params;
}, { replace: true });
const handleToggleActive = async (user: UserItem) => {
try {
await userApi.update(user.id, { is_active: !user.is_active });
toast('success', `用户已${!user.is_active ? '启用' : '禁用'}`);
fetchUsers();
} catch { toast('error', '操作失败'); }
};
const handleDelete = (id: number) => {
setDeleteConfirm({msg: '确定删除该用户?此操作不可恢复。', onConfirm: async () => {
try {
await userApi.delete(id);
setSelectedIds(prev => { const n = new Set(prev); n.delete(id); return n; });
toast('success', '用户已删除');
fetchUsers();
} catch { toast('error', '删除失败'); }
}});
};
const handleBatchAction = async (action: BatchActionKey) => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
if (action === 'delete') {
setDeleteConfirm({
msg: `确定删除选中的 ${ids.length} 个用户?此操作不可恢复。`,
onConfirm: async () => {
try {
await Promise.all(ids.map(id => userApi.delete(id)));
toast('success', `已删除 ${ids.length} 个用户`);
setSelectedIds(new Set());
fetchUsers();
} catch { toast('error', '批量删除失败'); }
},
});
return;
}
try {
if (action === 'enable') {
await Promise.all(ids.map(id => userApi.update(id, { is_active: true })));
toast('success', `已启用 ${ids.length} 个用户`);
} else if (action === 'disable') {
await Promise.all(ids.map(id => userApi.update(id, { is_active: false })));
toast('success', `已禁用 ${ids.length} 个用户`);
}
setSelectedIds(new Set());
fetchUsers();
} catch { toast('error', '批量操作失败'); }
};
const handleDownloadTemplate = async () => {
try {
const blob = await userApi.downloadTemplate() as any;
const url = URL.createObjectURL(new Blob([blob], { type: 'text/csv' }));
const a = document.createElement('a');
a.href = url;
a.download = 'users_import_template.csv';
a.click();
URL.revokeObjectURL(url);
} catch { toast('error', '下载模板失败'); }
};
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">用户管理</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">管理系统用户,分配角色和数据库访问权限</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleDownloadTemplate}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
>
<FileText className="w-4 h-4" /> 模板
</button>
<button
onClick={() => setShowImport(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
>
<Upload className="w-4 h-4" /> 导入
</button>
<button
onClick={() => exportUsersToCSV(filtered)}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
>
<Download className="w-4 h-4" /> 导出
</button>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-blue)] rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
>
<Plus className="w-4 h-4" /> 新建用户
</button>
</div>
</div>
{/* Filters */}
<UserFilters
search={search}
roleFilter={roleFilter}
statusFilter={statusFilter}
sourceFilter={sourceFilter}
onSearchChange={setSearch}
onRoleFilterChange={setRoleFilter}
onStatusFilterChange={setStatusFilter}
onSourceFilterChange={setSourceFilter}
onReset={resetFilters}
/>
{/* Batch Actions Bar */}
{selectedIds.size > 0 && (
<BatchActionBar selectedCount={selectedIds.size} onAction={handleBatchAction} />
)}
{/* User Table */}
<UserTable
users={filtered}
loading={loading}
total={total}
page={page}
pageSize={PAGE_SIZE}
selectedIds={selectedIds}
allSelected={allSelected}
showClearFilters={!!(search || roleFilter || statusFilter)}
onToggleSelectAll={toggleSelectAll}
onToggleSelect={toggleSelect}
onShowDetail={setShowDetail}
onShowEdit={setShowEdit}
onShowResetPassword={setShowResetPwd}
onToggleActive={handleToggleActive}
onDelete={handleDelete}
onClearFilters={clearListFilters}
onPageChange={setPage}
/>
{/* Create Modal */}
{showCreate && (
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} />
)}
{/* Detail Modal */}
{showDetail && (
<UserDetailModal user={showDetail} onClose={() => setShowDetail(null)} />
)}
{/* Edit Modal */}
{showEdit && (
<EditUserModal user={showEdit} onClose={() => setShowEdit(null)} onUpdated={() => { setShowEdit(null); fetchUsers(); }} />
)}
{/* Reset Password Modal */}
{showResetPwd && (
<ResetPasswordModal user={showResetPwd} onClose={() => setShowResetPwd(null)} />
)}
{/* Batch Import Modal */}
{showImport && (
<BatchImportModal onClose={() => setShowImport(false)} onImported={() => { setShowImport(false); fetchUsers(); }} />
)}
<ConfirmDialog
open={!!deleteConfirm}
title={deleteConfirm?.msg || '确认操作'}
variant="danger"
confirmText="确定"
onConfirm={() => { deleteConfirm?.onConfirm(); setDeleteConfirm(null); }}
onCancel={() => setDeleteConfirm(null)}
/>
</div>
);
}