Files
ele-HeiXiu/src/components/common/FloatingPanel.tsx
YoungestSongMo ada915f298 feat: 去 React Router 化 + 实现三种窗口类型的页面调度体系
移除 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 多窗口模型)
2026-06-08 16:33:47 +08:00

216 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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>
);
}