feat(components): 添加支持合并单元格的高级表格组件

新增 MergeTable 组件,支持跨行跨列合并单元格功能,类似 Typst 的 tablex 功能。组件提供多种样式配置选项,包括斑马纹、悬停效果和紧凑模式等。

同时更新 package.json 添加测试相关依赖,并清理 mdx-components.ts 中未使用的组件导入。
This commit is contained in:
Jiao77
2026-03-01 22:43:46 +08:00
parent 2de1869fd4
commit c8cb5cbdf4
5 changed files with 1515 additions and 17 deletions

1125
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View 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,
};
}

View File

@@ -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 公式
- 📊 **高级表格**:支持合并单元格的复杂表格
快去尝试创建属于你自己的动效组件吧! 🚀

View File

@@ -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,
};