163 lines
5.9 KiB
TypeScript
163 lines
5.9 KiB
TypeScript
import { useState, useCallback, useRef } from "react";
|
|
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
|
import type { DragEndEvent, Modifier } from "@dnd-kit/core";
|
|
import { SortableContext, useSortable, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
|
|
const restrictToHorizontalAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 });
|
|
import type { ModuleField } from "../types";
|
|
|
|
interface Props {
|
|
fields: ModuleField[];
|
|
activeId: number | null;
|
|
onActivate: (id: number) => void;
|
|
onReorder: (activeId: number, overId: number) => void;
|
|
onResize: (id: number, width: number) => void;
|
|
}
|
|
|
|
function SortableColumn({
|
|
field,
|
|
active,
|
|
isLast,
|
|
onActivate,
|
|
onResizeStart,
|
|
}: {
|
|
field: ModuleField;
|
|
active: boolean;
|
|
isLast: boolean;
|
|
onActivate: (id: number) => void;
|
|
onResizeStart: (e: React.MouseEvent, id: number) => void;
|
|
}) {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: field.id });
|
|
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 };
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{ ...style, width: field.listW, flexShrink: 0, position: "relative" }}
|
|
className={`px-3 py-2 border-r border-slate-200 text-xs font-medium cursor-pointer select-none ${
|
|
active ? "bg-blue-50 text-blue-600" : "text-slate-600 hover:bg-slate-100"
|
|
}`}
|
|
onClick={() => onActivate(field.id)}
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
<span
|
|
{...attributes}
|
|
{...listeners}
|
|
className="text-slate-300 hover:text-slate-500 cursor-grab active:cursor-grabbing shrink-0"
|
|
title="拖拽排序"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 10h12M6 14h12" />
|
|
</svg>
|
|
</span>
|
|
<span className="truncate">{field.label}</span>
|
|
<span className="text-[10px] text-slate-300 ml-auto">{field.listW}</span>
|
|
</div>
|
|
{!isLast && (
|
|
<div
|
|
className="col-resize-handle"
|
|
onMouseDown={(e) => onResizeStart(e, field.id)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function TablePreview({ fields, activeId, onActivate, onReorder, onResize }: Props) {
|
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
|
const [resizing, setResizing] = useState<{ id: number; startX: number; startW: number } | null>(null);
|
|
const tableRef = useRef<HTMLDivElement>(null);
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (over && active.id !== over.id) {
|
|
onReorder(Number(active.id), Number(over.id));
|
|
}
|
|
},
|
|
[onReorder]
|
|
);
|
|
|
|
const handleResizeStart = useCallback(
|
|
(e: React.MouseEvent, id: number) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const field = fields.find((f) => f.id === id);
|
|
if (!field) return;
|
|
setResizing({ id, startX: e.clientX, startW: field.listW });
|
|
},
|
|
[fields]
|
|
);
|
|
|
|
const handleMouseMove = useCallback(
|
|
(e: MouseEvent) => {
|
|
if (!resizing) return;
|
|
const dx = e.clientX - resizing.startX;
|
|
const w = Math.max(40, Math.min(400, Math.round(resizing.startW + dx)));
|
|
onResize(resizing.id, w);
|
|
setResizing((prev) => (prev ? { ...prev } : null));
|
|
},
|
|
[resizing, onResize]
|
|
);
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
setResizing(null);
|
|
}, []);
|
|
|
|
// Attach global listeners for resize
|
|
if (resizing) {
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
}
|
|
|
|
const visible = fields.filter((f) => f.listOn).sort((a, b) => a.order - b.order);
|
|
if (!visible.length) {
|
|
return <div className="text-center text-xs text-slate-300 mt-16">暂无可见列 · 请开启字段的列表开关</div>;
|
|
}
|
|
|
|
const totalW = visible.reduce((s, f) => s + f.listW, 0);
|
|
|
|
return (
|
|
<div ref={tableRef}>
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd} modifiers={[restrictToHorizontalAxis]}>
|
|
<SortableContext items={visible.map((f) => f.id)} strategy={horizontalListSortingStrategy}>
|
|
<div className="overflow-x-auto border border-slate-200 rounded-lg bg-white shadow-sm">
|
|
<div style={{ minWidth: Math.max(totalW, 400) }}>
|
|
<div className="flex bg-slate-50 border-b border-slate-200">
|
|
{visible.map((f, i) => (
|
|
<SortableColumn
|
|
key={f.id}
|
|
field={f}
|
|
active={f.id === activeId}
|
|
isLast={i === visible.length - 1}
|
|
onActivate={onActivate}
|
|
onResizeStart={handleResizeStart}
|
|
/>
|
|
))}
|
|
</div>
|
|
{[0, 1].map((r) => (
|
|
<div key={r} className="flex border-b border-slate-50 text-xs text-slate-300">
|
|
{visible.map((f) => (
|
|
<div
|
|
key={f.id}
|
|
className="shrink-0 px-3 py-2 border-r border-slate-50"
|
|
style={{ width: f.listW, textAlign: f.listAlign }}
|
|
>
|
|
{f.type === "select" ? "—" : f.type === "date" ? "2024-08-12" : f.type === "number" ? "100.00" : "示例"}
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
<div className="mt-2 text-[11px] text-slate-400 text-center">
|
|
拖拽列头排序 · 拖拽右边缘调整宽度 · 点击列头选中字段
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|