增加审批组件
This commit is contained in:
parent
913f56f0f9
commit
d131634b49
@ -0,0 +1,93 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
import type { DepartmentResponse } from '../types';
|
||||||
|
|
||||||
|
interface DeleteDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
record: DepartmentResponse | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部门删除确认对话框
|
||||||
|
*/
|
||||||
|
const DeleteDialog: React.FC<DeleteDialogProps> = ({
|
||||||
|
open,
|
||||||
|
record,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
const hasChildren = record.children && record.children.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||||
|
<AlertCircle className="h-5 w-5" /> 确认删除部门?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
您确定要删除部门 "<span className="font-semibold text-foreground">{record.name}</span>" 吗?
|
||||||
|
{hasChildren && (
|
||||||
|
<span className="block mt-2 text-red-600">
|
||||||
|
该部门下有子部门,请先删除子部门!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">部门编码:</span> <code className="text-sm">{record.code}</code>
|
||||||
|
</p>
|
||||||
|
{record.description && (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">描述:</span> {record.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{record.leaderName && (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">负责人:</span> {record.leaderName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">状态:</span>
|
||||||
|
<Badge variant={record.enabled ? 'success' : 'secondary'}>
|
||||||
|
{record.enabled ? '启用' : '禁用'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{hasChildren && (
|
||||||
|
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded">
|
||||||
|
<p className="text-red-600 text-sm font-medium">
|
||||||
|
包含 {record.children!.length} 个子部门
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={onConfirm} disabled={hasChildren}>
|
||||||
|
{hasChildren ? '无法删除' : '确认删除'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteDialog;
|
||||||
|
|
||||||
304
frontend/src/pages/System/Department/components/EditDialog.tsx
Normal file
304
frontend/src/pages/System/Department/components/EditDialog.tsx
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import request from '@/utils/request';
|
||||||
|
import type { DepartmentResponse, DepartmentRequest } from '../types';
|
||||||
|
import type { UserResponse } from '../../User/types';
|
||||||
|
|
||||||
|
interface EditDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
record?: DepartmentResponse;
|
||||||
|
departmentTree: DepartmentResponse[];
|
||||||
|
users: UserResponse[];
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部门编辑对话框
|
||||||
|
*/
|
||||||
|
const EditDialog: React.FC<EditDialogProps> = ({
|
||||||
|
open,
|
||||||
|
record,
|
||||||
|
departmentTree,
|
||||||
|
users,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [formData, setFormData] = useState<Partial<DepartmentRequest>>({
|
||||||
|
enabled: true,
|
||||||
|
sort: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (record) {
|
||||||
|
setFormData({
|
||||||
|
code: record.code,
|
||||||
|
name: record.name,
|
||||||
|
description: record.description,
|
||||||
|
parentId: record.parentId || undefined,
|
||||||
|
sort: record.sort,
|
||||||
|
enabled: record.enabled,
|
||||||
|
leaderId: record.leaderId,
|
||||||
|
leaderName: record.leaderName,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 新增时,计算下一个排序值
|
||||||
|
const allDepts = flattenDepartments(departmentTree);
|
||||||
|
const maxSort = Math.max(0, ...allDepts.map(d => d.sort));
|
||||||
|
setFormData({ enabled: true, sort: maxSort + 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, record, departmentTree]);
|
||||||
|
|
||||||
|
// 扁平化部门列表
|
||||||
|
const flattenDepartments = (depts: DepartmentResponse[], level: number = 0): Array<DepartmentResponse & { level: number }> => {
|
||||||
|
const result: Array<DepartmentResponse & { level: number }> = [];
|
||||||
|
depts.forEach(dept => {
|
||||||
|
// 过滤掉自己和子部门,避免循环引用
|
||||||
|
if (!record || (dept.id !== record.id && !isChildOf(dept.id, record, depts))) {
|
||||||
|
result.push({ ...dept, level });
|
||||||
|
if (dept.children) {
|
||||||
|
result.push(...flattenDepartments(dept.children, level + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查是否是子部门
|
||||||
|
const isChildOf = (deptId: number, parent: DepartmentResponse, allDepts: DepartmentResponse[]): boolean => {
|
||||||
|
const findDept = (depts: DepartmentResponse[], id: number): DepartmentResponse | null => {
|
||||||
|
for (const dept of depts) {
|
||||||
|
if (dept.id === id) return dept;
|
||||||
|
if (dept.children) {
|
||||||
|
const found = findDept(dept.children, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dept = findDept(allDepts, deptId);
|
||||||
|
if (!dept || !dept.children) return false;
|
||||||
|
|
||||||
|
const checkChildren = (children: DepartmentResponse[]): boolean => {
|
||||||
|
return children.some(child => {
|
||||||
|
if (child.id === parent.id) return true;
|
||||||
|
if (child.children) return checkChildren(child.children);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return checkChildren(dept.children);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
// 验证
|
||||||
|
if (!formData.code?.trim()) {
|
||||||
|
toast({
|
||||||
|
title: '提示',
|
||||||
|
description: '请输入部门编码',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证部门编码格式
|
||||||
|
if (!/^[A-Z_]+$/.test(formData.code)) {
|
||||||
|
toast({
|
||||||
|
title: '提示',
|
||||||
|
description: '部门编码只能包含大写字母和下划线',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.name?.trim()) {
|
||||||
|
toast({
|
||||||
|
title: '提示',
|
||||||
|
description: '请输入部门名称',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
...formData as DepartmentRequest,
|
||||||
|
parentId: formData.parentId || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
if (record) {
|
||||||
|
await request.put(`/api/v1/department/${record.id}`, {
|
||||||
|
...data,
|
||||||
|
version: record.version
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: '更新成功',
|
||||||
|
description: `部门 "${formData.name}" 已更新`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await request.post('/api/v1/department', data);
|
||||||
|
toast({
|
||||||
|
title: '创建成功',
|
||||||
|
description: `部门 "${formData.name}" 已创建`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '保存失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const flatDepartments = flattenDepartments(departmentTree);
|
||||||
|
const hasChildren = record?.children && record.children.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{record ? '编辑部门' : '新增部门'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="parentId">上级部门</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.parentId?.toString() || 'none'}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, parentId: value === 'none' ? undefined : Number(value) })}
|
||||||
|
disabled={hasChildren}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="不选择则为顶级部门" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">顶级部门</SelectItem>
|
||||||
|
{flatDepartments.map(dept => (
|
||||||
|
<SelectItem key={dept.id} value={dept.id.toString()}>
|
||||||
|
{' '.repeat(dept.level)}{dept.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{hasChildren && (
|
||||||
|
<p className="text-xs text-muted-foreground">该部门有子部门,不能修改上级部门</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="code">部门编码 *</Label>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
value={formData.code || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="请输入部门编码(大写字母和下划线)"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">只能包含大写字母和下划线</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">部门名称 *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="请输入部门名称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="description">部门描述</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={formData.description || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="请输入部门描述"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="sort">显示排序 *</Label>
|
||||||
|
<Input
|
||||||
|
id="sort"
|
||||||
|
type="number"
|
||||||
|
value={formData.sort || 1}
|
||||||
|
onChange={(e) => setFormData({ ...formData, sort: Number(e.target.value) })}
|
||||||
|
placeholder="请输入显示排序"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="leaderId">负责人</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.leaderId?.toString() || 'none'}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === 'none') {
|
||||||
|
setFormData({ ...formData, leaderId: undefined, leaderName: undefined });
|
||||||
|
} else {
|
||||||
|
const user = users.find(u => u.id === Number(value));
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
leaderId: Number(value),
|
||||||
|
leaderName: user?.nickname || user?.username
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择负责人" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">无</SelectItem>
|
||||||
|
{users.map(user => (
|
||||||
|
<SelectItem key={user.id} value={user.id.toString()}>
|
||||||
|
{user.nickname || user.username}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="enabled">启用状态</Label>
|
||||||
|
<Switch
|
||||||
|
id="enabled"
|
||||||
|
checked={formData.enabled}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit}>确定</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditDialog;
|
||||||
|
|
||||||
@ -1,346 +1,356 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { Table, Button, Modal, Form, Input, Space, InputNumber, Switch, TreeSelect, Select, message, Card } from 'antd';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, CaretDownOutlined, CaretRightOutlined } from '@ant-design/icons';
|
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 { DepartmentResponse } from './types';
|
||||||
import { getDepartmentTree } from './service';
|
import type { UserResponse } from '../User/types';
|
||||||
import type { UserResponse } from '@/pages/System/User/types';
|
|
||||||
import { getUsers } from './service';
|
|
||||||
import request from '@/utils/request';
|
import request from '@/utils/request';
|
||||||
import type { FixedType } from 'rc-table/lib/interface';
|
import EditDialog from './components/EditDialog';
|
||||||
|
import DeleteDialog from './components/DeleteDialog';
|
||||||
interface TreeSelectNode {
|
|
||||||
title: string;
|
|
||||||
value: number;
|
|
||||||
disabled?: boolean;
|
|
||||||
children?: TreeSelectNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部门管理页面
|
||||||
|
*/
|
||||||
const DepartmentPage: React.FC = () => {
|
const DepartmentPage: React.FC = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const { toast } = useToast();
|
||||||
const [departmentTree, setDepartmentTree] = useState<DepartmentResponse[]>([]);
|
const [loading, setLoading] = useState(false);
|
||||||
const [users, setUsers] = useState<UserResponse[]>([]);
|
const [departmentTree, setDepartmentTree] = useState<DepartmentResponse[]>([]);
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [users, setUsers] = useState<UserResponse[]>([]);
|
||||||
const [editingDepartment, setEditingDepartment] = useState<DepartmentResponse | null>(null);
|
const [expandedKeys, setExpandedKeys] = useState<Set<number>>(new Set());
|
||||||
const [form] = Form.useForm();
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [editRecord, setEditRecord] = useState<DepartmentResponse>();
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [deleteRecord, setDeleteRecord] = useState<DepartmentResponse | null>(null);
|
||||||
|
|
||||||
// 处理树形数据,添加 hasChildren 属性
|
// 加载部门树
|
||||||
const processTreeData = (departments: DepartmentResponse[]) => {
|
const loadDepartmentTree = async () => {
|
||||||
return departments.map(dept => ({
|
try {
|
||||||
...dept,
|
setLoading(true);
|
||||||
hasChildren: Boolean(dept.children?.length),
|
const data = await getDepartmentTree();
|
||||||
children: dept.children ? processTreeData(dept.children) : undefined
|
setDepartmentTree(data);
|
||||||
}));
|
// 默认展开所有节点
|
||||||
};
|
const allIds = new Set<number>();
|
||||||
|
const collectIds = (depts: DepartmentResponse[]) => {
|
||||||
// 加载部门树数据
|
depts.forEach(dept => {
|
||||||
const loadDepartmentTree = async () => {
|
if (dept.children && dept.children.length > 0) {
|
||||||
setLoading(true);
|
allIds.add(dept.id);
|
||||||
try {
|
collectIds(dept.children);
|
||||||
const response = await getDepartmentTree();
|
}
|
||||||
setDepartmentTree(processTreeData(response || []));
|
|
||||||
} catch (error) {
|
|
||||||
message.error('加载部门数据失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取扁平化的部门列表
|
|
||||||
const getFlatDepartments = (departments: DepartmentResponse[]): DepartmentResponse[] => {
|
|
||||||
return departments.reduce((acc, dept) => {
|
|
||||||
acc.push(dept);
|
|
||||||
if (dept.children?.length) {
|
|
||||||
acc.push(...getFlatDepartments(dept.children));
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, [] as DepartmentResponse[]);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Promise.all([
|
|
||||||
loadDepartmentTree(),
|
|
||||||
getUsers().then(setUsers)
|
|
||||||
]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
setEditingDepartment(null);
|
|
||||||
form.resetFields();
|
|
||||||
|
|
||||||
const allDepartments = getFlatDepartments(departmentTree);
|
|
||||||
const maxSort = Math.max(0, ...allDepartments.map(dept => dept.sort));
|
|
||||||
|
|
||||||
form.setFieldsValue({
|
|
||||||
enabled: true,
|
|
||||||
sort: maxSort + 1
|
|
||||||
});
|
});
|
||||||
setModalVisible(true);
|
};
|
||||||
};
|
collectIds(data);
|
||||||
|
setExpandedKeys(allIds);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载部门数据失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '加载失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleEdit = (record: DepartmentResponse) => {
|
// 加载用户列表
|
||||||
setEditingDepartment(record);
|
const loadUsers = async () => {
|
||||||
form.setFieldsValue({
|
try {
|
||||||
...record,
|
const response = await getUsers();
|
||||||
parentId: record.parentId || undefined
|
setUsers(response.content || []);
|
||||||
});
|
} catch (error) {
|
||||||
setModalVisible(true);
|
console.error('加载用户列表失败:', error);
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
useEffect(() => {
|
||||||
try {
|
loadDepartmentTree();
|
||||||
const values = await form.validateFields();
|
loadUsers();
|
||||||
const data = {
|
}, []);
|
||||||
...values,
|
|
||||||
parentId: values.parentId || 0
|
|
||||||
};
|
|
||||||
|
|
||||||
if (editingDepartment) {
|
// 新增
|
||||||
await request.put(`/api/v1/department/${editingDepartment.id}`, {
|
const handleCreate = () => {
|
||||||
...data,
|
setEditRecord(undefined);
|
||||||
version: editingDepartment.version
|
setEditDialogOpen(true);
|
||||||
});
|
};
|
||||||
message.success('更新成功');
|
|
||||||
} else {
|
|
||||||
await request.post('/api/v1/department', data);
|
|
||||||
message.success('创建成功');
|
|
||||||
}
|
|
||||||
setModalVisible(false);
|
|
||||||
loadDepartmentTree();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('操作失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (id: number) => {
|
// 编辑
|
||||||
Modal.confirm({
|
const handleEdit = (record: DepartmentResponse) => {
|
||||||
title: '确认删除',
|
setEditRecord(record);
|
||||||
content: '确定要删除该部门吗?',
|
setEditDialogOpen(true);
|
||||||
onOk: async () => {
|
};
|
||||||
try {
|
|
||||||
await request.delete(`/api/v1/department/${id}`);
|
|
||||||
message.success('删除成功');
|
|
||||||
loadDepartmentTree();
|
|
||||||
} catch (error) {
|
|
||||||
message.error('删除失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = [
|
// 删除
|
||||||
{
|
const handleDelete = (record: DepartmentResponse) => {
|
||||||
title: '部门名称',
|
setDeleteRecord(record);
|
||||||
dataIndex: 'name',
|
setDeleteDialogOpen(true);
|
||||||
key: 'name',
|
};
|
||||||
width: 250,
|
|
||||||
fixed: 'left' as FixedType
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '部门编码',
|
|
||||||
dataIndex: 'code',
|
|
||||||
key: 'code',
|
|
||||||
width: 120
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '部门描述',
|
|
||||||
dataIndex: 'description',
|
|
||||||
key: 'description',
|
|
||||||
width: 200,
|
|
||||||
ellipsis: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '排序',
|
|
||||||
dataIndex: 'sort',
|
|
||||||
key: 'sort',
|
|
||||||
width: 80
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '负责人',
|
|
||||||
dataIndex: 'leaderName',
|
|
||||||
key: 'leaderName',
|
|
||||||
width: 100
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'enabled',
|
|
||||||
key: 'enabled',
|
|
||||||
width: 80,
|
|
||||||
render: (enabled: boolean) => (
|
|
||||||
<Switch checked={enabled} disabled size="small"/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'action',
|
|
||||||
width: 160,
|
|
||||||
fixed: 'right' as FixedType,
|
|
||||||
render: (_: any, record: DepartmentResponse) => (
|
|
||||||
<Space size={0}>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
icon={<EditOutlined/>}
|
|
||||||
onClick={() => handleEdit(record)}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined/>}
|
|
||||||
onClick={() => handleDelete(record.id)}
|
|
||||||
disabled={Boolean(record.children?.length)}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
const confirmDelete = async () => {
|
||||||
<Card>
|
if (!deleteRecord) return;
|
||||||
<div style={{ marginBottom: 16 }}>
|
try {
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
await request.delete(`/api/v1/department/${deleteRecord.id}`);
|
||||||
新增部门
|
toast({
|
||||||
</Button>
|
title: '删除成功',
|
||||||
</div>
|
description: `部门 "${deleteRecord.name}" 已删除`,
|
||||||
|
});
|
||||||
|
loadDepartmentTree();
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setDeleteRecord(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '删除失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
<Table
|
// 切换展开/收起
|
||||||
loading={loading}
|
const toggleExpand = (id: number) => {
|
||||||
columns={columns}
|
setExpandedKeys(prev => {
|
||||||
dataSource={departmentTree}
|
const newSet = new Set(prev);
|
||||||
rowKey="id"
|
if (newSet.has(id)) {
|
||||||
scroll={{ x: 1200 }}
|
newSet.delete(id);
|
||||||
pagination={false}
|
} else {
|
||||||
size="middle"
|
newSet.add(id);
|
||||||
bordered
|
}
|
||||||
defaultExpandAllRows
|
return newSet;
|
||||||
expandable={{
|
});
|
||||||
showIcon: true,
|
};
|
||||||
expandIcon: ({ expanded, onExpand, record }) => {
|
|
||||||
if (!record.hasChildren) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return expanded ? (
|
|
||||||
<CaretDownOutlined onClick={e => onExpand(record, e)} />
|
|
||||||
) : (
|
|
||||||
<CaretRightOutlined onClick={e => onExpand(record, e)} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Modal
|
// 获取状态徽章
|
||||||
title={editingDepartment ? '编辑部门' : '新增部门'}
|
const getStatusBadge = (enabled: boolean) => {
|
||||||
open={modalVisible}
|
return enabled ? (
|
||||||
onOk={handleSubmit}
|
<Badge variant="success" className="inline-flex items-center gap-1">
|
||||||
onCancel={() => setModalVisible(false)}
|
<CheckCircle className="h-3 w-3" />
|
||||||
width={600}
|
启用
|
||||||
destroyOnClose
|
</Badge>
|
||||||
>
|
) : (
|
||||||
<Form form={form} layout="vertical">
|
<Badge variant="secondary" className="inline-flex items-center gap-1">
|
||||||
<Form.Item name="parentId" label="上级部门">
|
<XCircle className="h-3 w-3" />
|
||||||
<TreeSelect
|
禁用
|
||||||
treeData={departmentTree.map(dept => ({
|
</Badge>
|
||||||
title: dept.name,
|
|
||||||
value: dept.id,
|
|
||||||
disabled: editingDepartment?.id === dept.id,
|
|
||||||
children: dept.children?.map(child => ({
|
|
||||||
title: child.name,
|
|
||||||
value: child.id,
|
|
||||||
disabled: editingDepartment?.id === child.id
|
|
||||||
}))
|
|
||||||
}))}
|
|
||||||
placeholder="不选择则为顶级部门"
|
|
||||||
allowClear
|
|
||||||
treeDefaultExpandAll
|
|
||||||
disabled={Boolean(editingDepartment?.children?.length)}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="code"
|
|
||||||
label="部门编码"
|
|
||||||
rules={[
|
|
||||||
{required: true, message: '请输入部门编码'},
|
|
||||||
{pattern: /^[A-Z_]+$/, message: '部门编码只能包含大写字母和下划线'}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入部门编码"/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="name"
|
|
||||||
label="部门名称"
|
|
||||||
rules={[{required: true, message: '请输入部门名称'}]}
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入部门名称"/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="description"
|
|
||||||
label="部门描述"
|
|
||||||
>
|
|
||||||
<Input.TextArea placeholder="请输入部门描述"/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="sort"
|
|
||||||
label="显示排序"
|
|
||||||
rules={[{required: true, message: '请输入显示排序'}]}
|
|
||||||
>
|
|
||||||
<InputNumber
|
|
||||||
style={{width: '100%'}}
|
|
||||||
min={0}
|
|
||||||
placeholder="请输入显示排序"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="leaderId"
|
|
||||||
label="负责人"
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
placeholder="请选择负责人"
|
|
||||||
allowClear
|
|
||||||
showSearch
|
|
||||||
optionFilterProp="children"
|
|
||||||
onChange={(value, option: any) => {
|
|
||||||
form.setFieldsValue({
|
|
||||||
leaderId: value,
|
|
||||||
leaderName: option?.label
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{users.map(user => (
|
|
||||||
<Select.Option
|
|
||||||
key={user.id}
|
|
||||||
value={user.id}
|
|
||||||
label={user.nickname || user.username}
|
|
||||||
>
|
|
||||||
{user.nickname || user.username}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="leaderName" hidden>
|
|
||||||
<Input/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="enabled"
|
|
||||||
label="状态"
|
|
||||||
valuePropName="checked"
|
|
||||||
>
|
|
||||||
<Switch checkedChildren="启用" unCheckedChildren="禁用"/>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 递归渲染部门行
|
||||||
|
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;
|
export default DepartmentPage;
|
||||||
|
|||||||
106
frontend/src/pages/System/Menu/components/DeleteDialog.tsx
Normal file
106
frontend/src/pages/System/Menu/components/DeleteDialog.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
import type { MenuResponse } from '../types';
|
||||||
|
import { getIconComponent } from '@/config/icons.tsx';
|
||||||
|
|
||||||
|
interface DeleteDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
record: MenuResponse | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单删除确认对话框
|
||||||
|
*/
|
||||||
|
const DeleteDialog: React.FC<DeleteDialogProps> = ({
|
||||||
|
open,
|
||||||
|
record,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
const hasChildren = record.children && record.children.length > 0;
|
||||||
|
|
||||||
|
const getTypeText = (type: number) => {
|
||||||
|
const typeMap: Record<number, { text: string; variant: 'default' | 'secondary' | 'outline' }> = {
|
||||||
|
1: { text: '目录', variant: 'default' },
|
||||||
|
2: { text: '菜单', variant: 'secondary' },
|
||||||
|
3: { text: '按钮', variant: 'outline' },
|
||||||
|
};
|
||||||
|
return typeMap[type] || { text: '未知', variant: 'outline' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeInfo = getTypeText(record.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||||
|
<AlertCircle className="h-5 w-5" /> 确认删除菜单?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
您确定要删除菜单 "<span className="font-semibold text-foreground">{record.name}</span>" 吗?
|
||||||
|
{hasChildren && (
|
||||||
|
<span className="block mt-2 text-red-600">
|
||||||
|
该菜单下有子菜单,请先删除子菜单!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">菜单类型:</span>
|
||||||
|
<Badge variant={typeInfo.variant}>{typeInfo.text}</Badge>
|
||||||
|
</div>
|
||||||
|
{record.icon && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">图标:</span>
|
||||||
|
{getIconComponent(record.icon)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{record.path && (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">路由地址:</span> {record.path}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{record.component && (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">组件路径:</span> {record.component}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hasChildren && (
|
||||||
|
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded">
|
||||||
|
<p className="text-red-600 text-sm font-medium">
|
||||||
|
包含 {record.children!.length} 个子菜单
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={onConfirm} disabled={hasChildren}>
|
||||||
|
{hasChildren ? '无法删除' : '确认删除'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteDialog;
|
||||||
|
|
||||||
312
frontend/src/pages/System/Menu/components/EditDialog.tsx
Normal file
312
frontend/src/pages/System/Menu/components/EditDialog.tsx
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { HelpCircle } from 'lucide-react';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { createMenu, updateMenu } from '../service';
|
||||||
|
import type { MenuResponse, MenuRequest, MenuTypeEnum } from '../types';
|
||||||
|
import IconSelect from '@/components/IconSelect';
|
||||||
|
import { getIconComponent } from '@/config/icons.tsx';
|
||||||
|
|
||||||
|
interface EditDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
record?: MenuResponse;
|
||||||
|
menuTree: MenuResponse[];
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单编辑对话框
|
||||||
|
*/
|
||||||
|
const EditDialog: React.FC<EditDialogProps> = ({
|
||||||
|
open,
|
||||||
|
record,
|
||||||
|
menuTree,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [iconSelectVisible, setIconSelectVisible] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<Partial<MenuRequest>>({
|
||||||
|
type: 2, // 默认菜单类型
|
||||||
|
sort: 1,
|
||||||
|
hidden: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (record) {
|
||||||
|
setFormData({
|
||||||
|
name: record.name,
|
||||||
|
type: record.type,
|
||||||
|
parentId: record.parentId === 0 ? undefined : record.parentId,
|
||||||
|
path: record.path,
|
||||||
|
component: record.component,
|
||||||
|
permission: record.permission,
|
||||||
|
icon: record.icon,
|
||||||
|
sort: record.sort,
|
||||||
|
hidden: record.hidden,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFormData({ type: 2, sort: 1, hidden: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, record]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
// 验证
|
||||||
|
if (!formData.name?.trim()) {
|
||||||
|
toast({
|
||||||
|
title: '提示',
|
||||||
|
description: '请输入菜单名称',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.type) {
|
||||||
|
toast({
|
||||||
|
title: '提示',
|
||||||
|
description: '请选择菜单类型',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 目录和菜单需要路由地址
|
||||||
|
if (formData.type !== 3 && !formData.path?.trim()) {
|
||||||
|
toast({
|
||||||
|
title: '提示',
|
||||||
|
description: '请输入路由地址',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 菜单需要组件路径
|
||||||
|
if (formData.type === 2 && !formData.component?.trim()) {
|
||||||
|
toast({
|
||||||
|
title: '提示',
|
||||||
|
description: '请输入组件路径',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record) {
|
||||||
|
await updateMenu(record.id, {
|
||||||
|
...formData as MenuRequest,
|
||||||
|
version: record.version
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: '更新成功',
|
||||||
|
description: `菜单 "${formData.name}" 已更新`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createMenu(formData as MenuRequest);
|
||||||
|
toast({
|
||||||
|
title: '创建成功',
|
||||||
|
description: `菜单 "${formData.name}" 已创建`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '保存失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 扁平化菜单列表
|
||||||
|
const flattenMenus = (menus: MenuResponse[], level: number = 0): Array<MenuResponse & { level: number }> => {
|
||||||
|
const result: Array<MenuResponse & { level: number }> = [];
|
||||||
|
menus.forEach(menu => {
|
||||||
|
// 过滤掉自己,避免选择自己作为父菜单
|
||||||
|
if (!record || menu.id !== record.id) {
|
||||||
|
result.push({ ...menu, level });
|
||||||
|
if (menu.children) {
|
||||||
|
result.push(...flattenMenus(menu.children, level + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flatMenus = flattenMenus(menuTree);
|
||||||
|
const isButton = formData.type === 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{record ? '编辑菜单' : '新增菜单'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="type">菜单类型 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.type?.toString()}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, type: Number(value) as MenuTypeEnum })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择菜单类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">目录</SelectItem>
|
||||||
|
<SelectItem value="2">菜单</SelectItem>
|
||||||
|
<SelectItem value="3">按钮</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">菜单名称 *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="请输入菜单名称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="parentId">上级菜单</Label>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>不选择则为顶级菜单</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={formData.parentId?.toString() || 'none'}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, parentId: value === 'none' ? undefined : Number(value) })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="不选择则为顶级菜单" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">顶级菜单</SelectItem>
|
||||||
|
{flatMenus.map(menu => (
|
||||||
|
<SelectItem key={menu.id} value={menu.id.toString()}>
|
||||||
|
{' '.repeat(menu.level)}{menu.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isButton && (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="icon">菜单图标</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="icon"
|
||||||
|
value={formData.icon || ''}
|
||||||
|
readOnly
|
||||||
|
placeholder="请选择图标"
|
||||||
|
onClick={() => setIconSelectVisible(true)}
|
||||||
|
className="flex-1 cursor-pointer"
|
||||||
|
/>
|
||||||
|
{formData.icon && (
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 border rounded-md">
|
||||||
|
{getIconComponent(formData.icon)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="path">路由地址 *</Label>
|
||||||
|
<Input
|
||||||
|
id="path"
|
||||||
|
value={formData.path || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, path: e.target.value })}
|
||||||
|
placeholder="请输入路由地址"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.type === 2 && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="component">组件路径 *</Label>
|
||||||
|
<Input
|
||||||
|
id="component"
|
||||||
|
value={formData.component || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, component: e.target.value })}
|
||||||
|
placeholder="请输入组件路径"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="sort">显示排序 *</Label>
|
||||||
|
<Input
|
||||||
|
id="sort"
|
||||||
|
type="number"
|
||||||
|
value={formData.sort || 1}
|
||||||
|
onChange={(e) => setFormData({ ...formData, sort: Number(e.target.value) })}
|
||||||
|
placeholder="请输入显示排序"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="hidden">是否隐藏</Label>
|
||||||
|
<Switch
|
||||||
|
id="hidden"
|
||||||
|
checked={formData.hidden}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, hidden: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit}>确定</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 图标选择器 */}
|
||||||
|
<IconSelect
|
||||||
|
visible={iconSelectVisible}
|
||||||
|
onCancel={() => setIconSelectVisible(false)}
|
||||||
|
value={formData.icon}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFormData({ ...formData, icon: value });
|
||||||
|
setIconSelectVisible(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditDialog;
|
||||||
|
|
||||||
@ -1,332 +1,371 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { Card, Table, Button, Modal, Form, Input, Space, message, Select, TreeSelect, Tooltip, Switch } from 'antd';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import * as service from './service';
|
import { Button } from '@/components/ui/button';
|
||||||
import { MenuTypeEnum, MenuResponse, MenuRequest } from './types';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import IconSelect from '@/components/IconSelect';
|
import {
|
||||||
|
Loader2, Plus, Edit, Trash2, ChevronRight, ChevronDown,
|
||||||
|
FolderTree, Menu as MenuIcon
|
||||||
|
} from 'lucide-react';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { setMenus } from '@/store/userSlice';
|
import { setMenus } from '@/store/userSlice';
|
||||||
|
import { getMenuTree, deleteMenu, getCurrentUserMenus } from './service';
|
||||||
|
import type { MenuResponse } from './types';
|
||||||
import { getIconComponent } from '@/config/icons.tsx';
|
import { getIconComponent } from '@/config/icons.tsx';
|
||||||
|
import EditDialog from './components/EditDialog';
|
||||||
|
import DeleteDialog from './components/DeleteDialog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单管理页面
|
||||||
|
*/
|
||||||
const MenuPage: React.FC = () => {
|
const MenuPage: React.FC = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [form] = Form.useForm();
|
const { toast } = useToast();
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [editingMenu, setEditingMenu] = useState<MenuResponse | null>(null);
|
const [menuTree, setMenuTree] = useState<MenuResponse[]>([]);
|
||||||
const [iconSelectVisible, setIconSelectVisible] = useState(false);
|
const [expandedKeys, setExpandedKeys] = useState<Set<number>>(new Set());
|
||||||
const [loading, setLoading] = useState(false);
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
const [menuTree, setMenuTree] = useState<MenuResponse[]>([]);
|
const [editRecord, setEditRecord] = useState<MenuResponse>();
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [deleteRecord, setDeleteRecord] = useState<MenuResponse | null>(null);
|
||||||
|
|
||||||
// 加载菜单树
|
// 加载菜单树
|
||||||
const loadMenuTree = async () => {
|
const loadMenuTree = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await service.getMenuTree();
|
const data = await getMenuTree();
|
||||||
setMenuTree(data);
|
setMenuTree(data);
|
||||||
} catch (error) {
|
// 默认展开所有节点
|
||||||
message.error('加载菜单数据失败');
|
const allIds = new Set<number>();
|
||||||
} finally {
|
const collectIds = (menus: MenuResponse[]) => {
|
||||||
setLoading(false);
|
menus.forEach(menu => {
|
||||||
}
|
if (menu.children && menu.children.length > 0) {
|
||||||
};
|
allIds.add(menu.id);
|
||||||
|
collectIds(menu.children);
|
||||||
useEffect(() => {
|
}
|
||||||
loadMenuTree();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
setEditingMenu(null);
|
|
||||||
form.resetFields();
|
|
||||||
form.setFieldsValue({
|
|
||||||
type: MenuTypeEnum.MENU,
|
|
||||||
sort: 1,
|
|
||||||
hidden: false
|
|
||||||
});
|
});
|
||||||
setModalVisible(true);
|
};
|
||||||
|
collectIds(data);
|
||||||
|
setExpandedKeys(allIds);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载菜单数据失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '加载失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMenuTree();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 更新 Redux 中的菜单数据
|
||||||
|
const updateReduxMenus = async () => {
|
||||||
|
try {
|
||||||
|
const menus = await getCurrentUserMenus();
|
||||||
|
dispatch(setMenus(menus));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新菜单数据失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 新增
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditRecord(undefined);
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
const handleEdit = (record: MenuResponse) => {
|
||||||
|
setEditRecord(record);
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const handleDelete = (record: MenuResponse) => {
|
||||||
|
setDeleteRecord(record);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteRecord) return;
|
||||||
|
try {
|
||||||
|
await deleteMenu(deleteRecord.id);
|
||||||
|
toast({
|
||||||
|
title: '删除成功',
|
||||||
|
description: `菜单 "${deleteRecord.name}" 已删除`,
|
||||||
|
});
|
||||||
|
loadMenuTree();
|
||||||
|
updateReduxMenus();
|
||||||
|
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 getTypeBadge = (type: number) => {
|
||||||
|
const typeMap: Record<number, { text: string; variant: 'default' | 'secondary' | 'outline' }> = {
|
||||||
|
1: { text: '目录', variant: 'default' },
|
||||||
|
2: { text: '菜单', variant: 'secondary' },
|
||||||
|
3: { text: '按钮', variant: 'outline' },
|
||||||
};
|
};
|
||||||
|
const info = typeMap[type] || { text: '未知', variant: 'outline' };
|
||||||
|
return <Badge variant={info.variant}>{info.text}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
const handleEdit = (record: MenuResponse) => {
|
// 递归渲染菜单行
|
||||||
setEditingMenu(record);
|
const renderMenuRow = (menu: MenuResponse, level: number = 0): React.ReactNode[] => {
|
||||||
form.setFieldsValue({
|
const hasChildren = menu.children && menu.children.length > 0;
|
||||||
...record,
|
const isExpanded = expandedKeys.has(menu.id);
|
||||||
parentId: record.parentId === 0 ? undefined : record.parentId
|
const rows: React.ReactNode[] = [];
|
||||||
});
|
|
||||||
setModalVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
// 当前菜单行
|
||||||
try {
|
rows.push(
|
||||||
await service.deleteMenu(id);
|
<TableRow key={menu.id} className="hover:bg-muted/50">
|
||||||
message.success('删除成功');
|
<TableCell>
|
||||||
loadMenuTree();
|
<div className="flex items-center gap-2" style={{ paddingLeft: `${level * 24}px` }}>
|
||||||
await updateReduxMenus();
|
{hasChildren ? (
|
||||||
} catch (error) {
|
<button
|
||||||
message.error('删除失败');
|
onClick={() => toggleExpand(menu.id)}
|
||||||
}
|
className="p-0.5 hover:bg-muted rounded"
|
||||||
};
|
>
|
||||||
|
{isExpanded ? (
|
||||||
const handleSubmit = async () => {
|
<ChevronDown className="h-4 w-4" />
|
||||||
try {
|
) : (
|
||||||
const values = await form.validateFields();
|
<ChevronRight className="h-4 w-4" />
|
||||||
if (editingMenu) {
|
)}
|
||||||
await service.updateMenu(editingMenu.id, {
|
</button>
|
||||||
...values,
|
) : (
|
||||||
version: editingMenu.version
|
<span className="w-5" />
|
||||||
});
|
)}
|
||||||
message.success('更新成功');
|
<span className="font-medium">{menu.name}</span>
|
||||||
} else {
|
</div>
|
||||||
await service.createMenu(values);
|
</TableCell>
|
||||||
message.success('创建成功');
|
<TableCell>
|
||||||
}
|
{menu.icon && (
|
||||||
setModalVisible(false);
|
<div className="flex items-center">
|
||||||
loadMenuTree();
|
{getIconComponent(menu.icon)}
|
||||||
await updateReduxMenus();
|
|
||||||
} catch (error) {
|
|
||||||
message.error('操作失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 更新 Redux 中的菜单数据
|
|
||||||
const updateReduxMenus = async () => {
|
|
||||||
try {
|
|
||||||
const menus = await service.getCurrentUserMenus();
|
|
||||||
dispatch(setMenus(menus));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('更新菜单数据失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTreeSelectData = (menuList: MenuResponse[]) => {
|
|
||||||
return menuList.map(menu => ({
|
|
||||||
title: menu.name,
|
|
||||||
value: menu.id,
|
|
||||||
children: menu.children?.map(child => ({
|
|
||||||
title: child.name,
|
|
||||||
value: child.id,
|
|
||||||
disabled: editingMenu?.id === child.id
|
|
||||||
}))
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: ColumnsType<MenuResponse> = [
|
|
||||||
{
|
|
||||||
title: '菜单名称',
|
|
||||||
dataIndex: 'name',
|
|
||||||
width: 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '图标',
|
|
||||||
dataIndex: 'icon',
|
|
||||||
width: 100,
|
|
||||||
render: (icon: string) => {
|
|
||||||
return getIconComponent(icon);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '路由地址',
|
|
||||||
dataIndex: 'path',
|
|
||||||
width: 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '组件路径',
|
|
||||||
dataIndex: 'component',
|
|
||||||
width: 200
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '菜单类型',
|
|
||||||
dataIndex: 'type',
|
|
||||||
width: 120,
|
|
||||||
render: (type: MenuTypeEnum) => {
|
|
||||||
const typeMap = {
|
|
||||||
[MenuTypeEnum.DIRECTORY]: '目录',
|
|
||||||
[MenuTypeEnum.MENU]: '菜单',
|
|
||||||
[MenuTypeEnum.BUTTON]: '按钮'
|
|
||||||
};
|
|
||||||
return typeMap[type];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '显示排序',
|
|
||||||
dataIndex: 'sort',
|
|
||||||
width: 100
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'action',
|
|
||||||
width: 200,
|
|
||||||
render: (_, record) => (
|
|
||||||
<Space>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={() => handleEdit(record)}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
onClick={() => handleDelete(record.id)}
|
|
||||||
disabled={record.children?.length > 0}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={handleAdd}
|
|
||||||
>
|
|
||||||
新增菜单
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<Table
|
</TableCell>
|
||||||
columns={columns}
|
<TableCell className="text-sm">{menu.path || '-'}</TableCell>
|
||||||
dataSource={menuTree}
|
<TableCell className="text-sm max-w-[200px] truncate" title={menu.component}>
|
||||||
rowKey="id"
|
{menu.component || '-'}
|
||||||
loading={loading}
|
</TableCell>
|
||||||
pagination={false}
|
<TableCell>{getTypeBadge(menu.type)}</TableCell>
|
||||||
size="middle"
|
<TableCell className="text-center">{menu.sort}</TableCell>
|
||||||
bordered
|
<TableCell className="text-right">
|
||||||
defaultExpandAllRows
|
<div className="flex justify-end gap-2">
|
||||||
/>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
<Modal
|
size="sm"
|
||||||
title={editingMenu ? '编辑菜单' : '新增菜单'}
|
onClick={() => handleEdit(menu)}
|
||||||
open={modalVisible}
|
title="编辑"
|
||||||
onOk={handleSubmit}
|
|
||||||
onCancel={() => setModalVisible(false)}
|
|
||||||
width={600}
|
|
||||||
>
|
>
|
||||||
<Form
|
<Edit className="h-4 w-4" />
|
||||||
form={form}
|
</Button>
|
||||||
layout="vertical"
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<Form.Item
|
size="sm"
|
||||||
name="type"
|
onClick={() => handleDelete(menu)}
|
||||||
label="菜单类型"
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
rules={[{ required: true, message: '请选择菜单类型' }]}
|
title="删除"
|
||||||
>
|
disabled={hasChildren}
|
||||||
<Select>
|
>
|
||||||
<Select.Option value={MenuTypeEnum.DIRECTORY}>目录</Select.Option>
|
<Trash2 className="h-4 w-4" />
|
||||||
<Select.Option value={MenuTypeEnum.MENU}>菜单</Select.Option>
|
</Button>
|
||||||
<Select.Option value={MenuTypeEnum.BUTTON}>按钮</Select.Option>
|
</div>
|
||||||
</Select>
|
</TableCell>
|
||||||
</Form.Item>
|
</TableRow>
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="name"
|
|
||||||
label="菜单名称"
|
|
||||||
rules={[{ required: true, message: '请输入菜单名称' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入菜单名称" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="parentId"
|
|
||||||
label={
|
|
||||||
<span>
|
|
||||||
上级菜单
|
|
||||||
<Tooltip title="不选择则为顶级菜单">
|
|
||||||
<QuestionCircleOutlined style={{ marginLeft: 4 }} />
|
|
||||||
</Tooltip>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TreeSelect
|
|
||||||
treeData={getTreeSelectData(menuTree)}
|
|
||||||
placeholder="不选择则为顶级菜单"
|
|
||||||
allowClear
|
|
||||||
treeDefaultExpandAll
|
|
||||||
showSearch
|
|
||||||
treeNodeFilterProp="title"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
noStyle
|
|
||||||
shouldUpdate={(prevValues, currentValues) => prevValues.type !== currentValues.type}
|
|
||||||
>
|
|
||||||
{({ getFieldValue }) => {
|
|
||||||
const type = getFieldValue('type');
|
|
||||||
if (type === MenuTypeEnum.BUTTON) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Form.Item
|
|
||||||
name="icon"
|
|
||||||
label="菜单图标"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="请选择图标"
|
|
||||||
readOnly
|
|
||||||
onClick={() => setIconSelectVisible(true)}
|
|
||||||
suffix={getIconComponent(form.getFieldValue('icon'))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="path"
|
|
||||||
label="路由地址"
|
|
||||||
rules={[{ required: true, message: '请输入路由地址' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入路由地址" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{type === MenuTypeEnum.MENU && (
|
|
||||||
<Form.Item
|
|
||||||
name="component"
|
|
||||||
label="组件路径"
|
|
||||||
rules={[{ required: true, message: '请输入组件路径' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入组件路径" />
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="sort"
|
|
||||||
label="显示排序"
|
|
||||||
rules={[{ required: true, message: '请输入显示排序' }]}
|
|
||||||
>
|
|
||||||
<Input type="number" placeholder="请输入显示排序" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="hidden"
|
|
||||||
label="是否隐藏"
|
|
||||||
valuePropName="checked"
|
|
||||||
>
|
|
||||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<IconSelect
|
|
||||||
visible={iconSelectVisible}
|
|
||||||
onCancel={() => setIconSelectVisible(false)}
|
|
||||||
value={form.getFieldValue('icon')}
|
|
||||||
onChange={value => {
|
|
||||||
form.setFieldValue('icon', value);
|
|
||||||
setIconSelectVisible(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 递归渲染子菜单
|
||||||
|
if (hasChildren && isExpanded) {
|
||||||
|
menu.children!.forEach(child => {
|
||||||
|
rows.push(...renderMenuRow(child, level + 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const countMenus = (menus: MenuResponse[]): { total: number; directories: number; menuItems: number; buttons: number } => {
|
||||||
|
let total = 0;
|
||||||
|
let directories = 0;
|
||||||
|
let menuItems = 0;
|
||||||
|
let buttons = 0;
|
||||||
|
|
||||||
|
const traverse = (menu: MenuResponse) => {
|
||||||
|
total++;
|
||||||
|
if (menu.type === 1) directories++;
|
||||||
|
else if (menu.type === 2) menuItems++;
|
||||||
|
else if (menu.type === 3) buttons++;
|
||||||
|
|
||||||
|
if (menu.children) {
|
||||||
|
menu.children.forEach(traverse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
menus.forEach(traverse);
|
||||||
|
return { total, directories, menuItems, buttons };
|
||||||
|
};
|
||||||
|
|
||||||
|
return countMenus(menuTree);
|
||||||
|
}, [menuTree]);
|
||||||
|
|
||||||
|
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-4 gap-4 mb-6">
|
||||||
|
<Card className="bg-gradient-to-br from-indigo-500/10 to-indigo-500/5 border-indigo-500/20">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-indigo-700">总菜单数</CardTitle>
|
||||||
|
<FolderTree className="h-4 w-4 text-indigo-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-blue-500/10 to-blue-500/5 border-blue-500/20">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-blue-700">目录</CardTitle>
|
||||||
|
<FolderTree className="h-4 w-4 text-blue-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.directories}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">一级分类</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-cyan-500/10 to-cyan-500/5 border-cyan-500/20">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-cyan-700">菜单</CardTitle>
|
||||||
|
<MenuIcon className="h-4 w-4 text-cyan-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.menuItems}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">可访问页面</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-teal-500/10 to-teal-500/5 border-teal-500/20">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-teal-700">按钮</CardTitle>
|
||||||
|
<MenuIcon className="h-4 w-4 text-teal-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.buttons}</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-[80px]">图标</TableHead>
|
||||||
|
<TableHead className="w-[200px]">路由地址</TableHead>
|
||||||
|
<TableHead className="w-[200px]">组件路径</TableHead>
|
||||||
|
<TableHead className="w-[100px]">菜单类型</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center">显示排序</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>
|
||||||
|
) : menuTree.length > 0 ? (
|
||||||
|
menuTree.map(menu => renderMenuRow(menu))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<FolderTree 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}
|
||||||
|
menuTree={menuTree}
|
||||||
|
onOpenChange={setEditDialogOpen}
|
||||||
|
onSuccess={() => {
|
||||||
|
loadMenuTree();
|
||||||
|
updateReduxMenus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 删除确认对话框 */}
|
||||||
|
<DeleteDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
record={deleteRecord}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MenuPage;
|
export default MenuPage;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user