封装公共组件,调整样式文件的布局,修改路由页面样式
This commit is contained in:
@@ -20,11 +20,11 @@ export function Card({
|
||||
noDivider = true,
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div className={`ant-card ${className} bg-white shadow`}>
|
||||
<div className={`card ${className}`}>
|
||||
{(title || extra) && (
|
||||
<div className={`flex justify-between items-center px-5 py-3 ${noDivider ? '' : 'border-b border-gray-100'}`}>
|
||||
<div className={`card-header ${noDivider ? '' : 'border-b border-gray-100'}`}>
|
||||
{title && (
|
||||
<div className="card-title !mb-0">
|
||||
<div className="card-title">
|
||||
{icon && <i className={`${icon} mr-2`}></i>}
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
@@ -36,7 +36,7 @@ export function Card({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`ant-card-body ${bodyClassName}`}>
|
||||
<div className={`card-body ${bodyClassName}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 文件图标组件
|
||||
* 根据文件名后缀显示不同类型的图标
|
||||
*/
|
||||
interface FileIconProps {
|
||||
fileName: string;
|
||||
className?: string;
|
||||
size?: 'default' | 'sm' | 'lg';
|
||||
}
|
||||
|
||||
export function FileIcon({ fileName, className = '', size }: FileIconProps) {
|
||||
const sizeClass = size ? `file-icon-${size}` : '';
|
||||
|
||||
let fileType = 'unknown';
|
||||
let iconClass = 'ri-file-line';
|
||||
|
||||
if (fileName.endsWith('.pdf')) {
|
||||
fileType = 'pdf';
|
||||
iconClass = 'ri-file-pdf-line';
|
||||
} else if (fileName.endsWith('.docx') || fileName.endsWith('.doc')) {
|
||||
fileType = 'doc';
|
||||
iconClass = 'ri-file-word-2-line';
|
||||
} else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
|
||||
fileType = 'xls';
|
||||
iconClass = 'ri-file-excel-2-line';
|
||||
} else if (fileName.endsWith('.pptx') || fileName.endsWith('.ppt')) {
|
||||
fileType = 'ppt';
|
||||
iconClass = 'ri-file-ppt-2-line';
|
||||
} else if (fileName.endsWith('.zip') || fileName.endsWith('.rar')) {
|
||||
fileType = 'zip';
|
||||
iconClass = 'ri-file-zip-line';
|
||||
} else if (fileName.endsWith('.png') || fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.gif')) {
|
||||
fileType = 'img';
|
||||
iconClass = 'ri-image-line';
|
||||
} else if (fileName.endsWith('.txt')) {
|
||||
fileType = 'txt';
|
||||
iconClass = 'ri-file-text-line';
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`file-icon file-icon-${fileType} ${sizeClass} ${className}`}>
|
||||
<i className={iconClass}></i>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { FileType, FILE_TYPE_LABELS } from '~/routes/rules-files';
|
||||
|
||||
interface FileTypeTagProps {
|
||||
fileType: FileType;
|
||||
className?: string;
|
||||
size?: 'default' | 'sm' | 'lg';
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件类型标签组件
|
||||
* 根据文件类型显示不同样式的标签
|
||||
*/
|
||||
export function FileTypeTag({ fileType, className = '', size }: FileTypeTagProps) {
|
||||
const sizeClass = size ? `file-type-tag-${size}` : '';
|
||||
const tagClassName = `file-type-tag file-type-tag-${fileType.toLowerCase()} ${sizeClass} file-type-tag-with-icon ${className}`;
|
||||
|
||||
// 根据文件类型选择图标
|
||||
const getFileTypeIcon = () => {
|
||||
switch (fileType) {
|
||||
case FileType.CONTRACT:
|
||||
return <i className="ri-file-list-3-line"></i>;
|
||||
case FileType.LICENSE:
|
||||
return <i className="ri-vip-crown-line"></i>;
|
||||
case FileType.PUNISHMENT:
|
||||
return <i className="ri-scales-line"></i>;
|
||||
case FileType.REPORT:
|
||||
return <i className="ri-file-chart-line"></i>;
|
||||
default:
|
||||
return <i className="ri-file-line"></i>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={tagClassName}>
|
||||
{getFileTypeIcon()}
|
||||
{FILE_TYPE_LABELS[fileType]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { SearchBox } from '~/components/ui/SearchBox';
|
||||
|
||||
interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface FilterSelectProps {
|
||||
label: string;
|
||||
name: string;
|
||||
value: string;
|
||||
options: FilterOption[];
|
||||
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选下拉选择框组件
|
||||
*/
|
||||
const FilterSelect = ({ label, name, value, options, onChange, className = '' }: FilterSelectProps) => (
|
||||
<div className={`filter-item ${className}`}>
|
||||
<label className="filter-label">{label}</label>
|
||||
<select
|
||||
className="form-select filter-control"
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{options.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface FilterPanelProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
actions?: React.ReactNode; // 按钮组,如搜索、重置按钮
|
||||
noActionDivider?: boolean; // 是否取消按钮组与内容之间的分割线
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用筛选面板组件
|
||||
* 用于包裹筛选控件
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* <FilterPanel
|
||||
* className="mb-4"
|
||||
* noActionDivider={true}
|
||||
* actions={
|
||||
* <>
|
||||
* <Button type="default" icon="ri-refresh-line" onClick={handleReset}>重置</Button>
|
||||
* <Button type="primary" icon="ri-search-line">搜索</Button>
|
||||
* </>
|
||||
* }
|
||||
* >
|
||||
* <SearchFilter label="名称" placeholder="请输入名称" value={name} onSearch={handleNameSearch} instantSearch={true} />
|
||||
* <FilterSelect label="状态" name="status" value={status} options={statusOptions} onChange={handleStatusChange} />
|
||||
* </FilterPanel>
|
||||
*/
|
||||
export function FilterPanel({ children, className = '', actions, noActionDivider = false }: FilterPanelProps) {
|
||||
return (
|
||||
<div className={`filter-panel ${className}`}>
|
||||
<div className="filter-list">
|
||||
{children}
|
||||
{actions && (
|
||||
<div className={`filter-actions ${noActionDivider ? 'filter-actions-no-divider' : ''}`}>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchFilterProps {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onSearch: (value: string) => void;
|
||||
buttonText?: string;
|
||||
className?: string;
|
||||
instantSearch?: boolean; // 是否启用即时搜索(输入内容时立即搜索)
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索筛选组件
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* // 带搜索按钮的搜索框
|
||||
* <SearchFilter
|
||||
* label="关键词搜索"
|
||||
* placeholder="请输入关键词"
|
||||
* value={keyword}
|
||||
* onSearch={handleSearch}
|
||||
* />
|
||||
*
|
||||
* // 即时搜索的搜索框(无按钮)
|
||||
* <SearchFilter
|
||||
* label="关键词搜索"
|
||||
* placeholder="请输入关键词"
|
||||
* value={keyword}
|
||||
* onSearch={handleSearch}
|
||||
* instantSearch={true}
|
||||
* />
|
||||
*/
|
||||
export function SearchFilter({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onSearch,
|
||||
buttonText = "搜索",
|
||||
className = '',
|
||||
instantSearch = false
|
||||
}: SearchFilterProps) {
|
||||
return (
|
||||
<div className={`filter-item ${className}`}>
|
||||
<label className="filter-label">{label}</label>
|
||||
<SearchBox
|
||||
placeholder={placeholder}
|
||||
defaultValue={value}
|
||||
onSearch={onSearch}
|
||||
buttonText={instantSearch ? "" : buttonText}
|
||||
className={`filter-control ${instantSearch ? 'form-input-only' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 导出筛选下拉框组件
|
||||
export { FilterSelect };
|
||||
@@ -1,5 +1,4 @@
|
||||
// app/components/ui/Pagination.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
@@ -8,6 +7,8 @@ interface PaginationProps {
|
||||
onChange: (page: number) => void;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
pageSizeOptions?: number[];
|
||||
showTotal?: boolean;
|
||||
showPageSizeChanger?: boolean;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
@@ -16,7 +17,9 @@ export function Pagination({
|
||||
pageSize,
|
||||
onChange,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [10, 20, 50]
|
||||
pageSizeOptions = [10, 20, 50],
|
||||
showTotal = true,
|
||||
showPageSizeChanger = true
|
||||
}: PaginationProps) {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
@@ -51,19 +54,24 @@ export function Pagination({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center p-4">
|
||||
{onPageSizeChange && (
|
||||
<div className="flex flex-wrap justify-between items-center p-4 gap-4">
|
||||
{showTotal && (
|
||||
<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>
|
||||
<span className="text-sm font-normal">共 {total} 条</span>
|
||||
{onPageSizeChange && showPageSizeChanger && (
|
||||
<div className="flex items-center ml-3">
|
||||
<select
|
||||
className="form-select ant-pagination-options-size-changer"
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
aria-label="每页显示条数"
|
||||
>
|
||||
{pageSizeOptions.map(size => (
|
||||
<option key={size} value={size}>{size} 条/页</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface SearchBoxProps {
|
||||
placeholder?: string;
|
||||
@@ -32,21 +31,29 @@ export function SearchBox({
|
||||
}
|
||||
};
|
||||
|
||||
const isIconOnly = buttonText === '';
|
||||
const isFilterControl = className.includes('filter-control');
|
||||
const searchBoxClass = `search-box ${className} ${isFilterControl ? 'search-box-row' : ''}`;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={`search-box ${className}`}>
|
||||
<input
|
||||
<form onSubmit={handleSubmit} className={searchBoxClass}>
|
||||
<input
|
||||
type="text"
|
||||
id={name}
|
||||
name={name}
|
||||
className={className.includes('form-input-only') ? "form-input" : "form-input rounded-r-none"}
|
||||
className={`form-input ${isFilterControl ? 'flex-1' : ''}`}
|
||||
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>
|
||||
{!className.includes('form-input-only') && (
|
||||
<button
|
||||
type="submit"
|
||||
className={`search-button ${isIconOnly ? "icon-only-btn" : ""}`}
|
||||
>
|
||||
<i className="ri-search-line"></i>
|
||||
{buttonText && <span>{buttonText}</span>}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ReviewStatus, REVIEW_STATUS_LABELS } from '~/routes/rules-files';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: ReviewStatus;
|
||||
issueCount?: number;
|
||||
className?: string;
|
||||
size?: 'default' | 'sm' | 'lg';
|
||||
clickable?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件评查状态标签组件
|
||||
* 根据评查状态显示不同样式的标签
|
||||
*/
|
||||
export function StatusBadge({
|
||||
status,
|
||||
issueCount = 0,
|
||||
className = '',
|
||||
size,
|
||||
clickable = false,
|
||||
onClick
|
||||
}: StatusBadgeProps) {
|
||||
const statusMap: Record<ReviewStatus, string> = {
|
||||
[ReviewStatus.PASS]: 'success',
|
||||
[ReviewStatus.WARNING]: 'warning',
|
||||
[ReviewStatus.FAIL]: 'error',
|
||||
[ReviewStatus.PENDING]: 'processing'
|
||||
};
|
||||
|
||||
const badgeType = statusMap[status] || 'default';
|
||||
const sizeClass = size ? `status-badge-${size}` : '';
|
||||
const clickableClass = clickable ? 'status-badge-clickable' : '';
|
||||
|
||||
// 根据状态选择图标
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case ReviewStatus.PASS:
|
||||
return <i className="ri-checkbox-circle-line"></i>;
|
||||
case ReviewStatus.WARNING:
|
||||
return <i className="ri-alert-line"></i>;
|
||||
case ReviewStatus.FAIL:
|
||||
return <i className="ri-close-circle-line"></i>;
|
||||
case ReviewStatus.PENDING:
|
||||
return <i className="ri-time-line"></i>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (clickable && onClick) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`status-badge status-badge-${badgeType} status-badge-with-icon ${sizeClass} ${clickableClass} ${className}`}
|
||||
onClick={handleClick}
|
||||
role={clickable ? "button" : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
>
|
||||
{getStatusIcon()}
|
||||
{REVIEW_STATUS_LABELS[status]}
|
||||
{issueCount > 0 && ` (${issueCount})`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,16 @@ interface StatusDotProps {
|
||||
status: StatusType | boolean;
|
||||
text?: string;
|
||||
className?: string;
|
||||
size?: 'default' | 'sm' | 'lg';
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
export function StatusDot({
|
||||
status,
|
||||
text,
|
||||
className = ''
|
||||
className = '',
|
||||
size = 'default',
|
||||
pulse = false
|
||||
}: StatusDotProps) {
|
||||
// 如果status是布尔值,则转换为对应的状态类型
|
||||
const statusType = typeof status === 'boolean'
|
||||
@@ -23,11 +27,14 @@ export function StatusDot({
|
||||
statusType === 'warning' ? '警告' :
|
||||
statusType === 'processing' ? '处理中' : '未知'
|
||||
);
|
||||
|
||||
const sizeClass = size !== 'default' ? `status-dot-${size}` : '';
|
||||
const pulseClass = pulse ? 'status-dot-pulse' : '';
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center ${className}`}>
|
||||
<i className={`status-dot status-dot-${statusType}`}></i>
|
||||
{statusText}
|
||||
<span className={`status-dot-with-text ${className}`}>
|
||||
<span className={`status-dot status-dot-${statusType} ${sizeClass} ${pulseClass}`}></span>
|
||||
<span className="status-dot-text">{statusText}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -39,8 +39,8 @@ export function Table<T extends Record<string, any>>({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`ant-table-wrapper ${className} ${loading ? 'opacity-70' : ''}`}>
|
||||
<table className="ant-table">
|
||||
<div className={`ant-table-wrapper ${className} ${loading ? 'ant-table-loading' : ''}`}>
|
||||
<table className={`ant-table ${bordered ? 'ant-table-bordered' : ''}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
@@ -86,7 +86,7 @@ export function Table<T extends Record<string, any>>({
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="py-6 text-center text-gray-500"
|
||||
className="ant-table-empty py-6 text-center text-gray-500"
|
||||
>
|
||||
{emptyText}
|
||||
</td>
|
||||
@@ -96,7 +96,7 @@ export function Table<T extends Record<string, any>>({
|
||||
</table>
|
||||
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-60 z-10">
|
||||
<div className="ant-table-loading-indicator">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-600">加载中...</span>
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
export type TagColor = 'blue' | 'green' | 'cyan' | 'purple' | 'orange' | 'red' | 'default';
|
||||
export type TagColor = 'blue' | 'green' | 'red' | 'yellow' | 'purple' | 'gray' | 'cyan' | 'orange';
|
||||
|
||||
interface TagProps {
|
||||
color?: TagColor;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
size?: 'default' | 'sm' | 'lg';
|
||||
closable?: boolean;
|
||||
clickable?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function Tag({ color = 'default', children, className = '' }: TagProps) {
|
||||
export function Tag({
|
||||
color = 'blue',
|
||||
children,
|
||||
className = '',
|
||||
size,
|
||||
closable = false,
|
||||
clickable = false,
|
||||
onClose
|
||||
}: TagProps) {
|
||||
const sizeClass = size ? `tag-${size}` : '';
|
||||
const closableClass = closable ? 'tag-closable' : '';
|
||||
const clickableClass = clickable ? 'tag-clickable' : '';
|
||||
|
||||
const handleClose = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`ant-tag ant-tag-${color} ${className}`}>
|
||||
<span className={`tag tag-${color} ${sizeClass} ${closableClass} ${clickableClass} ${className}`}>
|
||||
{children}
|
||||
{closable && (
|
||||
<span className="tag-close-icon" onClick={handleClose}>
|
||||
<i className="ri-close-line"></i>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user