封装公共组件,调整样式文件的布局,修改路由页面样式

This commit is contained in:
2025-03-27 19:58:58 +08:00
parent d9b9ce4676
commit 540618b8ca
33 changed files with 2613 additions and 987 deletions
+4 -4
View File
@@ -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>
+45
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+135
View File
@@ -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 };
+22 -14
View File
@@ -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>
)}
+15 -8
View File
@@ -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>
);
+69
View File
@@ -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>
);
}
+11 -4
View File
@@ -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>
);
}
+4 -4
View File
@@ -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>
+29 -3
View File
@@ -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>
);
}