update
This commit is contained in:
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 { AutoSizer, List } from 'react-virtualized';
|
||||||
import { Mark } from '../mock/collection';
|
import { Mark } from '../mock/collection';
|
||||||
import { TableProps, SortState } from './types';
|
import { TableProps, SortState, DragSelectionState } from './types';
|
||||||
import './table.css';
|
import './table.css';
|
||||||
|
|
||||||
// 虚拟滚动常量
|
// 虚拟滚动常量
|
||||||
@@ -20,6 +20,24 @@ export const Table: React.FC<TableProps> = ({
|
|||||||
const rowHeight = virtualScroll?.rowHeight || DEFAULT_ROW_HEIGHT;
|
const rowHeight = virtualScroll?.rowHeight || DEFAULT_ROW_HEIGHT;
|
||||||
const [sortState, setSortState] = useState<SortState>({ field: null, order: null });
|
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) => {
|
const handleSort = (field: string) => {
|
||||||
let newOrder: 'asc' | 'desc' | null = 'asc';
|
let newOrder: 'asc' | 'desc' | null = 'asc';
|
||||||
@@ -65,17 +83,40 @@ export const Table: React.FC<TableProps> = ({
|
|||||||
rowSelection.onChange?.(selectedKeys, selectedRows);
|
rowSelection.onChange?.(selectedKeys, selectedRows);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 单行选择
|
// 上次点击的行索引(用于Shift范围选择)
|
||||||
const handleRowSelect = (record: Mark, checked: boolean) => {
|
const lastClickedRowRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// 单行选择 - 支持Shift范围选择
|
||||||
|
const handleRowSelect = (record: Mark, checked: boolean, event?: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!rowSelection) return;
|
if (!rowSelection) return;
|
||||||
|
|
||||||
const currentKeys = rowSelection.selectedRowKeys || [];
|
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
|
const newKeys = checked
|
||||||
? [...currentKeys, record.id]
|
? [...currentKeys, record.id]
|
||||||
: currentKeys.filter(key => key !== record.id);
|
: currentKeys.filter(key => key !== record.id);
|
||||||
|
|
||||||
const selectedRows = data.filter(item => newKeys.includes(item.id));
|
const selectedRows = data.filter(item => newKeys.includes(item.id));
|
||||||
rowSelection.onChange?.(newKeys, selectedRows);
|
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);
|
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 rowRenderer = ({ index, key, style }: any) => {
|
||||||
const record = displayData[index];
|
const record = displayData[index];
|
||||||
|
const isSelected = selectedKeys.includes(record.id);
|
||||||
|
const isDragSelected = dragState.selectedDuringDrag.has(record.id);
|
||||||
|
const isHighlighted = isSelected || isDragSelected;
|
||||||
|
|
||||||
return (
|
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 && (
|
{rowSelection && (
|
||||||
<div className="selection-column">
|
<div className="selection-column">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedKeys.includes(record.id)}
|
checked={isSelected}
|
||||||
onChange={(e) => handleRowSelect(record, e.target.checked)}
|
onChange={(e) => handleRowSelect(record, e.target.checked, e)}
|
||||||
disabled={rowSelection.getCheckboxProps?.(record)?.disabled}
|
disabled={rowSelection.getCheckboxProps?.(record)?.disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,10 +455,21 @@ export const Table: React.FC<TableProps> = ({
|
|||||||
{/* 表格工具栏 */}
|
{/* 表格工具栏 */}
|
||||||
{rowSelection && selectedKeys.length > 0 && (
|
{rowSelection && selectedKeys.length > 0 && (
|
||||||
<div className="table-toolbar">
|
<div className="table-toolbar">
|
||||||
<span className="selected-info">
|
<div className="selected-info">
|
||||||
已选择 {selectedKeys.length} 项
|
已选择 {selectedKeys.length} 项
|
||||||
|
{dragSelectionEnabled && (
|
||||||
|
<span className="drag-hint">
|
||||||
|
(支持拖拽多选,按住Ctrl/Cmd键可追加选择,按住Shift键可范围选择)
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="bulk-actions">
|
<div className="bulk-actions">
|
||||||
|
<button className="btn btn-secondary" onClick={() => {
|
||||||
|
// 取消选中所有项
|
||||||
|
handleSelectAll(false);
|
||||||
|
}}>
|
||||||
|
取消选中
|
||||||
|
</button>
|
||||||
<button className="btn btn-danger" onClick={() => {
|
<button className="btn btn-danger" onClick={() => {
|
||||||
// 批量删除逻辑
|
// 批量删除逻辑
|
||||||
console.log('批量删除:', selectedKeys);
|
console.log('批量删除:', selectedKeys);
|
||||||
@@ -207,11 +526,17 @@ export const Table: React.FC<TableProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 虚拟滚动内容区域 */}
|
{/* 虚拟滚动内容区域 */}
|
||||||
<div className="table-body-wrapper">
|
<div
|
||||||
|
className="table-body-wrapper"
|
||||||
|
ref={tableBodyRef}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
style={{ cursor: dragState.isDragging ? 'crosshair' : 'default' }}
|
||||||
|
>
|
||||||
{displayData.length > 0 ? (
|
{displayData.length > 0 ? (
|
||||||
<AutoSizer>
|
<AutoSizer>
|
||||||
{({ height, width }) => (
|
{({ height, width }) => (
|
||||||
<List
|
<List
|
||||||
|
ref={listRef}
|
||||||
height={height}
|
height={height}
|
||||||
width={width}
|
width={width}
|
||||||
rowCount={displayData.length}
|
rowCount={displayData.length}
|
||||||
@@ -226,6 +551,22 @@ export const Table: React.FC<TableProps> = ({
|
|||||||
<p>暂无数据</p>
|
<p>暂无数据</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -235,29 +235,6 @@ export const Base = (props: Props) => {
|
|||||||
支持多选、排序、虚拟滚动等功能的数据表格示例
|
支持多选、排序、虚拟滚动等功能的数据表格示例
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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={{
|
<div style={{
|
||||||
|
|||||||
@@ -27,6 +27,15 @@
|
|||||||
.selected-info {
|
.selected-info {
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulk-actions {
|
.bulk-actions {
|
||||||
@@ -85,6 +94,30 @@
|
|||||||
position: relative; /* 为AutoSizer提供相对定位上下文 */
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 虚拟行样式 */
|
/* 虚拟行样式 */
|
||||||
.virtual-row {
|
.virtual-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -92,12 +125,30 @@
|
|||||||
border-bottom: 1px solid #e8e8e8;
|
border-bottom: 1px solid #e8e8e8;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.virtual-row:hover {
|
.virtual-row:hover {
|
||||||
background: #f5f5f5;
|
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 {
|
.table-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -150,10 +201,38 @@
|
|||||||
.selection-column {
|
.selection-column {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-column input[type="checkbox"] {
|
.selection-column input[type="checkbox"] {
|
||||||
cursor: pointer;
|
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';
|
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> {
|
export interface RowSelection<T = any> {
|
||||||
type?: 'checkbox' | 'radio';
|
type?: 'checkbox' | 'radio';
|
||||||
selectedRowKeys?: React.Key[];
|
selectedRowKeys?: React.Key[];
|
||||||
onChange?: (selectedRowKeys: React.Key[], selectedRows: T[]) => void;
|
onChange?: (selectedRowKeys: React.Key[], selectedRows: T[]) => void;
|
||||||
getCheckboxProps?: (record: T) => { disabled?: boolean };
|
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