feat:前端整体调整

dev_na
puz 2026-07-02 08:36:49 +08:00
parent 90b1f60955
commit b0a3dedb99
54 changed files with 71 additions and 6190 deletions

View File

@ -1,110 +0,0 @@
import { Layout, Menu, Space, Button } from "antd";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { fetchMyMenuTree } from "../api";
import { PermissionNode } from "../types";
import { LogoutOutlined, HomeOutlined, SettingOutlined, UserOutlined, SafetyOutlined, ClusterOutlined, BookOutlined, DesktopOutlined, ShopOutlined, InfoCircleOutlined, ApartmentOutlined } from "@ant-design/icons";
const iconMap: Record<string, any> = {
'home': <HomeOutlined />,
'tenant': <ShopOutlined />,
'org': <ApartmentOutlined />,
'user': <UserOutlined />,
'role': <SafetyOutlined />,
'permission': <ClusterOutlined />,
'dict': <BookOutlined />,
'device': <DesktopOutlined />,
'setting': <SettingOutlined />,
'logs': <InfoCircleOutlined />
};
export default function AppLayout() {
const location = useLocation();
const navigate = useNavigate();
const [menuTree, setMenuTree] = useState<PermissionNode[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const loadMenu = async () => {
setLoading(true);
try {
const tree = await fetchMyMenuTree();
setMenuTree(tree || []);
} catch (e) {
console.error("Failed to load menu tree", e);
} finally {
setLoading(false);
}
};
loadMenu();
}, []);
const handleLogout = () => {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
navigate("/login");
};
const renderMenuItems = (nodes: PermissionNode[]): any[] => {
return nodes
.filter(node => node.isVisible !== 0 && node.status !== 0)
.map(node => {
const icon = node.icon ? iconMap[node.icon] || <SettingOutlined /> : undefined;
if (node.children && node.children.length > 0) {
return {
key: node.path || `parent-${node.permId}`,
icon: icon,
label: node.name,
children: renderMenuItems(node.children)
};
}
return {
key: node.path,
icon: icon,
label: node.path ? <Link to={node.path}>{node.name}</Link> : node.name
};
});
};
const menuItems = [
{ key: "/", label: <Link to="/"></Link>, icon: <HomeOutlined /> },
...renderMenuItems(menuTree),
{
key: "logout",
label: <span onClick={handleLogout}>退</span>,
icon: <LogoutOutlined />,
danger: true,
style: { marginTop: 'auto' }
}
];
return (
<Layout style={{ minHeight: "100vh" }}>
<Layout.Sider collapsible width={220}>
<div style={{ padding: '16px', display: 'flex', alignItems: 'center', gap: '8px', overflow: 'hidden' }}>
<img src="/logo.svg" alt="logo" style={{ width: 28, height: 28, flexShrink: 0 }} />
<span style={{ color: "#fff", fontWeight: 700, fontSize: '18px', whiteSpace: 'nowrap' }}>MeetingAI</span>
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
style={{ height: 'calc(100% - 64px)', display: 'flex', flexDirection: 'column' }}
/>
</Layout.Sider>
<Layout>
<Layout.Header style={{ background: "#fff", padding: '0 24px', display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
<Space>
<Button type="text" icon={<LogoutOutlined />} onClick={handleLogout}>退</Button>
</Space>
</Layout.Header>
<Layout.Content style={{ padding: 24, background: '#f0f2f5', overflowY: 'auto' }}>
<Outlet />
</Layout.Content>
</Layout>
</Layout>
);
}

View File

@ -1,259 +0,0 @@
/* 帮助面板样式 */
.action-help-panel .ant-drawer-header {
border-bottom: 2px solid #f0f0f0;
}
.help-panel-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 16px;
font-weight: 600;
}
.help-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.help-panel-header-text {
font-weight: 500;
color: rgba(0, 0, 0, 0.88);
}
/* 操作详情样式 */
.help-action-detail {
display: flex;
flex-direction: column;
gap: 20px;
}
.help-action-header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
color: white;
}
.help-action-icon {
font-size: 28px;
line-height: 1;
opacity: 0.95;
}
.help-action-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.help-action-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: white;
}
.help-action-badge {
align-self: flex-start;
margin: 0;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
}
/* 帮助区块样式 */
.help-section {
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border-left: 3px solid #1677ff;
}
.help-section-warning {
background: #fff7e6;
border-left-color: #faad14;
}
.help-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 12px;
}
.help-section-content {
font-size: 13px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.65);
}
.help-section-list {
margin: 0;
padding-left: 20px;
list-style-type: disc;
}
.help-section-list li {
font-size: 13px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.65);
margin-bottom: 8px;
}
.help-section-list li:last-child {
margin-bottom: 0;
}
.help-section-steps {
margin: 0;
padding-left: 20px;
counter-reset: step-counter;
list-style: none;
}
.help-section-steps li {
font-size: 13px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.65);
margin-bottom: 12px;
padding-left: 12px;
position: relative;
counter-increment: step-counter;
}
.help-section-steps li:before {
content: counter(step-counter);
position: absolute;
left: -20px;
top: 0;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: #1677ff;
color: white;
border-radius: 50%;
font-size: 11px;
font-weight: 600;
}
.help-section-steps li:last-child {
margin-bottom: 0;
}
.help-shortcut {
display: inline-block;
}
.help-shortcut kbd {
display: inline-block;
padding: 6px 12px;
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
border: 1px solid #d9d9d9;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
font-size: 12px;
font-family: 'Monaco', 'Consolas', monospace;
color: rgba(0, 0, 0, 0.88);
font-weight: 500;
}
/* 操作列表样式 */
.help-actions-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.help-action-item {
padding: 12px;
background: white;
border: 1px solid #f0f0f0;
border-radius: 8px;
transition: all 0.3s ease;
cursor: pointer;
}
.help-action-item:hover {
border-color: #1677ff;
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.1);
transform: translateY(-2px);
}
.help-action-item-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.help-action-item-icon {
font-size: 16px;
color: #1677ff;
}
.help-action-item-title {
flex: 1;
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.88);
}
.help-action-item-shortcut {
padding: 2px 6px;
background: #f0f0f0;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 11px;
font-family: 'Monaco', 'Consolas', monospace;
color: rgba(0, 0, 0, 0.65);
}
.help-action-item-desc {
font-size: 12px;
line-height: 1.6;
color: rgba(0, 0, 0, 0.45);
padding-left: 24px;
}
/* 折叠面板自定义样式 */
.action-help-panel .ant-collapse-ghost > .ant-collapse-item {
margin-bottom: 16px;
}
.action-help-panel .ant-collapse-ghost > .ant-collapse-item > .ant-collapse-header {
padding: 12px 16px;
background: #fafafa;
border-radius: 8px;
font-weight: 500;
}
.action-help-panel .ant-collapse-ghost > .ant-collapse-item > .ant-collapse-content {
padding-top: 12px;
}
/* 响应式调整 */
@media (max-width: 768px) {
.action-help-panel .ant-drawer-content-wrapper {
width: 100% !important;
}
.help-action-header {
padding: 12px;
}
.help-section {
padding: 12px;
}
}

View File

@ -1,228 +0,0 @@
import { useState, useEffect } from 'react'
import { Drawer, Collapse, Badge, Tag, Empty } from 'antd'
import {
QuestionCircleOutlined,
BulbOutlined,
WarningOutlined,
InfoCircleOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'
import './ActionHelpPanel.css'
const { Panel } = Collapse
/**
* 操作帮助面板组件
* 在页面侧边显示当前操作的详细说明和帮助信息
* @param {Object} props
* @param {boolean} props.visible - 是否显示面板
* @param {Function} props.onClose - 关闭回调
* @param {Object} props.currentAction - 当前操作信息
* @param {Array} props.allActions - 所有可用操作列表
* @param {string} props.placement - 面板位置
* @param {Function} props.onActionSelect - 选择操作的回调
*/
function ActionHelpPanel({
visible = false,
onClose,
currentAction = null,
allActions = [],
placement = 'right',
onActionSelect,
}) {
const [activeKey, setActiveKey] = useState(['current'])
// currentAction ""
useEffect(() => {
if (currentAction && visible) {
setActiveKey(['current'])
}
}, [currentAction, visible])
//
const renderCurrentAction = () => {
if (!currentAction) {
return (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="将鼠标悬停在按钮上查看帮助"
style={{ padding: '40px 0' }}
/>
)
}
return (
<div className="help-action-detail">
{/* 操作标题 */}
<div className="help-action-header">
<div className="help-action-icon">{currentAction.icon}</div>
<div className="help-action-info">
<h3 className="help-action-title">{currentAction.title}</h3>
{currentAction.badge && (
<Tag color={currentAction.badge.color} className="help-action-badge">
{currentAction.badge.text}
</Tag>
)}
</div>
</div>
{/* 操作描述 */}
{currentAction.description && (
<div className="help-section">
<div className="help-section-title">
<InfoCircleOutlined /> 功能说明
</div>
<div className="help-section-content">{currentAction.description}</div>
</div>
)}
{/* 使用场景 */}
{currentAction.scenarios && currentAction.scenarios.length > 0 && (
<div className="help-section">
<div className="help-section-title">
<BulbOutlined /> 使用场景
</div>
<ul className="help-section-list">
{currentAction.scenarios.map((scenario, index) => (
<li key={index}>{scenario}</li>
))}
</ul>
</div>
)}
{/* 操作步骤 */}
{currentAction.steps && currentAction.steps.length > 0 && (
<div className="help-section">
<div className="help-section-title">
<ThunderboltOutlined /> 操作步骤
</div>
<ol className="help-section-steps">
{currentAction.steps.map((step, index) => (
<li key={index}>{step}</li>
))}
</ol>
</div>
)}
{/* 注意事项 */}
{currentAction.warnings && currentAction.warnings.length > 0 && (
<div className="help-section help-section-warning">
<div className="help-section-title">
<WarningOutlined /> 注意事项
</div>
<ul className="help-section-list">
{currentAction.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
{/* 快捷键 */}
{currentAction.shortcut && (
<div className="help-section">
<div className="help-section-title"> 快捷键</div>
<div className="help-shortcut">
<kbd>{currentAction.shortcut}</kbd>
</div>
</div>
)}
{/* 权限要求 */}
{currentAction.permission && (
<div className="help-section">
<div className="help-section-title">🔐 权限要求</div>
<div className="help-section-content">
<Tag color="blue">{currentAction.permission}</Tag>
</div>
</div>
)}
</div>
)
}
//
const renderAllActions = () => {
if (allActions.length === 0) {
return <Empty description="暂无操作" />
}
return (
<div className="help-actions-list">
{allActions.map((action, index) => (
<div
key={index}
className="help-action-item"
onClick={() => {
if (onActionSelect) {
onActionSelect(action)
setActiveKey(['current'])
}
}}
>
<div className="help-action-item-header">
<span className="help-action-item-icon">{action.icon}</span>
<span className="help-action-item-title">{action.title}</span>
{action.shortcut && (
<kbd className="help-action-item-shortcut">{action.shortcut}</kbd>
)}
</div>
<div className="help-action-item-desc">{action.description}</div>
</div>
))}
</div>
)
}
return (
<Drawer
title={
<div className="help-panel-title">
<QuestionCircleOutlined style={{ marginRight: 8 }} />
操作帮助
{currentAction && <Badge status="processing" text="实时帮助" />}
</div>
}
placement={placement}
width={420}
open={visible}
onClose={onClose}
className="action-help-panel"
>
<Collapse
activeKey={activeKey}
onChange={setActiveKey}
ghost
expandIconPosition="end"
>
<Panel
header={
<div className="help-panel-header">
<span className="help-panel-header-text">当前操作</span>
{currentAction && (
<Badge
count="实时"
style={{
backgroundColor: '#52c41a',
fontSize: 10,
height: 18,
lineHeight: '18px',
}}
/>
)}
</div>
}
key="current"
>
{renderCurrentAction()}
</Panel>
<Panel header="所有可用操作" key="all">
{renderAllActions()}
</Panel>
</Collapse>
</Drawer>
)
}
export default ActionHelpPanel

View File

@ -1,304 +0,0 @@
/* 底部提示栏基础样式 */
.bottom-hint-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9999;
padding: 12px 24px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* 主题样式 */
.bottom-hint-bar-light {
background: #ffffff;
border-top: 1px solid #f0f0f0;
}
.bottom-hint-bar-dark {
background: #001529;
color: #ffffff;
}
.bottom-hint-bar-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff;
}
/* 容器布局 */
.hint-bar-container {
display: flex;
align-items: center;
gap: 24px;
max-width: 1400px;
margin: 0 auto;
}
/* 左侧区域 */
.hint-bar-left {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.hint-bar-icon {
font-size: 24px;
opacity: 0.9;
}
.bottom-hint-bar-light .hint-bar-icon {
color: #1677ff;
}
.hint-bar-title-section {
display: flex;
flex-direction: column;
gap: 4px;
}
.hint-bar-title {
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
}
.bottom-hint-bar-light .hint-bar-title {
color: rgba(0, 0, 0, 0.88);
}
.hint-bar-badge {
margin: 0;
font-size: 10px;
padding: 1px 6px;
align-self: flex-start;
}
/* 中间区域 */
.hint-bar-center {
flex: 1;
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.hint-bar-description,
.hint-bar-quick-tip,
.hint-bar-warning {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
line-height: 1.4;
}
.bottom-hint-bar-light .hint-bar-description,
.bottom-hint-bar-light .hint-bar-quick-tip {
color: rgba(0, 0, 0, 0.65);
}
.hint-info-icon {
font-size: 14px;
opacity: 0.8;
}
.bottom-hint-bar-light .hint-info-icon {
color: #1677ff;
}
.hint-tip-icon {
font-size: 14px;
color: #fadb14;
}
.hint-warning-icon {
font-size: 14px;
color: #ff7a45;
}
.bottom-hint-bar-light .hint-bar-warning {
color: #d46b08;
}
/* 右侧区域 */
.hint-bar-right {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.hint-bar-shortcut {
display: flex;
align-items: center;
gap: 8px;
}
.shortcut-label {
font-size: 11px;
opacity: 0.7;
}
.shortcut-kbd {
display: inline-block;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
font-size: 11px;
font-family: 'Monaco', 'Consolas', monospace;
color: inherit;
font-weight: 500;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.bottom-hint-bar-light .shortcut-kbd {
background: #f0f0f0;
border-color: #d9d9d9;
color: rgba(0, 0, 0, 0.88);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
}
.hint-bar-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: inherit;
cursor: pointer;
transition: all 0.3s ease;
}
.hint-bar-close:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.bottom-hint-bar-light .hint-bar-close {
background: #f0f0f0;
border-color: #d9d9d9;
color: rgba(0, 0, 0, 0.45);
}
.bottom-hint-bar-light .hint-bar-close:hover {
background: #e0e0e0;
color: rgba(0, 0, 0, 0.88);
}
/* 进度指示条 */
.hint-bar-progress {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: rgba(255, 255, 255, 0.3);
overflow: hidden;
}
.hint-bar-progress::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.6);
animation: progressWave 3s ease-in-out infinite;
}
.bottom-hint-bar-light .hint-bar-progress {
background: #f0f0f0;
}
.bottom-hint-bar-light .hint-bar-progress::after {
background: #1677ff;
}
@keyframes progressWave {
0%, 100% {
transform: translateX(-100%);
}
50% {
transform: translateX(0);
}
}
/* 响应式调整 */
@media (max-width: 1024px) {
.hint-bar-container {
flex-wrap: wrap;
gap: 12px;
}
.hint-bar-center {
flex-basis: 100%;
order: 3;
gap: 12px;
}
.hint-bar-description,
.hint-bar-quick-tip,
.hint-bar-warning {
font-size: 12px;
}
}
@media (max-width: 768px) {
.bottom-hint-bar {
padding: 10px 16px;
}
.hint-bar-left {
gap: 8px;
}
.hint-bar-icon {
font-size: 20px;
}
.hint-bar-title {
font-size: 14px;
}
.hint-bar-right {
gap: 8px;
}
.shortcut-label {
display: none;
}
.hint-bar-close {
width: 24px;
height: 24px;
}
}
@media (max-width: 480px) {
.hint-bar-quick-tip {
display: none;
}
.hint-bar-warning {
flex-basis: 100%;
}
}

View File

@ -1,90 +0,0 @@
import { Tag } from 'antd'
import {
InfoCircleOutlined,
BulbOutlined,
WarningOutlined,
CloseOutlined,
} from '@ant-design/icons'
import './BottomHintBar.css'
/**
* 底部固定提示栏组件
* 在页面底部显示当前悬停按钮的实时说明
* @param {Object} props
* @param {boolean} props.visible - 是否显示提示栏
* @param {Object} props.hintInfo - 当前提示信息
* @param {Function} props.onClose - 关闭回调
* @param {string} props.theme - 主题light, dark, gradient
*/
function BottomHintBar({ visible = false, hintInfo = null, onClose, theme = 'gradient' }) {
if (!visible || !hintInfo) return null
return (
<div
className={`bottom-hint-bar bottom-hint-bar-${theme}`}
onMouseEnter={(e) => e.stopPropagation()}
>
<div className="hint-bar-container">
{/* 左侧:图标和标题 */}
<div className="hint-bar-left">
<div className="hint-bar-icon">{hintInfo.icon}</div>
<div className="hint-bar-title-section">
<h4 className="hint-bar-title">{hintInfo.title}</h4>
{hintInfo.badge && (
<Tag color={hintInfo.badge.color} className="hint-bar-badge">
{hintInfo.badge.text}
</Tag>
)}
</div>
</div>
{/* 中间:主要信息 */}
<div className="hint-bar-center">
{/* 描述 */}
{hintInfo.description && (
<div className="hint-bar-description">
<InfoCircleOutlined className="hint-info-icon" />
<span>{hintInfo.description}</span>
</div>
)}
{/* 快速提示 */}
{hintInfo.quickTip && (
<div className="hint-bar-quick-tip">
<BulbOutlined className="hint-tip-icon" />
<span>{hintInfo.quickTip}</span>
</div>
)}
{/* 警告 */}
{hintInfo.warning && (
<div className="hint-bar-warning">
<WarningOutlined className="hint-warning-icon" />
<span>{hintInfo.warning}</span>
</div>
)}
</div>
{/* 右侧:快捷键和关闭 */}
<div className="hint-bar-right">
{hintInfo.shortcut && (
<div className="hint-bar-shortcut">
<span className="shortcut-label">快捷键</span>
<kbd className="shortcut-kbd">{hintInfo.shortcut}</kbd>
</div>
)}
{onClose && (
<button className="hint-bar-close" onClick={onClose}>
<CloseOutlined />
</button>
)}
</div>
</div>
{/* 进度指示条 */}
<div className="hint-bar-progress" />
</div>
)
}
export default BottomHintBar

View File

@ -1,196 +0,0 @@
/* 按钮带引导 - 简洁现代设计 */
.button-with-guide {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* 帮助图标按钮 - 简洁扁平设计 */
.guide-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: rgba(0, 0, 0, 0.35);
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.guide-icon-btn:hover {
background: rgba(22, 119, 255, 0.06);
color: #1677ff;
}
.guide-icon-btn:active {
background: rgba(22, 119, 255, 0.12);
}
/* 引导弹窗样式 */
.button-guide-modal .ant-modal-header {
padding: 20px 24px;
border-bottom: 2px solid #f0f0f0;
}
.button-guide-modal .ant-modal-body {
padding: 24px;
max-height: 600px;
overflow-y: auto;
}
.guide-modal-header {
display: flex;
align-items: center;
gap: 12px;
}
.guide-modal-icon {
font-size: 24px;
color: #1677ff;
}
.guide-modal-title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.guide-modal-badge {
margin: 0;
font-size: 11px;
padding: 2px 8px;
}
/* 引导区块样式 */
.guide-section {
margin-bottom: 20px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border-left: 3px solid #1677ff;
}
.guide-section:last-child {
margin-bottom: 0;
}
.guide-section-warning {
background: #fff7e6;
border-left-color: #faad14;
}
.guide-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 12px;
}
.guide-section-icon {
font-size: 16px;
color: #1677ff;
}
.guide-section-warning .guide-section-icon {
color: #faad14;
}
.guide-section-content {
margin: 0;
font-size: 14px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.65);
}
.guide-list {
margin: 0;
padding-left: 20px;
list-style-type: disc;
}
.guide-list li {
font-size: 13px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.65);
margin-bottom: 8px;
}
.guide-list li:last-child {
margin-bottom: 0;
}
/* 步骤样式 */
.guide-steps {
margin-top: 12px;
}
.guide-steps .ant-steps-item-title {
font-size: 13px !important;
font-weight: 600 !important;
}
.guide-steps .ant-steps-item-description {
font-size: 13px !important;
line-height: 1.6 !important;
color: rgba(0, 0, 0, 0.65) !important;
}
/* 引导底部 */
.guide-footer {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 20px;
padding: 16px;
background: white;
border-radius: 8px;
border: 1px solid #f0f0f0;
}
.guide-footer-item {
display: flex;
align-items: center;
gap: 8px;
}
.guide-footer-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.guide-footer-kbd {
display: inline-block;
padding: 4px 10px;
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
border: 1px solid #d9d9d9;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
font-size: 11px;
font-family: 'Monaco', 'Consolas', monospace;
color: rgba(0, 0, 0, 0.88);
font-weight: 500;
}
/* 响应式调整 */
@media (max-width: 768px) {
.button-guide-modal {
max-width: calc(100% - 32px);
}
.button-guide-modal .ant-modal-body {
max-height: 500px;
}
.guide-footer {
flex-direction: column;
gap: 12px;
}
}

View File

@ -1,165 +0,0 @@
import { useState } from 'react'
import { Button, Modal, Steps, Tag } from 'antd'
import {
QuestionCircleOutlined,
BulbOutlined,
WarningOutlined,
CheckCircleOutlined,
InfoCircleOutlined,
} from '@ant-design/icons'
import './ButtonWithGuide.css'
/**
* 带引导的按钮组件 - 简洁现代设计
* 在按钮旁边显示一个简洁的帮助图标点击后显示详细引导
*/
function ButtonWithGuide({
label,
icon,
type = 'default',
danger = false,
disabled = false,
onClick,
guide,
size = 'middle',
...restProps
}) {
const [showGuideModal, setShowGuideModal] = useState(false)
const handleGuideClick = (e) => {
e.stopPropagation()
if (guide) {
setShowGuideModal(true)
}
}
return (
<>
<div className="button-with-guide">
<Button
type={type}
icon={icon}
danger={danger}
disabled={disabled}
onClick={onClick}
size={size}
{...restProps}
>
{label}
</Button>
{guide && !disabled && (
<button className="guide-icon-btn" onClick={handleGuideClick} title="查看帮助">
<QuestionCircleOutlined />
</button>
)}
</div>
{/* 引导弹窗 */}
{guide && (
<Modal
title={
<div className="guide-modal-header">
<span className="guide-modal-icon">{guide.icon || icon}</span>
<span className="guide-modal-title">{guide.title}</span>
{guide.badge && (
<Tag color={guide.badge.color} className="guide-modal-badge">
{guide.badge.text}
</Tag>
)}
</div>
}
open={showGuideModal}
onCancel={() => setShowGuideModal(false)}
footer={[
<Button key="close" type="primary" onClick={() => setShowGuideModal(false)}>
知道了
</Button>,
]}
width={600}
className="button-guide-modal"
>
{/* 功能描述 */}
{guide.description && (
<div className="guide-section">
<div className="guide-section-title">
<InfoCircleOutlined className="guide-section-icon" />
功能说明
</div>
<p className="guide-section-content">{guide.description}</p>
</div>
)}
{/* 使用步骤 */}
{guide.steps && guide.steps.length > 0 && (
<div className="guide-section">
<div className="guide-section-title">
<CheckCircleOutlined className="guide-section-icon" />
操作步骤
</div>
<Steps
direction="vertical"
current={-1}
items={guide.steps.map((step, index) => ({
title: `步骤 ${index + 1}`,
description: step,
status: 'wait',
}))}
className="guide-steps"
/>
</div>
)}
{/* 使用场景 */}
{guide.scenarios && guide.scenarios.length > 0 && (
<div className="guide-section">
<div className="guide-section-title">
<BulbOutlined className="guide-section-icon" />
适用场景
</div>
<ul className="guide-list">
{guide.scenarios.map((scenario, index) => (
<li key={index}>{scenario}</li>
))}
</ul>
</div>
)}
{/* 注意事项 */}
{guide.warnings && guide.warnings.length > 0 && (
<div className="guide-section guide-section-warning">
<div className="guide-section-title">
<WarningOutlined className="guide-section-icon" />
注意事项
</div>
<ul className="guide-list">
{guide.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
{/* 快捷键和权限 */}
{(guide.shortcut || guide.permission) && (
<div className="guide-footer">
{guide.shortcut && (
<div className="guide-footer-item">
<span className="guide-footer-label">快捷键</span>
<kbd className="guide-footer-kbd">{guide.shortcut}</kbd>
</div>
)}
{guide.permission && (
<div className="guide-footer-item">
<span className="guide-footer-label">权限要求</span>
<Tag color="blue">{guide.permission}</Tag>
</div>
)}
</div>
)}
</Modal>
)}
</>
)
}
export default ButtonWithGuide

View File

@ -1,243 +0,0 @@
.button-guide-badge-wrapper {
display: inline-block;
position: relative;
}
/* 引导徽章样式 - 改为放在右上角外部 */
.button-guide-badge-wrapper .ant-badge {
display: block;
}
.button-guide-badge-wrapper .ant-badge-count {
top: -8px;
right: -8px;
transform: none;
}
/* 引导徽章样式 */
.guide-badge {
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
background: #1677ff;
border-radius: 10px;
color: white;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
animation: pulseBadge 2s ease-in-out infinite;
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.4);
border: 2px solid white;
}
.guide-badge:hover {
animation: none;
transform: scale(1.2);
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.6);
}
.guide-badge-new {
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
box-shadow: 0 2px 8px rgba(82, 196, 26, 0.4);
}
.guide-badge-new:hover {
box-shadow: 0 4px 12px rgba(82, 196, 26, 0.6);
}
.guide-badge-help {
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.4);
}
.guide-badge-help:hover {
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.6);
}
.guide-badge-warn {
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
box-shadow: 0 2px 8px rgba(250, 173, 20, 0.4);
}
.guide-badge-warn:hover {
box-shadow: 0 4px 12px rgba(250, 173, 20, 0.6);
}
@keyframes pulseBadge {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.15);
opacity: 0.8;
}
}
/* 引导弹窗样式 */
.button-guide-modal .ant-modal-header {
padding: 20px 24px;
border-bottom: 2px solid #f0f0f0;
}
.button-guide-modal .ant-modal-body {
padding: 24px;
max-height: 600px;
overflow-y: auto;
}
.guide-modal-header {
display: flex;
align-items: center;
gap: 12px;
}
.guide-modal-icon {
font-size: 24px;
color: #1677ff;
}
.guide-modal-title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.guide-modal-badge {
margin: 0;
font-size: 11px;
padding: 2px 8px;
}
/* 引导区块样式 */
.guide-section {
margin-bottom: 20px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border-left: 3px solid #1677ff;
}
.guide-section:last-child {
margin-bottom: 0;
}
.guide-section-warning {
background: #fff7e6;
border-left-color: #faad14;
}
.guide-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 12px;
}
.guide-section-icon {
font-size: 16px;
color: #1677ff;
}
.guide-section-warning .guide-section-icon {
color: #faad14;
}
.guide-section-content {
margin: 0;
font-size: 14px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.65);
}
.guide-list {
margin: 0;
padding-left: 20px;
list-style-type: disc;
}
.guide-list li {
font-size: 13px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.65);
margin-bottom: 8px;
}
.guide-list li:last-child {
margin-bottom: 0;
}
/* 步骤样式 */
.guide-steps {
margin-top: 12px;
}
.guide-steps .ant-steps-item-title {
font-size: 13px !important;
font-weight: 600 !important;
}
.guide-steps .ant-steps-item-description {
font-size: 13px !important;
line-height: 1.6 !important;
color: rgba(0, 0, 0, 0.65) !important;
}
/* 引导底部 */
.guide-footer {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 20px;
padding: 16px;
background: white;
border-radius: 8px;
border: 1px solid #f0f0f0;
}
.guide-footer-item {
display: flex;
align-items: center;
gap: 8px;
}
.guide-footer-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.guide-footer-kbd {
display: inline-block;
padding: 4px 10px;
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
border: 1px solid #d9d9d9;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
font-size: 11px;
font-family: 'Monaco', 'Consolas', monospace;
color: rgba(0, 0, 0, 0.88);
font-weight: 500;
}
/* 响应式调整 */
@media (max-width: 768px) {
.button-guide-modal {
max-width: calc(100% - 32px);
}
.button-guide-modal .ant-modal-body {
max-height: 500px;
}
.guide-footer {
flex-direction: column;
gap: 12px;
}
}

View File

@ -1,222 +0,0 @@
import { useState } from 'react'
import { Button, Badge, Modal, Steps, Tag, Divider } from 'antd'
import {
QuestionCircleOutlined,
BulbOutlined,
WarningOutlined,
CheckCircleOutlined,
InfoCircleOutlined,
} from '@ant-design/icons'
import './ButtonWithGuideBadge.css'
/**
* 智能引导徽章按钮组件
* 为新功能或复杂按钮添加脉冲动画的徽章点击后显示详细引导
* @param {Object} props
* @param {string} props.label - 按钮文本
* @param {ReactNode} props.icon - 按钮图标
* @param {string} props.type - 按钮类型
* @param {boolean} props.danger - 危险按钮
* @param {boolean} props.disabled - 禁用状态
* @param {Function} props.onClick - 点击回调
* @param {Object} props.guide - 引导配置
* @param {boolean} props.showBadge - 是否显示徽章
* @param {string} props.badgeType - 徽章类型new, help, warn
* @param {string} props.size - 按钮大小
*/
function ButtonWithGuideBadge({
label,
icon,
type = 'default',
danger = false,
disabled = false,
onClick,
guide,
showBadge = true,
badgeType = 'help',
size = 'middle',
...restProps
}) {
const [showGuideModal, setShowGuideModal] = useState(false)
const handleBadgeClick = (e) => {
e.stopPropagation()
if (guide) {
setShowGuideModal(true)
}
}
const getBadgeConfig = () => {
const configs = {
new: {
text: 'NEW',
color: '#52c41a',
icon: <InfoCircleOutlined />,
},
help: {
text: '?',
color: '#1677ff',
icon: <QuestionCircleOutlined />,
},
warn: {
text: '!',
color: '#faad14',
icon: <WarningOutlined />,
},
}
return configs[badgeType] || configs.help
}
const badgeConfig = getBadgeConfig()
return (
<>
<div className="button-guide-badge-wrapper">
{showBadge && guide && !disabled ? (
<Badge
count={
<div
className={`guide-badge guide-badge-${badgeType}`}
onClick={handleBadgeClick}
>
{badgeConfig.icon}
</div>
}
offset={[-5, 5]}
>
<Button
type={type}
icon={icon}
danger={danger}
disabled={disabled}
onClick={onClick}
size={size}
{...restProps}
>
{label}
</Button>
</Badge>
) : (
<Button
type={type}
icon={icon}
danger={danger}
disabled={disabled}
onClick={onClick}
size={size}
{...restProps}
>
{label}
</Button>
)}
</div>
{/* 引导弹窗 */}
{guide && (
<Modal
title={
<div className="guide-modal-header">
<span className="guide-modal-icon">{guide.icon || icon}</span>
<span className="guide-modal-title">{guide.title}</span>
{guide.badge && (
<Tag color={guide.badge.color} className="guide-modal-badge">
{guide.badge.text}
</Tag>
)}
</div>
}
open={showGuideModal}
onCancel={() => setShowGuideModal(false)}
footer={[
<Button key="close" type="primary" onClick={() => setShowGuideModal(false)}>
知道了
</Button>,
]}
width={600}
className="button-guide-modal"
>
{/* 功能描述 */}
{guide.description && (
<div className="guide-section">
<div className="guide-section-title">
<InfoCircleOutlined className="guide-section-icon" />
功能说明
</div>
<p className="guide-section-content">{guide.description}</p>
</div>
)}
{/* 使用步骤 */}
{guide.steps && guide.steps.length > 0 && (
<div className="guide-section">
<div className="guide-section-title">
<CheckCircleOutlined className="guide-section-icon" />
操作步骤
</div>
<Steps
direction="vertical"
current={-1}
items={guide.steps.map((step, index) => ({
title: `步骤 ${index + 1}`,
description: step,
status: 'wait',
}))}
className="guide-steps"
/>
</div>
)}
{/* 使用场景 */}
{guide.scenarios && guide.scenarios.length > 0 && (
<div className="guide-section">
<div className="guide-section-title">
<BulbOutlined className="guide-section-icon" />
适用场景
</div>
<ul className="guide-list">
{guide.scenarios.map((scenario, index) => (
<li key={index}>{scenario}</li>
))}
</ul>
</div>
)}
{/* 注意事项 */}
{guide.warnings && guide.warnings.length > 0 && (
<div className="guide-section guide-section-warning">
<div className="guide-section-title">
<WarningOutlined className="guide-section-icon" />
注意事项
</div>
<ul className="guide-list">
{guide.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
{/* 快捷键和权限 */}
{(guide.shortcut || guide.permission) && (
<div className="guide-footer">
{guide.shortcut && (
<div className="guide-footer-item">
<span className="guide-footer-label">快捷键</span>
<kbd className="guide-footer-kbd">{guide.shortcut}</kbd>
</div>
)}
{guide.permission && (
<div className="guide-footer-item">
<span className="guide-footer-label">权限要求</span>
<Tag color="blue">{guide.permission}</Tag>
</div>
)}
</div>
)}
</Modal>
)}
</>
)
}
export default ButtonWithGuideBadge

View File

@ -1,189 +0,0 @@
.button-hover-card-wrapper {
display: inline-block;
position: relative;
}
/* 悬浮卡片 */
.hover-info-card {
position: fixed;
z-index: 10000;
transform: translateY(-50%);
opacity: 0;
animation: slideInRight 0.3s ease forwards;
pointer-events: none;
}
.hover-info-card-visible {
opacity: 1;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateY(-50%) translateX(-20px);
}
to {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
.hover-info-card-content {
width: 340px;
background: white;
border-radius: 12px;
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.12),
0 6px 12px rgba(0, 0, 0, 0.08),
0 0 2px rgba(0, 0, 0, 0.04);
overflow: hidden;
}
.hover-info-card-content .ant-card-body {
padding: 16px;
}
/* 卡片头部 */
.hover-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.hover-card-title-wrapper {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.hover-card-icon {
font-size: 20px;
color: #1677ff;
}
.hover-card-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.hover-card-badge {
margin: 0;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
}
/* 卡片描述 */
.hover-card-description {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: rgba(0, 0, 0, 0.65);
}
/* 卡片区块 */
.hover-card-section {
margin-top: 12px;
padding: 10px;
background: #f8f9fa;
border-radius: 8px;
border-left: 3px solid #1677ff;
}
.hover-card-warning {
background: #fff7e6;
border-left-color: #faad14;
}
.hover-card-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 8px;
}
.section-icon {
font-size: 12px;
color: #1677ff;
}
.hover-card-warning .section-icon {
color: #faad14;
}
.hover-card-list {
margin: 0;
padding-left: 16px;
list-style-type: disc;
}
.hover-card-list li {
font-size: 12px;
line-height: 1.6;
color: rgba(0, 0, 0, 0.65);
margin-bottom: 4px;
}
.hover-card-list li:last-child {
margin-bottom: 0;
}
/* 卡片底部 */
.hover-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.footer-label {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.footer-kbd {
display: inline-block;
padding: 4px 10px;
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
border: 1px solid #d9d9d9;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
font-size: 11px;
font-family: 'Monaco', 'Consolas', monospace;
color: rgba(0, 0, 0, 0.88);
font-weight: 500;
}
/* 响应式调整 */
@media (max-width: 768px) {
.hover-info-card-content {
width: 280px;
}
.hover-info-card {
left: 50% !important;
transform: translateX(-50%) translateY(-50%);
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(-50%) translateY(-50%) scale(0.95);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(-50%) scale(1);
}
}
}

View File

@ -1,179 +0,0 @@
import { useState, useRef } from 'react'
import { createPortal } from 'react-dom'
import { Button, Card, Tag } from 'antd'
import {
BulbOutlined,
WarningOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'
import './ButtonWithHoverCard.css'
/**
* 悬浮展开卡片按钮组件
* 鼠标悬停时在按钮旁边展开一个精美的信息卡片
* @param {Object} props
* @param {string} props.label - 按钮文本
* @param {ReactNode} props.icon - 按钮图标
* @param {string} props.type - 按钮类型
* @param {boolean} props.danger - 危险按钮
* @param {boolean} props.disabled - 禁用状态
* @param {Function} props.onClick - 点击回调
* @param {Object} props.cardInfo - 卡片信息配置
* @param {string} props.size - 按钮大小
*/
function ButtonWithHoverCard({
label,
icon,
type = 'default',
danger = false,
disabled = false,
onClick,
cardInfo,
size = 'middle',
...restProps
}) {
const [showCard, setShowCard] = useState(false)
const [cardPosition, setCardPosition] = useState({ top: 0, left: 0 })
const wrapperRef = useRef(null)
const handleMouseEnter = () => {
if (!cardInfo || disabled) return
if (wrapperRef.current) {
const rect = wrapperRef.current.getBoundingClientRect()
setCardPosition({
top: rect.top + rect.height / 2,
left: rect.right + 12,
})
}
setShowCard(true)
}
const handleMouseLeave = () => {
setShowCard(false)
}
//
const renderCard = () => {
if (!showCard || !cardInfo) return null
return (
<div
className={`hover-info-card ${showCard ? 'hover-info-card-visible' : ''}`}
style={{
top: cardPosition.top,
left: cardPosition.left,
}}
>
<Card
size="small"
variant="borderless"
className="hover-info-card-content"
>
{/* 标题区 */}
<div className="hover-card-header">
<div className="hover-card-title-wrapper">
{cardInfo.icon && (
<span className="hover-card-icon">{cardInfo.icon}</span>
)}
<h4 className="hover-card-title">{cardInfo.title}</h4>
</div>
{cardInfo.badge && (
<Tag color={cardInfo.badge.color} className="hover-card-badge">
{cardInfo.badge.text}
</Tag>
)}
</div>
{/* 描述 */}
{cardInfo.description && (
<div className="hover-card-section">
<p className="hover-card-description">{cardInfo.description}</p>
</div>
)}
{/* 使用场景 */}
{cardInfo.scenarios && cardInfo.scenarios.length > 0 && (
<div className="hover-card-section">
<div className="hover-card-section-title">
<BulbOutlined className="section-icon" />
使用场景
</div>
<ul className="hover-card-list">
{cardInfo.scenarios.slice(0, 2).map((scenario, index) => (
<li key={index}>{scenario}</li>
))}
</ul>
</div>
)}
{/* 快速提示 */}
{cardInfo.quickTips && cardInfo.quickTips.length > 0 && (
<div className="hover-card-section">
<div className="hover-card-section-title">
<ThunderboltOutlined className="section-icon" />
快速提示
</div>
<ul className="hover-card-list">
{cardInfo.quickTips.map((tip, index) => (
<li key={index}>{tip}</li>
))}
</ul>
</div>
)}
{/* 注意事项 */}
{cardInfo.warnings && cardInfo.warnings.length > 0 && (
<div className="hover-card-section hover-card-warning">
<div className="hover-card-section-title">
<WarningOutlined className="section-icon" />
注意
</div>
<ul className="hover-card-list">
{cardInfo.warnings.slice(0, 2).map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
{/* 快捷键 */}
{cardInfo.shortcut && (
<div className="hover-card-footer">
<span className="footer-label">快捷键</span>
<kbd className="footer-kbd">{cardInfo.shortcut}</kbd>
</div>
)}
</Card>
</div>
)
}
return (
<>
<div
ref={wrapperRef}
className="button-hover-card-wrapper"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Button
type={type}
icon={icon}
danger={danger}
disabled={disabled}
onClick={onClick}
size={size}
{...restProps}
>
{label}
</Button>
</div>
{/* 使用 Portal 渲染悬浮卡片到 body */}
{typeof document !== 'undefined' && createPortal(renderCard(), document.body)}
</>
)
}
export default ButtonWithHoverCard

View File

@ -1,163 +0,0 @@
/* 按钮包裹容器 */
.button-with-tip-wrapper {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
}
.button-with-tip {
transition: all 0.3s ease;
}
/* 提示指示器 */
.button-tip-indicator {
font-size: 12px;
color: rgba(0, 0, 0, 0.25);
cursor: help;
transition: all 0.3s ease;
animation: pulse 2s ease-in-out infinite;
}
.button-with-tip-wrapper:hover .button-tip-indicator {
color: #1677ff;
animation: none;
}
/* 脉冲动画 */
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
}
}
/* 提示框样式 */
.button-tip-overlay {
max-width: 360px;
}
.button-tip-overlay .ant-tooltip-inner {
padding: 12px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3);
}
.button-tip-overlay .ant-tooltip-arrow {
--antd-arrow-background-color: #667eea;
}
.button-tip-overlay .ant-tooltip-arrow-content {
background: #667eea;
}
/* 提示内容布局 */
.button-tip-content {
display: flex;
flex-direction: column;
gap: 8px;
color: #ffffff;
font-size: 13px;
line-height: 1.6;
}
.button-tip-title {
font-size: 14px;
font-weight: 600;
color: #ffffff;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
padding-bottom: 6px;
}
.button-tip-description {
color: rgba(255, 255, 255, 0.95);
font-size: 13px;
}
.button-tip-shortcut {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.15);
}
.tip-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
}
.tip-kbd {
display: inline-block;
padding: 2px 8px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
font-size: 11px;
font-family: 'Monaco', 'Consolas', monospace;
color: #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.button-tip-notes {
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.15);
}
.tip-notes-title {
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 6px;
}
.tip-notes-list {
margin: 0;
padding-left: 16px;
list-style-type: disc;
}
.tip-notes-list li {
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
margin-bottom: 4px;
}
.tip-notes-list li:last-child {
margin-bottom: 0;
}
/* 不同主题的提示框 */
.tip-theme-success.button-tip-overlay .ant-tooltip-inner {
background: linear-gradient(135deg, #56ab2f 0%, #a8e063 100%);
}
.tip-theme-warning.button-tip-overlay .ant-tooltip-inner {
background: linear-gradient(135deg, #f7971e 0%, #ffd200 100%);
}
.tip-theme-danger.button-tip-overlay .ant-tooltip-inner {
background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);
}
.tip-theme-info.button-tip-overlay .ant-tooltip-inner {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
/* 响应式调整 */
@media (max-width: 768px) {
.button-tip-overlay {
max-width: 280px;
}
.button-tip-indicator {
display: none;
}
}

View File

@ -1,105 +0,0 @@
import { Button, Tooltip } from 'antd'
import { QuestionCircleOutlined } from '@ant-design/icons'
import './ButtonWithTip.css'
/**
* 带有增强提示的按钮组件
* @param {Object} props
* @param {string} props.label - 按钮文本
* @param {ReactNode} props.icon - 按钮图标
* @param {string} props.type - 按钮类型
* @param {boolean} props.danger - 危险按钮
* @param {boolean} props.disabled - 禁用状态
* @param {Function} props.onClick - 点击回调
* @param {Object} props.tip - 提示配置
* @param {string} props.tip.title - 提示标题
* @param {string} props.tip.description - 详细描述
* @param {string} props.tip.shortcut - 快捷键提示
* @param {Array} props.tip.notes - 注意事项列表
* @param {string} props.tip.placement - 提示位置
* @param {boolean} props.showTipIcon - 是否显示提示图标
* @param {string} props.size - 按钮大小
*/
function ButtonWithTip({
label,
icon,
type = 'default',
danger = false,
disabled = false,
onClick,
tip,
showTipIcon = true,
size = 'middle',
...restProps
}) {
//
if (!tip) {
return (
<Button
type={type}
icon={icon}
danger={danger}
disabled={disabled}
onClick={onClick}
size={size}
{...restProps}
>
{label}
</Button>
)
}
//
const tooltipContent = (
<div className="button-tip-content">
{tip.title && <div className="button-tip-title">{tip.title}</div>}
{tip.description && <div className="button-tip-description">{tip.description}</div>}
{tip.shortcut && (
<div className="button-tip-shortcut">
<span className="tip-label">快捷键</span>
<kbd className="tip-kbd">{tip.shortcut}</kbd>
</div>
)}
{tip.notes && tip.notes.length > 0 && (
<div className="button-tip-notes">
<div className="tip-notes-title">注意事项</div>
<ul className="tip-notes-list">
{tip.notes.map((note, index) => (
<li key={index}>{note}</li>
))}
</ul>
</div>
)}
</div>
)
return (
<Tooltip
title={tooltipContent}
placement={tip.placement || 'top'}
classNames={{ root: 'button-tip-overlay' }}
mouseEnterDelay={0.3}
arrow={{ pointAtCenter: true }}
>
<div className="button-with-tip-wrapper">
<Button
type={type}
icon={icon}
danger={danger}
disabled={disabled}
onClick={onClick}
size={size}
className="button-with-tip"
{...restProps}
>
{label}
</Button>
{showTipIcon && !disabled && (
<QuestionCircleOutlined className="button-tip-indicator" />
)}
</div>
</Tooltip>
)
}
export default ButtonWithTip

View File

@ -1,17 +0,0 @@
/* 图表面板 */
.chart-panel {
margin-bottom: 16px;
}
.chart-panel:last-child {
margin-bottom: 0;
}
.chart-panel-title {
font-size: 13px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 12px;
padding-left: 8px;
border-left: 3px solid #1677ff;
}

View File

@ -1,202 +0,0 @@
import { useEffect, useRef } from 'react'
import * as echarts from 'echarts'
import './ChartPanel.css'
/**
* 图表面板组件
* @param {Object} props
* @param {string} props.type - 图表类型: 'line' | 'bar' | 'pie' | 'ring'
* @param {string} props.title - 图表标题
* @param {Object} props.data - 图表数据
* @param {number} props.height - 图表高度默认 200px
* @param {Object} props.option - 自定义 ECharts 配置
* @param {string} props.className - 自定义类名
*/
function ChartPanel({ type = 'line', title, data, height = 200, option = {}, className = '' }) {
const chartRef = useRef(null)
const chartInstance = useRef(null)
useEffect(() => {
if (!chartRef.current || !data) return
// 使 setTimeout DOM
const timer = setTimeout(() => {
//
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current)
}
//
const chartOption = getChartOption(type, data, option)
chartInstance.current.setOption(chartOption, true)
}, 0)
// 使 passive
const handleResize = () => {
if (chartInstance.current) {
chartInstance.current.resize()
}
}
//
window.addEventListener('resize', handleResize, { passive: true })
return () => {
clearTimeout(timer)
window.removeEventListener('resize', handleResize)
}
}, [type, data, option])
//
useEffect(() => {
return () => {
chartInstance.current?.dispose()
}
}, [])
return (
<div className={`chart-panel ${className}`}>
{title && <div className="chart-panel-title">{title}</div>}
<div ref={chartRef} style={{ width: '100%', height: `${height}px` }} />
</div>
)
}
/**
* 根据图表类型生成 ECharts 配置
*/
function getChartOption(type, data, customOption) {
const baseOption = {
grid: {
left: '10%',
right: '5%',
top: '15%',
bottom: '15%',
},
tooltip: {
trigger: type === 'pie' || type === 'ring' ? 'item' : 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e8e8e8',
borderWidth: 1,
textStyle: {
color: '#333',
},
},
}
switch (type) {
case 'line':
return {
...baseOption,
xAxis: {
type: 'category',
data: data.xAxis || [],
boundaryGap: false,
axisLine: { lineStyle: { color: '#e8e8e8' } },
axisLabel: { color: '#8c8c8c', fontSize: 11 },
},
yAxis: {
type: 'value',
axisLine: { lineStyle: { color: '#e8e8e8' } },
axisLabel: { color: '#8c8c8c', fontSize: 11 },
splitLine: { lineStyle: { color: '#f0f0f0' } },
},
series: [
{
type: 'line',
data: data.series || [],
smooth: true,
lineStyle: { width: 2, color: '#1677ff' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(22, 119, 255, 0.3)' },
{ offset: 1, color: 'rgba(22, 119, 255, 0.05)' },
]),
},
symbol: 'circle',
symbolSize: 6,
itemStyle: { color: '#1677ff' },
},
],
...customOption,
}
case 'bar':
return {
...baseOption,
xAxis: {
type: 'category',
data: data.xAxis || [],
axisLine: { lineStyle: { color: '#e8e8e8' } },
axisLabel: { color: '#8c8c8c', fontSize: 11 },
},
yAxis: {
type: 'value',
axisLine: { lineStyle: { color: '#e8e8e8' } },
axisLabel: { color: '#8c8c8c', fontSize: 11 },
splitLine: { lineStyle: { color: '#f0f0f0' } },
},
series: [
{
type: 'bar',
data: data.series || [],
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#4096ff' },
{ offset: 1, color: '#1677ff' },
]),
borderRadius: [4, 4, 0, 0],
},
barWidth: '50%',
},
],
...customOption,
}
case 'pie':
case 'ring':
return {
...baseOption,
grid: undefined,
legend: {
orient: 'vertical',
right: '10%',
top: 'center',
textStyle: { color: '#8c8c8c', fontSize: 12 },
},
series: [
{
type: 'pie',
radius: type === 'ring' ? ['40%', '65%'] : '65%',
center: ['40%', '50%'],
data: data.series || [],
label: {
fontSize: 11,
color: '#8c8c8c',
},
labelLine: {
lineStyle: { color: '#d9d9d9' },
},
itemStyle: {
borderRadius: 4,
borderColor: '#fff',
borderWidth: 2,
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.3)',
},
},
},
],
...customOption,
}
default:
return { ...baseOption, ...customOption }
}
}
export default ChartPanel

View File

@ -1,138 +0,0 @@
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

@ -1,119 +0,0 @@
/* 详情抽屉容器 */
.detail-drawer-content {
height: 100%;
display: flex;
flex-direction: column;
}
/* 顶部信息区域 - 固定不滚动 */
.detail-drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
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;
padding: 24px;
}
/* 标签页区域 */
.detail-drawer-tabs {
background: #ffffff;
padding: 0;
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 0 16px 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: 0;
background: #ffffff;
}

View File

@ -1,113 +0,0 @@
import { Drawer, Button, Space, Tabs } from "antd";
import { CloseOutlined } from "@ant-design/icons";
import type { ReactNode } from "react";
import "./DetailDrawer.css";
type DrawerTitle = {
text: string;
badge?: ReactNode;
icon?: ReactNode;
};
type HeaderAction = {
key: string;
label: string;
type?: "default" | "primary" | "dashed" | "link" | "text";
icon?: ReactNode;
danger?: boolean;
disabled?: boolean;
onClick: () => void;
};
type DrawerTab = {
key: string;
label: ReactNode;
content: ReactNode;
};
interface DetailDrawerProps {
visible: boolean;
onClose: () => void;
title?: DrawerTitle;
headerActions?: HeaderAction[];
width?: number;
children?: ReactNode;
tabs?: DrawerTab[];
}
function DetailDrawer({
visible,
onClose,
title,
headerActions = [],
width = 1080,
children,
tabs,
}: DetailDrawerProps) {
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

@ -1,105 +0,0 @@
/* 扩展信息面板容器 */
.extend-info-panel {
display: flex;
gap: 16px;
width: 100%;
}
/* 垂直布局(默认) */
.extend-info-panel-vertical {
flex-direction: column;
}
/* 水平布局 */
.extend-info-panel-horizontal {
flex-direction: row;
flex-wrap: wrap;
}
/* 信息区块 */
.extend-info-section {
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
overflow: hidden;
transition: all 0.3s ease;
}
/* 水平布局时区块自适应宽度 */
.extend-info-panel-horizontal .extend-info-section {
flex: 1;
min-width: 0;
}
.extend-info-section:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 区块头部 */
.extend-info-section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
border-bottom: 1px solid #e8e8e8;
cursor: pointer;
user-select: none;
transition: background 0.2s ease;
}
.extend-info-section-header:hover {
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0ff 100%);
}
.extend-info-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.extend-info-section-icon {
display: flex;
align-items: center;
font-size: 16px;
color: #1677ff;
}
.extend-info-section-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: #8c8c8c;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 4px;
}
.extend-info-section-toggle:hover {
background: rgba(0, 0, 0, 0.06);
color: #1677ff;
}
/* 区块内容 */
.extend-info-section-content {
padding: 16px 20px;
animation: expandContent 0.3s ease-out;
}
@keyframes expandContent {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -1,68 +0,0 @@
import { useState } from 'react'
import { UpOutlined, DownOutlined } from '@ant-design/icons'
import './ExtendInfoPanel.css'
/**
* 扩展信息面板组件
* @param {Object} props
* @param {Array} props.sections - 信息区块配置数组
* @param {string} props.sections[].key - 区块唯一键
* @param {string} props.sections[].title - 区块标题
* @param {ReactNode} props.sections[].icon - 标题图标
* @param {ReactNode} props.sections[].content - 区块内容
* @param {boolean} props.sections[].defaultCollapsed - 默认是否折叠
* @param {boolean} props.sections[].hideTitleBar - 是否隐藏该区块的标题栏默认 false
* @param {string} props.layout - 布局方式'vertical'垂直堆叠| 'horizontal'水平排列
* @param {string} props.className - 自定义类名
*/
function ExtendInfoPanel({ sections = [], layout = 'vertical', className = '' }) {
const [collapsedSections, setCollapsedSections] = useState(() => {
const initial = {}
sections.forEach((section) => {
if (section.defaultCollapsed) {
initial[section.key] = true
}
})
return initial
})
const toggleSection = (key) => {
setCollapsedSections((prev) => ({
...prev,
[key]: !prev[key],
}))
}
return (
<div className={`extend-info-panel extend-info-panel-${layout} ${className}`}>
{sections.map((section) => {
const isCollapsed = collapsedSections[section.key]
const hideTitleBar = section.hideTitleBar === true
return (
<div key={section.key} className="extend-info-section">
{/* 区块头部 - 可配置隐藏 */}
{!hideTitleBar && (
<div className="extend-info-section-header" onClick={() => toggleSection(section.key)}>
<div className="extend-info-section-title">
{section.icon && <span className="extend-info-section-icon">{section.icon}</span>}
<span>{section.title}</span>
</div>
<button className="extend-info-section-toggle" type="button">
{isCollapsed ? <DownOutlined /> : <UpOutlined />}
</button>
</div>
)}
{/* 区块内容 - 如果隐藏标题栏则总是显示,否则根据折叠状态 */}
{(hideTitleBar || !isCollapsed) && (
<div className="extend-info-section-content">{section.content}</div>
)}
</div>
)
})}
</div>
)
}
export default ExtendInfoPanel

View File

@ -1,96 +0,0 @@
/* 信息面板 */
.info-panel {
padding: 0;
background: #ffffff;
}
/* 信息区域容器 */
.info-panel > :global(.ant-row) {
padding: 24px;
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: 0;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
background: linear-gradient(180deg, #1677ff 0%, #4096ff 100%);
border-radius: 2px;
transition: all 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: 3px;
height: 60%;
}
.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

@ -1,58 +0,0 @@
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

@ -1,96 +0,0 @@
.list-action-bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
padding: 16px;
background: var(--card-bg);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
width: 100%;
border: 1px solid var(--border-color);
}
.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;
}
/* 批量操作区域样式 */
.selection-info {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: var(--bg-color-secondary);
border: 1px solid var(--link-color);
border-radius: 6px;
font-size: 14px;
}
.selection-count {
color: var(--text-color);
}
.selection-count strong {
color: var(--link-color);
font-weight: 600;
margin: 0 4px;
}
.all-pages-tag {
color: var(--link-color);
font-weight: 500;
margin-left: 4px;
}
.select-all-link,
.clear-selection-link {
color: var(--link-color);
cursor: pointer;
text-decoration: none;
white-space: nowrap;
padding: 2px 8px;
border-radius: 4px;
transition: all 0.2s;
}
.select-all-link:hover,
.clear-selection-link:hover {
background: rgba(22, 119, 255, 0.1);
text-decoration: underline;
}
/* 响应式 */
@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

@ -1,134 +0,0 @@
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 {Array} props.batchActions - 批量操作按钮配置数组仅在有选中项时显示
* @param {Object} props.selectionInfo - 选中信息 { count: 选中数量, total: 总数量, isAllPagesSelected: 是否跨页全选 }
* @param {Function} props.onSelectAllPages - 选择所有页回调
* @param {Function} props.onClearSelection - 清除选择回调
* @param {Object} props.search - 搜索配置
* @param {Object} props.filter - 高级筛选配置可选
* @param {boolean} props.showRefresh - 是否显示刷新按钮
* @param {Function} props.onRefresh - 刷新回调
*/
function ListActionBar({
actions = [],
batchActions = [],
selectionInfo,
onSelectAllPages,
onClearSelection,
search,
filter,
showRefresh = false,
onRefresh,
}) {
//
const hasSelection = selectionInfo && selectionInfo.count > 0
return (
<div className="list-action-bar">
{/* 左侧操作按钮区 */}
<div className="list-action-bar-left">
{/* 常规操作按钮(无选中时显示) */}
{!hasSelection && 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>
))}
{/* 批量操作区域(有选中时显示) */}
{hasSelection && (
<Space>
{/* 选中信息 */}
<div className="selection-info">
<span className="selection-count">
已选择 <strong>{selectionInfo.count}</strong>
{selectionInfo.isAllPagesSelected && (
<span className="all-pages-tag">全部页</span>
)}
</span>
{!selectionInfo.isAllPagesSelected && selectionInfo.total > selectionInfo.count && (
<a onClick={onSelectAllPages} className="select-all-link">
选择全部 {selectionInfo.total}
</a>
)}
<a onClick={onClearSelection} className="clear-selection-link">
清除
</a>
</div>
{/* 批量操作按钮 */}
{batchActions.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 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

@ -134,6 +134,7 @@ function ListTable<T extends Record<string, any>>({
const emptyText = locale?.emptyText ?? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />
);
const emptyContent = typeof emptyText === "function" ? emptyText() : emptyText;
const tableLocale = {
...locale,
emptyText: null,
@ -158,7 +159,7 @@ function ListTable<T extends Record<string, any>>({
className: selectedRow?.[rowKey] === record[rowKey] ? "row-selected" : "",
})}
/>
{isEmptyTable ? <div className="list-table-empty-overlay">{emptyText}</div> : null}
{isEmptyTable ? <div className="list-table-empty-overlay">{emptyContent}</div> : null}
</div>
);
}

View File

@ -1,154 +0,0 @@
.app-header {
background: var(--header-bg);
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 var(--border-color);
color: var(--text-color);
}
/* 左侧区域 */
.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;
}
.trigger {
font-size: 18px;
cursor: pointer;
transition: color 0.3s;
padding: 8px;
border-radius: 4px;
color: var(--text-color-secondary);
display: flex;
align-items: center;
}
.trigger:hover {
color: #1677ff;
background: rgba(22, 119, 255, 0.08);
}
/* 右侧区域 */
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
/* Icon Buttons */
.header-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 8px;
cursor: pointer;
color: var(--text-color-secondary);
transition: all 0.2s;
background: transparent;
}
.header-icon-btn:hover {
background-color: var(--item-hover-bg);
color: var(--text-color);
}
/* 通知面板样式 */
.header-notification-popover .ant-popover-inner-content {
padding: 0;
}
.notification-popover {
width: 320px;
}
.popover-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.popover-header .title {
font-weight: 600;
font-size: 16px;
color: var(--text-color);
}
.notification-list {
max-height: 400px;
overflow-y: auto;
}
.notification-item {
padding: 12px 16px !important;
cursor: pointer;
transition: background 0.3s;
background: var(--bg-color);
}
.notification-item:hover {
background: var(--item-hover-bg);
}
.notification-item.unread {
background: #e6f7ff;
}
/* Dark mode adjustment for unread */
body.dark .notification-item.unread {
background: #111d2c;
}
.notification-item.unread:hover {
background: #bae7ff;
}
body.dark .notification-item.unread:hover {
background: #112a45;
}
.content-text {
font-size: 13px;
color: var(--text-color-secondary);
margin-top: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.time {
font-size: 12px;
color: var(--text-color-secondary);
opacity: 0.8;
margin-top: 4px;
}
.popover-footer {
padding: 8px;
border-top: 1px solid var(--border-color);
text-align: center;
}

View File

@ -1,203 +0,0 @@
import { useState, useEffect } from 'react'
import { Layout, Badge, Avatar, Dropdown, Space, Popover, List, Tabs, Button, Empty, Typography, Segmented, Tooltip } from 'antd'
import { useNavigate } from 'react-router-dom'
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
BellOutlined,
ProjectOutlined,
TeamOutlined,
NotificationOutlined,
MoonOutlined,
SunOutlined,
GlobalOutlined
} from '@ant-design/icons'
import useUserStore from '@/stores/userStore'
import useNotificationStore from '@/stores/notificationStore'
import useThemeStore from '@/stores/themeStore'
import { getNotifications, getUnreadCount, markAsRead, markAllAsRead } from '@/api/notification'
import Toast from '@/components/Toast/Toast'
import './AppHeader.css'
const { Header } = Layout
const { Text } = Typography
function AppHeader({ collapsed, onToggle, showLogo = true }) {
const navigate = useNavigate()
const { user } = useUserStore()
const { unreadCount, fetchUnreadCount, decrementUnreadCount, resetUnreadCount } = useNotificationStore()
const { isDarkMode, toggleTheme } = useThemeStore()
const [notifications, setNotifications] = useState([])
const [loading, setLoading] = useState(false)
const [popoverVisible, setPopoverVisible] = useState(false)
const [lang, setLang] = useState('zh')
useEffect(() => {
if (user) {
fetchUnreadCount()
const timer = setInterval(fetchUnreadCount, 120000)
return () => clearInterval(timer)
}
}, [user])
const fetchNotifications = async () => {
setLoading(true)
try {
const res = await getNotifications({ page: 1, page_size: 5 })
setNotifications(res.data || [])
} catch (error) {
console.error('Fetch notifications error:', error)
} finally {
setLoading(false)
}
}
const handleMarkRead = async (id) => {
try {
await markAsRead(id)
setNotifications(notifications.map(n => n.id === id ? { ...n, is_read: true } : n))
decrementUnreadCount()
} catch (error) {
console.error('Mark read error:', error)
}
}
const handleMarkAllRead = async () => {
try {
await markAllAsRead()
setNotifications(notifications.map(n => ({ ...n, is_read: true })))
resetUnreadCount()
Toast.success('操作成功', '所有通知已标记为已读')
} catch (error) {
console.error('Mark all read error:', error)
}
}
const handleNotificationClick = (n) => {
if (!n.is_read) {
handleMarkRead(n.id)
}
if (n.link) {
navigate(n.link)
setPopoverVisible(false)
}
}
const getCategoryIcon = (category) => {
switch (category) {
case 'project': return <ProjectOutlined style={{ color: '#1890ff' }} />
case 'collaboration': return <TeamOutlined style={{ color: '#52c41a' }} />
default: return <NotificationOutlined style={{ color: '#faad14' }} />
}
}
const notificationContent = (
<div className="notification-popover">
<div className="popover-header">
<span className="title">消息通知</span>
{unreadCount > 0 && (
<Button type="link" size="small" onClick={handleMarkAllRead}>
全部已读
</Button>
)}
</div>
<List
className="notification-list"
loading={loading}
itemLayout="horizontal"
dataSource={notifications}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无新消息" /> }}
renderItem={(item) => (
<List.Item
className={`notification-item ${!item.is_read ? 'unread' : ''}`}
onClick={() => handleNotificationClick(item)}
>
<List.Item.Meta
avatar={<Avatar icon={getCategoryIcon(item.category)} />}
title={<Text strong={!item.is_read}>{item.title}</Text>}
description={
<div>
<div className="content-text">{item.content}</div>
<div className="time">{new Date(item.created_at).toLocaleString('zh-CN')}</div>
</div>
}
/>
</List.Item>
)}
/>
<div className="popover-footer">
<Button type="link" block onClick={() => { navigate('/notifications'); setPopoverVisible(false); }}>
查看全部消息
</Button>
</div>
</div>
)
return (
<Header className="app-header" style={{ paddingLeft: showLogo ? 24 : 0 }}>
{/* 左侧Logo + 折叠按钮 */}
{showLogo && (
<div className="header-left">
{/* Logo 区域 */}
<div className="header-logo">
<img src="/favicon.svg" alt="logo" style={{ width: 32, height: 32, marginRight: 8 }} />
<h2 style={{ margin: 0, color: '#1677ff', fontWeight: 'bold' }}>NexDocus</h2>
</div>
{/* 折叠按钮 */}
<div className="trigger" onClick={onToggle}>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
</div>
)}
{!showLogo && <div />} {/* Spacer if left is empty */}
{/* 右侧:功能按钮 */}
<div className="header-right">
<Space size={16} className="header-actions">
{/* 1. 主题切换 */}
<div className="header-icon-btn" title="切换主题" onClick={toggleTheme}>
{isDarkMode ? <SunOutlined style={{ fontSize: 18 }} /> : <MoonOutlined style={{ fontSize: 18 }} />}
</div>
{/* 2. 语言切换 */}
<Segmented
value={lang}
onChange={setLang}
options={[
{ label: '中', value: 'zh' },
{ label: 'EN', value: 'en' },
]}
style={{ fontWeight: 500 }}
/>
{/* 3. 消息通知 */}
<Popover
content={notificationContent}
trigger="click"
open={popoverVisible}
onOpenChange={(visible) => {
setPopoverVisible(visible)
if (visible) {
fetchNotifications()
}
}}
placement="bottomRight"
overlayClassName="header-notification-popover"
>
<div className="header-icon-btn" title="消息中心">
<Badge count={unreadCount} size="small" offset={[2, -2]}>
<BellOutlined style={{ fontSize: 18 }} />
</Badge>
</div>
</Popover>
</Space>
</div>
</Header>
)
}
export default AppHeader

View File

@ -1,96 +0,0 @@
.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

@ -1,195 +0,0 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import {
DashboardOutlined,
DesktopOutlined,
GlobalOutlined,
CloudServerOutlined,
UserOutlined,
AppstoreOutlined,
SettingOutlined,
BlockOutlined,
FolderOutlined,
FileTextOutlined,
SafetyOutlined,
TeamOutlined,
ProjectOutlined,
RocketOutlined,
ReadOutlined,
BookOutlined,
} from '@ant-design/icons'
import { App } from 'antd'
import { getUserMenus } from '@/api/menu'
import useUserStore from '@/stores/userStore'
import ModernSidebar from '../ModernSidebar/ModernSidebar'
//
const iconMap = {
DashboardOutlined: <DashboardOutlined />,
DesktopOutlined: <DesktopOutlined />,
GlobalOutlined: <GlobalOutlined />,
CloudServerOutlined: <CloudServerOutlined />,
UserOutlined: <UserOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
SettingOutlined: <SettingOutlined />,
BlockOutlined: <BlockOutlined />,
FolderOutlined: <FolderOutlined />,
FileTextOutlined: <FileTextOutlined />,
SafetyOutlined: <SafetyOutlined />,
TeamOutlined: <TeamOutlined />,
ProjectOutlined: <ProjectOutlined />,
ReadOutlined: <ReadOutlined />,
BookOutlined: <BookOutlined />,
}
function AppSider({ collapsed, onToggle }) {
const { message } = App.useApp();
const navigate = useNavigate()
const location = useLocation()
const { user, logout } = useUserStore()
const [menuGroups, setMenuGroups] = useState([])
//
useEffect(() => {
loadMenus()
}, [])
const loadMenus = async () => {
try {
const res = await getUserMenus()
if (res.data) {
// type=1 () type=2 ()
const validMenus = res.data.filter(item => [1, 2].includes(item.menu_type))
transformMenuData(validMenus)
}
} catch (error) {
console.error('Load menus error:', error)
message.error('加载菜单失败')
}
}
const transformMenuData = (data) => {
const groups = []
// ()
const defaultGroup = {
title: '', // ''
items: []
}
data.forEach(item => {
//
const validChildren = item.children ? item.children.filter(child => [1, 2].includes(child.menu_type)) : []
if (validChildren.length > 0) {
//
const groupItems = validChildren.map(child => {
const icon = typeof child.icon === 'string' ? (iconMap[child.icon] || <AppstoreOutlined />) : child.icon
return {
key: child.menu_code,
label: child.menu_name,
icon: icon,
path: child.path
}
})
groups.push({
title: item.menu_name, // e.g. ""
items: groupItems
})
} else {
//
const icon = typeof item.icon === 'string' ? (iconMap[item.icon] || <AppstoreOutlined />) : item.icon
defaultGroup.items.push({
key: item.menu_code,
label: item.menu_name,
icon: icon,
path: item.path
})
}
})
//
if (defaultGroup.items.length > 0) {
groups.unshift(defaultGroup)
}
setMenuGroups(groups)
}
const handleNavigate = (key, item) => {
if (item.path) {
navigate(item.path)
}
}
const handleLogout = () => {
logout()
navigate('/login')
}
const handleProfileClick = () => {
navigate('/profile')
}
// key
// path
const getActiveKey = () => {
const path = location.pathname
// items
for (const group of menuGroups) {
for (const item of group.items) {
if (item.path === path) return item.key
}
}
return ''
}
const logoNode = (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingLeft: 8 }}>
<img src="/favicon.svg" alt="logo" style={{ width: 32, height: 32 }} />
{!collapsed && (
<span style={{ fontSize: 18, fontWeight: 'bold', color: 'var(--text-color)' }}>NexDocus</span>
)}
</div>
)
// URL
const getUserAvatarUrl = () => {
if (!user?.avatar) return null
// avatar 2/avatar/xxx.jpg
// API : /api/v1/auth/avatar/{user_id}/{filename}
// http
if (user.avatar.startsWith('http')) return user.avatar
const parts = user.avatar.split('/')
if (parts.length >= 3) {
const userId = parts[0]
const filename = parts[2]
return `/api/v1/auth/avatar/${userId}/${filename}`
}
return null
}
const userObj = user ? {
name: user.nickname || user.username,
role: user.role_name || 'Admin',
avatar: getUserAvatarUrl()
} : null
return (
<ModernSidebar
logo={logoNode}
menuGroups={menuGroups}
activeKey={getActiveKey()}
onNavigate={handleNavigate}
user={userObj}
onLogout={handleLogout}
onProfileClick={handleProfileClick}
collapsed={collapsed}
onCollapse={onToggle}
/>
)
}
export default AppSider

View File

@ -1,32 +0,0 @@
.main-layout {
min-height: 100vh;
display: flex;
flex-direction: row; /* Changed to row for Sider-Left layout */
background: var(--bg-color-secondary);
}
.main-content-wrapper {
display: flex;
flex-direction: column;
flex: 1;
height: 100vh;
background: var(--bg-color-secondary);
overflow: hidden;
}
.main-content {
background: var(--bg-color-secondary);
overflow: hidden;
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
}
.content-wrapper {
padding: 0;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}

View File

@ -1,31 +0,0 @@
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" hasSider>
<AppSider collapsed={collapsed} onToggle={toggleCollapsed} />
<Layout className="main-content-wrapper">
<AppHeader collapsed={collapsed} onToggle={toggleCollapsed} showLogo={false} />
<Content className="main-content">
<div className="content-wrapper">
{children}
</div>
</Content>
</Layout>
</Layout>
)
}
export default MainLayout

View File

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

View File

@ -1,221 +0,0 @@
.modern-sidebar {
height: 100vh;
position: relative;
background: var(--sider-bg) !important;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.modern-sidebar .ant-layout-sider-children {
display: flex;
flex-direction: column;
height: 100%;
}
/* Header */
.modern-sidebar-header {
padding: 24px 20px;
position: relative;
display: flex;
align-items: center;
height: 80px;
flex-shrink: 0;
}
.logo-container {
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
}
/* Collapse Trigger */
.collapse-trigger {
position: absolute;
right: -12px;
top: 32px;
width: 24px;
height: 24px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
color: var(--text-color-secondary);
transition: all 0.3s;
}
.collapse-trigger:hover {
color: #1677ff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Menu Area */
.modern-sidebar-menu {
flex: 1;
overflow-y: auto;
padding: 0 16px;
}
.modern-sidebar-menu::-webkit-scrollbar {
width: 4px;
}
.modern-sidebar-menu::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 2px;
}
/* Menu Group */
.menu-group {
margin-bottom: 24px;
}
.group-title {
font-size: 12px;
color: var(--text-color-secondary);
font-weight: 600;
letter-spacing: 0.5px;
margin-bottom: 12px;
padding-left: 12px;
text-transform: uppercase;
}
/* Menu Item */
.modern-sidebar-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
margin-bottom: 8px;
cursor: pointer;
border-radius: 12px; /* Rounded corners */
transition: all 0.2s;
color: var(--text-color);
font-weight: 500;
}
.modern-sidebar-item:hover {
background-color: var(--item-hover-bg);
color: var(--text-color);
}
.modern-sidebar-item.active {
background-color: #2563eb; /* Royal Blue */
color: #fff;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25);
}
.item-content {
display: flex;
align-items: center;
gap: 12px;
}
.item-icon {
font-size: 18px;
display: flex;
align-items: center;
}
.item-label {
font-size: 14px;
}
.item-arrow {
font-size: 12px;
opacity: 0.8;
}
/* Collapsed State */
.modern-sidebar-item.collapsed {
justify-content: center;
padding: 12px;
border-radius: 12px;
}
/* Footer */
.modern-sidebar-footer {
padding: 16px;
flex-shrink: 0;
background: var(--sider-bg);
}
.footer-link {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-color-secondary);
font-size: 14px;
margin-bottom: 16px;
padding-left: 12px;
cursor: pointer;
transition: color 0.2s;
}
.footer-link:hover {
color: var(--text-color);
}
.footer-link.collapsed {
justify-content: center;
padding-left: 0;
}
/* User Card */
.user-card {
background-color: var(--bg-color-secondary); /* Light gray background */
border-radius: 12px;
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.2s;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
overflow: hidden;
}
.user-details {
display: flex;
flex-direction: column;
overflow: hidden;
}
.user-name {
font-size: 14px;
font-weight: 600;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-role {
font-size: 12px;
color: var(--text-color-secondary);
text-transform: uppercase;
font-weight: 500;
}
.logout-btn {
color: var(--text-color-secondary);
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s;
}
.logout-btn:hover {
background-color: var(--border-color);
color: #ef4444; /* Red for logout */
}

View File

@ -1,154 +0,0 @@
import React, { useState } from 'react';
import { Layout, Avatar, Tooltip, Button } from 'antd';
import {
MenuUnfoldOutlined,
MenuFoldOutlined,
LogoutOutlined,
QuestionCircleOutlined,
RightOutlined,
LeftOutlined
} from '@ant-design/icons';
import './ModernSidebar.css';
const { Sider } = Layout;
const ModernSidebar = ({
logo,
menuGroups = [],
activeKey,
onNavigate,
user,
onLogout,
onProfileClick,
collapsed,
onCollapse,
width = 260,
collapsedWidth = 80,
className = '',
style = {}
}) => {
const handleItemClick = (item) => {
if (onNavigate) {
onNavigate(item.key, item);
}
};
const renderMenuItem = (item) => {
const isActive = activeKey === item.key;
// 使Tooltip
if (collapsed) {
return (
<Tooltip title={item.label} placement="right" key={item.key}>
<div
className={`modern-sidebar-item collapsed ${isActive ? 'active' : ''}`}
onClick={() => handleItemClick(item)}
>
<div className="item-icon">{item.icon}</div>
</div>
</Tooltip>
);
}
return (
<div
key={item.key}
className={`modern-sidebar-item ${isActive ? 'active' : ''}`}
onClick={() => handleItemClick(item)}
>
<div className="item-content">
<div className="item-icon">{item.icon}</div>
<span className="item-label">{item.label}</span>
</div>
{isActive && <RightOutlined className="item-arrow" />}
</div>
);
};
return (
<Sider
width={width}
collapsed={collapsed}
collapsedWidth={collapsedWidth}
trigger={null}
className={`modern-sidebar ${className}`}
theme="light"
style={style}
>
{/* 顶部 Logo 区域 */}
<div className="modern-sidebar-header">
<div className="logo-container">
{logo}
</div>
{/* 折叠按钮 - 悬浮在边缘 */}
<div
className="collapse-trigger"
onClick={() => onCollapse && onCollapse(!collapsed)}
>
{collapsed ? <RightOutlined /> : <LeftOutlined />}
</div>
</div>
{/* 菜单列表区域 */}
<div className="modern-sidebar-menu">
{menuGroups.map((group, index) => (
<div key={index} className="menu-group">
{!collapsed && group.title && (
<div className="group-title">{group.title}</div>
)}
<div className="group-items">
{group.items.map(item => renderMenuItem(item))}
</div>
</div>
))}
</div>
{/* 底部区域 */}
<div className="modern-sidebar-footer">
{/* 帮助支持 */}
{!collapsed && (
<div className="footer-link">
<QuestionCircleOutlined />
<span>帮助支持</span>
</div>
)}
{collapsed && (
<div className="footer-link collapsed">
<QuestionCircleOutlined />
</div>
)}
{/* 用户卡片 */}
<div className="user-card">
<div
className="user-info"
onClick={onProfileClick}
style={{ cursor: onProfileClick ? 'pointer' : 'default' }}
>
<Avatar
size={collapsed ? 32 : 40}
src={user?.avatar}
style={{ backgroundColor: '#1677ff' }}
>
{user?.name?.[0]?.toUpperCase() || 'U'}
</Avatar>
{!collapsed && (
<div className="user-details">
<div className="user-name">{user?.name || 'User'}</div>
<div className="user-role">{user?.role || 'Member'}</div>
</div>
)}
</div>
{!collapsed && (
<div className="logout-btn" onClick={onLogout} title="退出登录">
<LogoutOutlined />
</div>
)}
</div>
</div>
</Sider>
);
};
export default ModernSidebar;

View File

@ -1,62 +0,0 @@
.pdf-viewer-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: #f5f5f5;
flex: 1;
min-height: 0;
}
.pdf-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
flex-shrink: 0;
}
.pdf-content {
flex: 1;
overflow: auto;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 20px;
}
.pdf-content .react-pdf__Document {
display: flex;
justify-content: center;
}
.pdf-content .react-pdf__Page {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
margin-bottom: 20px;
background: #fff;
}
.pdf-content .react-pdf__Page canvas {
max-width: 100%;
height: auto !important;
}
.pdf-loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
color: #999;
font-size: 14px;
}
.pdf-error {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
color: #f5222d;
font-size: 14px;
}

View File

@ -1,138 +0,0 @@
import { useState, useMemo } from 'react'
import { Document, Page, pdfjs } from 'react-pdf'
import { Button, Space, InputNumber, Spin, App } from 'antd'
import {
LeftOutlined,
RightOutlined,
ZoomInOutlined,
ZoomOutOutlined,
} from '@ant-design/icons'
import 'react-pdf/dist/Page/AnnotationLayer.css'
import 'react-pdf/dist/Page/TextLayer.css'
import './PDFViewer.css'
// PDF.js worker - 使
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs'
function PDFViewer({ url, filename }) {
const { message } = App.useApp();
const [numPages, setNumPages] = useState(null)
const [pageNumber, setPageNumber] = useState(1)
const [scale, setScale] = useState(1.0)
// 使 useMemo
const fileConfig = useMemo(() => ({ url }), [url])
const onDocumentLoadSuccess = ({ numPages }) => {
setNumPages(numPages)
setPageNumber(1)
}
const onDocumentLoadError = (error) => {
message.error('PDF文件加载失败')
}
const goToPrevPage = () => {
setPageNumber((prev) => Math.max(prev - 1, 1))
}
const goToNextPage = () => {
setPageNumber((prev) => Math.min(prev + 1, numPages))
}
const zoomIn = () => {
setScale((prev) => Math.min(prev + 0.2, 3.0))
}
const zoomOut = () => {
setScale((prev) => Math.max(prev - 0.2, 0.5))
}
const handlePageChange = (value) => {
if (value >= 1 && value <= numPages) {
setPageNumber(value)
}
}
return (
<div className="pdf-viewer-container">
{/* 工具栏 */}
<div className="pdf-toolbar">
<Space>
<Button
icon={<LeftOutlined />}
onClick={goToPrevPage}
disabled={pageNumber <= 1}
size="small"
>
上一页
</Button>
<Space.Compact>
<InputNumber
min={1}
max={numPages || 1}
value={pageNumber}
onChange={handlePageChange}
size="small"
style={{ width: 60 }}
/>
<Button size="small" disabled>
/ {numPages || 0}
</Button>
</Space.Compact>
<Button
icon={<RightOutlined />}
onClick={goToNextPage}
disabled={pageNumber >= numPages}
size="small"
>
下一页
</Button>
</Space>
<Space>
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">
缩小
</Button>
<span style={{ minWidth: 50, textAlign: 'center' }}>
{Math.round(scale * 100)}%
</span>
<Button icon={<ZoomInOutlined />} onClick={zoomIn} size="small">
放大
</Button>
</Space>
</div>
{/* PDF内容区 */}
<div className="pdf-content">
<Document
file={fileConfig}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={
<div className="pdf-loading">
<Spin size="large" />
<div style={{ marginTop: 16 }}>正在加载PDF...</div>
</div>
}
error={<div className="pdf-error">PDF加载失败请稍后重试</div>}
>
<Page
pageNumber={pageNumber}
scale={scale}
renderTextLayer={true}
renderAnnotationLayer={true}
loading={
<div className="pdf-loading">
<Spin size="large" />
<div style={{ marginTop: 16 }}>正在渲染页面...</div>
</div>
}
/>
</Document>
</div>
</div>
)
}
export default PDFViewer

View File

@ -1,132 +0,0 @@
.virtual-pdf-viewer-container {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-color-secondary);
}
.pdf-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
z-index: 10;
color: var(--text-color);
}
.pdf-content {
flex: 1;
overflow: auto;
position: relative;
}
.pdf-virtual-list {
background: var(--bg-color-secondary);
}
.pdf-page-wrapper {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
background: var(--bg-color-secondary);
}
.pdf-page-wrapper canvas {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
background: white;
margin-bottom: 8px;
}
.pdf-page-number {
margin-top: 8px;
font-size: 13px;
color: var(--text-color);
font-weight: 600;
text-align: center;
}
.pdf-page-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 600px;
gap: 8px;
color: var(--text-color-secondary);
font-size: 14px;
}
.pdf-page-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 600px;
background: var(--item-hover-bg);
border: 1px dashed var(--border-color);
color: var(--text-color-secondary);
font-size: 14px;
}
.pdf-page-skeleton {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
min-height: 800px;
}
.pdf-page-error {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
margin: 20px;
}
.pdf-page-error p {
color: #ff4d4f;
font-size: 14px;
}
.pdf-loading {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
min-height: 400px;
color: var(--text-color);
}
.pdf-error {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
min-height: 400px;
color: #ff4d4f;
font-size: 16px;
}
/* 文本层样式优化 */
.react-pdf__Page__textContent {
user-select: text;
}
/* 注释层样式优化 */
.react-pdf__Page__annotations {
user-select: none;
}
/* Document容器样式 - 确保不限制高度 */
.react-pdf__Document {
height: 100%;
width: 100%;
}

View File

@ -1,268 +0,0 @@
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import { Document, Page, pdfjs } from 'react-pdf'
import { Button, Space, InputNumber, App, Spin } from 'antd'
import {
ZoomInOutlined,
ZoomOutOutlined,
VerticalAlignTopOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons'
import 'react-pdf/dist/Page/AnnotationLayer.css'
import 'react-pdf/dist/Page/TextLayer.css'
import './VirtualPDFViewer.css'
// PDF.js worker
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs'
function VirtualPDFViewer({ url, filename }) {
const { message } = App.useApp();
const [numPages, setNumPages] = useState(null)
const [scale, setScale] = useState(1.0)
const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // A4
const [currentPage, setCurrentPage] = useState(1)
const [visiblePages, setVisiblePages] = useState(new Set([1]))
const containerRef = useRef(null)
const pageRefs = useRef({})
// 使 useMemo
const fileConfig = useMemo(() => ({ url }), [url])
// 线 unpkg
const pdfOptions = useMemo(() => undefined, [])
// PDF
const pageHeight = useMemo(() => {
// PDF + padding (40px) + (20px)
return Math.ceil(pdfOriginalSize.height * scale) + 60
}, [scale, pdfOriginalSize.height])
const onDocumentLoadError = (error) => {
console.error('[PDF] Document load error:', error)
message.error('PDF文件加载失败')
}
// Handle scroll to update visible pages
const handleScroll = useCallback(() => {
if (!containerRef.current || !numPages) return
const container = containerRef.current
const scrollTop = container.scrollTop
const containerHeight = container.clientHeight
// Calculate which pages are visible
// Add small tolerance (1px) to handle browser scroll precision issues
const pageIndex = scrollTop / pageHeight
let firstVisiblePage = Math.max(1, Math.ceil(pageIndex + 0.001))
// Special case: if scrolled to bottom, show last page
const isAtBottom = scrollTop + containerHeight >= container.scrollHeight - 1
if (isAtBottom) {
firstVisiblePage = numPages
}
const lastVisiblePage = Math.min(numPages, Math.ceil((scrollTop + containerHeight) / pageHeight))
// Add buffer pages (2 before and 2 after)
const newVisiblePages = new Set()
for (let i = Math.max(1, firstVisiblePage - 2); i <= Math.min(numPages, lastVisiblePage + 2); i++) {
newVisiblePages.add(i)
}
setVisiblePages(newVisiblePages)
setCurrentPage(firstVisiblePage)
}, [numPages, pageHeight])
const onDocumentLoadSuccess = useCallback(async (pdf) => {
setNumPages(pdf.numPages)
try {
//
const page = await pdf.getPage(1)
const viewport = page.getViewport({ scale: 1.0 })
const { width, height } = viewport
setPdfOriginalSize({ width, height })
// PDF
if (containerRef.current) {
const containerWidth = containerRef.current.clientWidth - 40 //
if (width > containerWidth) {
const autoScale = Math.floor((containerWidth / width) * 10) / 10 //
setScale(Math.min(Math.max(autoScale, 0.5), 1.0)) // 1.0
} else {
setScale(1.0) // 100%
}
}
} catch (err) {
console.error('Error calculating initial scale:', err)
}
// Initially show first 3 pages
setVisiblePages(new Set([1, 2, 3]))
// Trigger scroll calculation after a short delay to ensure DOM is ready
setTimeout(() => {
handleScroll()
}, 200)
}, [handleScroll])
// Attach scroll listener
useEffect(() => {
const container = containerRef.current
if (!container) return
container.addEventListener('scroll', handleScroll)
return () => container.removeEventListener('scroll', handleScroll)
}, [handleScroll, numPages, pageHeight])
const zoomIn = () => {
setScale((prev) => Math.min(prev + 0.2, 3.0))
}
const zoomOut = () => {
setScale((prev) => Math.max(prev - 0.2, 0.5))
}
const handlePageChange = (value) => {
if (value >= 1 && value <= numPages && containerRef.current) {
const scrollTop = (value - 1) * pageHeight
const container = containerRef.current
container.scrollTo({ top: scrollTop, behavior: 'auto' })
// Manually trigger handleScroll after scrolling to ensure page number updates
setTimeout(() => {
handleScroll()
}, 50)
}
}
const scrollToTop = () => {
if (containerRef.current) {
containerRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}
return (
<div className="virtual-pdf-viewer-container">
{/* 工具栏 */}
<div className="pdf-toolbar">
<Space>
<Button
icon={<LeftOutlined />}
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
size="small"
/>
<Space.Compact>
<InputNumber
min={1}
max={numPages || 1}
value={currentPage}
onChange={handlePageChange}
size="small"
style={{ width: 60 }}
/>
<Button size="small" disabled>
/ {numPages || 0}
</Button>
</Space.Compact>
<Button
icon={<RightOutlined />}
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= (numPages || 0)}
size="small"
/>
<Button
icon={<VerticalAlignTopOutlined />}
onClick={scrollToTop}
size="small"
disabled={currentPage === 1}
>
回到顶部
</Button>
</Space>
<Space>
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">
缩小
</Button>
<span style={{ minWidth: 50, textAlign: 'center' }}>
{Math.round(scale * 100)}%
</span>
<Button icon={<ZoomInOutlined />} onClick={zoomIn} size="small">
放大
</Button>
</Space>
</div>
{/* PDF内容区 - 自定义虚拟滚动 */}
<div className="pdf-content" ref={containerRef}>
<Document
file={fileConfig}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={
<div className="pdf-loading">
<Spin size="large" />
<div style={{ marginTop: 16 }}>正在加载PDF...</div>
</div>
}
error={<div className="pdf-error">PDF加载失败请稍后重试</div>}
options={pdfOptions}
>
{numPages && (
<div style={{ height: numPages * pageHeight, position: 'relative' }}>
{Array.from({ length: numPages }, (_, index) => {
const pageNumber = index + 1
const isVisible = visiblePages.has(pageNumber)
return (
<div
key={pageNumber}
ref={el => pageRefs.current[pageNumber] = el}
className="pdf-page-wrapper"
style={{
position: 'absolute',
top: index * pageHeight,
left: 0,
right: 0,
height: pageHeight,
}}
>
{isVisible ? (
<>
<Page
pageNumber={pageNumber}
scale={scale}
renderTextLayer={true}
renderAnnotationLayer={true}
loading={
<div className="pdf-page-loading">
<Spin size="small" />
<div>加载第 {pageNumber} ...</div>
</div>
}
/>
<div className="pdf-page-number"> {pageNumber} </div>
</>
) : (
<div className="pdf-page-placeholder">
<div> {pageNumber} </div>
</div>
)}
</div>
)
})}
</div>
)}
</Document>
</div>
</div>
)
}
export default VirtualPDFViewer

View File

@ -1,14 +0,0 @@
import { Navigate } from 'react-router-dom'
import useUserStore from '@/stores/userStore'
function ProtectedRoute({ children }) {
const token = localStorage.getItem('access_token')
if (!token) {
return <Navigate to="/login" replace />
}
return children
}
export default ProtectedRoute

View File

@ -1,49 +0,0 @@
.selection-alert-container {
margin-bottom: 16px;
}
.selection-alert-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.selection-alert-content span {
flex: 1;
color: rgba(0, 0, 0, 0.85);
}
.selection-alert-content strong {
color: #1677ff;
font-weight: 600;
margin: 0 4px;
}
.selection-alert-content a {
color: #1677ff;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
text-decoration: none;
padding: 0 8px;
border-radius: 4px;
}
.selection-alert-content a:hover {
background: rgba(22, 119, 255, 0.08);
text-decoration: underline;
}
/* 响应式处理 */
@media (max-width: 768px) {
.selection-alert-content {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.selection-alert-content a {
padding: 4px 8px;
}
}

View File

@ -1,89 +0,0 @@
import { Alert } from 'antd'
import './SelectionAlert.css'
/**
* 全选提示条组件
* @param {Object} props
* @param {number} props.currentPageCount - 当前页选中数量
* @param {number} props.totalCount - 总数据量
* @param {boolean} props.isAllPagesSelected - 是否已选择所有页
* @param {Function} props.onSelectAllPages - 选择所有页的回调
* @param {Function} props.onClearSelection - 清除选择的回调
*/
function SelectionAlert({
currentPageCount,
totalCount,
isAllPagesSelected,
onSelectAllPages,
onClearSelection,
}) {
//
if (currentPageCount === 0) {
return null
}
//
if (isAllPagesSelected) {
return (
<div className="selection-alert-container">
<Alert
message={
<div className="selection-alert-content">
<span>
已选择全部 <strong>{totalCount}</strong> 条数据
</span>
<a onClick={onClearSelection}>清除选择</a>
</div>
}
type="info"
showIcon
closable={false}
/>
</div>
)
}
//
if (currentPageCount > 0 && totalCount > currentPageCount) {
return (
<div className="selection-alert-container">
<Alert
message={
<div className="selection-alert-content">
<span>
已选择当前页 <strong>{currentPageCount}</strong> 条数据
</span>
<a onClick={onSelectAllPages}>
选择全部 {totalCount} 条数据
</a>
</div>
}
type="warning"
showIcon
closable={false}
/>
</div>
)
}
//
return (
<div className="selection-alert-container">
<Alert
message={
<div className="selection-alert-content">
<span>
已选择 <strong>{currentPageCount}</strong> 条数据
</span>
<a onClick={onClearSelection}>清除选择</a>
</div>
}
type="info"
showIcon
closable={false}
/>
</div>
)
}
export default SelectionAlert

View File

@ -1,88 +0,0 @@
/* 侧边信息面板容器 */
.side-info-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 信息区块 */
.side-info-section {
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
overflow: hidden;
transition: all 0.3s ease;
}
.side-info-section:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 区块头部 */
.side-info-section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
border-bottom: 1px solid #e8e8e8;
cursor: pointer;
user-select: none;
transition: background 0.2s ease;
}
.side-info-section-header:hover {
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0ff 100%);
}
.side-info-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.side-info-section-icon {
display: flex;
align-items: center;
font-size: 16px;
color: #1677ff;
}
.side-info-section-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: #8c8c8c;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 4px;
}
.side-info-section-toggle:hover {
background: rgba(0, 0, 0, 0.06);
color: #1677ff;
}
/* 区块内容 */
.side-info-section-content {
padding: 16px 20px;
animation: expandContent 0.3s ease-out;
}
@keyframes expandContent {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -1,58 +0,0 @@
import { useState } from 'react'
import { UpOutlined, DownOutlined } from '@ant-design/icons'
import './SideInfoPanel.css'
/**
* 侧边信息面板组件
* @param {Object} props
* @param {Array} props.sections - 信息区块配置数组
* @param {string} props.className - 自定义类名
*/
function SideInfoPanel({ sections = [], className = '' }) {
const [collapsedSections, setCollapsedSections] = useState(() => {
const initial = {}
sections.forEach((section) => {
if (section.defaultCollapsed) {
initial[section.key] = true
}
})
return initial
})
const toggleSection = (key) => {
setCollapsedSections((prev) => ({
...prev,
[key]: !prev[key],
}))
}
return (
<div className={`side-info-panel ${className}`}>
{sections.map((section) => {
const isCollapsed = collapsedSections[section.key]
return (
<div key={section.key} className="side-info-section">
{/* 区块头部 */}
<div className="side-info-section-header" onClick={() => toggleSection(section.key)}>
<div className="side-info-section-title">
{section.icon && <span className="side-info-section-icon">{section.icon}</span>}
<span>{section.title}</span>
</div>
<button className="side-info-section-toggle" type="button">
{isCollapsed ? <DownOutlined /> : <UpOutlined />}
</button>
</div>
{/* 区块内容 */}
{!isCollapsed && (
<div className="side-info-section-content">{section.content}</div>
)}
</div>
)
})}
</div>
)
}
export default SideInfoPanel

View File

@ -1,72 +0,0 @@
/* 分栏布局容器 */
.split-layout {
display: flex;
width: 100%;
align-items: flex-start;
}
/* 横向布局(左右分栏) */
.split-layout-horizontal {
flex-direction: row;
}
/* 纵向布局(上下分栏) */
.split-layout-vertical {
flex-direction: column;
}
/* 主内容区 */
.split-layout-main {
flex: 1;
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
}
/* 扩展信息区 */
.split-layout-extend {
flex-shrink: 0;
background: #ffffff;
}
/* 右侧扩展区(横向布局) */
.split-layout-extend-right {
height: 693px;
overflow-y: auto;
overflow-x: hidden;
position: sticky;
top: 16px;
padding-right: 4px;
}
/* 顶部扩展区(纵向布局) */
.split-layout-extend-top {
width: 100%;
}
/* 滚动条样式(横向布局右侧扩展区) */
.split-layout-extend-right::-webkit-scrollbar {
width: 6px;
}
.split-layout-extend-right::-webkit-scrollbar-track {
background: #f5f5f5;
border-radius: 3px;
}
.split-layout-extend-right::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 3px;
}
.split-layout-extend-right::-webkit-scrollbar-thumb:hover {
background: #bfbfbf;
}
/* 响应式:小屏幕时隐藏右侧扩展区 */
@media (max-width: 1200px) {
.split-layout-extend-right {
display: none;
}
}

View File

@ -1,69 +0,0 @@
import './SplitLayout.css'
/**
* 主内容区布局组件
* @param {Object} props
* @param {string} props.direction - 布局方向'horizontal'左右| 'vertical'上下
* @param {ReactNode} props.mainContent - 主内容区
* @param {ReactNode} props.extendContent - 扩展内容区
* @param {number} props.extendSize - 扩展区尺寸horizontal 模式下为宽度px
* @param {number} props.gap - 主内容与扩展区间距px
* @param {boolean} props.showExtend - 是否显示扩展区
* @param {string} props.extendPosition - 扩展区位置horizontal: 'right', vertical: 'top'
* @param {string} props.className - 自定义类名
*
* @deprecated 旧参数向后兼容leftContent, rightContent, rightWidth, showRight
*/
function SplitLayout({
// API
direction = 'horizontal',
mainContent,
extendContent,
extendSize = 360,
gap = 16,
showExtend = true,
extendPosition,
className = '',
// API
leftContent,
rightContent,
rightWidth,
showRight,
}) {
// 使 API API
const actualMainContent = mainContent || leftContent
const actualExtendContent = extendContent || rightContent
const actualExtendSize = extendSize !== 360 ? extendSize : (rightWidth || 360)
const actualShowExtend = showExtend !== undefined ? showExtend : (showRight !== undefined ? showRight : true)
const actualDirection = direction
const actualExtendPosition = extendPosition || (actualDirection === 'horizontal' ? 'right' : 'top')
return (
<div
className={`split-layout split-layout-${actualDirection} ${className}`}
style={{ gap: `${gap}px` }}
>
{/* 纵向布局且扩展区在顶部时,先渲染扩展区 */}
{actualDirection === 'vertical' && actualExtendPosition === 'top' && actualShowExtend && actualExtendContent && (
<div className="split-layout-extend split-layout-extend-top">
{actualExtendContent}
</div>
)}
{/* 主内容区 */}
<div className="split-layout-main">{actualMainContent}</div>
{/* 横向布局时,扩展区在右侧 */}
{actualDirection === 'horizontal' && actualShowExtend && actualExtendContent && (
<div
className="split-layout-extend split-layout-extend-right"
style={{ width: `${actualExtendSize}px` }}
>
{actualExtendContent}
</div>
)}
</div>
)
}
export default SplitLayout

View File

@ -1,108 +0,0 @@
/* 统计卡片 */
.stat-card {
padding: 16px;
background: #ffffff;
border-radius: 8px;
border: 1px solid #f0f0f0;
transition: all 0.3s ease;
}
.stat-card:hover {
border-color: #d9d9d9;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
/* 一列布局(默认) */
.stat-card-column {
/* 继承默认样式 */
}
/* 两列布局 */
.stat-card.stat-card-row {
display: flex;
align-items: center;
gap: 16px;
}
.stat-card.stat-card-row .stat-card-header {
flex: 1;
margin-bottom: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.stat-card.stat-card-row .stat-card-body {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
/* 卡片头部 */
.stat-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.stat-card-title {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
font-weight: 500;
}
.stat-card-icon {
font-size: 18px;
display: flex;
align-items: center;
}
/* 卡片内容 */
.stat-card-body {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 8px;
}
.stat-card-value {
font-size: 24px;
font-weight: 600;
line-height: 1;
}
.stat-card-suffix {
font-size: 14px;
font-weight: 400;
margin-left: 4px;
color: rgba(0, 0, 0, 0.45);
}
/* 趋势指示器 */
.stat-card-trend {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
}
.stat-card-trend.trend-up {
color: #52c41a;
background: #f6ffed;
}
.stat-card-trend.trend-down {
color: #ff4d4f;
background: #fff1f0;
}
.stat-card-trend svg {
font-size: 10px;
}

View File

@ -1,82 +0,0 @@
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
import './StatCard.css'
import { ReactNode } from 'react'
export interface StatCardProps {
title: string
value: number | string
icon?: ReactNode
color?: string
trend?: { value: number; direction: 'up' | 'down' }
suffix?: string
layout?: 'column' | 'row'
gridColumn?: string
className?: string
onClick?: () => void
style?: React.CSSProperties
}
/**
*
*/
function StatCard({
title,
value,
icon,
color = 'blue',
trend,
suffix = '',
layout = 'column',
gridColumn,
className = '',
onClick,
style: customStyle = {},
}: StatCardProps) {
const colorMap: Record<string, string> = {
blue: '#1677ff',
green: '#52c41a',
orange: '#faad14',
red: '#ff4d4f',
purple: '#722ed1',
gray: '#8c8c8c',
}
const themeColor = colorMap[color] || color
const style = {
...(gridColumn ? { gridColumn } : {}),
...customStyle,
}
return (
<div className={`stat-card stat-card-${layout} ${className}`} style={style} onClick={onClick}>
<div className="stat-card-header">
<span className="stat-card-title">{title}</span>
{icon && (
<span className="stat-card-icon" style={{ color: themeColor }} aria-hidden="true">
{icon}
</span>
)}
</div>
<div className="stat-card-body">
<div className="stat-card-value tabular-nums" style={{ color: themeColor }}>
{value}
{suffix && <span className="stat-card-suffix">{suffix}</span>}
</div>
{trend && (
<div
className={`stat-card-trend ${trend.direction === 'up' ? 'trend-up' : 'trend-down'} tabular-nums`}
aria-label={`${trend.direction === 'up' ? 'Increase' : 'Decrease'} of ${trend.value}%`}
>
{trend.direction === 'up' ? <ArrowUpOutlined aria-hidden="true" /> : <ArrowDownOutlined aria-hidden="true" />}
<span>{Math.abs(trend.value)}%</span>
</div>
)}
</div>
</div>
)
}
export default StatCard

View File

@ -1,81 +0,0 @@
import { notification } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
InfoCircleOutlined,
} from "@ant-design/icons";
notification.config({
placement: "topRight",
top: 24,
duration: 3,
maxCount: 3,
});
const Toast = {
success: (message: string, 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)",
},
});
},
error: (message: string, 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)",
},
});
},
warning: (message: string, 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)",
},
});
},
info: (message: string, 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)",
},
});
},
custom: (config: any) => {
notification.open({
...config,
style: {
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
...config.style,
},
});
},
};
export default Toast;

View File

@ -1,58 +0,0 @@
/* 树形筛选面板 */
.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

@ -1,119 +0,0 @@
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

View File

@ -11,6 +11,7 @@
linear-gradient(90deg, rgba(255, 255, 255, 0.34) 1px, transparent 1px);
--app-bg-overlay-size: 36px 36px;
--app-bg-card: rgba(255, 255, 255, 0.74);
--app-bg-layout: #f5f6fa;
--app-text-main: #1f2937;
--app-text-secondary: #66758f;
--app-border-color: rgba(103, 126, 189, 0.12);
@ -19,6 +20,7 @@
--app-bg-surface: rgba(255, 255, 255, 0.68);
--app-bg-surface-soft: rgba(255, 255, 255, 0.56);
--app-bg-surface-strong: rgba(255, 255, 255, 0.82);
--app-surface-color: rgba(255, 255, 255, 0.82);
--app-text-muted: #66758f;
--item-hover-bg: rgba(22, 119, 255, 0.08);
--text-color-secondary: #66758f;
@ -35,6 +37,7 @@
linear-gradient(rgba(255, 255, 255, 0.24) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.24) 1px, transparent 1px);
--app-bg-card: rgba(255, 255, 255, 0.82);
--app-bg-layout: #f5f6fa;
--app-text-main: #111827;
--app-text-secondary: #5b6474;
--app-border-color: rgba(148, 163, 184, 0.16);
@ -43,6 +46,7 @@
--app-bg-surface: rgba(255, 255, 255, 0.74);
--app-bg-surface-soft: rgba(255, 255, 255, 0.62);
--app-bg-surface-strong: rgba(255, 255, 255, 0.86);
--app-surface-color: rgba(255, 255, 255, 0.86);
--app-text-muted: #5b6474;
--item-hover-bg: rgba(22, 119, 255, 0.08);
--text-color-secondary: #5b6474;
@ -58,6 +62,7 @@
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
--app-bg-card: rgba(13, 23, 39, 0.62);
--app-bg-layout: #08101c;
--app-text-main: #e2e8f0;
--app-text-secondary: rgba(190, 206, 229, 0.74);
--app-border-color: rgba(88, 151, 255, 0.18);
@ -66,6 +71,7 @@
--app-bg-surface: rgba(10, 21, 37, 0.78);
--app-bg-surface-soft: rgba(10, 21, 37, 0.72);
--app-bg-surface-strong: rgba(8, 17, 31, 0.88);
--app-surface-color: rgba(8, 17, 31, 0.88);
--app-text-muted: rgba(190, 206, 229, 0.74);
--item-hover-bg: rgba(88, 151, 255, 0.18);
--text-color-secondary: rgba(190, 206, 229, 0.74);
@ -1306,6 +1312,7 @@ body::after {
--app-bg-main: #f5f6fa;
--app-bg-overlay: none;
--app-bg-card: #fff;
--app-bg-layout: #f5f6fa;
--app-text-main: #333333;
--app-text-secondary: #9095a1;
--app-border-color: #e6e6e6;
@ -1314,6 +1321,7 @@ body::after {
--app-bg-surface: #fff;
--app-bg-surface-soft: #fafafa;
--app-bg-surface-strong: #fff;
--app-surface-color: #fff;
--app-text-muted: #9095a1;
--text-color-secondary: #9095a1;
--link-color: #1677ff;

View File

@ -1,12 +1,33 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Card, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, App } from 'antd';
import * as AntIcons from "@ant-design/icons";
import { Button, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Table, Tag, Tooltip, Typography, App } from 'antd';
import { DndContext, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { CheckSquareOutlined, ClusterOutlined, DeleteOutlined, EditOutlined, FolderOutlined, InfoCircleOutlined, MenuOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, HolderOutlined } from "@ant-design/icons";
import {
ApartmentOutlined,
BookOutlined,
CheckSquareOutlined,
ClusterOutlined,
DashboardOutlined,
DeleteOutlined,
DesktopOutlined,
EditOutlined,
FolderOutlined,
HolderOutlined,
InfoCircleOutlined,
MenuOutlined,
PlusOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
SearchOutlined,
SettingOutlined,
ShopOutlined,
TeamOutlined,
UserOutlined,
VideoCameraOutlined
} from "@ant-design/icons";
import { createPermission, deletePermission, listMyPermissions, updatePermission } from "@/api";
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
@ -32,6 +53,19 @@ const legacyIconAliases: Record<string, string> = {
setting: "SettingOutlined"
};
const menuIconComponents: Record<string, React.ComponentType<any>> = {
ApartmentOutlined,
BookOutlined,
DashboardOutlined,
DesktopOutlined,
SafetyCertificateOutlined,
SettingOutlined,
ShopOutlined,
TeamOutlined,
UserOutlined,
VideoCameraOutlined
};
interface RowProps extends React.HTMLAttributes<HTMLTableRowElement> {
'data-row-key': string;
}
@ -80,15 +114,12 @@ const DragHandle = () => {
);
};
const menuIconOptions = Object.keys(AntIcons)
.filter((key) => /(?:Outlined|Filled|TwoTone)$/.test(key))
.sort((left, right) => left.localeCompare(right));
const menuIconOptions = Object.keys(menuIconComponents).sort((left, right) => left.localeCompare(right));
function renderSelectableIcon(iconName?: string) {
const resolvedName = iconName ? (legacyIconAliases[iconName] || iconName) : undefined;
if (!resolvedName) return null;
const iconsMap = AntIcons as unknown as Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>>;
const IconComponent = iconsMap[resolvedName];
const IconComponent = menuIconComponents[resolvedName];
return IconComponent ? <IconComponent aria-hidden="true" /> : null;
}

View File

@ -1,5 +0,0 @@
export function hasPermission(perm?: string, codes?: string[]) {
if (!perm) return true;
if (!codes || codes.length === 0) return true;
return codes.includes(perm);
}

View File

@ -17,10 +17,32 @@ export default defineConfig({
return undefined;
}
const normalizedId = id.replaceAll("\\", "/");
if (id.includes("node_modules/react/") || id.includes("node_modules/react-dom/") || id.includes("node_modules/scheduler/")) {
return "vendor-react";
}
if (normalizedId.includes("node_modules/react-router/") || normalizedId.includes("node_modules/react-router-dom/")) {
return "vendor-router";
}
if (normalizedId.includes("node_modules/i18next/") || normalizedId.includes("node_modules/react-i18next/") || normalizedId.includes("node_modules/i18next-browser-languagedetector/")) {
return "vendor-i18n";
}
if (normalizedId.includes("node_modules/axios/")) {
return "vendor-http";
}
if (normalizedId.includes("node_modules/zustand/")) {
return "vendor-state";
}
if (normalizedId.includes("node_modules/rc-") || normalizedId.includes("node_modules/@rc-component/")) {
return "vendor-rc";
}
if (id.includes("node_modules/jspdf/") || id.includes("node_modules/react-markdown/") || id.includes("node_modules/remark-") || id.includes("node_modules/rehype-") || id.includes("node_modules/unified/")) {
return "vendor-docs";
}