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

679 lines
20 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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;
}