Files
ele-HeiXiu/src/components/ui/FloatingPanel.tsx
YoungestSongMo 0e6d996718 refactor: 重组 components/ 和 pages/ 文件结构,按职责分层
components/ 拆为三层:
  - providers/   — 状态管理(App/Theme/SettingsProvider)
  - shell/       — 应用外壳(Layout/NavBar/PageDispatcher)
  - ui/          — 通用 UI 原子(FloatingPanel/LegalTextModal)

pages/ 重命名 + 重组:
  - headers/ → panels/        NavBar 触发的面板页
  - login/   → auth/          认证模块(含注册/忘记密码)
  - settings/blocks/ → sections/
  - home/components/ → panels/ features/ controls/ hooks/
    按职责分:panels(布局壳) + features(功能面板) + controls(表单控件) + hooks

其他清理:
  - 删除空占位 Test3.tsx
  - HomeContext 从 HomeContent 拆出独立文件
  - uploadValidation 内联到 ImageInput
  - 修复过时注释(useUI → controls)
2026-06-15 11:19:06 +08:00

216 lines
7.3 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 '@/components/providers/ThemeProvider';
// ---------- 类型 ----------
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>
);
}