refactor: CSS 拆分 + auth 持久化提取 + 目录整理
- globals.css 拆分为 4 文件:tokens.css(颜色)/ transitions.css(过渡)/ base.css(重置)/ globals.css(入口) - 提取 auth-persistence.ts 到 services/,解除 AppProvider→pages/login/hooks 反向依赖 - use-auth-state.ts 精简为纯 React Hook,持久化逻辑委托给 services/auth-persistence.ts - components/common/ → components/ui/(目录名语义更清晰) - TypeScript + Vite 构建通过 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
215
src/components/ui/FloatingPanel.tsx
Normal file
215
src/components/ui/FloatingPanel.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
// ============================================================
|
||||
// FloatingPanel — 非模态可拖拽浮动面板
|
||||
//
|
||||
// 特性:
|
||||
// - 标题栏拖拽移动,无遮罩,不阻断主页面交互
|
||||
// - 初始居中定位,支持暗色主题
|
||||
// - 边界检测(不拖出视口)
|
||||
// - 多个面板可同时打开(点击置顶)
|
||||
// - 关闭按钮 + 最小尺寸约束
|
||||
//
|
||||
// 对应 QT 原版"非模态窗口"的 Web 映射
|
||||
// ============================================================
|
||||
|
||||
import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react';
|
||||
import { useTheme } from '@/components/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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user