完成评查点分组列表和评查点列表的页面,封装部分组件,重新构造样式文件结构
This commit is contained in:
@@ -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