feat:前端整体调整
parent
90b1f60955
commit
b0a3dedb99
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
|
|
@ -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 */
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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%;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue