feat(components): 添加支持合并单元格的高级表格组件
新增 MergeTable 组件,支持跨行跨列合并单元格功能,类似 Typst 的 tablex 功能。组件提供多种样式配置选项,包括斑马纹、悬停效果和紧凑模式等。 同时更新 package.json 添加测试相关依赖,并清理 mdx-components.ts 中未使用的组件导入。
This commit is contained in:
1125
package-lock.json
generated
1125
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,5 +23,13 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vue": "^3.5.29"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"jsdom": "^28.1.0",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
219
src/components/react/MergeTable.tsx
Normal file
219
src/components/react/MergeTable.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface CellConfig {
|
||||
content: React.ReactNode;
|
||||
colspan?: number;
|
||||
rowspan?: number;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
header?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
type CellInput = React.ReactNode | CellConfig;
|
||||
|
||||
interface MergeTableProps {
|
||||
data?: CellInput[][];
|
||||
columns?: (React.ReactNode | CellConfig)[];
|
||||
rows?: CellInput[][];
|
||||
headerRows?: number;
|
||||
bordered?: boolean;
|
||||
striped?: boolean;
|
||||
hoverable?: boolean;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
function isCellConfig(cell: CellInput): cell is CellConfig {
|
||||
return typeof cell === 'object' && cell !== null && 'content' in cell;
|
||||
}
|
||||
|
||||
function normalizeCell(cell: CellInput): CellConfig {
|
||||
if (isCellConfig(cell)) {
|
||||
return cell;
|
||||
}
|
||||
return { content: cell };
|
||||
}
|
||||
|
||||
export default function MergeTable({
|
||||
data,
|
||||
columns,
|
||||
rows,
|
||||
headerRows = 0,
|
||||
bordered = true,
|
||||
striped = false,
|
||||
hoverable = true,
|
||||
compact = false,
|
||||
className = '',
|
||||
style = {},
|
||||
}: MergeTableProps) {
|
||||
const tableData = useMemo(() => {
|
||||
if (data) return data;
|
||||
const result: CellInput[][] = [];
|
||||
if (columns) {
|
||||
result.push(columns);
|
||||
}
|
||||
if (rows) {
|
||||
result.push(...rows);
|
||||
}
|
||||
return result;
|
||||
}, [data, columns, rows]);
|
||||
|
||||
const { grid, colCount } = useMemo(() => {
|
||||
if (tableData.length === 0) return { grid: [], colCount: 0 };
|
||||
|
||||
let maxCols = 0;
|
||||
tableData.forEach(row => {
|
||||
let cols = 0;
|
||||
row.forEach(cell => {
|
||||
const config = normalizeCell(cell);
|
||||
cols += config.colspan || 1;
|
||||
});
|
||||
maxCols = Math.max(maxCols, cols);
|
||||
});
|
||||
|
||||
const occupied: boolean[][] = tableData.map(() => Array(maxCols).fill(false));
|
||||
const grid: { cell: CellConfig; rowSpan: number; colSpan: number }[][] = [];
|
||||
|
||||
tableData.forEach((row, rowIndex) => {
|
||||
const gridRow: { cell: CellConfig; rowSpan: number; colSpan: number }[] = [];
|
||||
let colIndex = 0;
|
||||
|
||||
row.forEach(cell => {
|
||||
while (colIndex < maxCols && occupied[rowIndex][colIndex]) {
|
||||
gridRow.push({ cell: { content: null }, rowSpan: 0, colSpan: 0 });
|
||||
colIndex++;
|
||||
}
|
||||
|
||||
const config = normalizeCell(cell);
|
||||
const rowSpan = config.rowspan || 1;
|
||||
const colSpan = config.colspan || 1;
|
||||
|
||||
for (let r = 0; r < rowSpan; r++) {
|
||||
for (let c = 0; c < colSpan; c++) {
|
||||
if (rowIndex + r < occupied.length && colIndex + c < maxCols) {
|
||||
occupied[rowIndex + r][colIndex + c] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gridRow.push({ cell: config, rowSpan, colSpan });
|
||||
colIndex += colSpan;
|
||||
});
|
||||
|
||||
while (colIndex < maxCols) {
|
||||
if (!occupied[rowIndex][colIndex]) {
|
||||
gridRow.push({ cell: { content: null }, rowSpan: 0, colSpan: 0 });
|
||||
}
|
||||
colIndex++;
|
||||
}
|
||||
|
||||
grid.push(gridRow);
|
||||
});
|
||||
|
||||
return { grid, colCount: maxCols };
|
||||
}, [tableData]);
|
||||
|
||||
if (grid.length === 0) return null;
|
||||
|
||||
const baseStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: compact ? '0.875rem' : '1rem',
|
||||
...style,
|
||||
};
|
||||
|
||||
const getCellStyle = (cell: CellConfig, rowIndex: number): React.CSSProperties => {
|
||||
const isHeader = cell.header || rowIndex < headerRows;
|
||||
return {
|
||||
padding: compact ? '0.5rem 0.75rem' : '0.75rem 1rem',
|
||||
textAlign: cell.align || (isHeader ? 'center' : 'left'),
|
||||
fontWeight: isHeader ? 600 : 400,
|
||||
backgroundColor: cell.style?.backgroundColor,
|
||||
borderBottom: bordered ? '1px solid #e5e7eb' : undefined,
|
||||
borderRight: bordered ? '1px solid #e5e7eb' : undefined,
|
||||
borderLeft: bordered && rowIndex === 0 ? '1px solid #e5e7eb' : undefined,
|
||||
...cell.style,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`merge-table-wrapper ${className}`}
|
||||
style={{
|
||||
overflowX: 'auto',
|
||||
margin: '1rem 0',
|
||||
border: bordered ? '1px solid #e5e7eb' : undefined,
|
||||
borderRadius: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<table style={baseStyle}>
|
||||
<tbody>
|
||||
{grid.map((row, rowIndex) => {
|
||||
const isStriped = striped && rowIndex >= headerRows && (rowIndex - headerRows) % 2 === 1;
|
||||
return (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
style={{
|
||||
backgroundColor: isStriped ? '#f9fafb' : undefined,
|
||||
}}
|
||||
className={hoverable && rowIndex >= headerRows ? 'hover:bg-gray-50 transition-colors' : ''}
|
||||
>
|
||||
{row.map((cellData, colIndex) => {
|
||||
if (cellData.rowSpan === 0 || cellData.colSpan === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { cell, rowSpan, colSpan } = cellData;
|
||||
const isHeader = cell.header || rowIndex < headerRows;
|
||||
const CellTag = isHeader ? 'th' : 'td';
|
||||
|
||||
return (
|
||||
<CellTag
|
||||
key={colIndex}
|
||||
rowSpan={rowSpan > 1 ? rowSpan : undefined}
|
||||
colSpan={colSpan > 1 ? colSpan : undefined}
|
||||
style={getCellStyle(cell, rowIndex)}
|
||||
className={cell.className}
|
||||
>
|
||||
{cell.content}
|
||||
</CellTag>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Cell({
|
||||
children,
|
||||
colspan,
|
||||
rowspan,
|
||||
align,
|
||||
header,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
colspan?: number;
|
||||
rowspan?: number;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
header?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}): CellConfig {
|
||||
return {
|
||||
content: children,
|
||||
colspan,
|
||||
rowspan,
|
||||
align,
|
||||
header,
|
||||
className,
|
||||
style,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import TypewriterText from '../../components/react/TypewriterText';
|
||||
import FlipCard from '../../components/react/FlipCard';
|
||||
import ParticleBackground from '../../components/react/ParticleBackground';
|
||||
import MathFlipCard from '../../components/react/MathFlipCard';
|
||||
import MergeTable, { Cell } from '../../components/react/MergeTable';
|
||||
|
||||
# React 动效组件展示
|
||||
|
||||
@@ -182,6 +183,169 @@ import MathFlipCard from '../../components/react/MathFlipCard';
|
||||
- 支持 KaTeX 的所有语法
|
||||
- 适合教学和技术文档
|
||||
|
||||
## 📊 合并单元格表格 (MergeTable)
|
||||
|
||||
支持跨行跨列合并的高级表格组件,类似 Typst 的 tablex 功能。
|
||||
|
||||
### 基础示例:课程表
|
||||
|
||||
<MergeTable
|
||||
headerRows={1}
|
||||
client:load
|
||||
data={[
|
||||
[
|
||||
Cell({ children: '时间/星期', header: true, rowspan: 2, align: 'center' }),
|
||||
Cell({ children: '星期一', header: true, colspan: 2, align: 'center' }),
|
||||
Cell({ children: '星期二', header: true, colspan: 2, align: 'center' }),
|
||||
Cell({ children: '星期三', header: true, colspan: 2, align: 'center' }),
|
||||
],
|
||||
[
|
||||
Cell({ children: '上午', header: true }),
|
||||
Cell({ children: '下午', header: true }),
|
||||
Cell({ children: '上午', header: true }),
|
||||
Cell({ children: '下午', header: true }),
|
||||
Cell({ children: '上午', header: true }),
|
||||
Cell({ children: '下午', header: true }),
|
||||
],
|
||||
['第1节', '数学', '语文', '英语', '物理', '化学'],
|
||||
['第2节', '语文', '数学', '物理', '英语', '生物'],
|
||||
[
|
||||
Cell({ children: '午休', rowspan: 2, align: 'center'}),
|
||||
Cell({ children: '午休', rowspan: 2, align: 'center'}),
|
||||
Cell({ children: '午休', rowspan: 2, align: 'center'}),
|
||||
Cell({ children: '午休', rowspan: 2, align: 'center'}),
|
||||
Cell({ children: '午休', rowspan: 2, align: 'center'}),
|
||||
Cell({ children: '午休', rowspan: 2, align: 'center'}),
|
||||
],
|
||||
[],
|
||||
['第3节', '体育', '音乐', '美术', '历史', '地理'],
|
||||
['第4节', '自习', '自习', '自习', '自习', '自习'],
|
||||
]}
|
||||
/>
|
||||
|
||||
### 复杂合并示例:项目进度表
|
||||
|
||||
<MergeTable
|
||||
headerRows={2}
|
||||
striped
|
||||
client:load
|
||||
data={[
|
||||
[
|
||||
Cell({ children: '项目阶段', header: true, rowspan: 2, align: 'center' }),
|
||||
Cell({ children: '任务详情', header: true, colspan: 3, align: 'center' }),
|
||||
Cell({ children: '状态', header: true, rowspan: 2, align: 'center' }),
|
||||
],
|
||||
[
|
||||
Cell({ children: '任务名称', header: true }),
|
||||
Cell({ children: '负责人', header: true }),
|
||||
Cell({ children: '截止日期', header: true }),
|
||||
],
|
||||
[
|
||||
Cell({ children: '需求分析', rowspan: 2, align: 'center'}),
|
||||
'用户调研',
|
||||
'张三',
|
||||
'2024-02-01',
|
||||
Cell({ children: '✅ 已完成'}),
|
||||
],
|
||||
[
|
||||
'需求文档',
|
||||
'李四',
|
||||
'2024-02-15',
|
||||
Cell({ children: '✅ 已完成'}),
|
||||
],
|
||||
[
|
||||
Cell({ children: '开发阶段', rowspan: 3, align: 'center'}),
|
||||
'前端开发',
|
||||
'王五',
|
||||
'2024-03-01',
|
||||
Cell({ children: '🔄 进行中'}),
|
||||
],
|
||||
[
|
||||
'后端开发',
|
||||
'赵六',
|
||||
'2024-03-15',
|
||||
Cell({ children: '🔄 进行中'}),
|
||||
],
|
||||
[
|
||||
'数据库设计',
|
||||
'钱七',
|
||||
'2024-02-28',
|
||||
Cell({ children: '✅ 已完成'}),
|
||||
],
|
||||
[
|
||||
Cell({ children: '测试上线', rowspan: 2, align: 'center'}),
|
||||
'功能测试',
|
||||
'孙八',
|
||||
'2024-04-01',
|
||||
Cell({ children: '⏳ 待开始'}),
|
||||
],
|
||||
[
|
||||
'部署上线',
|
||||
'周九',
|
||||
'2024-04-15',
|
||||
Cell({ children: '⏳ 待开始'}),
|
||||
],
|
||||
]}
|
||||
/>
|
||||
|
||||
### 简洁语法示例
|
||||
|
||||
你也可以使用更简洁的语法,直接传入二维数组:
|
||||
|
||||
<MergeTable
|
||||
headerRows={1}
|
||||
client:load
|
||||
data={[
|
||||
['姓名', '年龄', '城市', '职业'],
|
||||
['张三', '28', '北京', '工程师'],
|
||||
['李四', '32', '上海', '设计师'],
|
||||
['王五', '25', '广州', '产品经理'],
|
||||
]}
|
||||
/>
|
||||
|
||||
### 使用方式
|
||||
|
||||
```tsx
|
||||
import MergeTable, { Cell } from '../../components/react/MergeTable';
|
||||
|
||||
// 使用 Cell 函数定义合并单元格
|
||||
<MergeTable
|
||||
headerRows={1} // 前几行作为表头
|
||||
bordered={true} // 显示边框(默认 true)
|
||||
striped={false} // 斑马纹(默认 false)
|
||||
hoverable={true} // 悬停效果(默认 true)
|
||||
compact={false} // 紧凑模式(默认 false)
|
||||
client:load
|
||||
data={[
|
||||
[
|
||||
Cell({ children: '标题', header: true, colspan: 2 }),
|
||||
Cell({ children: '操作', header: true }),
|
||||
],
|
||||
[
|
||||
Cell({ children: '合并内容', colspan: 2 }),
|
||||
'编辑',
|
||||
],
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
**Cell 函数属性**:
|
||||
- `children`:单元格内容
|
||||
- `colspan`:跨列数
|
||||
- `rowspan`:跨行数
|
||||
- `align`:对齐方式(`'left' | 'center' | 'right'`)
|
||||
- `header`:是否为表头单元格(使用 `<th>` 标签)
|
||||
- `style`:自定义样式
|
||||
- `className`:自定义 CSS 类名
|
||||
|
||||
**MergeTable 属性**:
|
||||
- `data`:表格数据(二维数组)
|
||||
- `headerRows`:表头行数(这些行会使用 `<th>` 标签)
|
||||
- `bordered`:是否显示边框
|
||||
- `striped`:是否显示斑马纹
|
||||
- `hoverable`:是否启用悬停效果
|
||||
- `compact`:紧凑模式(更小的内边距)
|
||||
|
||||
## 📝 如何在文章中使用
|
||||
|
||||
### 1. 导入组件
|
||||
@@ -257,5 +421,6 @@ NovaBlog 提供了灵活的组件系统,让你可以在 Markdown 中嵌入丰
|
||||
- ✨ **背景特效**:粒子、波浪、光效
|
||||
- 🎮 **交互功能**:计数器、表单、游戏
|
||||
- 📐 **数学公式**:翻转卡片展示 LaTeX 公式
|
||||
- 📊 **高级表格**:支持合并单元格的复杂表格
|
||||
|
||||
快去尝试创建属于你自己的动效组件吧! 🚀
|
||||
@@ -1,20 +1,5 @@
|
||||
import Counter from './components/Counter.vue';
|
||||
import TypstBlock from './components/TypstBlock.astro';
|
||||
|
||||
/**
|
||||
* MDX 组件映射
|
||||
*
|
||||
* 在 MDX 文件中可以直接使用这些组件,无需 import。
|
||||
* 例如:
|
||||
* ```mdx
|
||||
* <Counter client:visible />
|
||||
* <TypstBlock>$ E = mc^2 $</TypstBlock>
|
||||
* ```
|
||||
*/
|
||||
export const components = {
|
||||
// Vue 交互组件
|
||||
Counter,
|
||||
|
||||
// Astro 静态组件
|
||||
TypstBlock,
|
||||
};
|
||||
Reference in New Issue
Block a user