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';
|
||||
|
||||
// 虚拟滚动常量
|
||||
@@ -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>
|
||||
|
||||
@@ -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