Compare commits

...

2 Commits

Author SHA1 Message Date
003dce4a3e update 2025-10-21 03:52:07 +08:00
c8ffbfefb4 fix: table 高度 2025-10-21 03:30:26 +08:00
8 changed files with 1045 additions and 57 deletions

View File

@@ -0,0 +1,172 @@
# 表格拖拽多选功能
## 功能概述
表格组件现在支持拖拽多选功能,用户可以通过鼠标拖拽来快速选择多行数据,提升批量操作的效率。
## 功能特性
### ✅ 已实现的功能
1. **基础拖拽选择**
- 在表格行上拖拽可以选择多行
- 实时显示选择框和选中状态
- 拖拽结束后更新选中状态
2. **智能交互**
- 避免在复选框和操作按钮区域启动拖拽
- 设置最小拖拽距离阈值,避免意外触发
- 拖拽过程中禁用文本选择
3. **键盘修饰键支持**
- **Ctrl/Cmd + 拖拽**:追加选择模式(切换选中状态)
- **Shift + 点击复选框**:范围选择模式
- **Ctrl/Cmd + A**:全选
- **Escape**:取消拖拽或清空选择
4. **虚拟滚动兼容**
- 正确处理虚拟滚动的坐标转换
- 考虑滚动位置的行索引计算
5. **视觉反馈**
- 拖拽选择框的实时显示
- 选中行的高亮效果
- 拖拽过程中的样式变化
- 用户操作提示
## 使用方法
### 基本配置
```typescript
import { Table } from './base/table/Table';
import { RowSelection } from './base/table/types';
const rowSelection: RowSelection<Mark> = {
type: 'checkbox',
selectedRowKeys,
onChange: (selectedRowKeys, selectedRows) => {
// 处理选择变化
setSelectedRowKeys(selectedRowKeys);
},
dragSelection: {
enabled: true, // 启用拖拽多选
multi: true, // 支持多选
onDragStart: (startRowIndex) => {
console.log('开始拖拽选择');
},
onDragEnd: (selectedRows) => {
console.log('拖拽选择结束');
},
},
};
<Table
data={data}
columns={columns}
rowSelection={rowSelection}
// ... 其他属性
/>
```
### 配置选项
#### `DragSelectionConfig`
```typescript
interface DragSelectionConfig {
enabled?: boolean; // 是否启用拖拽选择,默认为 true
multi?: boolean; // 是否支持多选,默认为 true
onDragStart?: (startRowIndex: number) => void; // 拖拽开始回调
onDragEnd?: (selectedRows: Mark[]) => void; // 拖拽结束回调
}
```
### 用户操作指南
1. **基础拖拽选择**
- 在表格行(非复选框/操作按钮区域)按下鼠标左键
- 拖拽至目标行
- 释放鼠标完成选择
2. **追加选择**
- 按住 `Ctrl`Windows/Linux`Cmd`Mac
- 进行拖拽选择
- 已选中的行会切换状态(选中变未选中,未选中变选中)
3. **范围选择**
- 先点击一个复选框选中一行
- 按住 `Shift`
- 点击另一个复选框
- 两行之间的所有行都会被选中
4. **键盘快捷键**
- `Ctrl/Cmd + A`:全选所有行
- `Escape`:取消当前拖拽操作或清空所有选择
## 技术实现
### 核心组件
1. **状态管理**
- `DragSelectionState`:管理拖拽状态
- 鼠标位置跟踪
- 选择范围计算
2. **事件处理**
- `handleMouseDown`:开始拖拽检测
- `handleMouseMove`:拖拽过程处理
- `handleMouseUp`:完成选择操作
3. **坐标转换**
- 虚拟滚动坐标映射
- 行索引计算
- 碰撞检测算法
### 样式类名
- `.row-selected`:选中行样式
- `.row-drag-selected`:拖拽选中行样式
- `.drag-selection-box`:拖拽选择框样式
- `.drag-hint`:操作提示样式
## 性能优化
1. **事件防抖**
- 设置拖拽距离阈值
- 避免频繁状态更新
2. **虚拟滚动适配**
- 只处理可视区域内的行
- 优化大数据量场景
3. **内存管理**
- 及时清理事件监听器
- 合理的状态重置
## 兼容性
- ✅ 支持现有的复选框选择
- ✅ 兼容虚拟滚动
- ✅ 支持排序和筛选
- ✅ 响应式设计
- ✅ 键盘导航友好
## 示例代码
查看 `DragSelectionExample.tsx` 文件获取完整的使用示例。
## 注意事项
1. 拖拽选择不会在复选框和操作按钮区域启动
2. 需要设置合适的行高以确保拖拽体验
3. 大数据量时建议启用虚拟滚动
4. 移动端设备可能需要额外的触摸事件处理
## 未来规划
- [ ] 触摸设备支持
- [ ] 自定义拖拽选择框样式
- [ ] 更多键盘快捷键
- [ ] 拖拽选择动画效果
- [ ] 选择统计和操作面板

View File

@@ -0,0 +1,115 @@
// 使用拖拽多选功能的示例
import React, { useState } from 'react';
import { Table } from './Table';
import { Mark } from '../mock/collection';
import { TableColumn, RowSelection } from './types';
const ExampleTable: React.FC = () => {
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
// 示例数据
const data: Mark[] = [
{
id: '1',
title: '示例标记 1',
description: '这是第一个标记的描述',
data: { content: '这是第一个标记' },
createdAt: new Date(),
updatedAt: new Date()
},
{
id: '2',
title: '示例标记 2',
description: '这是第二个标记的描述',
data: { content: '这是第二个标记' },
createdAt: new Date(),
updatedAt: new Date()
},
{
id: '3',
title: '示例标记 3',
description: '这是第三个标记的描述',
data: { content: '这是第三个标记' },
createdAt: new Date(),
updatedAt: new Date()
},
// ... 更多数据
];
// 表格列配置
const columns: TableColumn<Mark>[] = [
{
key: 'title',
title: '标题',
dataIndex: 'title',
width: 200,
sortable: true,
},
{
key: 'description',
title: '描述',
dataIndex: 'description',
width: 300,
},
{
key: 'createdAt',
title: '创建时间',
dataIndex: 'createdAt',
width: 180,
render: (value: Date) => value.toLocaleString(),
},
];
// 行选择配置,启用拖拽多选
const rowSelection: RowSelection<Mark> = {
type: 'checkbox',
selectedRowKeys,
onChange: (selectedRowKeys: React.Key[], selectedRows: Mark[]) => {
console.log('选中的行键:', selectedRowKeys);
console.log('选中的行数据:', selectedRows);
setSelectedRowKeys(selectedRowKeys);
},
dragSelection: {
enabled: true, // 启用拖拽多选
multi: true, // 支持多选
onDragStart: (startRowIndex: number) => {
console.log('开始拖拽选择,起始行索引:', startRowIndex);
},
onDragEnd: (selectedRows: Mark[]) => {
console.log('拖拽选择结束,选中的行:', selectedRows);
},
},
};
return (
<div style={{ height: '600px', padding: '20px' }}>
<h2></h2>
<p>
<strong>使</strong>
<br />
<br />
<br />
Ctrl/Cmd +
<br />
Shift +
<br />
Ctrl/Cmd + A
<br />
Escape
</p>
<Table
data={data}
columns={columns}
rowSelection={rowSelection}
virtualScroll={{ rowHeight: 48 }}
loading={false}
/>
</div>
);
};
export default ExampleTable;

View File

@@ -1,7 +1,7 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { AutoSizer, List } from 'react-virtualized';
import { Mark } from '../mock/collection';
import { TableProps, SortState } from './types';
import { TableProps, SortState, DragSelectionState } from './types';
import './table.css';
// 虚拟滚动常量
@@ -19,11 +19,29 @@ export const Table: React.FC<TableProps> = ({
}) => {
const rowHeight = virtualScroll?.rowHeight || DEFAULT_ROW_HEIGHT;
const [sortState, setSortState] = useState<SortState>({ field: null, order: null });
// 拖拽选择状态
const [dragState, setDragState] = useState<DragSelectionState>({
isDragging: false,
startPosition: null,
endPosition: null,
startRowIndex: null,
dragRect: null,
selectedDuringDrag: new Set()
});
// DOM 引用
const tableBodyRef = useRef<HTMLDivElement>(null);
const selectionBoxRef = useRef<HTMLDivElement>(null);
const listRef = useRef<List>(null);
// 拖拽选择配置
const dragSelectionEnabled = rowSelection?.dragSelection?.enabled !== false;
// 处理排序
const handleSort = (field: string) => {
let newOrder: 'asc' | 'desc' | null = 'asc';
if (sortState.field === field) {
if (sortState.order === 'asc') {
newOrder = 'desc';
@@ -31,7 +49,7 @@ export const Table: React.FC<TableProps> = ({
newOrder = null;
}
}
const newSortState = { field: newOrder ? field : null, order: newOrder };
setSortState(newSortState);
onSort?.(newSortState.field!, newSortState.order!);
@@ -40,11 +58,11 @@ export const Table: React.FC<TableProps> = ({
// 排序后的数据
const sortedData = useMemo(() => {
if (!sortState.field || !sortState.order) return data;
return [...data].sort((a, b) => {
const aVal = getNestedValue(a, sortState.field!);
const bVal = getNestedValue(b, sortState.field!);
if (aVal < bVal) return sortState.order === 'asc' ? -1 : 1;
if (aVal > bVal) return sortState.order === 'asc' ? 1 : -1;
return 0;
@@ -57,25 +75,48 @@ export const Table: React.FC<TableProps> = ({
// 全选/取消全选
const handleSelectAll = (checked: boolean) => {
if (!rowSelection) return;
const allKeys = displayData.map(item => item.id);
const selectedKeys = checked ? allKeys : [];
const selectedRows = checked ? displayData : [];
rowSelection.onChange?.(selectedKeys, selectedRows);
};
// 单行选择
const handleRowSelect = (record: Mark, checked: boolean) => {
// 上次点击的行索引用于Shift范围选择
const lastClickedRowRef = useRef<number | null>(null);
// 单行选择 - 支持Shift范围选择
const handleRowSelect = (record: Mark, checked: boolean, event?: React.ChangeEvent<HTMLInputElement>) => {
if (!rowSelection) return;
const currentKeys = rowSelection.selectedRowKeys || [];
const newKeys = checked
? [...currentKeys, record.id]
: currentKeys.filter(key => key !== record.id);
const recordIndex = displayData.findIndex(item => item.id === record.id);
const selectedRows = data.filter(item => newKeys.includes(item.id));
rowSelection.onChange?.(newKeys, selectedRows);
// 处理Shift键范围选择
if (event?.nativeEvent && (event.nativeEvent as any).shiftKey && lastClickedRowRef.current !== null) {
const startIndex = Math.min(lastClickedRowRef.current, recordIndex);
const endIndex = Math.max(lastClickedRowRef.current, recordIndex);
const rangeKeys = displayData.slice(startIndex, endIndex + 1).map(item => item.id);
const newKeys = checked
? [...new Set([...currentKeys, ...rangeKeys])]
: currentKeys.filter(key => !rangeKeys.includes(key as string));
const selectedRows = data.filter(item => newKeys.includes(item.id));
rowSelection.onChange?.(newKeys, selectedRows);
} else {
// 普通单行选择
const newKeys = checked
? [...currentKeys, record.id]
: currentKeys.filter(key => key !== record.id);
const selectedRows = data.filter(item => newKeys.includes(item.id));
rowSelection.onChange?.(newKeys, selectedRows);
}
// 更新上次点击的行索引
lastClickedRowRef.current = recordIndex;
};
// 获取嵌套值
@@ -83,25 +124,292 @@ export const Table: React.FC<TableProps> = ({
return path.split('.').reduce((o, p) => o?.[p], obj);
};
// 根据坐标获取行索引(考虑虚拟滚动的滚动位置)
const getRowIndexFromPosition = useCallback((y: number): number => {
if (!tableBodyRef.current || !listRef.current) return -1;
const rect = tableBodyRef.current.getBoundingClientRect();
const relativeY = y - rect.top;
// 获取当前滚动位置
const scrollTop = (listRef.current as any).Grid?._scrollingContainer?.scrollTop || 0;
// 计算实际的行索引,考虑滚动位置
const actualY = relativeY + scrollTop;
const rowIndex = Math.floor(actualY / rowHeight);
return Math.max(0, Math.min(rowIndex, displayData.length - 1));
}, [rowHeight, displayData.length]);
// 计算拖拽选择范围内的行
const getRowsInDragRange = useCallback((startY: number, endY: number): number[] => {
if (!tableBodyRef.current) return [];
const minY = Math.min(startY, endY);
const maxY = Math.max(startY, endY);
const startRowIndex = getRowIndexFromPosition(minY);
const endRowIndex = getRowIndexFromPosition(maxY);
const rows: number[] = [];
for (let i = startRowIndex; i <= endRowIndex; i++) {
if (i >= 0 && i < displayData.length) {
rows.push(i);
}
}
return rows;
}, [getRowIndexFromPosition, displayData.length]);
// 检查拖拽选择框与行的碰撞检测
const isRowInDragSelection = useCallback((rowIndex: number): boolean => {
if (!dragState.dragRect || !tableBodyRef.current) return false;
const rect = tableBodyRef.current.getBoundingClientRect();
const rowTop = rowIndex * rowHeight;
const rowBottom = rowTop + rowHeight;
// 将拖拽选择框坐标转换为相对于表格的坐标
const selectionTop = dragState.dragRect.top - rect.top;
const selectionBottom = dragState.dragRect.bottom - rect.top;
// 检查行和选择框的垂直重叠
return !(rowBottom < selectionTop || rowTop > selectionBottom);
}, [dragState.dragRect, rowHeight]);
// 更新拖拽选择状态
const updateDragSelection = useCallback((currentPosition: { x: number; y: number }) => {
if (!dragState.isDragging || !dragState.startPosition) return;
const rowsInRange = getRowsInDragRange(dragState.startPosition.y, currentPosition.y);
const newSelectedKeys = new Set(rowsInRange.map(index => displayData[index].id));
setDragState(prev => ({
...prev,
endPosition: currentPosition,
selectedDuringDrag: newSelectedKeys,
dragRect: {
left: Math.min(dragState.startPosition!.x, currentPosition.x),
top: Math.min(dragState.startPosition!.y, currentPosition.y),
right: Math.max(dragState.startPosition!.x, currentPosition.x),
bottom: Math.max(dragState.startPosition!.y, currentPosition.y),
width: Math.abs(currentPosition.x - dragState.startPosition!.x),
height: Math.abs(currentPosition.y - dragState.startPosition!.y),
x: Math.min(dragState.startPosition!.x, currentPosition.x),
y: Math.min(dragState.startPosition!.y, currentPosition.y),
toJSON: () => ({})
} as DOMRect
}));
}, [dragState.isDragging, dragState.startPosition, getRowsInDragRange, displayData]);
// 拖拽最小距离阈值(像素)
const DRAG_THRESHOLD = 5;
// 鼠标按下事件处理
const handleMouseDown = useCallback((event: React.MouseEvent) => {
if (!dragSelectionEnabled || !rowSelection || event.button !== 0) return;
// 如果点击的是复选框或操作按钮,不启动拖拽选择
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.closest('.selection-column') || target.closest('.actions-column')) {
return;
}
const rect = tableBodyRef.current?.getBoundingClientRect();
if (!rect) return;
const startPosition = {
x: event.clientX,
y: event.clientY
};
const startRowIndex = getRowIndexFromPosition(event.clientY);
setDragState({
isDragging: false, // 先不设为true等达到阈值再设置
startPosition,
endPosition: startPosition,
startRowIndex,
dragRect: null,
selectedDuringDrag: new Set()
});
event.preventDefault();
}, [dragSelectionEnabled, rowSelection, getRowIndexFromPosition]);
// 鼠标移动事件处理
const handleMouseMove = useCallback((event: MouseEvent) => {
if (!dragState.startPosition) return;
const currentPosition = {
x: event.clientX,
y: event.clientY
};
// 检查是否超过拖拽阈值
const deltaX = Math.abs(currentPosition.x - dragState.startPosition.x);
const deltaY = Math.abs(currentPosition.y - dragState.startPosition.y);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (!dragState.isDragging && distance > DRAG_THRESHOLD) {
// 达到阈值,开始拖拽选择
setDragState(prev => ({
...prev,
isDragging: true
}));
// 调用拖拽开始回调
rowSelection?.dragSelection?.onDragStart?.(dragState.startRowIndex!);
}
if (dragState.isDragging) {
updateDragSelection(currentPosition);
}
}, [dragState.startPosition, dragState.isDragging, dragState.startRowIndex, updateDragSelection, rowSelection]);
// 鼠标抬起事件处理
const handleMouseUp = useCallback((event: MouseEvent) => {
if (!dragState.startPosition) return;
// 如果没有开始拖拽(未达到阈值),直接重置状态
if (!dragState.isDragging) {
setDragState({
isDragging: false,
startPosition: null,
endPosition: null,
startRowIndex: null,
dragRect: null,
selectedDuringDrag: new Set()
});
return;
}
const selectedRows = displayData.filter(item => dragState.selectedDuringDrag.has(item.id));
const selectedKeys = Array.from(dragState.selectedDuringDrag);
// 处理选择模式 - Ctrl/Cmd 键多选Shift 键范围选择
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
const currentSelectedKeys = rowSelection?.selectedRowKeys || [];
let newSelectedKeys: React.Key[];
if (isCtrlOrCmd) {
// Ctrl/Cmd + 拖拽:切换选择状态
const existingKeys = new Set(currentSelectedKeys);
selectedKeys.forEach(key => {
if (existingKeys.has(key)) {
existingKeys.delete(key);
} else {
existingKeys.add(key);
}
});
newSelectedKeys = Array.from(existingKeys);
} else {
// 普通拖拽:替换选择
newSelectedKeys = selectedKeys;
}
const finalSelectedRows = displayData.filter(item => newSelectedKeys.includes(item.id));
// 更新选择状态
rowSelection?.onChange?.(newSelectedKeys, finalSelectedRows);
// 调用拖拽结束回调
rowSelection?.dragSelection?.onDragEnd?.(finalSelectedRows);
// 重置拖拽状态
setDragState({
isDragging: false,
startPosition: null,
endPosition: null,
startRowIndex: null,
dragRect: null,
selectedDuringDrag: new Set()
});
}, [dragState.isDragging, dragState.selectedDuringDrag, displayData, rowSelection]);
// 键盘事件处理
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (!rowSelection) return;
// Ctrl/Cmd + A 全选
if ((event.ctrlKey || event.metaKey) && event.key === 'a') {
event.preventDefault();
handleSelectAll(true);
return;
}
// Escape 取消选择
if (event.key === 'Escape') {
if (dragState.isDragging) {
// 取消拖拽
setDragState({
isDragging: false,
startPosition: null,
endPosition: null,
startRowIndex: null,
dragRect: null,
selectedDuringDrag: new Set()
});
} else {
// 清空选择
handleSelectAll(false);
}
return;
}
}, [rowSelection, dragState.isDragging, handleSelectAll]);
// 添加全局鼠标事件监听器
useEffect(() => {
if (dragState.startPosition) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
if (dragState.isDragging) {
document.body.style.userSelect = 'none'; // 禁用文本选择
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = '';
};
}
}, [dragState.startPosition, dragState.isDragging, handleMouseMove, handleMouseUp]);
// 添加键盘事件监听器
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
// 渲染虚拟滚动行
const rowRenderer = ({ index, key, style }: any) => {
const record = displayData[index];
const isSelected = selectedKeys.includes(record.id);
const isDragSelected = dragState.selectedDuringDrag.has(record.id);
const isHighlighted = isSelected || isDragSelected;
return (
<div key={key} style={style} className="table-row virtual-row">
<div
key={key}
style={style}
className={`table-row virtual-row ${isHighlighted ? 'row-selected' : ''} ${isDragSelected ? 'row-drag-selected' : ''}`}
>
{rowSelection && (
<div className="selection-column">
<input
type="checkbox"
checked={selectedKeys.includes(record.id)}
onChange={(e) => handleRowSelect(record, e.target.checked)}
checked={isSelected}
onChange={(e) => handleRowSelect(record, e.target.checked, e)}
disabled={rowSelection.getCheckboxProps?.(record)?.disabled}
/>
</div>
)}
{columns.map(column => (
<div key={column.key} className="table-cell" style={{ width: column.width }}>
{column.render
{column.render
? column.render(getNestedValue(record, column.dataIndex), record, index)
: getNestedValue(record, column.dataIndex)
}
@@ -143,14 +451,25 @@ export const Table: React.FC<TableProps> = ({
const isIndeterminate = selectedKeys.length > 0 && !isAllSelected;
return (
<div className="table-container ">
<div className="table-container">
{/* 表格工具栏 */}
{rowSelection && selectedKeys.length > 0 && (
<div className="table-toolbar">
<span className="selected-info">
<div className="selected-info">
{selectedKeys.length}
</span>
{dragSelectionEnabled && (
<span className="drag-hint">
Ctrl/Cmd键可追加选择Shift键可范围选择
</span>
)}
</div>
<div className="bulk-actions">
<button className="btn btn-secondary" onClick={() => {
// 取消选中所有项
handleSelectAll(false);
}}>
</button>
<button className="btn btn-danger" onClick={() => {
// 批量删除逻辑
console.log('批量删除:', selectedKeys);
@@ -179,7 +498,7 @@ export const Table: React.FC<TableProps> = ({
</div>
)}
{columns.map(column => (
<div
<div
key={column.key}
style={{ width: column.width }}
className={`table-header-cell ${column.sortable ? 'sortable' : ''}`}
@@ -187,16 +506,14 @@ export const Table: React.FC<TableProps> = ({
<div className="table-header">
<span>{column.title}</span>
{column.sortable && (
<div
<div
className="sort-indicators"
onClick={() => handleSort(column.dataIndex)}
>
<span className={`sort-arrow sort-up ${
sortState.field === column.dataIndex && sortState.order === 'asc' ? 'active' : ''
}`}></span>
<span className={`sort-arrow sort-down ${
sortState.field === column.dataIndex && sortState.order === 'desc' ? 'active' : ''
}`}></span>
<span className={`sort-arrow sort-up ${sortState.field === column.dataIndex && sortState.order === 'asc' ? 'active' : ''
}`}></span>
<span className={`sort-arrow sort-down ${sortState.field === column.dataIndex && sortState.order === 'desc' ? 'active' : ''
}`}></span>
</div>
)}
</div>
@@ -209,11 +526,17 @@ export const Table: React.FC<TableProps> = ({
</div>
{/* 虚拟滚动内容区域 */}
<div className="table-body-wrapper">
<div
className="table-body-wrapper"
ref={tableBodyRef}
onMouseDown={handleMouseDown}
style={{ cursor: dragState.isDragging ? 'crosshair' : 'default' }}
>
{displayData.length > 0 ? (
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
height={height}
width={width}
rowCount={displayData.length}
@@ -228,6 +551,22 @@ export const Table: React.FC<TableProps> = ({
<p></p>
</div>
)}
{/* 拖拽选择框 */}
{dragState.isDragging && dragState.dragRect && (
<div
ref={selectionBoxRef}
className="drag-selection-box"
style={{
position: 'absolute',
left: dragState.dragRect.x - (tableBodyRef.current?.getBoundingClientRect().left || 0),
top: dragState.dragRect.y - (tableBodyRef.current?.getBoundingClientRect().top || 0),
width: dragState.dragRect.width,
height: dragState.dragRect.height,
pointerEvents: 'none'
}}
/>
)}
</div>
</div>
</div>

View File

@@ -235,29 +235,6 @@ export const Base = (props: Props) => {
</p>
</div>
<div>
{/* 批量操作条 */}
{selectedRowKeys.length > 0 && (
<div style={{
marginBottom: '16px',
padding: '12px',
backgroundColor: '#e6f7ff',
borderRadius: '4px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexShrink: 0
}}>
<span> {selectedRowKeys.length} </span>
<button
className="btn btn-danger"
onClick={handleBatchDelete}
>
</button>
</div>
)}
</div>
{/* 表格容器 - 占据剩余空间并支持滚动 */}
<div style={{

View File

@@ -8,6 +8,7 @@
margin-bottom: 24px; /* 底部间距 */
display: flex;
flex-direction: column;
max-height: calc(100% - 24px); /* 添加最大高度限制 */
}
/* 工具栏 */
@@ -19,11 +20,22 @@
background: #f5f5f5;
border-bottom: 1px solid #e8e8e8;
flex-shrink: 0; /* 工具栏不压缩,保持固定高度 */
min-height: 56px; /* 确保工具栏有固定的最小高度 */
box-sizing: border-box; /* 包含padding和border在内的尺寸计算 */
}
.selected-info {
color: #666;
font-size: 14px;
display: flex;
flex-direction: column;
gap: 4px;
}
.drag-hint {
font-size: 12px;
color: #999;
font-style: italic;
}
.bulk-actions {
@@ -36,9 +48,9 @@
display: flex;
flex-direction: column;
flex: 1; /* 占满剩余空间 */
height: 100%; /* 占满父容器高度 */
min-height: 200px; /* 最小高度,避免过小 */
overflow: hidden; /* 防止溢出 */
height: 0; /* 重要配合flex: 1使用确保正确计算可用空间 */
}
/* 固定表头容器 */
@@ -79,6 +91,31 @@
overflow: hidden;
background: #fff;
min-height: 0; /* 重要允许flex子项收缩 */
position: relative; /* 为AutoSizer提供相对定位上下文 */
}
/* 拖拽选择框 */
.drag-selection-box {
background: rgba(24, 144, 255, 0.1);
border: 2px dashed #1890ff;
border-radius: 4px;
z-index: 1000;
animation: dragBoxPulse 0.3s ease-in-out;
}
@keyframes dragBoxPulse {
0% {
transform: scale(0.95);
opacity: 0.8;
}
50% {
transform: scale(1.02);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* 虚拟行样式 */
@@ -88,12 +125,30 @@
border-bottom: 1px solid #e8e8e8;
background: #fff;
transition: background-color 0.2s;
position: relative;
}
.virtual-row:hover {
background: #f5f5f5;
}
/* 选中行样式 */
.virtual-row.row-selected {
background: #e6f7ff !important;
border-color: #91d5ff;
}
.virtual-row.row-selected:hover {
background: #bae7ff !important;
}
/* 拖拽选中行样式 */
.virtual-row.row-drag-selected {
background: #f0f9ff !important;
border-color: #69c0ff;
box-shadow: inset 0 0 0 1px #40a9ff;
}
.table-cell {
display: flex;
align-items: center;
@@ -146,10 +201,38 @@
.selection-column {
width: 48px;
text-align: center;
position: relative;
}
.selection-column input[type="checkbox"] {
cursor: pointer;
transform: scale(1.1);
transition: all 0.2s;
}
.selection-column input[type="checkbox"]:hover {
transform: scale(1.2);
}
.selection-column input[type="checkbox"]:checked {
accent-color: #1890ff;
}
/* 拖拽选择时的表格样式 */
.table-body-wrapper[style*="cursor: crosshair"] {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.table-body-wrapper[style*="cursor: crosshair"] .virtual-row {
pointer-events: none;
}
.table-body-wrapper[style*="cursor: crosshair"] .selection-column,
.table-body-wrapper[style*="cursor: crosshair"] .actions-column {
pointer-events: auto;
}
/* 操作列 */

View File

@@ -11,12 +11,31 @@ export interface TableColumn<T = any> {
fixed?: 'left' | 'right';
}
// 拖拽选择配置
export interface DragSelectionConfig {
enabled?: boolean; // 是否启用拖拽选择,默认为 true
multi?: boolean; // 是否支持多选,默认为 true
onDragStart?: (startRowIndex: number) => void; // 拖拽开始回调
onDragEnd?: (selectedRows: Mark[]) => void; // 拖拽结束回调
}
// 拖拽选择状态
export interface DragSelectionState {
isDragging: boolean;
startPosition: { x: number; y: number } | null;
endPosition: { x: number; y: number } | null;
startRowIndex: number | null;
dragRect: DOMRect | null;
selectedDuringDrag: Set<React.Key>;
}
// 表格行选择配置
export interface RowSelection<T = any> {
type?: 'checkbox' | 'radio';
selectedRowKeys?: React.Key[];
onChange?: (selectedRowKeys: React.Key[], selectedRows: T[]) => void;
getCheckboxProps?: (record: T) => { disabled?: boolean };
dragSelection?: DragSelectionConfig; // 拖拽选择配置
}
// 虚拟滚动配置

View File

@@ -0,0 +1,179 @@
/* MarkDetail 组件样式 */
.mark-detail-container {
background: #ffffff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
max-height: 200px;
overflow: hidden;
position: relative;
}
.mark-detail-container.expanded {
max-height: none;
overflow: visible;
}
.mark-detail-container h2 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #333;
line-height: 1.4;
}
.mark-detail-container p {
margin: 0 0 12px 0;
color: #666;
line-height: 1.5;
}
/* 封面图片样式 */
.mark-cover {
margin-bottom: 12px;
text-align: center;
}
.mark-cover img {
max-width: 100%;
height: auto;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* 摘要样式 */
.mark-summary {
margin-bottom: 12px;
padding: 12px;
background-color: #f8f9fa;
border-radius: 6px;
border-left: 4px solid #007bff;
}
.mark-summary h3 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
color: #333;
}
.mark-summary p {
margin: 0;
font-size: 13px;
color: #555;
line-height: 1.4;
}
/* 链接样式 */
.mark-link {
margin-bottom: 12px;
}
.mark-link a {
color: #007bff;
text-decoration: none;
padding: 6px 12px;
border: 1px solid #007bff;
border-radius: 4px;
display: inline-block;
font-size: 13px;
transition: all 0.2s ease;
}
.mark-link a:hover {
background-color: #007bff;
color: white;
}
/* 类型和日期样式 */
.mark-type,
.mark-date {
margin-bottom: 8px;
font-size: 12px;
color: #666;
}
.mark-type span,
.mark-date span {
background-color: #f1f3f4;
padding: 2px 6px;
border-radius: 3px;
}
/* 标签样式 */
.mark-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.mark-tag {
background-color: #e3f2fd;
color: #1976d2;
padding: 4px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
/* 展开/收起按钮样式 */
.mark-expand-button {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 12px;
padding: 8px 12px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: #495057;
transition: all 0.2s ease;
user-select: none;
}
.mark-expand-button:hover {
background-color: #e9ecef;
border-color: #adb5bd;
}
.mark-expand-button:active {
transform: translateY(1px);
}
/* 收起状态下的渐变遮罩 */
.mark-detail-container.collapsed::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
background: linear-gradient(transparent, white);
pointer-events: none;
}
/* 响应式设计 */
@media (max-width: 768px) {
.mark-detail-container {
padding: 12px;
}
.mark-detail-container h2 {
font-size: 16px;
}
.mark-detail-container p {
font-size: 14px;
}
.mark-expand-button {
font-size: 12px;
padding: 6px 10px;
}
}

View File

@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { Maximize2, Minimize2 } from 'lucide-react';
import './MarkDetail.css';
export type MarkShow = {
id: string;
title?: string;
description?: string;
tags?: string[];
markType?: string;
cover?: string;
link?: string;
summary?: string;
key?: string;
data: any;
createdAt?: string;
updatedAt?: string;
markedAt?: Date;
}
export type SimpleMarkShow = {
id: string;
title?: string;
description?: string;
tags?: string[];
cover?: string;
link?: string;
summary?: string;
}
interface MarkDetailProps {
data: MarkShow;
}
export const MarkDetail: React.FC<MarkDetailProps> = ({ data }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpanded = () => {
setIsExpanded(!isExpanded);
};
return (
<div className={`mark-detail-container ${isExpanded ? 'expanded' : 'collapsed'}`}>
<h2>{data.title}</h2>
<p>{data.description}</p>
{data.cover && (
<div className="mark-cover">
<img src={data.cover} alt={data.title} />
</div>
)}
{data.summary && isExpanded && (
<div className="mark-summary">
<h3></h3>
<p>{data.summary}</p>
</div>
)}
{data.link && isExpanded && (
<div className="mark-link">
<a href={data.link} target="_blank" rel="noopener noreferrer">
访
</a>
</div>
)}
{data.markType && isExpanded && (
<div className="mark-type">
<span>: {data.markType}</span>
</div>
)}
{data.createdAt && isExpanded && (
<div className="mark-date">
<span>: {new Date(data.createdAt).toLocaleString()}</span>
</div>
)}
{data.updatedAt && isExpanded && (
<div className="mark-date">
<span>: {new Date(data.updatedAt).toLocaleString()}</span>
</div>
)}
<div className="mark-tags">
{data.tags?.map(tag => (
<span key={tag} className="mark-tag">{tag}</span>
))}
</div>
<div className="mark-expand-button" onClick={toggleExpanded}>
{isExpanded ? (
<>
<Minimize2 size={16} />
</>
) : (
<>
<Maximize2 size={16} />
</>
)}
</div>
</div>
)
}