重构前端逻辑

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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogBody,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
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 { Loader2, Tag as TagIcon } from 'lucide-react';
import { Loader2, Tag as TagIcon, Search, CheckCircle2 } from 'lucide-react';
import type { RoleTagResponse } from '../types';
import { getAllTags, assignTags } from '../service';
@ -38,6 +39,7 @@ const AssignTagDialog: React.FC<AssignTagDialogProps> = ({
const [submitting, setSubmitting] = useState(false);
const [allTags, setAllTags] = useState<RoleTagResponse[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
const [searchText, setSearchText] = useState('');
useEffect(() => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TagIcon className="h-5 w-5" />
<DialogContent className="sm:max-w-[580px]">
<DialogHeader className="space-y-3">
<DialogTitle className="flex items-center gap-2 text-lg">
<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>
</DialogHeader>
<div className="px-6 py-4">
<DialogBody>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">...</span>
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-3 text-sm text-muted-foreground">...</span>
</div>
) : (
<>
<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.map(tag => (
<div key={tag.id} className="flex items-center space-x-3">
<div className="space-y-4">
{/* 搜索框 */}
{allTags.length > 0 && (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<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
id={`tag-${tag.id}`}
checked={selectedTagIds.includes(tag.id)}
onCheckedChange={() => handleToggle(tag.id)}
onClick={(e) => e.stopPropagation()}
/>
<label
htmlFor={`tag-${tag.id}`}
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
variant="outline"
style={{ backgroundColor: tag.color, color: '#fff' }}
>
{tag.name}
</Badge>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge
variant="secondary"
className="font-medium"
style={{
backgroundColor: tag.color + '20',
color: tag.color,
borderColor: tag.color + '40'
}}
>
{tag.name}
</Badge>
</div>
{tag.description && (
<span className="text-muted-foreground text-xs">
<p className="text-xs text-muted-foreground line-clamp-1">
{tag.description}
</span>
</p>
)}
</label>
</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>
</DialogBody>
<DialogFooter>
<Button
variant="outline"
@ -153,7 +218,12 @@ const AssignTagDialog: React.FC<AssignTagDialogProps> = ({
>
</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 ? '保存中...' : '确定'}
</Button>
</DialogFooter>

View File

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

View File

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

View File

@ -1,5 +1,5 @@
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";
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 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`);

View File

@ -67,4 +67,10 @@ export interface Permission {
enabled: boolean;
sort: number;
description?: string;
}
}
// 菜单和权限数据
export interface MenusAndPermissions {
menuIds: number[];
permissionIds: number[];
}