完成评查点分组列表和评查点列表的页面,封装部分组件,重新构造样式文件结构
This commit is contained in:
@@ -34,7 +34,7 @@ export function Layout({ children }: LayoutProps) {
|
||||
<div className={`main-content ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
|
||||
{/* <Header username="系统管理员" /> */}
|
||||
<div className="content-container">
|
||||
<Breadcrumb className="px-6 pt-4" />
|
||||
<Breadcrumb />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,17 +58,23 @@ export function Sidebar({ onToggle, collapsed }: SidebarProps) {
|
||||
icon: 'ri-folder-open-line'
|
||||
},
|
||||
{
|
||||
id: 'rule-list',
|
||||
id: 'rules-list',
|
||||
title: '评查点列表',
|
||||
path: '/rules',
|
||||
icon: 'ri-list-check-3'
|
||||
},
|
||||
// {
|
||||
// id: 'rule-new',
|
||||
// title: '新增评查点',
|
||||
// path: '/rules/new',
|
||||
// icon: 'ri-add-circle-line'
|
||||
// }
|
||||
{
|
||||
id: 'rules-file',
|
||||
title: '评查文件列表',
|
||||
path: '/rules/files',
|
||||
icon: 'ri-list-check-2'
|
||||
},
|
||||
{
|
||||
id: 'rule-new',
|
||||
title: '新增评查点',
|
||||
path: '/rules/new',
|
||||
icon: 'ri-add-circle-line'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -125,9 +131,12 @@ export function Sidebar({ onToggle, collapsed }: SidebarProps) {
|
||||
}, []);
|
||||
|
||||
const toggleMenu = (id: string, e: React.MouseEvent) => {
|
||||
// 防止事件冒泡和默认行为
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// console.log('%c父菜单展开/折叠 ===> ', 'background: #f5222d; color: white; padding: 2px 4px; border-radius: 2px;', id);
|
||||
|
||||
setExpandedMenus(prev => ({
|
||||
...prev,
|
||||
[id]: !prev[id]
|
||||
@@ -140,11 +149,19 @@ export function Sidebar({ onToggle, collapsed }: SidebarProps) {
|
||||
|
||||
// 处理侧边栏切换事件
|
||||
const handleToggleSidebar = (e: React.MouseEvent) => {
|
||||
// console.log('%c侧边栏折叠/展开 ===> ', 'background: #1890ff; color: white; padding: 2px 4px; border-radius: 2px;');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
};
|
||||
|
||||
// 处理子菜单项点击事件
|
||||
const handleSubMenuClick = (child: MenuItem, e: React.MouseEvent) => {
|
||||
// 需要阻止冒泡,否则会触发父级菜单的展开/折叠事件
|
||||
e.stopPropagation();
|
||||
// console.log('%c子菜单点击 ===> ', 'background: #00684a; color: white; padding: 2px 4px; border-radius: 2px;', child.title, '路径:', child.path);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="py-6 px-4 border-b border-gray-100 flex justify-between items-center">
|
||||
@@ -181,6 +198,10 @@ export function Sidebar({ onToggle, collapsed }: SidebarProps) {
|
||||
<Link
|
||||
to={item.path}
|
||||
className={`sidebar-menu-item ${isActive(item.path) ? 'active' : ''} flex items-center ${collapsed ? 'justify-center' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// console.log('%c单级菜单点击 ===> ', 'background: #52c41a; color: white; padding: 2px 4px; border-radius: 2px;', item.title, '路径:', item.path);
|
||||
}}
|
||||
>
|
||||
<i className={`${item.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
@@ -188,8 +209,11 @@ export function Sidebar({ onToggle, collapsed }: SidebarProps) {
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`sidebar-menu-item flex items-center ${collapsed ? 'justify-center' : 'justify-between'} cursor-pointer`}
|
||||
onClick={(e) => toggleMenu(item.id, e)}
|
||||
className={`sidebar-menu-item flex items-center ${collapsed ? 'justify-center' : 'justify-between'} cursor-pointer z-10`}
|
||||
onClick={(e) => {
|
||||
// console.log('%c父菜单点击 ===> ', 'background: #722ed1; color: white; padding: 2px 4px; border-radius: 2px;', item.title);
|
||||
toggleMenu(item.id, e);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expandedMenus[item.id] || false}
|
||||
@@ -212,7 +236,7 @@ export function Sidebar({ onToggle, collapsed }: SidebarProps) {
|
||||
|
||||
{(expandedMenus[item.id] || collapsed) && (
|
||||
<div
|
||||
className={`${collapsed ? 'border-l-0 pl-0' : 'border-l border-gray-100 ml-4 pl-3'}`}
|
||||
className={`submenu-container ${collapsed ? 'border-l-0 pl-0' : 'border-l border-gray-100 ml-4 pl-3'} z-20`}
|
||||
id={`submenu-${item.id}`}
|
||||
>
|
||||
{item.children.map((child) => (
|
||||
@@ -220,6 +244,7 @@ export function Sidebar({ onToggle, collapsed }: SidebarProps) {
|
||||
key={child.id}
|
||||
to={child.path}
|
||||
className={`sidebar-menu-item ${isActive(child.path) ? 'active' : ''} flex items-center ${collapsed ? 'justify-center' : ''}`}
|
||||
onClick={(e) => handleSubMenuClick(child, e)}
|
||||
>
|
||||
<i className={`${child.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
|
||||
{!collapsed && <span>{child.title}</span>}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// app/components/ui/Modal.tsx
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, footer, width = 500 }: ModalProps) {
|
||||
// 点击ESC键关闭模态框
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// 禁用背景滚动
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
style={{ maxWidth: typeof width === 'number' ? `${width}px` : width }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3 className="text-lg font-medium">{title}</h3>
|
||||
<button
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<i className="ri-close-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body py-4">
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="modal-footer flex justify-end space-x-2 pt-4 border-t border-gray-100">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// app/components/ui/Pagination.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
onChange: (page: number) => void;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
pageSizeOptions?: number[];
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
currentPage,
|
||||
total,
|
||||
pageSize,
|
||||
onChange,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [10, 20, 50]
|
||||
}: PaginationProps) {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
// 生成页码数组
|
||||
const getPageNumbers = () => {
|
||||
const pages = [];
|
||||
const maxVisiblePages = 5;
|
||||
|
||||
if (totalPages <= maxVisiblePages) {
|
||||
// 总页数小于等于最大可见页数,显示所有页码
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else if (currentPage <= 3) {
|
||||
// 当前页靠近开始
|
||||
for (let i = 1; i <= maxVisiblePages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
// 当前页靠近结尾
|
||||
for (let i = totalPages - maxVisiblePages + 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// 当前页在中间
|
||||
for (let i = currentPage - 2; i <= currentPage + 2; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center p-4">
|
||||
{onPageSizeChange && (
|
||||
<div className="ant-pagination-options">
|
||||
<span className="text-sm mr-2">共 {total} 条</span>
|
||||
<select
|
||||
className="form-select ant-pagination-options-size-changer"
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
>
|
||||
{pageSizeOptions.map(size => (
|
||||
<option key={size} value={size}>{size} 条/页</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ant-pagination">
|
||||
<button
|
||||
className={`ant-pagination-item ant-pagination-prev ${currentPage <= 1 ? 'ant-pagination-disabled' : ''}`}
|
||||
onClick={() => currentPage > 1 && onChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
aria-label="上一页"
|
||||
>
|
||||
<i className="ri-arrow-left-s-line"></i>
|
||||
</button>
|
||||
|
||||
{getPageNumbers().map(page => (
|
||||
<button
|
||||
key={page}
|
||||
className={`ant-pagination-item ${page === currentPage ? 'ant-pagination-item-active' : ''}`}
|
||||
onClick={() => onChange(page)}
|
||||
aria-label={`第${page}页`}
|
||||
aria-current={page === currentPage ? 'page' : undefined}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
className={`ant-pagination-item ant-pagination-next ${currentPage >= totalPages ? 'ant-pagination-disabled' : ''}`}
|
||||
onClick={() => currentPage < totalPages && onChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<i className="ri-arrow-right-s-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface SearchBoxProps {
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
onSearch: (value: string) => void;
|
||||
buttonText?: string;
|
||||
className?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function SearchBox({
|
||||
placeholder = '请输入搜索内容',
|
||||
defaultValue = '',
|
||||
onSearch,
|
||||
buttonText = '搜索',
|
||||
className = '',
|
||||
name = 'keyword'
|
||||
}: SearchBoxProps) {
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const value = formData.get(name) as string;
|
||||
onSearch(value);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// 对于没有按钮的输入框,我们希望在输入时就触发搜索
|
||||
if (className.includes('form-input-only')) {
|
||||
onSearch(e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={`search-box ${className}`}>
|
||||
<input
|
||||
type="text"
|
||||
id={name}
|
||||
name={name}
|
||||
className={className.includes('form-input-only') ? "form-input" : "form-input rounded-r-none"}
|
||||
placeholder={placeholder}
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{buttonText && !className.includes('form-input-only') && (
|
||||
<Button type="primary" icon="ri-search-line" className="rounded-l-none">
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
type StatusType = 'success' | 'error' | 'warning' | 'default' | 'processing';
|
||||
|
||||
interface StatusDotProps {
|
||||
status: StatusType | boolean;
|
||||
text?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatusDot({
|
||||
status,
|
||||
text,
|
||||
className = ''
|
||||
}: StatusDotProps) {
|
||||
// 如果status是布尔值,则转换为对应的状态类型
|
||||
const statusType = typeof status === 'boolean'
|
||||
? (status ? 'success' : 'default')
|
||||
: status;
|
||||
|
||||
// 如果没有提供文本,则根据状态类型提供默认文本
|
||||
const statusText = text ?? (
|
||||
statusType === 'success' ? '启用' :
|
||||
statusType === 'error' ? '禁用' :
|
||||
statusType === 'warning' ? '警告' :
|
||||
statusType === 'processing' ? '处理中' : '未知'
|
||||
);
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center ${className}`}>
|
||||
<i className={`status-dot status-dot-${statusType}`}></i>
|
||||
{statusText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,58 +3,15 @@ import React from 'react';
|
||||
export type TagColor = 'blue' | 'green' | 'cyan' | 'purple' | 'orange' | 'red' | 'default';
|
||||
|
||||
interface TagProps {
|
||||
children: React.ReactNode;
|
||||
color?: TagColor;
|
||||
closable?: boolean;
|
||||
onClose?: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Tag({
|
||||
children,
|
||||
color = 'default',
|
||||
closable = false,
|
||||
onClose,
|
||||
className = '',
|
||||
}: TagProps) {
|
||||
const baseClasses = 'ant-tag';
|
||||
|
||||
const colorClasses = {
|
||||
default: '',
|
||||
blue: 'ant-tag-blue',
|
||||
green: 'ant-tag-green',
|
||||
cyan: 'ant-tag-cyan',
|
||||
purple: 'ant-tag-purple',
|
||||
orange: 'ant-tag-orange',
|
||||
red: 'ant-tag-red'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
baseClasses,
|
||||
colorClasses[color],
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClose?.(e as unknown as React.MouseEvent<HTMLElement>);
|
||||
}
|
||||
};
|
||||
|
||||
export function Tag({ color = 'default', children, className = '' }: TagProps) {
|
||||
return (
|
||||
<span className={classes}>
|
||||
<span className={`ant-tag ant-tag-${color} ${className}`}>
|
||||
{children}
|
||||
{closable && (
|
||||
<i
|
||||
className="ri-close-line ml-1 cursor-pointer text-opacity-60 hover:text-opacity-100"
|
||||
onClick={onClose}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="关闭标签"
|
||||
></i>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user