feat(dashboard): 编辑态交互重设计 — 整卡可拖(顶部被遮挡也能从任意位置拖起), 内容 pointer-events:none 阻断内部点击/悬停/iframe 吞拖拽, 八向缩放(四边通长细条+四角直角标线, accent 主题色), 占位格虚线框吸附反馈; 修复 RGL 浏览态残留 104 个隐形手柄 span 挡内容/滚动条(display:none 根除)

This commit is contained in:
shy3130
2026-09-26 22:47:33 +08:00
parent fbf390187f
commit 2f657e3d07
2 changed files with 140 additions and 12 deletions
@@ -1,12 +1,16 @@
/**
* 看板网格 — react-grid-layout v2 封装: 12 列吸附网格, 编辑态拖拽换位/角柄调宽高。
* 看板网格 — react-grid-layout v2 封装: 12 列吸附网格, 编辑态拖拽换位/八向调宽高。
*
* 设计要点:
* - 编辑入口与编辑控制组(添加组件/恢复默认/完成)在页面头部「重载」右侧,
* 由 Dashboard 页持有 editing 状态传入; 本组件只负责网格渲染与编辑态覆盖层。
* - 浏览态零编辑元素(视觉与固定布局时代一致); 编辑态在每个组件顶部叠加
* 半透明把手条(拖拽手柄 + 删除), dragConfig.handle 限定只有把手可拖,
* 组件内部链接/点击在编辑态不受影响。
* - 编辑态整卡可拖(不限定把手, 顶部被遮挡也能从任意位置拖起);
* 组件内容 pointer-events:none 阻断内部点击/悬停(编辑时不误触行情链接),
* 外链 iframe 同样被阻断 — 拖拽不会被 iframe 吞掉。
* - 缩放八向: 四边通长细条 + 四角直角标线(样式在 grid.css, 挂 .dash-grid-editing);
* 西/北向缩放由 RGL 联动平移 x/y 固定对侧边。
* - 吸附: 拖拽/缩放全程按网格单元吸附, 拖动时占位格实时标出落点(grid.css 美化),
* 松手按 200ms 过渡滑入格子。
* - <768px 不挂网格, 组件按顺序纵排(RGL 只服务桌面宽度)。
* - onLayoutChange 仅编辑态提交(挂载期 RGL 的规范化回调被忽略, 避免
* 非编辑状态下布局意外写回)。
@@ -14,6 +18,7 @@
import { useCallback, useMemo, type ReactNode } from 'react'
import GridLayout, { useContainerWidth, type Layout } from 'react-grid-layout'
import 'react-grid-layout/css/styles.css'
import './grid.css'
import { X } from 'lucide-react'
import { cn } from '@/lib/cn'
import { GRID_COLS, GRID_MARGIN, GRID_ROW_HEIGHT, compactLayout, normalizeLayout, type DashboardItem } from './layout'
@@ -67,11 +72,11 @@ export function DashboardGrid({ ctx, items, onItemsChange, editing }: DashboardG
const isMobile = mounted && width < 768
return (
<div ref={containerRef as unknown as React.RefObject<HTMLDivElement>} className="relative">
<div ref={containerRef as unknown as React.RefObject<HTMLDivElement>} className={cn('dash-grid relative', editing && 'dash-grid-editing')}>
{/* 编辑态操作提示 (入口与控制组在页面头部「重载」右侧) */}
{editing && (
<div className="mb-1.5 text-[11px] text-muted">
拖动顶部把手移动 · 右下角柄调宽高, 自动吸附网格 · 改动自动保存
拖动组件任意位置移动 · 悬停卡片四边/四角调宽高 · 自动吸附网格 · 改动自动保存
</div>
)}
@@ -89,17 +94,27 @@ export function DashboardGrid({ ctx, items, onItemsChange, editing }: DashboardG
width={width}
layout={rglLayout}
gridConfig={{ cols: GRID_COLS, rowHeight: GRID_ROW_HEIGHT, margin: [GRID_MARGIN, GRID_MARGIN], containerPadding: null, maxRows: Infinity }}
dragConfig={{ enabled: editing, handle: '.dash-widget-handle', threshold: 3 }}
resizeConfig={{ enabled: editing, handles: ['se'] }}
dragConfig={{
enabled: editing,
// 整卡可拖(不限把手); ✕ 移除钮不触发拖拽 — RGL 自动并入 .react-resizable-handle
cancel: '.dash-widget-remove',
threshold: 3,
}}
resizeConfig={{ enabled: editing, handles: ['n', 'e', 's', 'w', 'ne', 'nw', 'se', 'sw'] }}
onLayoutChange={editing ? handleLayoutChange : undefined}
>
{items.map(it => {
const def = widgetDef(it.t)
const Icon = def?.icon
return (
<div key={it.i} className={cn(editing && 'rounded-card ring-1 ring-accent/25')}>
<div
key={it.i}
className={cn(
editing && 'cursor-grab select-none rounded-card ring-1 ring-accent/30 hover:ring-accent/60 active:cursor-grabbing',
)}
>
{editing && (
<div className="dash-widget-handle absolute inset-x-0 top-0 z-10 flex h-6 cursor-grab items-center justify-between rounded-t-card bg-surface/95 px-2 text-[10px] text-secondary shadow-sm active:cursor-grabbing">
<div className="absolute inset-x-0 top-0 z-10 flex h-6 items-center justify-between rounded-t-card bg-surface/95 pl-2 pr-5 text-[10px] text-secondary shadow-sm">
<span className="flex min-w-0 items-center gap-1">
{Icon && <Icon className="h-3 w-3 shrink-0 text-accent" />}
<span className="truncate">{def?.label ?? it.t}{it.t === 'ext-link' && it.p?.title ? ` · ${it.p.title}` : ''}</span>
@@ -107,14 +122,15 @@ export function DashboardGrid({ ctx, items, onItemsChange, editing }: DashboardG
<button
type="button"
onClick={() => removeItem(it.i)}
className="flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted hover:bg-danger/15 hover:text-danger"
className="dash-widget-remove flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted hover:bg-danger/15 hover:text-danger"
title="移除组件"
>
<X className="h-3 w-3" />
</button>
</div>
)}
{renderWidget(it)}
{/* 编辑态阻断组件内部交互(点击/悬停/iframe), 拖拽由外层网格项接管 */}
<div className={cn(editing && 'pointer-events-none')}>{renderWidget(it)}</div>
</div>
)
})}
+112
View File
@@ -0,0 +1,112 @@
/*
* 看板网格编辑态样式 — 覆盖 react-grid-layout 默认手柄/占位格外观。
* 全部规则挂在 .dash-grid-editing 下(仅编辑态挂载该类), 浏览态保持库默认。
* 库默认手柄为黑色角箭头(暗色主题不可见)且仅 20px 角块; 这里改为:
* 四边通长 8px 细条 + 四角 14px 直角标线, grip 用主题 accent 色。
*/
/* 浏览态: RGL 在 enabled=false 时仍渲染手柄 span(不卸载), 隐形元素会挡住
* 卡片边缘的点击与列表滚动条 — 整组 display:none 根除。 */
.dash-grid:not(.dash-grid-editing) .react-grid-item > .react-resizable-handle {
display: none;
}
.dash-grid-editing .react-grid-item > .react-resizable-handle {
position: absolute;
width: 14px;
height: 14px;
z-index: 20; /* 压过组件标题栏(z-10), 顶部边缘缩放不被栏挡住 */
opacity: 0;
transform: none;
transition: opacity 120ms ease;
}
.dash-grid-editing .react-grid-item:hover > .react-resizable-handle,
.dash-grid-editing .react-grid-item > .react-resizable-handle:hover {
opacity: 1;
}
/* 四角: 直角标线 */
.dash-grid-editing .react-grid-item > .react-resizable-handle-nw { top: 0; left: 0; cursor: nw-resize; }
.dash-grid-editing .react-grid-item > .react-resizable-handle-ne { top: 0; right: 0; cursor: ne-resize; }
.dash-grid-editing .react-grid-item > .react-resizable-handle-sw { bottom: 0; left: 0; cursor: sw-resize; }
.dash-grid-editing .react-grid-item > .react-resizable-handle-se { bottom: 0; right: 0; cursor: se-resize; }
/* 四边: 通长细条 (避开角块 16px) */
.dash-grid-editing .react-grid-item > .react-resizable-handle-n,
.dash-grid-editing .react-grid-item > .react-resizable-handle-s {
left: 16px;
right: 16px;
width: auto;
height: 8px;
margin-left: 0;
cursor: ns-resize;
}
.dash-grid-editing .react-grid-item > .react-resizable-handle-n { top: 0; }
.dash-grid-editing .react-grid-item > .react-resizable-handle-s { bottom: 0; }
.dash-grid-editing .react-grid-item > .react-resizable-handle-e,
.dash-grid-editing .react-grid-item > .react-resizable-handle-w {
top: 16px;
bottom: 16px;
width: 8px;
height: auto;
margin-top: 0;
cursor: ew-resize;
}
.dash-grid-editing .react-grid-item > .react-resizable-handle-e { right: 0; }
.dash-grid-editing .react-grid-item > .react-resizable-handle-w { left: 0; }
/* grip 视觉: 边柄=短横/竖线, 角柄=直角标线 */
.dash-grid-editing .react-grid-item > .react-resizable-handle::after {
content: "";
position: absolute;
left: auto;
right: auto;
top: auto;
bottom: auto;
background: hsl(var(--accent));
border: none;
border-radius: 1px;
}
.dash-grid-editing .react-grid-item > .react-resizable-handle-n::after,
.dash-grid-editing .react-grid-item > .react-resizable-handle-s::after {
left: 50%;
top: 50%;
width: 14px;
height: 2px;
transform: translate(-50%, -50%);
}
.dash-grid-editing .react-grid-item > .react-resizable-handle-e::after,
.dash-grid-editing .react-grid-item > .react-resizable-handle-w::after {
left: 50%;
top: 50%;
width: 2px;
height: 14px;
transform: translate(-50%, -50%);
}
.dash-grid-editing .react-grid-item > .react-resizable-handle-nw::after,
.dash-grid-editing .react-grid-item > .react-resizable-handle-ne::after,
.dash-grid-editing .react-grid-item > .react-resizable-handle-sw::after,
.dash-grid-editing .react-grid-item > .react-resizable-handle-se::after {
width: 7px;
height: 7px;
border-style: solid;
border-color: hsl(var(--accent));
background: none;
}
.dash-grid-editing .react-grid-item > .react-resizable-handle-nw::after { top: 2px; left: 2px; border-width: 2px 0 0 2px; }
.dash-grid-editing .react-grid-item > .react-resizable-handle-ne::after { top: 2px; right: 2px; border-width: 2px 2px 0 0; }
.dash-grid-editing .react-grid-item > .react-resizable-handle-sw::after { bottom: 2px; left: 2px; border-width: 0 0 2px 2px; }
.dash-grid-editing .react-grid-item > .react-resizable-handle-se::after { bottom: 2px; right: 2px; border-width: 0 2px 2px 0; }
/* 拖拽/缩放时的落点占位格: accent 虚线框 = 吸附目标反馈 (库默认为红色块) */
.dash-grid-editing .react-grid-item.react-grid-placeholder {
background: hsl(var(--accent) / 0.08);
border: 1.5px dashed hsl(var(--accent) / 0.55);
border-radius: 8px;
opacity: 1;
}
/* 拖拽中的本体轻微半透明, 突出落点 */
.dash-grid-editing .react-grid-item.react-draggable-dragging {
opacity: 0.88;
}