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
240 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|