feat(components): 添加基础UI组件库

- 新增 AppHeader 组件,包含头部布局、Logo、导航菜单和用户信息
- 新增 AppSider 组件,实现侧边栏菜单、折叠功能和徽章提示
- 新增 ConfirmDialog 组件,提供标准确认对话框和删除确认功能- 新增 DetailDrawer 组件,支持详情抽屉展示和标签页切换
- 新增 InfoPanel 组件,用于信息展示和操作按钮布局
- 新增 ListActionBar 组件,提供列表操作栏和搜索筛选功能
- 新增 ListTable 组件,实现带选择功能的表格展示- 新增 MainLayout 组件,构建整体页面布局结构
- 新增 PageHeader 组件,提供标准化页面头部样式
dev_1.0.0
chenhao 2025-11-10 17:22:20 +08:00
parent 15f0d9aba5
commit 19a226a5de
23 changed files with 1989 additions and 0 deletions

View File

@ -0,0 +1,138 @@
import { Modal } from 'antd'
import { ExclamationCircleOutlined, DeleteOutlined } from '@ant-design/icons'
/**
* 标准确认对话框组件
* @param {Object} options - 对话框配置
* @param {string} options.title - 标题
* @param {string|ReactNode} options.content - 内容
* @param {string} options.okText - 确认按钮文字
* @param {string} options.cancelText - 取消按钮文字
* @param {string} options.type - 类型: 'warning', 'danger', 'info'
* @param {Function} options.onOk - 确认回调
* @param {Function} options.onCancel - 取消回调
*/
const ConfirmDialog = {
/**
* 显示删除确认对话框单个项目
*/
delete: ({ title = '确认删除', itemName, itemInfo, onOk, onCancel }) => {
Modal.confirm({
title,
content: (
<div>
<p>您确定要删除以下项目吗</p>
<div style={{ marginTop: 12, padding: 12, background: '#f5f5f5', borderRadius: 6 }}>
<p style={{ margin: 0, fontWeight: 500 }}>{itemName}</p>
{itemInfo && (
<p style={{ margin: '4px 0 0 0', fontSize: 13, color: '#666' }}>{itemInfo}</p>
)}
</div>
<p style={{ marginTop: 12, color: '#ff4d4f', fontSize: 13 }}>
此操作不可恢复请谨慎操作
</p>
</div>
),
okText: '确认删除',
cancelText: '取消',
okType: 'danger',
centered: true,
icon: <DeleteOutlined style={{ color: '#ff4d4f' }} />,
onOk,
onCancel,
})
},
/**
* 显示批量删除确认对话框
*/
batchDelete: ({ count, items, onOk, onCancel }) => {
Modal.confirm({
title: '批量删除确认',
content: (
<div>
<p>您确定要删除选中的 {count} 个项目吗</p>
<div
style={{
marginTop: 12,
padding: 12,
background: '#f5f5f5',
borderRadius: 6,
maxHeight: 200,
overflowY: 'auto',
}}
>
{items.map((item, index) => (
<div
key={index}
style={{
padding: '6px 0',
borderBottom: index < items.length - 1 ? '1px solid #e8e8e8' : 'none',
}}
>
<span style={{ fontWeight: 500 }}>{item.name}</span>
{item.info && (
<span style={{ marginLeft: 12, fontSize: 13, color: '#666' }}>
({item.info})
</span>
)}
</div>
))}
</div>
<p style={{ marginTop: 12, color: '#ff4d4f', fontSize: 13 }}>
此操作不可恢复请谨慎操作
</p>
</div>
),
okText: '确认删除',
cancelText: '取消',
okType: 'danger',
centered: true,
icon: <DeleteOutlined style={{ color: '#ff4d4f' }} />,
onOk,
onCancel,
})
},
/**
* 显示警告确认对话框
*/
warning: ({ title, content, okText = '确定', cancelText = '取消', onOk, onCancel }) => {
Modal.confirm({
title,
content,
okText,
cancelText,
centered: true,
icon: <ExclamationCircleOutlined style={{ color: '#faad14' }} />,
onOk,
onCancel,
})
},
/**
* 显示通用确认对话框
*/
confirm: ({
title,
content,
okText = '确定',
cancelText = '取消',
okType = 'primary',
onOk,
onCancel,
}) => {
Modal.confirm({
title,
content,
okText,
cancelText,
okType,
centered: true,
onOk,
onCancel,
})
},
}
export default ConfirmDialog

View File

@ -0,0 +1,120 @@
/* 详情抽屉容器 */
.detail-drawer-content {
height: 100%;
display: flex;
flex-direction: column;
}
/* 顶部信息区域 - 固定不滚动 */
.detail-drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
.detail-drawer-header-left {
display: flex;
align-items: center;
gap: 16px;
}
.detail-drawer-close-button {
font-size: 18px;
color: #666;
}
.detail-drawer-close-button:hover {
color: #1677ff;
}
.detail-drawer-header-info {
display: flex;
align-items: center;
gap: 12px;
}
.detail-drawer-title-icon {
font-size: 18px;
color: #1677ff;
}
.detail-drawer-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.detail-drawer-badge {
display: flex;
align-items: center;
}
.detail-drawer-header-right {
flex: 1;
display: flex;
justify-content: flex-end;
}
/* 可滚动内容区域 */
.detail-drawer-scrollable-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* 标签页区域 */
.detail-drawer-tabs {
background: #ffffff;
padding-top: 16px;
padding-left: 12px;
min-height: 400px;
}
.detail-drawer-tabs :global(.ant-tabs) {
height: 100%;
}
.detail-drawer-tabs :global(.ant-tabs-content-holder) {
overflow: visible;
}
.detail-drawer-tabs :global(.ant-tabs-nav) {
padding: 0;
margin: 0 24px;
margin-bottom: 0;
background: transparent;
}
.detail-drawer-tabs :global(.ant-tabs-nav::before) {
border-bottom: 1px solid #f0f0f0;
}
.detail-drawer-tabs :global(.ant-tabs-tab) {
padding: 12px 0;
margin: 0 32px 0 0;
font-size: 14px;
font-weight: 500;
}
.detail-drawer-tabs :global(.ant-tabs-tab:first-child) {
margin-left: 0;
}
.detail-drawer-tabs :global(.ant-tabs-tab-active .ant-tabs-tab-btn) {
color: #d946ef;
}
.detail-drawer-tabs :global(.ant-tabs-ink-bar) {
background: #d946ef;
height: 3px;
}
.detail-drawer-tab-content {
padding: 24px;
background: #ffffff;
}

View File

@ -0,0 +1,97 @@
import { Drawer, Button, Space, Tabs } from 'antd'
import { CloseOutlined } from '@ant-design/icons'
import './DetailDrawer.css'
/**
* 详情抽屉组件
* @param {Object} props
* @param {boolean} props.visible - 是否显示抽屉
* @param {Function} props.onClose - 关闭回调
* @param {Object} props.title - 标题配置
* @param {string} props.title.text - 标题文本
* @param {ReactNode} props.title.badge - 状态徽标可选
* @param {ReactNode} props.title.icon - 图标可选
* @param {Array} props.headerActions - 顶部操作按钮
* @param {number} props.width - 抽屉宽度
* @param {ReactNode} props.children - 主要内容
* @param {Array} props.tabs - 标签页配置可选
*/
function DetailDrawer({
visible,
onClose,
title,
headerActions = [],
width = 1080,
children,
tabs,
}) {
return (
<Drawer
title={null}
placement="right"
width={width}
onClose={onClose}
open={visible}
closable={false}
styles={{ body: { padding: 0 } }}
>
<div className="detail-drawer-content">
{/* 顶部标题栏 - 固定不滚动 */}
<div className="detail-drawer-header">
<div className="detail-drawer-header-left">
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
className="detail-drawer-close-button"
/>
<div className="detail-drawer-header-info">
{title?.icon && <span className="detail-drawer-title-icon">{title.icon}</span>}
<h2 className="detail-drawer-title">{title?.text}</h2>
{title?.badge && <span className="detail-drawer-badge">{title.badge}</span>}
</div>
</div>
<div className="detail-drawer-header-right">
<Space size="middle">
{headerActions.map((action) => (
<Button
key={action.key}
type={action.type || 'default'}
icon={action.icon}
danger={action.danger}
disabled={action.disabled}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</Space>
</div>
</div>
{/* 可滚动内容区域 */}
<div className="detail-drawer-scrollable-content">
{children}
{/* 可选的标签页区域 */}
{tabs && tabs.length > 0 && (
<div className="detail-drawer-tabs">
<Tabs
defaultActiveKey={tabs[0].key}
type="line"
size="large"
items={tabs.map((tab) => ({
key: tab.key,
label: tab.label,
children: <div className="detail-drawer-tab-content">{tab.content}</div>,
}))}
/>
</div>
)}
</div>
</div>
</Drawer>
)
}
export default DetailDrawer

View File

@ -0,0 +1,94 @@
/* 信息面板 */
.info-panel {
padding: 6px 8px;
background: #ffffff;
}
/* 信息区域容器 */
.info-panel > :global(.ant-row) {
padding: 32px;
background: #ffffff;
border-bottom: 1px solid #f0f0f0;
}
.info-panel-item {
display: flex;
flex-direction: column;
gap: 5px;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
transition: all 0.2s ease;
position: relative;
}
.info-panel-item:last-child {
border-bottom: none;
}
/* 添加底部装饰条 */
.info-panel-item::before {
content: '';
position: absolute;
left: 3px;
bottom: -1px;
width: 0;
height: 3px;
background: linear-gradient(90deg, #1677ff 0%, #4096ff 100%);
border-radius: 2px;
transition: width 0.3s ease;
}
.info-panel-item:hover {
background: linear-gradient(90deg, #f0f7ff 0%, transparent 100%);
padding-left: 10px;
padding-right: 16px;
margin-left: -12px;
margin-right: -16px;
border-radius: 8px;
border-bottom-color: transparent;
}
.info-panel-item:hover::before {
width: 60px;
}
.info-panel-label {
color: rgba(0, 0, 0, 0.45);
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.info-panel-value {
color: rgba(0, 0, 0, 0.88);
font-size: 15px;
font-weight: 500;
word-break: break-all;
line-height: 1.6;
}
/* 操作按钮区 */
.info-panel-actions {
padding: 24px 32px;
background: linear-gradient(to bottom, #fafafa 0%, #f5f5f5 100%);
border-top: 2px solid #e8e8e8;
position: relative;
}
/* 操作区域顶部装饰线 */
.info-panel-actions::before {
content: '';
position: absolute;
top: -2px;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, #1677ff 0%, transparent 50%, #1677ff 100%);
opacity: 0.3;
}

View File

@ -0,0 +1,58 @@
import { Row, Col, Space, Button } from 'antd'
import './InfoPanel.css'
/**
* 信息展示面板组件
* @param {Object} props
* @param {Object} props.data - 数据源
* @param {Array} props.fields - 字段配置数组
* @param {Array} props.actions - 操作按钮配置可选
* @param {Array} props.gutter - Grid间距配置
*/
function InfoPanel({ data, fields = [], actions = [], gutter = [24, 16] }) {
if (!data) {
return null
}
return (
<div className="info-panel">
<Row gutter={gutter}>
{fields.map((field) => {
const value = data[field.key]
const displayValue = field.render ? field.render(value, data) : value
return (
<Col key={field.key} span={field.span || 6}>
<div className="info-panel-item">
<div className="info-panel-label">{field.label}</div>
<div className="info-panel-value">{displayValue}</div>
</div>
</Col>
)
})}
</Row>
{/* 可选的操作按钮区 */}
{actions && actions.length > 0 && (
<div className="info-panel-actions">
<Space size="middle">
{actions.map((action) => (
<Button
key={action.key}
type={action.type || 'default'}
icon={action.icon}
disabled={action.disabled}
danger={action.danger}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</Space>
</div>
)}
</div>
)
}
export default InfoPanel

View File

@ -0,0 +1,49 @@
.list-action-bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 16px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.list-action-bar-left,
.list-action-bar-right {
display: flex;
gap: 12px;
align-items: center;
}
/* 搜索和筛选组合 */
.list-action-bar-right :global(.ant-space-compact) {
display: flex;
}
.list-action-bar-right :global(.ant-space-compact .ant-input-search) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.list-action-bar-right :global(.ant-space-compact > .ant-btn) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
/* 响应式 */
@media (max-width: 768px) {
.list-action-bar {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.list-action-bar-left,
.list-action-bar-right {
flex-wrap: wrap;
}
}

View File

@ -0,0 +1,86 @@
import { Button, Input, Space, Popover } from 'antd'
import { ReloadOutlined, FilterOutlined } from '@ant-design/icons'
import './ListActionBar.css'
const { Search } = Input
/**
* 列表操作栏组件
* @param {Object} props
* @param {Array} props.actions - 左侧操作按钮配置数组
* @param {Object} props.search - 搜索配置
* @param {Object} props.filter - 高级筛选配置可选
* @param {boolean} props.showRefresh - 是否显示刷新按钮
* @param {Function} props.onRefresh - 刷新回调
*/
function ListActionBar({
actions = [],
search,
filter,
showRefresh = false,
onRefresh,
}) {
return (
<div className="list-action-bar">
{/* 左侧操作按钮区 */}
<div className="list-action-bar-left">
{actions.map((action) => (
<Button
key={action.key}
type={action.type || 'default'}
icon={action.icon}
disabled={action.disabled}
danger={action.danger}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</div>
{/* 右侧搜索筛选区 */}
<div className="list-action-bar-right">
<Space.Compact>
<Search
placeholder={search?.placeholder || '请输入搜索关键词'}
allowClear
style={{ width: search?.width || 280 }}
onSearch={search?.onSearch}
onChange={(e) => search?.onChange?.(e.target.value)}
value={search?.value}
/>
{filter && (
<Popover
content={filter.content}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FilterOutlined />
<span>{filter.title || '高级筛选'}</span>
</div>
}
trigger="click"
open={filter.visible}
onOpenChange={filter.onVisibleChange}
placement="bottomRight"
overlayClassName="filter-popover"
>
<Button
icon={<FilterOutlined />}
type={filter.isActive ? 'primary' : 'default'}
>
{filter.selectedLabel || '筛选'}
</Button>
</Popover>
)}
</Space.Compact>
{showRefresh && (
<Button icon={<ReloadOutlined />} onClick={onRefresh}>
刷新
</Button>
)}
</div>
</div>
)
}
export default ListActionBar

View File

@ -0,0 +1,43 @@
/* 列表表格容器 */
.list-table-container {
background: #ffffff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
/* 表格行样式 */
.list-table-container :global(.ant-table-row) {
cursor: pointer;
transition: all 0.3s;
}
.list-table-container :global(.ant-table-row:hover) {
background: #f5f5f5;
}
.list-table-container :global(.ant-table-row.row-selected) {
background: #e6f4ff;
}
/* 操作列样式 - 重新设计 */
.list-table-container :global(.ant-table-thead > tr > th:last-child) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
color: #ffffff !important;
font-weight: 600;
border-left: 2px solid #e8e8e8;
}
.list-table-container :global(.ant-table-tbody > tr > td:last-child) {
background: #f8f9ff !important;
border-left: 2px solid #e8e8e8;
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.02);
}
.list-table-container :global(.ant-table-tbody > tr:hover > td:last-child) {
background: #eef0ff !important;
}
.list-table-container :global(.ant-table-tbody > tr.row-selected > td:last-child) {
background: #e1e6ff !important;
}

View File

@ -0,0 +1,67 @@
import { Table } from 'antd'
import './ListTable.css'
/**
* 列表表格组件
* @param {Object} props
* @param {Array} props.columns - 表格列配置
* @param {Array} props.dataSource - 数据源
* @param {string} props.rowKey - 行唯一标识字段
* @param {Array} props.selectedRowKeys - 选中的行
* @param {Function} props.onSelectionChange - 选择变化回调
* @param {Object} props.pagination - 分页配置
* @param {Object} props.scroll - 表格滚动配置
* @param {Function} props.onRowClick - 行点击回调
* @param {Object} props.selectedRow - 当前选中的行
* @param {boolean} props.loading - 加载状态
* @param {string} props.className - 自定义类名
*/
function ListTable({
columns,
dataSource,
rowKey = 'id',
selectedRowKeys = [],
onSelectionChange,
pagination = {
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
},
scroll = { x: 1200 },
onRowClick,
selectedRow,
loading = false,
className = '',
}) {
//
const rowSelection = {
selectedRowKeys,
onChange: (newSelectedRowKeys) => {
onSelectionChange?.(newSelectedRowKeys)
},
}
return (
<div className={`list-table-container ${className}`}>
<Table
rowSelection={rowSelection}
columns={columns}
dataSource={dataSource}
rowKey={rowKey}
pagination={{
...pagination,
total: dataSource?.length || 0,
}}
scroll={scroll}
loading={loading}
onRow={(record) => ({
onClick: () => onRowClick?.(record),
className: selectedRow?.[rowKey] === record[rowKey] ? 'row-selected' : '',
})}
/>
</div>
)
}
export default ListTable

View File

@ -0,0 +1,129 @@
.app-header {
background: #fff;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
height: 64px;
border-bottom: 1px solid #f0f0f0;
}
/* 左侧区域 */
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
/* Logo 区域 */
.header-logo {
display: flex;
align-items: center;
justify-content: center;
width: 168px;
transition: width 0.2s;
}
.logo-small {
width: 40px;
height: 40px;
border-radius: 8px;
transition: all 0.2s;
}
.logo-full {
height: 32px;
width: auto;
transition: all 0.2s;
}
.trigger {
font-size: 18px;
cursor: pointer;
transition: color 0.3s;
padding: 8px;
border-radius: 4px;
color: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
}
.trigger:hover {
color: #b8178d;
background: rgba(184, 23, 141, 0.06);
}
/* 右侧区域 */
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.header-search {
border-radius: 16px;
}
.header-actions {
display: flex;
align-items: center;
}
.header-icon {
font-size: 16px;
color: rgba(0, 0, 0, 0.65);
cursor: pointer;
transition: all 0.3s;
padding: 8px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.header-icon:hover {
color: #b8178d;
background: rgba(184, 23, 141, 0.06);
}
.header-link {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
cursor: pointer;
transition: all 0.3s;
padding: 6px 12px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.header-link:hover {
color: #b8178d;
background: rgba(184, 23, 141, 0.06);
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.3s;
}
.user-info:hover {
background: rgba(184, 23, 141, 0.06);
}
.username {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
font-weight: 500;
}
.ml-1 {
margin-left: 4px;
}

View File

@ -0,0 +1,128 @@
import { Layout, Input, Badge, Avatar, Dropdown, Space } from 'antd'
import { useNavigate } from 'react-router-dom'
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
SearchOutlined,
BellOutlined,
QuestionCircleOutlined,
FileTextOutlined,
CustomerServiceOutlined,
UserOutlined,
} from '@ant-design/icons'
import headerMenuData from '../../constants/headerMenuData.json'
import logoFull from '../../assets/logo-full.png'
import './AppHeader.css'
const { Header } = Layout
//
const iconMap = {
QuestionCircleOutlined: <QuestionCircleOutlined />,
FileTextOutlined: <FileTextOutlined />,
CustomerServiceOutlined: <CustomerServiceOutlined />,
}
function AppHeader({ collapsed, onToggle }) {
const navigate = useNavigate()
//
const userMenuItems = [
{
key: 'profile',
label: '个人中心',
},
{
key: 'settings',
label: '账户设置',
},
{
type: 'divider',
},
{
key: 'logout',
label: '退出登录',
},
]
const handleUserMenuClick = ({ key }) => {
if (key === 'logout') {
console.log('退出登录')
}
}
const handleHeaderMenuClick = (key) => {
console.log('Header menu clicked:', key)
//
if (key === 'docs') {
navigate('/design')
}
}
return (
<Header className="app-header">
{/* 左侧Logo + 折叠按钮 */}
<div className="header-left">
{/* Logo 区域 */}
<div className="header-logo">
<img src={logoFull} alt="NEX Console" className="logo-full" />
</div>
{/* 折叠按钮 */}
<div className="trigger" onClick={onToggle}>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
</div>
{/* 右侧:搜索 + 功能按钮 + 用户信息 */}
<div className="header-right">
{/* 搜索框 */}
<Input
className="header-search"
placeholder="搜索..."
prefix={<SearchOutlined />}
style={{ width: 200 }}
/>
{/* 功能图标 */}
<Space size={16} className="header-actions">
{/* 动态渲染 header 菜单 */}
{headerMenuData.map((item) => (
<div
key={item.key}
className="header-link"
title={item.label}
onClick={() => handleHeaderMenuClick(item.key)}
>
{iconMap[item.icon]}
<span className="ml-1">{item.label}</span>
</div>
))}
{/* 消息中心 */}
<Badge count={5} size="small" offset={[-3, 3]}>
<div className="header-icon" title="消息中心">
<BellOutlined />
</div>
</Badge>
{/* 用户下拉菜单 */}
<Dropdown
menu={{
items: userMenuItems,
onClick: handleUserMenuClick,
}}
placement="bottomRight"
>
<div className="user-info">
<Avatar size={32} icon={<UserOutlined />} />
<span className="username">Admin</span>
</div>
</Dropdown>
</Space>
</div>
</Header>
)
}
export default AppHeader

View File

@ -0,0 +1,96 @@
.app-sider {
height: 100%;
overflow: auto;
background: #fafafa;
border-right: 1px solid #f0f0f0;
transition: all 0.2s;
}
.app-sider::-webkit-scrollbar {
width: 6px;
}
.app-sider::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
}
.app-sider::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
/* 菜单样式 */
.sider-menu {
border-right: none;
padding-top: 8px;
background: #fafafa;
}
/* 收起状态下的图标放大 */
:global(.ant-layout-sider-collapsed) .sider-menu :global(.ant-menu-item) {
padding: 0 !important;
display: flex;
align-items: center;
justify-content: center;
height: 56px;
margin: 8px 0;
}
/* 收起状态下的 SubMenu 样式 */
:global(.ant-layout-sider-collapsed) .sider-menu :global(.ant-menu-submenu) {
padding: 0 !important;
}
:global(.ant-layout-sider-collapsed) .sider-menu :global(.ant-menu-submenu-title) {
padding: 0 !important;
display: flex;
align-items: center;
justify-content: center;
height: 56px;
margin: 8px 0;
}
:global(.ant-layout-sider-collapsed) .sider-menu :global(.anticon) {
font-size: 24px;
margin: 0;
}
/* 收起状态下的 Tooltip */
:global(.ant-layout-sider-collapsed) .sider-menu :global(.ant-menu-item-icon) {
font-size: 24px;
}
:global(.ant-layout-sider-collapsed) .sider-menu :global(.ant-menu-submenu-title) :global(.anticon) {
font-size: 24px;
margin: 0;
}
/* 菜单项徽章 */
.menu-item-with-badge {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.menu-badge {
font-size: 10px;
height: 18px;
line-height: 18px;
border-radius: 9px;
padding: 0 6px;
margin-left: 8px;
}
.badge-hot :global(.ant-badge-count) {
background: #ff4d4f;
}
.badge-new :global(.ant-badge-count) {
background: #52c41a;
}
/* 收起状态下隐藏徽章 */
:global(.ant-layout-sider-collapsed) .menu-badge {
display: none;
}

View File

@ -0,0 +1,172 @@
import { useState, useEffect } from 'react'
import { Layout, Menu, Badge, Tooltip } from 'antd'
import { useNavigate, useLocation } from 'react-router-dom'
import {
DashboardOutlined,
GlobalOutlined,
CloudServerOutlined,
UserOutlined,
AppstoreOutlined,
SettingOutlined,
} from '@ant-design/icons'
import menuData from '../../constants/menuData.json'
import './AppSider.css'
const { Sider } = Layout
const { SubMenu } = Menu
//
const iconMap = {
DashboardOutlined: DashboardOutlined,
GlobalOutlined: GlobalOutlined,
CloudServerOutlined: CloudServerOutlined,
UserOutlined: UserOutlined,
AppstoreOutlined: AppstoreOutlined,
SettingOutlined: SettingOutlined,
}
function AppSider({ collapsed, onToggle }) {
const navigate = useNavigate()
const location = useLocation()
const [openKeys, setOpenKeys] = useState([])
//
const getDefaultOpenKeys = () => {
const path = location.pathname
for (const item of menuData) {
if (item.children) {
const hasChild = item.children.some((c) => c.path === path)
if (hasChild) {
return [item.key]
}
}
}
return []
}
//
useEffect(() => {
if (!collapsed) {
const defaultKeys = getDefaultOpenKeys()
setOpenKeys(defaultKeys)
}
}, [location.pathname, collapsed])
const handleMenuClick = ({ key }) => {
//
for (const item of menuData) {
if (item.key === key && item.path) {
navigate(item.path)
return
}
if (item.children) {
const child = item.children.find((c) => c.key === key)
if (child) {
navigate(child.path)
return
}
}
}
}
const handleOpenChange = (keys) => {
setOpenKeys(keys)
}
//
const getSelectedKey = () => {
const path = location.pathname
for (const item of menuData) {
if (item.path === path) return item.key
if (item.children) {
const child = item.children.find((c) => c.path === path)
if (child) return child.key
}
}
return 'overview'
}
//
const renderMenuItems = () => {
return menuData.map((item) => {
const IconComponent = iconMap[item.icon]
const icon = IconComponent ? <IconComponent /> : null
//
if (item.children) {
// Tooltip SubMenu
const subMenuIcon = collapsed ? (
<Tooltip title={item.label} placement="right">
{icon}
</Tooltip>
) : (
icon
)
return (
<SubMenu
key={item.key}
icon={subMenuIcon}
title={item.label}
popupClassName="sider-submenu-popup"
>
{item.children.map((child) => (
<Menu.Item key={child.key}>
{child.badge ? (
<span className="menu-item-with-badge">
{child.label}
<Badge
count={child.badge}
className={`menu-badge ${child.badge === 'HOT' ? 'badge-hot' : 'badge-new'}`}
/>
</span>
) : (
child.label
)}
</Menu.Item>
))}
</SubMenu>
)
}
// - Tooltip
const menuIcon = collapsed ? (
<Tooltip title={item.label} placement="right">
{icon}
</Tooltip>
) : (
icon
)
return (
<Menu.Item key={item.key} icon={menuIcon} title="">
{item.label}
</Menu.Item>
)
})
}
return (
<Sider
className="app-sider"
collapsed={collapsed}
width={200}
collapsedWidth={64}
trigger={null}
>
{/* 菜单 */}
<Menu
mode="inline"
selectedKeys={[getSelectedKey()]}
openKeys={collapsed ? [] : openKeys}
onOpenChange={handleOpenChange}
onClick={handleMenuClick}
className="sider-menu"
>
{renderMenuItems()}
</Menu>
</Sider>
)
}
export default AppSider

View File

@ -0,0 +1,24 @@
.main-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #fafafa;
}
.main-content-wrapper {
display: flex;
flex: 1;
height: calc(100vh - 64px);
background: #fafafa;
}
.main-content {
background: #f5f5f5;
overflow-y: auto;
flex: 1;
}
.content-wrapper {
padding: 24px;
min-height: 100%;
}

View File

@ -0,0 +1,31 @@
import { useState } from 'react'
import { Layout } from 'antd'
import AppSider from './AppSider'
import AppHeader from './AppHeader'
import './MainLayout.css'
const { Content } = Layout
function MainLayout({ children }) {
const [collapsed, setCollapsed] = useState(false)
const toggleCollapsed = () => {
setCollapsed(!collapsed)
}
return (
<Layout className="main-layout">
<AppHeader collapsed={collapsed} onToggle={toggleCollapsed} />
<Layout className="main-content-wrapper">
<AppSider collapsed={collapsed} onToggle={toggleCollapsed} />
<Content className="main-content">
<div className="content-wrapper">
{children}
</div>
</Content>
</Layout>
</Layout>
)
}
export default MainLayout

View File

@ -0,0 +1,4 @@
export { default } from './MainLayout'
export { default as MainLayout } from './MainLayout'
export { default as AppSider } from './AppSider'
export { default as AppHeader } from './AppHeader'

View File

@ -0,0 +1,109 @@
.page-header-standard {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 24px 28px;
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
position: relative;
overflow: hidden;
}
.page-header-standard::before {
content: '';
position: absolute;
top: -50%;
right: -10%;
width: 300px;
height: 300px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
.page-header-main {
display: flex;
align-items: center;
gap: 16px;
position: relative;
z-index: 1;
}
.back-button {
width: 36px;
height: 36px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
font-size: 16px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-header-content {
display: flex;
align-items: center;
gap: 16px;
}
.page-header-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.page-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.page-header-title {
font-size: 22px;
font-weight: 600;
color: #ffffff;
margin: 0;
letter-spacing: 0.3px;
}
.page-header-description {
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
line-height: 1.5;
}
.page-header-extra {
position: relative;
z-index: 1;
}
@media (max-width: 768px) {
.page-header-standard {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.page-header-extra {
width: 100%;
}
}

View File

@ -0,0 +1,35 @@
import { ArrowLeftOutlined } from '@ant-design/icons'
import './PageHeader.css'
function PageHeader({
title,
description,
icon,
showBack = false,
onBack,
extra
}) {
return (
<div className="page-header-standard">
<div className="page-header-main">
{showBack && (
<button className="back-button" onClick={onBack}>
<ArrowLeftOutlined />
</button>
)}
<div className="page-header-content">
{icon && <div className="page-header-icon">{icon}</div>}
<div className="page-header-text">
<h1 className="page-header-title">{title}</h1>
{description && (
<p className="page-header-description">{description}</p>
)}
</div>
</div>
</div>
{extra && <div className="page-header-extra">{extra}</div>}
</div>
)
}
export default PageHeader

View File

@ -0,0 +1,165 @@
.page-title-bar {
background: linear-gradient(135deg, #e0e7ff 0%, #f3e8ff 100%);
border-radius: 12px;
padding: 16px 24px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
position: relative;
overflow: hidden;
border: 1px solid rgba(139, 92, 246, 0.1);
}
.page-title-bar::before {
content: '';
position: absolute;
top: -50%;
right: -5%;
width: 200px;
height: 200px;
background: rgba(139, 92, 246, 0.05);
border-radius: 50%;
}
.title-bar-content {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.title-bar-left {
flex: 1;
}
.title-row {
display: flex;
align-items: center;
gap: 16px;
}
.title-group {
display: flex;
align-items: center;
gap: 12px;
}
.page-title {
font-size: 20px;
font-weight: 600;
color: #1e293b;
margin: 0;
letter-spacing: 0.3px;
}
.title-badge {
background: rgba(139, 92, 246, 0.15);
color: #7c3aed;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
}
.page-description {
font-size: 13px;
color: #64748b;
margin: 0;
white-space: nowrap;
}
.title-bar-right {
display: flex;
align-items: center;
gap: 12px;
}
.title-actions {
display: flex;
gap: 10px;
}
.title-actions button {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
border: none;
outline: none;
}
.title-actions button.primary {
background: #7c3aed;
color: #ffffff;
}
.title-actions button.primary:hover {
background: #6d28d9;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.25);
}
.title-actions button.secondary {
background: rgba(139, 92, 246, 0.1);
color: #7c3aed;
border: 1px solid rgba(139, 92, 246, 0.2);
}
.title-actions button.secondary:hover {
background: rgba(139, 92, 246, 0.15);
transform: translateY(-1px);
}
.toggle-button {
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(139, 92, 246, 0.1);
border: 1px solid rgba(139, 92, 246, 0.2);
color: #7c3aed;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
font-size: 14px;
}
.toggle-button:hover {
background: rgba(139, 92, 246, 0.2);
transform: translateY(-1px);
}
/* 响应式适配 */
@media (max-width: 768px) {
.title-bar-content {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.title-row {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.page-description {
white-space: normal;
}
.title-bar-right {
width: 100%;
justify-content: space-between;
}
.title-actions {
flex: 1;
}
.title-actions button {
flex: 1;
}
}

View File

@ -0,0 +1,53 @@
import { useState } from 'react'
import { UpOutlined, DownOutlined } from '@ant-design/icons'
import './PageTitleBar.css'
function PageTitleBar({
title,
badge,
description,
actions,
showToggle = false,
onToggle,
defaultExpanded = true,
}) {
const [expanded, setExpanded] = useState(defaultExpanded)
const handleToggle = () => {
const newExpanded = !expanded
setExpanded(newExpanded)
if (onToggle) {
onToggle(newExpanded)
}
}
return (
<div className="page-title-bar">
<div className="title-bar-content">
<div className="title-bar-left">
<div className="title-row">
<div className="title-group">
<h1 className="page-title">{title}</h1>
{badge && <span className="title-badge">{badge}</span>}
</div>
{description && <p className="page-description">{description}</p>}
</div>
</div>
<div className="title-bar-right">
{actions && <div className="title-actions">{actions}</div>}
{showToggle && (
<button
className="toggle-button"
onClick={handleToggle}
title={expanded ? '收起信息面板' : '展开信息面板'}
>
{expanded ? <UpOutlined /> : <DownOutlined />}
</button>
)}
</div>
</div>
</div>
)
}
export default PageTitleBar

View File

@ -0,0 +1,114 @@
import { notification } from 'antd'
import {
CheckCircleOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
InfoCircleOutlined,
} from '@ant-design/icons'
//
notification.config({
placement: 'topRight',
top: 24,
duration: 3,
maxCount: 3,
})
/**
* 标准通知反馈组件
* 从右上角滑出默认3秒后消失
*/
const Toast = {
/**
* 成功通知
* @param {string} message - 消息内容
* @param {string} description - 详细描述可选
* @param {number} duration - 显示时长默认3秒
*/
success: (message, description = '', duration = 3) => {
notification.success({
message,
description,
duration,
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
style: {
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
})
},
/**
* 错误通知
* @param {string} message - 消息内容
* @param {string} description - 详细描述可选
* @param {number} duration - 显示时长默认3秒
*/
error: (message, description = '', duration = 3) => {
notification.error({
message,
description,
duration,
icon: <CloseCircleOutlined style={{ color: '#ff4d4f' }} />,
style: {
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
})
},
/**
* 警告通知
* @param {string} message - 消息内容
* @param {string} description - 详细描述可选
* @param {number} duration - 显示时长默认3秒
*/
warning: (message, description = '', duration = 3) => {
notification.warning({
message,
description,
duration,
icon: <ExclamationCircleOutlined style={{ color: '#faad14' }} />,
style: {
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
})
},
/**
* 信息通知
* @param {string} message - 消息内容
* @param {string} description - 详细描述可选
* @param {number} duration - 显示时长默认3秒
*/
info: (message, description = '', duration = 3) => {
notification.info({
message,
description,
duration,
icon: <InfoCircleOutlined style={{ color: '#1677ff' }} />,
style: {
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
})
},
/**
* 自定义通知
* @param {Object} config - 完整的通知配置对象
*/
custom: (config) => {
notification.open({
...config,
style: {
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
...config.style,
},
})
},
}
export default Toast

View File

@ -0,0 +1,58 @@
/* 树形筛选面板 */
.tree-filter-panel {
width: 320px;
max-height: 500px;
overflow-y: auto;
}
/* 已选择的筛选条件 */
.tree-filter-selected {
min-height: 40px;
padding: 12px;
background: #f5f7fa;
border-radius: 6px;
border: 1px dashed #d9d9d9;
}
.tree-filter-tag {
display: flex;
align-items: center;
gap: 8px;
}
.tree-filter-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
font-weight: 500;
}
.tree-filter-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 24px;
}
.tree-filter-placeholder span {
color: #8c8c8c;
font-size: 13px;
}
/* 树形选择器容器 */
.tree-filter-container {
max-height: 280px;
overflow-y: auto;
}
.tree-filter-header {
font-size: 14px;
font-weight: 500;
margin-bottom: 12px;
color: rgba(0, 0, 0, 0.85);
}
/* 操作按钮 */
.tree-filter-actions {
display: flex;
justify-content: flex-end;
}

View File

@ -0,0 +1,119 @@
import { Tree, Tag, Divider, Button, Space } from 'antd'
import { useState, useEffect } from 'react'
import './TreeFilterPanel.css'
/**
* 树形筛选面板组件
* @param {Object} props
* @param {Array} props.treeData - 树形数据
* @param {string} props.selectedKey - 当前选中的节点ID
* @param {string} props.tempSelectedKey - 临时选中的节点ID确认前
* @param {string} props.treeTitle - 树标题
* @param {Function} props.onSelect - 选择变化回调
* @param {Function} props.onConfirm - 确认筛选
* @param {Function} props.onClear - 清除筛选
* @param {string} props.placeholder - 占位提示文本
*/
function TreeFilterPanel({
treeData,
selectedKey,
tempSelectedKey,
treeTitle = '分组筛选',
onSelect,
onConfirm,
onClear,
placeholder = '请选择分组进行筛选',
}) {
// key
const getAllKeys = (nodes) => {
let keys = []
const traverse = (node) => {
keys.push(node.key)
if (node.children) {
node.children.forEach(traverse)
}
}
nodes.forEach(traverse)
return keys
}
const [expandedKeys, setExpandedKeys] = useState([])
//
useEffect(() => {
if (treeData && treeData.length > 0) {
setExpandedKeys(getAllKeys(treeData))
}
}, [treeData])
//
const findNodeName = (nodes, id) => {
for (const node of nodes) {
if (node.key === id) return node.title
if (node.children) {
const found = findNodeName(node.children, id)
if (found) return found
}
}
return ''
}
const handleTreeSelect = (selectedKeys) => {
const key = selectedKeys[0] || null
onSelect?.(key)
}
const handleExpand = (keys) => {
setExpandedKeys(keys)
}
return (
<div className="tree-filter-panel">
{/* 已选择的筛选条件 */}
<div className="tree-filter-selected">
{tempSelectedKey ? (
<div className="tree-filter-tag">
<span className="tree-filter-label">已选择分组</span>
<Tag color="blue" closable onClose={() => onSelect?.(null)}>
{findNodeName(treeData, tempSelectedKey)}
</Tag>
</div>
) : (
<div className="tree-filter-placeholder">
<span>{placeholder}</span>
</div>
)}
</div>
<Divider style={{ margin: '12px 0' }} />
{/* 树形选择器 */}
<div className="tree-filter-container">
<div className="tree-filter-header">{treeTitle}</div>
<Tree
treeData={treeData}
expandedKeys={expandedKeys}
onExpand={handleExpand}
onSelect={handleTreeSelect}
selectedKeys={tempSelectedKey ? [tempSelectedKey] : []}
/>
</div>
<Divider style={{ margin: '12px 0' }} />
{/* 操作按钮 */}
<div className="tree-filter-actions">
<Space>
<Button size="small" onClick={onClear}>
清除筛选
</Button>
<Button size="small" type="primary" onClick={onConfirm}>
确定
</Button>
</Space>
</div>
</div>
)
}
export default TreeFilterPanel