重新构建路由和配置样式文件
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Link } from '@remix-run/react';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
status: number;
|
||||
statusText: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ status, statusText, message }: ErrorBoundaryProps) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 px-4">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold text-primary mb-4">{status}</h1>
|
||||
<p className="text-xl font-medium text-gray-800 mb-6">{statusText}</p>
|
||||
<p className="text-base text-gray-600 mb-8">{message}</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="ant-btn ant-btn-primary"
|
||||
>
|
||||
<i className="ri-home-line mr-1"></i> 返回首页
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-sm text-gray-500">如果问题持续存在,请联系系统管理员</p>
|
||||
<p className="text-sm text-gray-500 mt-1">技术支持:support@tobacco-ai-system.com</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Link, useMatches } from '@remix-run/react';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
title: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbProps {
|
||||
items?: BreadcrumbItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Handle {
|
||||
breadcrumb: string | ((data: any) => string);
|
||||
}
|
||||
|
||||
interface Match {
|
||||
handle?: Handle;
|
||||
pathname: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export function Breadcrumb({ items = [], className = '' }: BreadcrumbProps) {
|
||||
const matches = useMatches() as Match[];
|
||||
const breadcrumbs = items.length > 0 ? items : matches
|
||||
.filter(match => match.handle?.breadcrumb)
|
||||
.map(match => ({
|
||||
title: typeof match.handle?.breadcrumb === 'function'
|
||||
? match.handle.breadcrumb(match.data)
|
||||
: match.handle?.breadcrumb,
|
||||
to: match.pathname
|
||||
}));
|
||||
|
||||
if (breadcrumbs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className={`mb-4 ${className}`} aria-label="面包屑导航">
|
||||
<ol className="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<li>
|
||||
<Link to="/" className="hover:text-primary-600 flex items-center">
|
||||
<i className="ri-home-line mr-1" />
|
||||
首页
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<li key={index} className="flex items-center">
|
||||
<i className="ri-arrow-right-s-line mx-1 text-gray-400" />
|
||||
{index === breadcrumbs.length - 1 ? (
|
||||
<span className="text-gray-900 font-medium">{item.title}</span>
|
||||
) : (
|
||||
<Link
|
||||
to={item.to || '#'}
|
||||
className="hover:text-primary-600"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Form } from '@remix-run/react';
|
||||
|
||||
interface HeaderProps {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export function Header({ username }: HeaderProps) {
|
||||
return (
|
||||
<header className="bg-white shadow-sm py-3 px-6 flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索..."
|
||||
className="form-input pl-9 pr-3 py-1.5 w-64"
|
||||
/>
|
||||
<i className="ri-search-line absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<button className="flex items-center justify-center w-10 h-10 rounded-full hover:bg-gray-100">
|
||||
<i className="ri-notification-3-line text-xl text-gray-600"></i>
|
||||
<span className="absolute top-1 right-1 w-4 h-4 bg-red-500 text-white text-xs flex items-center justify-center rounded-full">3</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<button className="flex items-center space-x-2 focus:outline-none">
|
||||
<div className="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center font-medium">
|
||||
{username.charAt(0)}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{username}</span>
|
||||
<i className="ri-arrow-down-s-line text-gray-400"></i>
|
||||
</button>
|
||||
|
||||
<div className="absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white hidden group-hover:block z-10">
|
||||
<a href="#" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i className="ri-user-line mr-2"></i> 个人资料
|
||||
</a>
|
||||
<a href="#" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i className="ri-settings-3-line mr-2"></i> 账号设置
|
||||
</a>
|
||||
<Form action="/auth/logout" method="post">
|
||||
<button
|
||||
type="submit"
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<i className="ri-logout-box-line mr-2"></i> 退出登录
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import { Breadcrumb } from './Breadcrumb';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
// 从本地存储中获取侧边栏状态
|
||||
useEffect(() => {
|
||||
const savedState = localStorage.getItem('sidebarCollapsed');
|
||||
if (savedState) {
|
||||
setSidebarCollapsed(savedState === 'true');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
const newState = !sidebarCollapsed;
|
||||
setSidebarCollapsed(newState);
|
||||
localStorage.setItem('sidebarCollapsed', String(newState));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout-container">
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={toggleSidebar}
|
||||
/>
|
||||
|
||||
<div className={`main-content ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
|
||||
{/* <Header username="系统管理员" /> */}
|
||||
<div className="content-container">
|
||||
<Breadcrumb className="px-6 pt-4" />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useLocation } from '@remix-run/react';
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
children?: MenuItem[];
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
onToggle: () => void;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export function Sidebar({ onToggle, collapsed }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const [expandedMenus, setExpandedMenus] = useState<Record<string, boolean>>({});
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: 'home',
|
||||
title: '首页',
|
||||
path: '/',
|
||||
icon: 'ri-home-line'
|
||||
},
|
||||
{
|
||||
id: 'file-management',
|
||||
title: '文件管理',
|
||||
path: '/files',
|
||||
icon: 'ri-folder-line',
|
||||
children: [
|
||||
{
|
||||
id: 'file-upload',
|
||||
title: '文件上传',
|
||||
path: '/files/new',
|
||||
icon: 'ri-upload-cloud-line'
|
||||
},
|
||||
{
|
||||
id: 'file-list',
|
||||
title: '文件列表',
|
||||
path: '/files',
|
||||
icon: 'ri-file-list-3-line'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'rule-management',
|
||||
title: '评查规则库',
|
||||
path: '/rules',
|
||||
icon: 'ri-book-3-line',
|
||||
children: [
|
||||
{
|
||||
id: 'rule-groups',
|
||||
title: '评查点分组',
|
||||
path: '/rule-groups',
|
||||
icon: 'ri-folder-open-line'
|
||||
},
|
||||
{
|
||||
id: 'rule-list',
|
||||
title: '评查点列表',
|
||||
path: '/rules',
|
||||
icon: 'ri-list-check-3'
|
||||
},
|
||||
// {
|
||||
// id: 'rule-new',
|
||||
// title: '新增评查点',
|
||||
// path: '/rules/new',
|
||||
// icon: 'ri-add-circle-line'
|
||||
// }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'review-management',
|
||||
title: '评查结果',
|
||||
path: '/reviews',
|
||||
icon: 'ri-bar-chart-box-line',
|
||||
children: [
|
||||
{
|
||||
id: 'review-detail',
|
||||
title: '评查详情',
|
||||
path: '/reviews',
|
||||
icon: 'ri-file-chart-line'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'system-settings',
|
||||
title: '系统设置',
|
||||
path: '/settings',
|
||||
icon: 'ri-settings-4-line',
|
||||
children: [
|
||||
{
|
||||
id: 'basic-settings',
|
||||
title: '基础设置',
|
||||
path: '/settings',
|
||||
icon: 'ri-equalizer-line'
|
||||
},
|
||||
{
|
||||
id: 'document-types',
|
||||
title: '文档类型',
|
||||
path: '/doc-types',
|
||||
icon: 'ri-file-list-line'
|
||||
},
|
||||
{
|
||||
id: 'prompt-management',
|
||||
title: '提示词管理',
|
||||
path: '/prompts',
|
||||
icon: 'ri-chat-1-line'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// 初始化展开状态,默认全部展开
|
||||
useEffect(() => {
|
||||
const initialExpandedState: Record<string, boolean> = {};
|
||||
menuItems.forEach(item => {
|
||||
if (item.children) {
|
||||
initialExpandedState[item.id] = true;
|
||||
}
|
||||
});
|
||||
setExpandedMenus(initialExpandedState);
|
||||
}, []);
|
||||
|
||||
const toggleMenu = (id: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setExpandedMenus(prev => ({
|
||||
...prev,
|
||||
[id]: !prev[id]
|
||||
}));
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path || location.pathname.startsWith(`${path}/`);
|
||||
};
|
||||
|
||||
// 处理侧边栏切换事件
|
||||
const handleToggleSidebar = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="py-6 px-4 border-b border-gray-100 flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
<i className="ri-file-search-line text-primary text-xl mr-2"></i>
|
||||
{!collapsed && <h2 className="text-lg font-medium">AI审核系统</h2>}
|
||||
</div>
|
||||
<button
|
||||
className="sidebar-toggle"
|
||||
onClick={handleToggleSidebar}
|
||||
aria-label={collapsed ? "展开侧边栏" : "折叠侧边栏"}
|
||||
type="button"
|
||||
>
|
||||
<i className={`${collapsed ? 'ri-menu-unfold-line' : 'ri-menu-fold-line'}`}></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="user-profile p-4 border-b border-gray-100 flex items-center">
|
||||
<div className="avatar w-10 h-10 rounded-full bg-primary text-white flex items-center justify-center">
|
||||
<span>管</span>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium">系统管理员</p>
|
||||
<p className="text-xs text-gray-500">超级管理员</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="py-4 px-[10px]">
|
||||
{menuItems.map((item) => (
|
||||
<div key={item.id} className={`${collapsed ? 'px-0' : ''}`}>
|
||||
{!item.children ? (
|
||||
<Link
|
||||
to={item.path}
|
||||
className={`sidebar-menu-item ${isActive(item.path) ? 'active' : ''} flex items-center ${collapsed ? 'justify-center' : ''}`}
|
||||
>
|
||||
<i className={`${item.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`sidebar-menu-item flex items-center ${collapsed ? 'justify-center' : 'justify-between'} cursor-pointer`}
|
||||
onClick={(e) => toggleMenu(item.id, e)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expandedMenus[item.id] || false}
|
||||
aria-controls={`submenu-${item.id}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleMenu(item.id, e as unknown as React.MouseEvent);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<i className={`${item.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<i className={`ri-arrow-${expandedMenus[item.id] ? 'down' : 'right'}-s-line`}></i>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(expandedMenus[item.id] || collapsed) && (
|
||||
<div
|
||||
className={`${collapsed ? 'border-l-0 pl-0' : 'border-l border-gray-100 ml-4 pl-3'}`}
|
||||
id={`submenu-${item.id}`}
|
||||
>
|
||||
{item.children.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={child.path}
|
||||
className={`sidebar-menu-item ${isActive(child.path) ? 'active' : ''} flex items-center ${collapsed ? 'justify-center' : ''}`}
|
||||
>
|
||||
<i className={`${child.icon} ${collapsed ? 'text-xl' : 'mr-3'}`}></i>
|
||||
{!collapsed && <span>{child.title}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Link } from '@remix-run/react';
|
||||
|
||||
type ButtonType = 'primary' | 'default' | 'danger';
|
||||
type ButtonSize = 'small' | 'medium' | 'large';
|
||||
|
||||
interface ButtonProps {
|
||||
children: React.ReactNode;
|
||||
type?: ButtonType;
|
||||
size?: ButtonSize;
|
||||
to?: string;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
type = 'default',
|
||||
size = 'medium',
|
||||
to,
|
||||
icon,
|
||||
disabled = false,
|
||||
className = '',
|
||||
onClick,
|
||||
...rest
|
||||
}: ButtonProps & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'type'>) {
|
||||
const baseClasses = 'ant-btn';
|
||||
|
||||
const typeClasses = {
|
||||
primary: 'ant-btn-primary',
|
||||
default: 'ant-btn-default',
|
||||
danger: 'ant-btn-danger'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
small: 'ant-btn-sm',
|
||||
medium: '',
|
||||
large: 'text-base px-5 py-2.5'
|
||||
};
|
||||
|
||||
const classes = [
|
||||
baseClasses,
|
||||
typeClasses[type],
|
||||
sizeClasses[size],
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={classes}
|
||||
{...(rest as any)}
|
||||
>
|
||||
{icon && <i className={`${icon} mr-1.5`}></i>}
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classes}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
{...rest}
|
||||
>
|
||||
{icon && <i className={`${icon} mr-1.5`}></i>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
title?: React.ReactNode;
|
||||
icon?: string;
|
||||
extra?: React.ReactNode;
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
noDivider?: boolean;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
title,
|
||||
icon,
|
||||
extra,
|
||||
className = '',
|
||||
bodyClassName = '',
|
||||
noDivider = true,
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div className={`ant-card ${className} bg-white shadow`}>
|
||||
{(title || extra) && (
|
||||
<div className={`flex justify-between items-center px-5 py-3 ${noDivider ? '' : 'border-b border-gray-100'}`}>
|
||||
{title && (
|
||||
<div className="card-title !mb-0">
|
||||
{icon && <i className={`${icon} mr-2`}></i>}
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
)}
|
||||
{extra && (
|
||||
<div className="card-extra">
|
||||
{extra}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`ant-card-body ${bodyClassName}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TableColumn<T> {
|
||||
title: React.ReactNode;
|
||||
dataIndex?: keyof T;
|
||||
key?: string;
|
||||
width?: number | string;
|
||||
render?: (value: any, record: T, index: number) => React.ReactNode;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface TableProps<T> {
|
||||
columns: TableColumn<T>[];
|
||||
dataSource: T[];
|
||||
rowKey: keyof T | ((record: T) => string);
|
||||
loading?: boolean;
|
||||
bordered?: boolean;
|
||||
emptyText?: React.ReactNode;
|
||||
className?: string;
|
||||
onRow?: (record: T, index: number) => React.HTMLAttributes<HTMLTableRowElement>;
|
||||
}
|
||||
|
||||
export function Table<T extends Record<string, any>>({
|
||||
columns,
|
||||
dataSource,
|
||||
rowKey,
|
||||
loading = false,
|
||||
bordered = false,
|
||||
emptyText = '暂无数据',
|
||||
className = '',
|
||||
onRow,
|
||||
}: TableProps<T>) {
|
||||
const getRowKey = (record: T, index: number): string => {
|
||||
if (typeof rowKey === 'function') {
|
||||
return rowKey(record);
|
||||
}
|
||||
return String(record[rowKey]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`ant-table-wrapper ${className} ${loading ? 'opacity-70' : ''}`}>
|
||||
<table className="ant-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
key={column.key || column.dataIndex?.toString() || index}
|
||||
className={column.className}
|
||||
style={{
|
||||
width: column.width,
|
||||
textAlign: column.align || 'left',
|
||||
}}
|
||||
>
|
||||
{column.title}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dataSource.length > 0 ? (
|
||||
dataSource.map((record, index) => (
|
||||
<tr
|
||||
key={getRowKey(record, index)}
|
||||
{...(onRow ? onRow(record, index) : {})}
|
||||
>
|
||||
{columns.map((column, colIndex) => (
|
||||
<td
|
||||
key={column.key || column.dataIndex?.toString() || colIndex}
|
||||
style={{ textAlign: column.align || 'left' }}
|
||||
>
|
||||
{column.render
|
||||
? column.render(
|
||||
column.dataIndex ? record[column.dataIndex] : undefined,
|
||||
record,
|
||||
index
|
||||
)
|
||||
: column.dataIndex
|
||||
? record[column.dataIndex]
|
||||
: undefined}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="py-6 text-center text-gray-500"
|
||||
>
|
||||
{emptyText}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-60 z-10">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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;
|
||||
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>);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={classes}>
|
||||
{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