deploy-ease-platform/frontend/src/pages/System/Department/index.tsx
2025-10-24 20:13:16 +08:00

357 lines
12 KiB
TypeScript
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.

import React, { useState, useEffect, useMemo } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import {
Loader2, Plus, Edit, Trash2, ChevronRight, ChevronDown,
Building2, Network, CheckCircle, XCircle
} from 'lucide-react';
import { getDepartmentTree, getUsers } from './service';
import type { DepartmentResponse } from './types';
import type { UserResponse } from '../User/types';
import request from '@/utils/request';
import EditDialog from './components/EditDialog';
import DeleteDialog from './components/DeleteDialog';
/**
* 部门管理页面
*/
const DepartmentPage: React.FC = () => {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [departmentTree, setDepartmentTree] = useState<DepartmentResponse[]>([]);
const [users, setUsers] = useState<UserResponse[]>([]);
const [expandedKeys, setExpandedKeys] = useState<Set<number>>(new Set());
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editRecord, setEditRecord] = useState<DepartmentResponse>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteRecord, setDeleteRecord] = useState<DepartmentResponse | null>(null);
// 加载部门树
const loadDepartmentTree = async () => {
try {
setLoading(true);
const data = await getDepartmentTree();
setDepartmentTree(data);
// 默认展开所有节点
const allIds = new Set<number>();
const collectIds = (depts: DepartmentResponse[]) => {
depts.forEach(dept => {
if (dept.children && dept.children.length > 0) {
allIds.add(dept.id);
collectIds(dept.children);
}
});
};
collectIds(data);
setExpandedKeys(allIds);
} catch (error) {
console.error('加载部门数据失败:', error);
toast({
title: '加载失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
});
} finally {
setLoading(false);
}
};
// 加载用户列表
const loadUsers = async () => {
try {
const response = await getUsers();
setUsers(response.content || []);
} catch (error) {
console.error('加载用户列表失败:', error);
}
};
useEffect(() => {
loadDepartmentTree();
loadUsers();
}, []);
// 新增
const handleCreate = () => {
setEditRecord(undefined);
setEditDialogOpen(true);
};
// 编辑
const handleEdit = (record: DepartmentResponse) => {
setEditRecord(record);
setEditDialogOpen(true);
};
// 删除
const handleDelete = (record: DepartmentResponse) => {
setDeleteRecord(record);
setDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (!deleteRecord) return;
try {
await request.delete(`/api/v1/department/${deleteRecord.id}`);
toast({
title: '删除成功',
description: `部门 "${deleteRecord.name}" 已删除`,
});
loadDepartmentTree();
setDeleteDialogOpen(false);
setDeleteRecord(null);
} catch (error) {
console.error('删除失败:', error);
toast({
title: '删除失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
});
}
};
// 切换展开/收起
const toggleExpand = (id: number) => {
setExpandedKeys(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
};
// 获取状态徽章
const getStatusBadge = (enabled: boolean) => {
return enabled ? (
<Badge variant="success" className="inline-flex items-center gap-1">
<CheckCircle className="h-3 w-3" />
</Badge>
) : (
<Badge variant="secondary" className="inline-flex items-center gap-1">
<XCircle className="h-3 w-3" />
</Badge>
);
};
// 递归渲染部门行
const renderDepartmentRow = (dept: DepartmentResponse, level: number = 0): React.ReactNode[] => {
const hasChildren = dept.children && dept.children.length > 0;
const isExpanded = expandedKeys.has(dept.id);
const rows: React.ReactNode[] = [];
// 当前部门行
rows.push(
<TableRow key={dept.id} className="hover:bg-muted/50">
<TableCell>
<div className="flex items-center gap-2" style={{ paddingLeft: `${level * 24}px` }}>
{hasChildren ? (
<button
onClick={() => toggleExpand(dept.id)}
className="p-0.5 hover:bg-muted rounded"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
) : (
<span className="w-5" />
)}
<span className="font-medium">{dept.name}</span>
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-0.5 rounded">{dept.code}</code>
</TableCell>
<TableCell className="text-sm max-w-[200px] truncate" title={dept.description}>
{dept.description || '-'}
</TableCell>
<TableCell className="text-center">{dept.sort}</TableCell>
<TableCell>{dept.leaderName || '-'}</TableCell>
<TableCell>{getStatusBadge(dept.enabled)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(dept)}
title="编辑"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(dept)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
title="删除"
disabled={hasChildren}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
// 递归渲染子部门
if (hasChildren && isExpanded) {
dept.children!.forEach(child => {
rows.push(...renderDepartmentRow(child, level + 1));
});
}
return rows;
};
// 统计数据
const stats = useMemo(() => {
const countDepartments = (depts: DepartmentResponse[]): { total: number; enabled: number; disabled: number } => {
let total = 0;
let enabled = 0;
let disabled = 0;
const traverse = (dept: DepartmentResponse) => {
total++;
if (dept.enabled) enabled++;
else disabled++;
if (dept.children) {
dept.children.forEach(traverse);
}
};
depts.forEach(traverse);
return { total, enabled, disabled };
};
return countDepartments(departmentTree);
}, [departmentTree]);
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="text-muted-foreground mt-2">
</p>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<Card className="bg-gradient-to-br from-emerald-500/10 to-emerald-500/5 border-emerald-500/20">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-emerald-700"></CardTitle>
<Building2 className="h-4 w-4 text-emerald-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-500/10 to-green-500/5 border-green-500/20">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-green-700"></CardTitle>
<Network className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.enabled}</div>
<p className="text-xs text-muted-foreground mt-1">使</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-gray-500/10 to-gray-500/5 border-gray-500/20">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-gray-700"></CardTitle>
<XCircle className="h-4 w-4 text-gray-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.disabled}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle>
<Button onClick={handleCreate}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</CardHeader>
<CardContent>
{/* 表格 */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">...</span>
</div>
</TableCell>
</TableRow>
) : departmentTree.length > 0 ? (
departmentTree.map(dept => renderDepartmentRow(dept))
) : (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Building2 className="w-16 h-16 mb-4 text-muted-foreground/50" />
<div className="text-lg font-semibold mb-2"></div>
<div className="text-sm">"新增部门"</div>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* 编辑对话框 */}
<EditDialog
open={editDialogOpen}
record={editRecord}
departmentTree={departmentTree}
users={users}
onOpenChange={setEditDialogOpen}
onSuccess={loadDepartmentTree}
/>
{/* 删除确认对话框 */}
<DeleteDialog
open={deleteDialogOpen}
record={deleteRecord}
onOpenChange={setDeleteDialogOpen}
onConfirm={confirmDelete}
/>
</div>
);
};
export default DepartmentPage;