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",
|
"remark-math": "^6.0.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"vue": "^3.5.29"
|
"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 FlipCard from '../../components/react/FlipCard';
|
||||||
import ParticleBackground from '../../components/react/ParticleBackground';
|
import ParticleBackground from '../../components/react/ParticleBackground';
|
||||||
import MathFlipCard from '../../components/react/MathFlipCard';
|
import MathFlipCard from '../../components/react/MathFlipCard';
|
||||||
|
import MergeTable, { Cell } from '../../components/react/MergeTable';
|
||||||
|
|
||||||
# React 动效组件展示
|
# React 动效组件展示
|
||||||
|
|
||||||
@@ -182,6 +183,169 @@ import MathFlipCard from '../../components/react/MathFlipCard';
|
|||||||
- 支持 KaTeX 的所有语法
|
- 支持 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. 导入组件
|
### 1. 导入组件
|
||||||
@@ -257,5 +421,6 @@ NovaBlog 提供了灵活的组件系统,让你可以在 Markdown 中嵌入丰
|
|||||||
- ✨ **背景特效**:粒子、波浪、光效
|
- ✨ **背景特效**:粒子、波浪、光效
|
||||||
- 🎮 **交互功能**:计数器、表单、游戏
|
- 🎮 **交互功能**:计数器、表单、游戏
|
||||||
- 📐 **数学公式**:翻转卡片展示 LaTeX 公式
|
- 📐 **数学公式**:翻转卡片展示 LaTeX 公式
|
||||||
|
- 📊 **高级表格**:支持合并单元格的复杂表格
|
||||||
|
|
||||||
快去尝试创建属于你自己的动效组件吧! 🚀
|
快去尝试创建属于你自己的动效组件吧! 🚀
|
||||||
@@ -1,20 +1,5 @@
|
|||||||
import Counter from './components/Counter.vue';
|
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 = {
|
export const components = {
|
||||||
// Vue 交互组件
|
|
||||||
Counter,
|
Counter,
|
||||||
|
|
||||||
// Astro 静态组件
|
|
||||||
TypstBlock,
|
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user