移除 react-router-dom 依赖,将导航从 URL 路由切换为事件总线驱动,
新增 Modal / Drawer / FloatingPanel 三种窗口类型支持。
架构变更:
- 删除 src/router/(唯一路由 /* → HomePage,无实际用途)
- App.tsx 移除 HashRouter,直接渲染 Layout > HomePage
- Layout 从 useLocation + Outlet 改为 children prop
- NavBar 移除 useNavigate,所有页面导航改为 emit(OPEN_PAGE)
- NavAction 精简:'navigate'|'spa' 合并为 'page'
新增组件:
- FloatingPanel:纯手写非模态浮动面板(可拖拽、无遮罩、多面板共存、
暗色主题适配、边界约束),对应 QT 非模态窗口的 Web 映射
Modal(居中模态)| Drawer(侧滑)| FloatingPanel(浮动可拖拽)
- 8 个页面占位组件(compliance-assets/account/vip/recharge/
billing/orders/projects/quota),各显示导航栏名称作为占位
配置扩展:
- NavItemConfig 新增 modalType?: 'modal' | 'drawer' | 'floating'
- nav-config 所有 page 项补齐 modalType(支付类→modal,浏览类→floating)
- event-bus 新增 OPEN_PAGE 事件,payload 携带 {target, modalType, label}
- 卸载 react-router-dom@^7.16.0(连带移除 4 个包)
设计原则:
- 页面组件不感知窗口类型(内容与容器分离),切换窗口类型只需改配置
- Modal/Drawer 单实例(模态含义=独占注意力),Floating 多实例共存
- 主页面始终挂载不卸载,所有子页面为叠加层(对应 QT 多窗口模型)
216 lines
7.2 KiB
TypeScript
216 lines
7.2 KiB
TypeScript
// ============================================================
|
||
// FloatingPanel — 非模态可拖拽浮动面板
|
||
//
|
||
// 特性:
|
||
// - 标题栏拖拽移动,无遮罩,不阻断主页面交互
|
||
// - 初始居中定位,支持暗色主题
|
||
// - 边界检测(不拖出视口)
|
||
// - 多个面板可同时打开(点击置顶)
|
||
// - 关闭按钮 + 最小尺寸约束
|
||
//
|
||
// 对应 QT 原版"非模态窗口"的 Web 映射
|
||
// ============================================================
|
||
|
||
import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react';
|
||
import { useTheme } from '@/hooks/use-theme';
|
||
|
||
// ---------- 类型 ----------
|
||
|
||
export interface FloatingPanelProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
title: string;
|
||
children: ReactNode;
|
||
/** 初始宽度(px),默认 480 */
|
||
width?: number;
|
||
/** 初始高度(px),默认 360 */
|
||
height?: number;
|
||
}
|
||
|
||
// ---------- 全局 z-index 管理 ----------
|
||
|
||
let globalZIndex = 1000;
|
||
function nextZIndex(): number {
|
||
globalZIndex += 1;
|
||
return globalZIndex;
|
||
}
|
||
|
||
// ---------- 组件 ----------
|
||
|
||
export function FloatingPanel({ open, onClose, title, children, width = 480, height = 360 }: FloatingPanelProps) {
|
||
const { isDark } = useTheme();
|
||
|
||
// 位置与尺寸
|
||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||
const [zIndex, setZIndex] = useState(() => nextZIndex());
|
||
const panelRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 拖拽状态
|
||
const draggingRef = useRef(false);
|
||
const dragStartRef = useRef({ x: 0, y: 0, panelX: 0, panelY: 0 });
|
||
|
||
// 初始居中(首次 open 时计算)
|
||
useEffect(() => {
|
||
if (open && position === null) {
|
||
const x = Math.max(0, (window.innerWidth - width) / 2);
|
||
const y = Math.max(0, (window.innerHeight - height) / 2);
|
||
setPosition({ x, y });
|
||
}
|
||
}, [open, position, width, height]);
|
||
|
||
// 拖拽启动
|
||
const handleMouseDown = useCallback(
|
||
(e: React.MouseEvent) => {
|
||
// 仅标题栏区域可拖拽
|
||
if ((e.target as HTMLElement).closest('.fp-close-btn')) return;
|
||
e.preventDefault();
|
||
draggingRef.current = true;
|
||
dragStartRef.current = {
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
panelX: position?.x ?? 0,
|
||
panelY: position?.y ?? 0,
|
||
};
|
||
},
|
||
[position],
|
||
);
|
||
|
||
// 全局 mousemove / mouseup
|
||
useEffect(() => {
|
||
const handleMouseMove = (e: MouseEvent) => {
|
||
if (!draggingRef.current) return;
|
||
const ds = dragStartRef.current;
|
||
const newX = ds.panelX + (e.clientX - ds.x);
|
||
const newY = ds.panelY + (e.clientY - ds.y);
|
||
|
||
// 边界约束:保留标题栏至少 40px 可见,确保用户能拖回
|
||
const minVisible = 40;
|
||
const maxX = window.innerWidth - minVisible;
|
||
const maxY = window.innerHeight - minVisible;
|
||
|
||
setPosition({
|
||
x: Math.max(-width + minVisible, Math.min(maxX, newX)),
|
||
y: Math.max(0, Math.min(maxY, newY)),
|
||
});
|
||
};
|
||
|
||
const handleMouseUp = () => {
|
||
draggingRef.current = false;
|
||
};
|
||
|
||
document.addEventListener('mousemove', handleMouseMove);
|
||
document.addEventListener('mouseup', handleMouseUp);
|
||
return () => {
|
||
document.removeEventListener('mousemove', handleMouseMove);
|
||
document.removeEventListener('mouseup', handleMouseUp);
|
||
};
|
||
}, [width]);
|
||
|
||
// 点击面板 → 置顶
|
||
const bringToFront = useCallback(() => {
|
||
setZIndex(nextZIndex());
|
||
}, []);
|
||
|
||
if (!open || !position) return null;
|
||
|
||
return (
|
||
<div
|
||
ref={panelRef}
|
||
onMouseDown={bringToFront}
|
||
className="fp-panel"
|
||
style={{
|
||
position: 'fixed',
|
||
left: position.x,
|
||
top: position.y,
|
||
width,
|
||
height,
|
||
zIndex,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
borderRadius: 8,
|
||
boxShadow: isDark
|
||
? '0 8px 32px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.08)'
|
||
: '0 8px 32px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.06)',
|
||
background: isDark ? '#1E1B4B' : '#FFFFFF',
|
||
overflow: 'hidden',
|
||
userSelect: 'none',
|
||
}}
|
||
>
|
||
{/* 标题栏 — 拖拽手柄 */}
|
||
<div
|
||
onMouseDown={handleMouseDown}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
padding: '0 12px 0 16px',
|
||
height: 40,
|
||
flexShrink: 0,
|
||
cursor: 'grab',
|
||
background: isDark
|
||
? 'linear-gradient(180deg, #2E2A5A 0%, #252050 100%)'
|
||
: 'linear-gradient(180deg, #F5F3FF 0%, #EEF2FF 100%)',
|
||
borderBottom: `1px solid ${isDark ? '#3E3A6A' : '#E5E7EB'}`,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
color: isDark ? '#E8E6F0' : '#1E1B4B',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{title}
|
||
</span>
|
||
|
||
<button
|
||
className="fp-close-btn"
|
||
type="button"
|
||
onClick={onClose}
|
||
title="关闭"
|
||
style={{
|
||
width: 28,
|
||
height: 28,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
border: 'none',
|
||
borderRadius: 4,
|
||
cursor: 'pointer',
|
||
fontSize: 16,
|
||
color: isDark ? '#A5A3C0' : '#9CA3AF',
|
||
background: 'transparent',
|
||
transition: 'all 0.15s',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = isDark ? 'rgba(239,68,68,0.2)' : 'rgba(239,68,68,0.1)';
|
||
e.currentTarget.style.color = '#EF4444';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = 'transparent';
|
||
e.currentTarget.style.color = isDark ? '#A5A3C0' : '#9CA3AF';
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* 内容区 */}
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
overflow: 'auto',
|
||
userSelect: 'text',
|
||
padding: 16,
|
||
color: isDark ? '#E8E6F0' : '#1E1B4B',
|
||
}}
|
||
>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|