casualroom/apps/fenghuo/web/app/[locale]/dashboard/organization/page.tsx

679 lines
20 KiB
TypeScript
Raw Normal View History

2025-07-28 07:50:50 +08:00
'use client';
import * as React from 'react';
import { useState, useMemo } from 'react';
import { Button } from '@nice/ui/components/button';
import { Input } from '@nice/ui/components/input';
import { Badge } from '@nice/ui/components/badge';
import { IconSearch, IconPlus, IconBuilding, IconBuildingBank, IconUsers } from '@tabler/icons-react';
import { UsersDataTable } from '@/components/organization/users-data-table';
import { UserDialog } from '@/components/organization/user-dialog';
import { PageInfo, SiteHeader } from '@/components/site-header';
import { useTRPC, useOrganization, useUser } from '@fenghuo/client';
import { useQuery } from '@tanstack/react-query';
import { toast } from '@nice/ui/components/sonner';
import { TreeSelector, type TreeNode, type TreeDragConfig } from '@/components/selector';
import { type OrganizationTreeNode, UserWithRelations, userWithRelationsSelect } from '@fenghuo/common';
import { type OrganizationDialogState, type UserDialogState } from '@/components/organization/types';
import { OrganizationDialog } from '@/components/organization/organization-dialog';
import { useSetPageInfo } from '@/components/providers/dashboard-provider';
export default function OrganizationPage() {
const trpc = useTRPC();
// 状态管理 - 将所有状态声明放在一起
const [selectedOrganization, setSelectedOrganization] = useState<TreeNode | null>(null);
const [organizationDialog, setOrganizationDialog] = useState<OrganizationDialogState>({
open: false,
});
const [userDialog, setUserDialog] = useState<UserDialogState>({
open: false,
});
const [organizationSearchQuery, setOrganizationSearchQuery] = useState('');
// 分页和筛选状态
const [pagination, setPagination] = useState({
page: 1,
pageSize: 10,
});
const [searchQuery, setSearchQuery] = useState('');
const [selectedRole, setSelectedRole] = useState('');
// 获取组织树数据(使用 tRPC
const { data: rawOrganizationTree = [], isLoading: organizationsLoading } = useQuery({
...trpc.organization.getTree.queryOptions({
includeInactive: false,
include: {
terms: {
include: {
taxonomy: true,
},
},
},
}),
});
// 将 OrganizationTreeNode 转换为与 TreeSelector 兼容的 TreeNode
const organizationTree = useMemo(() => {
const convertNode = (node: OrganizationTreeNode): TreeNode => ({
...node,
slug: node.slug ?? undefined,
description: node.description ?? undefined,
parentId: node.parentId ?? undefined,
path: node.path ?? undefined,
deletedAt: node.deletedAt ?? undefined,
children: node.children ? node.children.map(convertNode) : [],
});
return rawOrganizationTree.map(convertNode);
}, [rawOrganizationTree]);
// 构建用户查询的 where 条件
const buildUserWhereCondition = useMemo(() => {
const conditions: any = {
deletedAt: null,
OR: [{ organization: { deletedAt: null } }, { organizationId: null }],
};
// 搜索条件 - 支持用户名搜索
if (searchQuery) {
conditions.username = {
contains: searchQuery,
mode: 'insensitive', // 忽略大小写
};
}
// 角色筛选
if (selectedRole) {
conditions.userRoles = {
some: {
role: {
id: selectedRole,
},
},
};
}
// 部门筛选 - 考虑选中的部门及其子部门
if (selectedOrganization) {
// 获取选中部门及其所有子部门的ID
const findOrganizationInTree = (depts: any[], targetId: string): any => {
for (const dept of depts) {
if (dept.id === targetId) {
return dept;
}
if (dept.children) {
const found = findOrganizationInTree(dept.children, targetId);
if (found) return found;
}
}
return null;
};
const selectedDeptWithChildren = findOrganizationInTree(organizationTree, selectedOrganization.id);
if (selectedDeptWithChildren) {
const organizationIds = getAllOrganizationIds(selectedDeptWithChildren as OrganizationTreeNode);
conditions.organizationId = {
in: organizationIds,
};
} else {
conditions.organizationId = selectedOrganization.id;
}
}
return conditions;
}, [searchQuery, selectedRole, selectedOrganization, organizationTree]);
// 获取用户数据(使用服务端分页)
const { data: usersResponse, isLoading: usersLoading } = useQuery({
...trpc.user.findManyWithPagination.queryOptions({
page: pagination.page,
pageSize: pagination.pageSize,
where: buildUserWhereCondition,
select: userWithRelationsSelect,
orderBy: {
createdAt: 'desc',
},
}),
});
// 获取角色列表用于筛选选项
const { data: rolesResponse } = useQuery({
...trpc.role.findManyWithPagination.queryOptions({
page: 1,
pageSize: 100,
where: {
isActive: true,
},
}),
});
// 处理用户数据
const userData = useMemo(() => {
if (usersLoading || !usersResponse || !usersResponse.items) return [];
return usersResponse.items
}, [usersResponse, usersLoading]);
// 处理可用角色选项
const availableRoles = useMemo(() => {
if (!rolesResponse?.items) return [];
return rolesResponse.items.map((role) => ({
value: role.id,
label: role.name,
}));
}, [rolesResponse]);
// 分页信息
const paginationInfo = useMemo(() => {
if (!usersResponse) {
return {
totalPages: 0,
totalCount: 0,
hasNextPage: false,
hasPreviousPage: false,
};
}
return {
totalPages: usersResponse.totalPages,
totalCount: usersResponse.totalCount,
hasNextPage: usersResponse.hasNextPage,
hasPreviousPage: usersResponse.hasPreviousPage,
};
}, [usersResponse]);
// 过滤部门数据 - 修改为支持已有树形结构的搜索
const filteredOrganizations = React.useMemo(() => {
if (!organizationSearchQuery) return organizationTree;
const filterOrganizations = (depts: TreeNode[]): TreeNode[] => {
return depts.reduce<TreeNode[]>((acc, dept) => {
const matchesCurrent =
dept.name.toLowerCase().includes(organizationSearchQuery.toLowerCase()) ||
dept.description?.toLowerCase().includes(organizationSearchQuery.toLowerCase());
const filteredChildren = dept.children ? filterOrganizations(dept.children as TreeNode[]) : [];
// 如果当前部门匹配或有匹配的子部门,就包含这个部门
if (matchesCurrent || filteredChildren.length > 0) {
acc.push({
...dept,
children: filteredChildren,
});
}
return acc;
}, []);
};
return filterOrganizations(organizationTree);
}, [organizationTree, organizationSearchQuery]);
// 拖拽排序处理函数
const handleDragEnd = async (oldData: TreeNode[], newData: TreeNode[]) => {
// 提取排序变化
const changes = extractOrderChanges(oldData, newData);
// 如果有变化调用后端API更新排序
if (changes.length > 0) {
try {
await organizationMutations.updateDeptOrder.mutateAsync(
changes.map((change) => ({ id: change.id, order: change.newOrder })),
);
toast.success('部门排序已更新');
} catch (error: any) {
console.error('排序更新失败:', error);
toast.error('排序更新失败: ' + (error.message || '未知错误'));
}
}
};
// 提取排序变化的辅助函数
const extractOrderChanges = (oldData: TreeNode[], newData: TreeNode[]): Array<{ id: string; newOrder: number }> => {
const changes: Array<{ id: string; newOrder: number }> = [];
const compareLevel = (oldNodes: TreeNode[], newNodes: TreeNode[], level = 0) => {
newNodes.forEach((newNode, index) => {
const oldNode = oldNodes.find((n) => n.id === newNode.id);
if (oldNode && oldNode.order !== newNode.order) {
changes.push({
id: newNode.id,
newOrder: newNode.order || index,
});
}
// 递归处理子节点
if (newNode.children && oldNode?.children) {
compareLevel(oldNode.children as TreeNode[], newNode.children as TreeNode[], level + 1);
}
});
};
compareLevel(oldData, newData);
return changes;
};
// 拖拽配置
const dragConfig: TreeDragConfig<TreeNode> = {
enabled: true,
showHandle: true,
onDragEnd: handleDragEnd,
onDragStart: (event) => {
console.log('开始拖拽:', event.active.id);
},
};
// 处理分页变化
const handlePaginationChange = (newPagination: { page: number; pageSize: number }) => {
setPagination(newPagination);
};
// 处理搜索变化
const handleSearchChange = (query: string) => {
setSearchQuery(query);
// 搜索时重置到第一页
setPagination((prev) => ({ ...prev, page: 1 }));
};
// 处理角色筛选变化
const handleRoleChange = (role: string) => {
setSelectedRole(role);
// 筛选时重置到第一页
setPagination((prev) => ({ ...prev, page: 1 }));
};
// 处理部门筛选变化 - 当选中部门时自动应用筛选
React.useEffect(() => {
// 当选中部门变化时,重置到第一页
setPagination((prev) => ({ ...prev, page: 1 }));
}, [selectedOrganization]);
// 处理部门选择 - 支持取消选中
const handleOrganizationSelect = (organization: TreeNode) => {
// 如果点击的是已选中的部门,则取消选中
if (selectedOrganization && selectedOrganization.id === organization.id) {
setSelectedOrganization(null);
} else {
// 否则选中新的部门
setSelectedOrganization(organization);
}
};
// 处理编辑部门
const handleEditOrganization = (organization: TreeNode) => {
setOrganizationDialog({
open: true,
organization: organization as OrganizationTreeNode,
mode: 'edit',
});
};
// 处理删除部门
const handleDeleteOrganization = (organization: TreeNode) => {
if (confirm(`确定要删除部门 "${organization.name}" 吗?这将同时删除该部门下的所有子部门。`)) {
organizationMutations.softDeleteByIds.mutate(
{ ids: [organization.id] },
{
onSuccess: () => {
toast.success('部门删除成功');
// 如果删除的是当前选中的部门,清空选择
if (selectedOrganization?.id === organization.id) {
setSelectedOrganization(null);
}
},
onError: (error) => {
toast.error('删除失败: ' + error.message);
},
},
);
}
};
// 处理添加子部门
const handleAddChildOrganization = (parentId: string) => {
setOrganizationDialog({
open: true,
parentId,
mode: 'addChild',
});
};
// 处理添加根部门
const handleAddRootOrganization = () => {
setOrganizationDialog({
open: true,
mode: 'add',
});
};
// 添加 organization mutations
const organizationMutations = useOrganization();
// 修改 handleSaveOrganization 方法的参数类型和实现
const handleSaveOrganization = (data: {
name: string;
slug: string;
description: string;
parentId?: string;
organizationTypeId?: string;
professionIds?: string[];
}) => {
// 构建术语连接数组
const termConnections: Array<{ id: string }> = [];
if (data.organizationTypeId) {
termConnections.push({ id: data.organizationTypeId });
}
if (data.professionIds && data.professionIds.length > 0) {
data.professionIds.forEach((professionId) => {
termConnections.push({ id: professionId });
});
}
// 构建要保存的数据
const createData = {
name: data.name,
slug: data.slug || null, // 使用用户输入的 slug
description: data.description || null,
parentId: data.parentId || null,
// 关联术语
terms:
termConnections.length > 0
? {
connect: termConnections,
}
: undefined,
};
// 检查是否为编辑模式
if (organizationDialog.organization && organizationDialog.mode === 'edit') {
// 更新部门时,需要先断开所有术语连接,再重新连接
const updateData = {
...createData,
terms: {
// 先断开所有现有的术语连接
set: termConnections, // 使用 set 替换所有关联
},
};
// 更新部门
organizationMutations.update.mutate(
{
where: { id: organizationDialog.organization.id },
data: updateData,
},
{
onSuccess: () => {
toast.success('部门更新成功');
setOrganizationDialog({ open: false });
},
onError: (error: any) => {
console.error('更新失败:', error);
let errorMessage = '更新失败';
if (error.message) {
if (
error.message.includes('already exists') ||
error.message.includes('same') ||
error.message.includes('unique') ||
error.message.includes('Unique constraint')
) {
errorMessage = '该部门名称或别名已存在,请使用其他名称';
} else {
errorMessage = error.message;
}
}
toast.error(errorMessage);
},
},
);
} else {
// 创建新部门
organizationMutations.create.mutate(
{
data: createData,
},
{
onSuccess: () => {
toast.success('部门创建成功');
setOrganizationDialog({ open: false });
},
onError: (error: any) => {
console.error('创建失败:', error);
let errorMessage = '创建失败';
if (error.message) {
if (
error.message.includes('already exists') ||
error.message.includes('same') ||
error.message.includes('unique') ||
error.message.includes('Unique constraint')
) {
errorMessage = '该部门名称或别名已存在,请使用其他名称';
} else {
errorMessage = error.message;
}
}
toast.error(errorMessage);
},
},
);
}
};
// 处理添加用户
const handleAddUser = () => {
setUserDialog({
open: true,
mode: 'add',
});
};
const { create: createUser } = useUser();
// 处理保存用户
const handleSaveUser = (data: {
username: string;
roleIds: string[];
organizationId: string;
password: string;
description?: string; // 添加可选的个人简介字段
}) => {
const { roleIds, ...others } = data
createUser.mutate(
{
data: {
...others,
roles: { connect: roleIds.map(id => ({ id })) }
}
}, // 由于我们传入完整的 data 对象roleIds 字段会自动包含在内
{
onSuccess: () => {
toast.success('用户创建成功');
setUserDialog({ open: false });
},
onError: (error: any) => {
console.error('创建失败:', error);
let errorMessage = '创建失败';
if (error.message) {
if (
error.message.includes('already exists') ||
error.message.includes('same') ||
error.message.includes('unique') ||
error.message.includes('Unique constraint')
) {
errorMessage = '该用户名已存在,请使用其他名称';
} else {
errorMessage = error.message;
}
}
toast.error(errorMessage);
},
},
);
};
useSetPageInfo({
title: '组织管理',
subtitle: '管理组织结构,支持人员、组织的创建、编辑、删除以及查看等操作',
})
return (
<div className="flex flex-1 flex-col bg-gray-50/50 dark:bg-gray-900/50">
<div className="@container/main flex flex-1">
{/* 左侧:部门树 */}
<div className="w-96 border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
{/* 标题区域 */}
<div className="border-b border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<IconBuildingBank className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h2>
<p className="text-sm text-gray-500 dark:text-gray-400"></p>
</div>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleAddRootOrganization} className="shrink-0">
<IconPlus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
</div>
{/* 搜索框 */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<IconSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索部门..."
value={organizationSearchQuery}
onChange={(e) => setOrganizationSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* 部门树 */}
<div className="flex-1 overflow-y-auto max-h-[670px]">
{organizationsLoading ? (
<div className="p-6 text-center text-gray-500">
<div className="animate-pulse">...</div>
</div>
) : filteredOrganizations.length > 0 ? (
<div className="rounded-lg bg-white dark:bg-gray-800/50 p-2">
<TreeSelector
data={filteredOrganizations}
selectedNode={selectedOrganization}
onSelect={handleOrganizationSelect}
iconConfig={{
getIcon: (node, hasChildren, isExpanded) => {
if (node.level === 0) {
return IconBuildingBank;
}
return IconBuilding;
},
}}
actionConfig={{
onEdit: handleEditOrganization,
onDelete: handleDeleteOrganization,
onAddChild: handleAddChildOrganization,
editLabel: '编辑部门',
deleteLabel: '删除部门',
addChildLabel: '添加下级',
}}
displayConfig={{
showDescription: true,
selectedClassName: 'bg-primary/10 border border-primary/20',
hoverClassName: 'hover:bg-muted/50 border border-transparent',
}}
indentSize={20}
containerized={false}
className="space-y-1 bg-white"
dragConfig={dragConfig}
/>
</div>
) : (
<div className="p-6 text-center">
<div className="text-gray-400 mb-2">{organizationSearchQuery ? '没有找到匹配的部门' : '暂无部门'}</div>
{!organizationSearchQuery && <div className="text-sm text-gray-500"></div>}
</div>
)}
</div>
</div>
{/* 右侧:用户管理 */}
<div className="flex-1 bg-white dark:bg-gray-800">
{/* 标题区域 */}
<div className="border-b border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<IconUsers className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h2>
{selectedOrganization && (
<Badge variant="outline" className="text-xs">
{selectedOrganization.name}
</Badge>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{selectedOrganization
? `显示 ${selectedOrganization.name} 及其下属部门的用户`
: `${paginationInfo.totalCount} 名用户`}
</p>
</div>
</div>
<Button size="sm" onClick={handleAddUser} className="shrink-0">
<IconPlus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
{/* 用户内容区域 */}
<div className="flex-1 p-6">
<UsersDataTable
data={userData as UserWithRelations[]}
pagination={pagination}
paginationInfo={paginationInfo}
onPaginationChange={handlePaginationChange}
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
selectedRole={selectedRole}
onRoleChange={handleRoleChange}
selectedOrganizationId={selectedOrganization?.id || ''}
onOrganizationChange={() => { }} // 部门筛选通过左侧树形选择处理
isLoading={usersLoading}
availableRoles={availableRoles}
onAddUser={handleAddUser}
/>
</div>
</div>
</div>
{/* 部门编辑对话框 */}
<OrganizationDialog
dialog={organizationDialog}
onClose={() => setOrganizationDialog({ open: false })}
onSave={handleSaveOrganization}
/>
{/* 用户编辑对话框 */}
<UserDialog dialog={userDialog} onClose={() => setUserDialog({ open: false })} onSave={handleSaveUser} />
</div>
);
}
// 递归获取部门及其所有子部门的ID
function getAllOrganizationIds(organization: OrganizationTreeNode): string[] {
const ids = [organization.id];
if (organization.children) {
organization.children.forEach((child) => {
ids.push(...getAllOrganizationIds(child));
});
}
return ids;
}