增加审批组件

This commit is contained in:
dengqichen 2025-10-24 20:13:16 +08:00
parent 913f56f0f9
commit d131634b49
6 changed files with 1510 additions and 646 deletions

View File

@ -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;

View 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;

View File

@ -1,346 +1,356 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, Space, InputNumber, Switch, TreeSelect, Select, message, Card } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, CaretDownOutlined, CaretRightOutlined } from '@ant-design/icons';
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 { getDepartmentTree } from './service';
import type { UserResponse } from '@/pages/System/User/types';
import { getUsers } from './service';
import type { UserResponse } from '../User/types';
import request from '@/utils/request';
import type { FixedType } from 'rc-table/lib/interface';
interface TreeSelectNode {
title: string;
value: number;
disabled?: boolean;
children?: TreeSelectNode[];
}
import EditDialog from './components/EditDialog';
import DeleteDialog from './components/DeleteDialog';
/**
*
*/
const DepartmentPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [departmentTree, setDepartmentTree] = useState<DepartmentResponse[]>([]);
const [users, setUsers] = useState<UserResponse[]>([]);
const [modalVisible, setModalVisible] = useState(false);
const [editingDepartment, setEditingDepartment] = useState<DepartmentResponse | null>(null);
const [form] = Form.useForm();
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);
// 处理树形数据,添加 hasChildren 属性
const processTreeData = (departments: DepartmentResponse[]) => {
return departments.map(dept => ({
...dept,
hasChildren: Boolean(dept.children?.length),
children: dept.children ? processTreeData(dept.children) : undefined
}));
};
// 加载部门树数据
const loadDepartmentTree = async () => {
setLoading(true);
try {
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
// 加载部门树
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);
}
});
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);
form.setFieldsValue({
...record,
parentId: record.parentId || undefined
});
setModalVisible(true);
};
// 加载用户列表
const loadUsers = async () => {
try {
const response = await getUsers();
setUsers(response.content || []);
} catch (error) {
console.error('加载用户列表失败:', error);
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const data = {
...values,
parentId: values.parentId || 0
};
useEffect(() => {
loadDepartmentTree();
loadUsers();
}, []);
if (editingDepartment) {
await request.put(`/api/v1/department/${editingDepartment.id}`, {
...data,
version: editingDepartment.version
});
message.success('更新成功');
} else {
await request.post('/api/v1/department', data);
message.success('创建成功');
}
setModalVisible(false);
loadDepartmentTree();
} catch (error) {
console.error('操作失败:', error);
}
};
// 新增
const handleCreate = () => {
setEditRecord(undefined);
setEditDialogOpen(true);
};
const handleDelete = (id: number) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除该部门吗?',
onOk: async () => {
try {
await request.delete(`/api/v1/department/${id}`);
message.success('删除成功');
loadDepartmentTree();
} catch (error) {
message.error('删除失败');
}
}
});
};
// 编辑
const handleEdit = (record: DepartmentResponse) => {
setEditRecord(record);
setEditDialogOpen(true);
};
const columns = [
{
title: '部门名称',
dataIndex: 'name',
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>
)
}
];
// 删除
const handleDelete = (record: DepartmentResponse) => {
setDeleteRecord(record);
setDeleteDialogOpen(true);
};
return (
<Card>
<div style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
</div>
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'
});
}
};
<Table
loading={loading}
columns={columns}
dataSource={departmentTree}
rowKey="id"
scroll={{ x: 1200 }}
pagination={false}
size="middle"
bordered
defaultExpandAllRows
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)} />
);
}
}}
/>
// 切换展开/收起
const toggleExpand = (id: number) => {
setExpandedKeys(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
};
<Modal
title={editingDepartment ? '编辑部门' : '新增部门'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
width={600}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="parentId" label="上级部门">
<TreeSelect
treeData={departmentTree.map(dept => ({
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 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;
export default DepartmentPage;

View 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;

View 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;

View File

@ -1,332 +1,371 @@
import React, { useEffect, useState } from 'react';
import { Card, Table, Button, Modal, Form, Input, Space, message, Select, TreeSelect, Tooltip, Switch } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import * as service from './service';
import { MenuTypeEnum, MenuResponse, MenuRequest } from './types';
import IconSelect from '@/components/IconSelect';
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,
FolderTree, Menu as MenuIcon
} from 'lucide-react';
import { useDispatch } from 'react-redux';
import { setMenus } from '@/store/userSlice';
import { getMenuTree, deleteMenu, getCurrentUserMenus } from './service';
import type { MenuResponse } from './types';
import { getIconComponent } from '@/config/icons.tsx';
import EditDialog from './components/EditDialog';
import DeleteDialog from './components/DeleteDialog';
/**
*
*/
const MenuPage: React.FC = () => {
const dispatch = useDispatch();
const [form] = Form.useForm();
const [modalVisible, setModalVisible] = useState(false);
const [editingMenu, setEditingMenu] = useState<MenuResponse | null>(null);
const [iconSelectVisible, setIconSelectVisible] = useState(false);
const [loading, setLoading] = useState(false);
const [menuTree, setMenuTree] = useState<MenuResponse[]>([]);
const dispatch = useDispatch();
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [menuTree, setMenuTree] = useState<MenuResponse[]>([]);
const [expandedKeys, setExpandedKeys] = useState<Set<number>>(new Set());
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editRecord, setEditRecord] = useState<MenuResponse>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteRecord, setDeleteRecord] = useState<MenuResponse | null>(null);
// 加载菜单树
const loadMenuTree = async () => {
try {
setLoading(true);
const data = await service.getMenuTree();
setMenuTree(data);
} catch (error) {
message.error('加载菜单数据失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadMenuTree();
}, []);
const handleAdd = () => {
setEditingMenu(null);
form.resetFields();
form.setFieldsValue({
type: MenuTypeEnum.MENU,
sort: 1,
hidden: false
// 加载菜单树
const loadMenuTree = async () => {
try {
setLoading(true);
const data = await getMenuTree();
setMenuTree(data);
// 默认展开所有节点
const allIds = new Set<number>();
const collectIds = (menus: MenuResponse[]) => {
menus.forEach(menu => {
if (menu.children && menu.children.length > 0) {
allIds.add(menu.id);
collectIds(menu.children);
}
});
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);
form.setFieldsValue({
...record,
parentId: record.parentId === 0 ? undefined : record.parentId
});
setModalVisible(true);
};
// 递归渲染菜单行
const renderMenuRow = (menu: MenuResponse, level: number = 0): React.ReactNode[] => {
const hasChildren = menu.children && menu.children.length > 0;
const isExpanded = expandedKeys.has(menu.id);
const rows: React.ReactNode[] = [];
const handleDelete = async (id: number) => {
try {
await service.deleteMenu(id);
message.success('删除成功');
loadMenuTree();
await updateReduxMenus();
} catch (error) {
message.error('删除失败');
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
if (editingMenu) {
await service.updateMenu(editingMenu.id, {
...values,
version: editingMenu.version
});
message.success('更新成功');
} else {
await service.createMenu(values);
message.success('创建成功');
}
setModalVisible(false);
loadMenuTree();
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>
// 当前菜单行
rows.push(
<TableRow key={menu.id} className="hover:bg-muted/50">
<TableCell>
<div className="flex items-center gap-2" style={{ paddingLeft: `${level * 24}px` }}>
{hasChildren ? (
<button
onClick={() => toggleExpand(menu.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">{menu.name}</span>
</div>
</TableCell>
<TableCell>
{menu.icon && (
<div className="flex items-center">
{getIconComponent(menu.icon)}
</div>
<Table
columns={columns}
dataSource={menuTree}
rowKey="id"
loading={loading}
pagination={false}
size="middle"
bordered
defaultExpandAllRows
/>
<Modal
title={editingMenu ? '编辑菜单' : '新增菜单'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
width={600}
)}
</TableCell>
<TableCell className="text-sm">{menu.path || '-'}</TableCell>
<TableCell className="text-sm max-w-[200px] truncate" title={menu.component}>
{menu.component || '-'}
</TableCell>
<TableCell>{getTypeBadge(menu.type)}</TableCell>
<TableCell className="text-center">{menu.sort}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(menu)}
title="编辑"
>
<Form
form={form}
layout="vertical"
>
<Form.Item
name="type"
label="菜单类型"
rules={[{ required: true, message: '请选择菜单类型' }]}
>
<Select>
<Select.Option value={MenuTypeEnum.DIRECTORY}></Select.Option>
<Select.Option value={MenuTypeEnum.MENU}></Select.Option>
<Select.Option value={MenuTypeEnum.BUTTON}></Select.Option>
</Select>
</Form.Item>
<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>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(menu)}
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) {
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;