Compare commits
	
		
			2 Commits
		
	
	
		
			df859762ad
			...
			003dce4a3e
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 003dce4a3e | |||
| c8ffbfefb4 | 
							
								
								
									
										172
									
								
								web/src/apps/muse/base/table/DragSelection.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								web/src/apps/muse/base/table/DragSelection.md
									
									
									
									
									
										Normal 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. 移动端设备可能需要额外的触摸事件处理
 | 
			
		||||
 | 
			
		||||
## 未来规划
 | 
			
		||||
 | 
			
		||||
- [ ] 触摸设备支持
 | 
			
		||||
- [ ] 自定义拖拽选择框样式
 | 
			
		||||
- [ ] 更多键盘快捷键
 | 
			
		||||
- [ ] 拖拽选择动画效果
 | 
			
		||||
- [ ] 选择统计和操作面板
 | 
			
		||||
							
								
								
									
										115
									
								
								web/src/apps/muse/base/table/DragSelectionExample.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								web/src/apps/muse/base/table/DragSelectionExample.tsx
									
									
									
									
									
										Normal 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;
 | 
			
		||||
@@ -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';
 | 
			
		||||
 | 
			
		||||
// 虚拟滚动常量
 | 
			
		||||
@@ -20,6 +20,24 @@ 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';
 | 
			
		||||
@@ -65,17 +83,40 @@ export const Table: React.FC<TableProps> = ({
 | 
			
		||||
    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 recordIndex = displayData.findIndex(item => item.id === record.id);
 | 
			
		||||
    
 | 
			
		||||
    // 处理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,18 +124,285 @@ 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>
 | 
			
		||||
@@ -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} 项
 | 
			
		||||
            {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);
 | 
			
		||||
@@ -191,11 +510,9 @@ export const Table: React.FC<TableProps> = ({
 | 
			
		||||
                      className="sort-indicators"
 | 
			
		||||
                      onClick={() => handleSort(column.dataIndex)}
 | 
			
		||||
                    >
 | 
			
		||||
                      <span className={`sort-arrow sort-up ${
 | 
			
		||||
                        sortState.field === column.dataIndex && sortState.order === 'asc' ? 'active' : ''
 | 
			
		||||
                      <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 className={`sort-arrow sort-down ${sortState.field === column.dataIndex && sortState.order === 'desc' ? 'active' : ''
 | 
			
		||||
                        }`}>▼</span>
 | 
			
		||||
                    </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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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={{
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* 操作列 */
 | 
			
		||||
 
 | 
			
		||||
@@ -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; // 拖拽选择配置
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 虚拟滚动配置
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										179
									
								
								web/src/apps/muse/components/MarkDetail.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								web/src/apps/muse/components/MarkDetail.css
									
									
									
									
									
										Normal 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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										104
									
								
								web/src/apps/muse/components/MarkDetal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								web/src/apps/muse/components/MarkDetal.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user