重构前端逻辑

This commit is contained in:
dengqichen 2025-11-11 10:10:11 +08:00
parent 161978b1be
commit 13ae354929
5 changed files with 270 additions and 83 deletions

View File

@ -1,17 +1,18 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
DialogBody,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { Loader2, Tag as TagIcon } from 'lucide-react'; import { Loader2, Tag as TagIcon, Search, CheckCircle2 } from 'lucide-react';
import type { RoleTagResponse } from '../types'; import type { RoleTagResponse } from '../types';
import { getAllTags, assignTags } from '../service'; import { getAllTags, assignTags } from '../service';
@ -38,6 +39,7 @@ const AssignTagDialog: React.FC<AssignTagDialogProps> = ({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [allTags, setAllTags] = useState<RoleTagResponse[]>([]); const [allTags, setAllTags] = useState<RoleTagResponse[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]); const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
const [searchText, setSearchText] = useState('');
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@ -92,59 +94,122 @@ const AssignTagDialog: React.FC<AssignTagDialogProps> = ({
} }
}; };
// 过滤标签
const filteredTags = useMemo(() => {
if (!searchText.trim()) return allTags;
const searchLower = searchText.toLowerCase();
return allTags.filter(tag =>
tag.name.toLowerCase().includes(searchLower) ||
tag.description?.toLowerCase().includes(searchLower)
);
}, [allTags, searchText]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[580px]">
<DialogHeader> <DialogHeader className="space-y-3">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2 text-lg">
<TagIcon className="h-5 w-5" /> <div className="flex items-center justify-center w-10 h-10 rounded-lg bg-gradient-to-br from-purple-500 to-pink-500">
<TagIcon className="h-5 w-5 text-white" />
</div>
<div className="flex-1">
<div className="font-semibold"></div>
<div className="text-sm font-normal text-muted-foreground mt-0.5">
</div>
</div>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="px-6 py-4">
<DialogBody>
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin mr-2" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">...</span> <span className="ml-3 text-sm text-muted-foreground">...</span>
</div> </div>
) : ( ) : (
<> <div className="space-y-4">
<Label className="mb-3 block"></Label> {/* 搜索框 */}
<div className="space-y-3 max-h-[300px] overflow-y-auto border rounded-md p-4"> {allTags.length > 0 && (
{allTags.length > 0 ? ( <div className="relative">
allTags.map(tag => ( <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<div key={tag.id} className="flex items-center space-x-3"> <Input
placeholder="搜索标签名称或描述..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-9"
/>
</div>
)}
{/* 统计信息 */}
<div className="flex items-center justify-between px-1 py-2 rounded-lg bg-muted/50">
<span className="text-sm text-muted-foreground">
{filteredTags.length}
</span>
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium">
{selectedTagIds.length}
</span>
</div>
</div>
{/* 标签列表 */}
<div className="space-y-2 max-h-[360px] overflow-y-auto pr-2 -mr-2">
{filteredTags.length > 0 ? (
filteredTags.map(tag => (
<div
key={tag.id}
className="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer group"
onClick={() => handleToggle(tag.id)}
>
<Checkbox <Checkbox
id={`tag-${tag.id}`} id={`tag-${tag.id}`}
checked={selectedTagIds.includes(tag.id)} checked={selectedTagIds.includes(tag.id)}
onCheckedChange={() => handleToggle(tag.id)} onCheckedChange={() => handleToggle(tag.id)}
onClick={(e) => e.stopPropagation()}
/> />
<label <div className="flex-1 min-w-0">
htmlFor={`tag-${tag.id}`} <div className="flex items-center gap-2 mb-1">
className="flex items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer flex-1"
>
<Badge <Badge
variant="outline" variant="secondary"
style={{ backgroundColor: tag.color, color: '#fff' }} className="font-medium"
style={{
backgroundColor: tag.color + '20',
color: tag.color,
borderColor: tag.color + '40'
}}
> >
{tag.name} {tag.name}
</Badge> </Badge>
</div>
{tag.description && ( {tag.description && (
<span className="text-muted-foreground text-xs"> <p className="text-xs text-muted-foreground line-clamp-1">
{tag.description} {tag.description}
</span> </p>
)} )}
</label> </div>
</div> </div>
)) ))
) : searchText ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Search className="w-12 h-12 mb-3 opacity-20" />
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div>
) : ( ) : (
<div className="text-sm text-muted-foreground text-center py-4"> <div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<TagIcon className="w-12 h-12 mb-3 opacity-20" />
<p className="text-sm"></p>
<p className="text-xs mt-1"></p>
</div> </div>
)} )}
</div> </div>
</>
)}
</div> </div>
)}
</DialogBody>
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"
@ -153,7 +218,12 @@ const AssignTagDialog: React.FC<AssignTagDialogProps> = ({
> >
</Button> </Button>
<Button onClick={handleSubmit} disabled={submitting || loading}> <Button
onClick={handleSubmit}
disabled={submitting || loading}
className="min-w-[100px]"
>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{submitting ? '保存中...' : '确定'} {submitting ? '保存中...' : '确定'}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -16,8 +16,9 @@ import { getPermissionTree } from '../service';
interface PermissionDialogProps { interface PermissionDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onConfirm: (permissionIds: number[]) => Promise<void>; onConfirm: (menuIds: number[], permissionIds: number[]) => Promise<void>;
defaultCheckedKeys?: number[]; defaultMenuIds?: number[];
defaultPermissionIds?: number[];
} }
interface Permission { interface Permission {
@ -40,17 +41,30 @@ interface MenuItem {
/** /**
* 使 Accordion * 使 Accordion
*
*
* 1.
* 2.
* 3. ID和权限ID
* 4.
*
*
* - "用户管理" "用户管理" + "系统管理"
* - "用户管理" "系统管理" +
* - ID和权限ID
*/ */
const PermissionDialog: React.FC<PermissionDialogProps> = ({ const PermissionDialog: React.FC<PermissionDialogProps> = ({
open, open,
onOpenChange, onOpenChange,
onConfirm, onConfirm,
defaultCheckedKeys = [] defaultMenuIds = [],
defaultPermissionIds = []
}) => { }) => {
const { toast } = useToast(); const { toast } = useToast();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [menuTree, setMenuTree] = useState<MenuItem[]>([]); const [menuTree, setMenuTree] = useState<MenuItem[]>([]);
const [checkedMenuIds, setCheckedMenuIds] = useState<number[]>([]);
const [checkedPermissionIds, setCheckedPermissionIds] = useState<number[]>([]); const [checkedPermissionIds, setCheckedPermissionIds] = useState<number[]>([]);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
@ -62,7 +76,8 @@ const PermissionDialog: React.FC<PermissionDialogProps> = ({
try { try {
const response = await getPermissionTree(); const response = await getPermissionTree();
setMenuTree(response); setMenuTree(response);
setCheckedPermissionIds(defaultCheckedKeys); setCheckedMenuIds(defaultMenuIds);
setCheckedPermissionIds(defaultPermissionIds);
} catch (error) { } catch (error) {
console.error('获取权限树失败:', error); console.error('获取权限树失败:', error);
toast({ toast({
@ -76,17 +91,89 @@ const PermissionDialog: React.FC<PermissionDialogProps> = ({
}; };
loadPermissionTree(); loadPermissionTree();
}, [open, defaultCheckedKeys]); }, [open, defaultMenuIds, defaultPermissionIds]);
// 切换权限选中状态 // 获取某个菜单的所有父菜单ID递归向上查找
const handleTogglePermission = (permissionId: number) => { const getParentMenuIds = (targetMenuId: number, menus: MenuItem[], parentIds: number[] = []): number[] => {
setCheckedPermissionIds(prev => for (const menu of menus) {
prev.includes(permissionId) if (menu.id === targetMenuId) {
? prev.filter(id => id !== permissionId) return parentIds;
: [...prev, permissionId] }
if (menu.permissionChildren && menu.permissionChildren.length > 0) {
const found = menu.permissionChildren.some(child => child.id === targetMenuId);
if (found) {
return [...parentIds, menu.id];
}
const result = getParentMenuIds(targetMenuId, menu.permissionChildren, [...parentIds, menu.id]);
if (result.length > 0) {
return result;
}
}
}
return [];
};
// 根据权限ID找到它所属的菜单
const getMenuByPermissionId = (permissionId: number, menus: MenuItem[]): MenuItem | null => {
for (const menu of menus) {
if (menu.permissions?.some(p => p.id === permissionId)) {
return menu;
}
if (menu.permissionChildren && menu.permissionChildren.length > 0) {
const result = getMenuByPermissionId(permissionId, menu.permissionChildren);
if (result) return result;
}
}
return null;
};
// 切换菜单选中状态
const handleToggleMenuId = (menuId: number) => {
setCheckedMenuIds(prev =>
prev.includes(menuId)
? prev.filter(id => id !== menuId)
: [...prev, menuId]
); );
}; };
// 切换权限选中状态(带级联)
const handleTogglePermission = (permissionId: number) => {
setCheckedPermissionIds(prev => {
if (prev.includes(permissionId)) {
// 取消勾选权限
return prev.filter(id => id !== permissionId);
} else {
// 勾选权限:自动勾选所属菜单及其所有父菜单
const menu = getMenuByPermissionId(permissionId, menuTree);
if (menu) {
const parentIds = getParentMenuIds(menu.id, menuTree);
const allParentMenuIds = [...parentIds, menu.id];
// 添加所有父菜单ID
setCheckedMenuIds(prevMenuIds => {
const newMenuIds = new Set(prevMenuIds);
allParentMenuIds.forEach(id => newMenuIds.add(id));
return Array.from(newMenuIds);
});
}
// 添加权限ID
return [...prev, permissionId];
}
});
};
// 递归获取菜单下所有子菜单ID包括自己
const getAllMenuIds = (menu: MenuItem): number[] => {
const ids: number[] = [menu.id];
if (menu.permissionChildren) {
menu.permissionChildren.forEach(child => {
ids.push(...getAllMenuIds(child));
});
}
return ids;
};
// 递归获取菜单下所有权限ID // 递归获取菜单下所有权限ID
const getAllPermissionIds = (menu: MenuItem): number[] => { const getAllPermissionIds = (menu: MenuItem): number[] => {
const ids: number[] = []; const ids: number[] = [];
@ -101,37 +188,64 @@ const PermissionDialog: React.FC<PermissionDialogProps> = ({
return ids; return ids;
}; };
// 检查菜单下所有权限是否全选 // 检查菜单是否应该显示为选中状态
const isMenuChecked = (menu: MenuItem): boolean => {
// 如果菜单ID在checkedMenuIds中直接返回true
if (checkedMenuIds.includes(menu.id)) {
return true;
}
// 否则检查菜单下所有权限是否都被选中
const allPermissionIds = getAllPermissionIds(menu);
return allPermissionIds.length > 0 && allPermissionIds.every(id => checkedPermissionIds.includes(id));
};
// 检查菜单下所有权限是否全选(用于判断取消逻辑)
const isMenuFullyChecked = (menu: MenuItem): boolean => { const isMenuFullyChecked = (menu: MenuItem): boolean => {
const allIds = getAllPermissionIds(menu); const allIds = getAllPermissionIds(menu);
return allIds.length > 0 && allIds.every(id => checkedPermissionIds.includes(id)); return allIds.length > 0 && allIds.every(id => checkedPermissionIds.includes(id));
}; };
// 检查菜单下是否有部分权限选中 // 检查菜单下是否有部分权限选中(用于半选状态)
const isMenuPartiallyChecked = (menu: MenuItem): boolean => { const isMenuPartiallyChecked = (menu: MenuItem): boolean => {
// 如果菜单ID在checkedMenuIds中不显示半选
if (checkedMenuIds.includes(menu.id)) {
return false;
}
// 检查是否有部分权限被选中
const allIds = getAllPermissionIds(menu); const allIds = getAllPermissionIds(menu);
const checkedCount = allIds.filter(id => checkedPermissionIds.includes(id)).length; const checkedCount = allIds.filter(id => checkedPermissionIds.includes(id)).length;
return checkedCount > 0 && checkedCount < allIds.length; return checkedCount > 0 && checkedCount < allIds.length;
}; };
// 切换菜单的全选/取消全选 // 切换菜单的全选/取消全选(带级联)
const handleToggleMenu = (menu: MenuItem) => { const handleToggleMenu = (menu: MenuItem) => {
const allIds = getAllPermissionIds(menu); const allPermissionIds = getAllPermissionIds(menu);
const isFullyChecked = isMenuFullyChecked(menu); const allMenuIds = getAllMenuIds(menu);
// 使用 isMenuChecked 来判断当前状态
const currentChecked = isMenuChecked(menu);
if (isFullyChecked) { if (currentChecked) {
// 取消全选 // 取消全选移除菜单ID和权限ID
setCheckedPermissionIds(prev => prev.filter(id => !allIds.includes(id))); setCheckedMenuIds(prev => prev.filter(id => !allMenuIds.includes(id)));
setCheckedPermissionIds(prev => prev.filter(id => !allPermissionIds.includes(id)));
} else { } else {
// 全选 // 全选添加菜单ID、权限ID并自动勾选所有父菜单
setCheckedPermissionIds(prev => { const parentIds = getParentMenuIds(menu.id, menuTree);
const newIds = [...prev];
allIds.forEach(id => { setCheckedMenuIds(prev => {
if (!newIds.includes(id)) { const newIds = new Set(prev);
newIds.push(id); // 添加当前菜单及所有子菜单ID
} allMenuIds.forEach(id => newIds.add(id));
// 添加所有父菜单ID
parentIds.forEach(id => newIds.add(id));
return Array.from(newIds);
}); });
return newIds;
setCheckedPermissionIds(prev => {
const newIds = new Set(prev);
// 添加当前菜单及所有子菜单的权限ID
allPermissionIds.forEach(id => newIds.add(id));
return Array.from(newIds);
}); });
} }
}; };
@ -179,7 +293,7 @@ const PermissionDialog: React.FC<PermissionDialogProps> = ({
const renderMenuItem = (menu: MenuItem, level: number = 0): React.ReactNode => { const renderMenuItem = (menu: MenuItem, level: number = 0): React.ReactNode => {
const hasChildren = menu.permissionChildren && menu.permissionChildren.length > 0; const hasChildren = menu.permissionChildren && menu.permissionChildren.length > 0;
const hasPermissions = menu.permissions && menu.permissions.length > 0; const hasPermissions = menu.permissions && menu.permissions.length > 0;
const isChecked = isMenuFullyChecked(menu); const isChecked = isMenuChecked(menu);
const isPartial = isMenuPartiallyChecked(menu); const isPartial = isMenuPartiallyChecked(menu);
if (!hasChildren && !hasPermissions) { if (!hasChildren && !hasPermissions) {
@ -281,7 +395,7 @@ const PermissionDialog: React.FC<PermissionDialogProps> = ({
const handleSubmit = async () => { const handleSubmit = async () => {
setSubmitting(true); setSubmitting(true);
try { try {
await onConfirm(checkedPermissionIds); await onConfirm(checkedMenuIds, checkedPermissionIds);
onOpenChange(false); onOpenChange(false);
} catch (error) { } catch (error) {
console.error('分配权限失败:', error); console.error('分配权限失败:', error);

View File

@ -10,7 +10,7 @@ import {
ShieldCheck, Settings, Shield ShieldCheck, Settings, Shield
} from 'lucide-react'; } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { getRoleList, deleteRole, getRolePermissions, assignPermissions } from './service'; import { getRoleList, deleteRole, getRoleMenusAndPermissions, assignMenusAndPermissions } from './service';
import type { RoleResponse, RoleQuery } from './types'; import type { RoleResponse, RoleQuery } from './types';
import type { Page } from '@/types/base'; import type { Page } from '@/types/base';
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page'; import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
@ -36,7 +36,8 @@ const RolePage: React.FC = () => {
const [tagDialogOpen, setTagDialogOpen] = useState(false); const [tagDialogOpen, setTagDialogOpen] = useState(false);
const [assignTagDialogOpen, setAssignTagDialogOpen] = useState(false); const [assignTagDialogOpen, setAssignTagDialogOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<RoleResponse | null>(null); const [selectedRole, setSelectedRole] = useState<RoleResponse | null>(null);
const [defaultCheckedKeys, setDefaultCheckedKeys] = useState<number[]>([]); const [defaultMenuIds, setDefaultMenuIds] = useState<number[]>([]);
const [defaultPermissionIds, setDefaultPermissionIds] = useState<number[]>([]);
const [query, setQuery] = useState<RoleQuery>({ const [query, setQuery] = useState<RoleQuery>({
pageNum: DEFAULT_CURRENT - 1, pageNum: DEFAULT_CURRENT - 1,
pageSize: DEFAULT_PAGE_SIZE, pageSize: DEFAULT_PAGE_SIZE,
@ -123,16 +124,11 @@ const RolePage: React.FC = () => {
// 分配权限 // 分配权限
const handleAssignPermissions = async (record: RoleResponse) => { const handleAssignPermissions = async (record: RoleResponse) => {
try { try {
const permissions = await getRolePermissions(record.id); const data = await getRoleMenusAndPermissions(record.id);
setSelectedRole(record); setSelectedRole(record);
setPermissionDialogOpen(true); setPermissionDialogOpen(true);
setDefaultCheckedKeys( setDefaultMenuIds(data.menuIds || []);
Array.isArray(permissions) setDefaultPermissionIds(data.permissionIds || []);
? typeof permissions[0] === 'number'
? permissions
: permissions.map((p: any) => p.id)
: []
);
} catch (error) { } catch (error) {
console.error('获取角色权限失败:', error); console.error('获取角色权限失败:', error);
toast({ toast({
@ -143,10 +139,10 @@ const RolePage: React.FC = () => {
} }
}; };
const confirmAssignPermissions = async (permissionIds: number[]) => { const confirmAssignPermissions = async (menuIds: number[], permissionIds: number[]) => {
if (!selectedRole) return; if (!selectedRole) return;
try { try {
await assignPermissions(selectedRole.id, permissionIds); await assignMenusAndPermissions(selectedRole.id, { menuIds, permissionIds });
toast({ toast({
title: '分配成功', title: '分配成功',
description: `已为角色 "${selectedRole.name}" 分配权限`, description: `已为角色 "${selectedRole.name}" 分配权限`,
@ -409,7 +405,8 @@ const RolePage: React.FC = () => {
open={permissionDialogOpen} open={permissionDialogOpen}
onOpenChange={setPermissionDialogOpen} onOpenChange={setPermissionDialogOpen}
onConfirm={confirmAssignPermissions} onConfirm={confirmAssignPermissions}
defaultCheckedKeys={defaultCheckedKeys} defaultMenuIds={defaultMenuIds}
defaultPermissionIds={defaultPermissionIds}
/> />
<AssignTagDialog <AssignTagDialog
open={assignTagDialogOpen} open={assignTagDialogOpen}

View File

@ -1,5 +1,5 @@
import request from '@/utils/request'; import request from '@/utils/request';
import type {RoleResponse, RoleRequest, RoleQuery, RoleTagResponse, RoleTagRequest, Permission} from './types'; import type {RoleResponse, RoleRequest, RoleQuery, RoleTagResponse, RoleTagRequest, Permission, MenusAndPermissions} from './types';
import {Page} from "@/types/base.ts"; import {Page} from "@/types/base.ts";
const BASE_URL = '/api/v1/role'; const BASE_URL = '/api/v1/role';
@ -31,11 +31,11 @@ export const updateRoleTag = (id: number, data: RoleTagRequest) => request.put(`
export const createRoleTag = (data: RoleTagRequest) => request.post<RoleTagResponse>(ROLE_TAG_BASE_URL, data); export const createRoleTag = (data: RoleTagRequest) => request.post<RoleTagResponse>(ROLE_TAG_BASE_URL, data);
// 获取角色的权限列表 // 获取角色的菜单和权限列表
export const getRolePermissions = (roleId: number) => request.get<string[]>(`${BASE_URL}/${roleId}/permissions`); export const getRoleMenusAndPermissions = (roleId: number) => request.get<MenusAndPermissions>(`${BASE_URL}/${roleId}/menus-and-permissions`);
// 分配权限 // 分配菜单和权限
export const assignPermissions = (roleId: number, permissionIds: Number[]) => request.post(`${BASE_URL}/${roleId}/permissions`, permissionIds); export const assignMenusAndPermissions = (roleId: number, data: MenusAndPermissions) => request.post(`${BASE_URL}/${roleId}/menus-and-permissions`, data);
// 获取所有权限列表 // 获取所有权限列表
export const getAllPermissions = () => request.get<Permission[]>(`${PERMISSION_BASE_URL}/list`); export const getAllPermissions = () => request.get<Permission[]>(`${PERMISSION_BASE_URL}/list`);

View File

@ -68,3 +68,9 @@ export interface Permission {
sort: number; sort: number;
description?: string; description?: string;
} }
// 菜单和权限数据
export interface MenusAndPermissions {
menuIds: number[];
permissionIds: number[];
}