feat: 修复表格节点显示和SVG导出问题

- 添加html_content字段到Django Node模型,支持dangerouslySetInnerHTML内容持久化
- 修复表格节点无法正常显示的问题,确保HTML内容正确保存到数据库
- 优化SVG导出功能,支持表格和图片的正确渲染
- 改进AI提示词,支持复杂表格结构(rowspan/colspan)
- 增强表格检测逻辑,支持HTML表格和Markdown表格格式
- 修复节点文本居中对齐问题,避免与连线错位
- 更新前端节点创建逻辑,确保HTML内容正确传递到后端
This commit is contained in:
lixinran 2025-10-10 13:36:34 +08:00
parent bbddf200cf
commit ef1b94d959
13 changed files with 623 additions and 441 deletions

Binary file not shown.

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2025-10-10 05:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mindmap', '0003_node_image_fit_node_image_height_node_image_url_and_more'),
]
operations = [
migrations.AddField(
model_name='node',
name='html_content',
field=models.TextField(blank=True, default=''),
),
]

View File

@ -35,6 +35,9 @@ class Node(models.Model):
image_height = models.IntegerField(default=80, null=True, blank=True) # 图片高度 image_height = models.IntegerField(default=80, null=True, blank=True) # 图片高度
image_fit = models.CharField(max_length=20, default='contain', blank=True) # 图片适配方式 image_fit = models.CharField(max_length=20, default='contain', blank=True) # 图片适配方式
# HTML内容字段
html_content = models.TextField(blank=True, default='') # dangerouslySetInnerHTML内容
created_at = models.DateTimeField(auto_now_add=True) # createDate created_at = models.DateTimeField(auto_now_add=True) # createDate
updated_at = models.DateTimeField(auto_now=True) # updateDate updated_at = models.DateTimeField(auto_now=True) # updateDate
deleted = models.BooleanField(default=False) # delete deleted = models.BooleanField(default=False) # delete

View File

@ -10,6 +10,7 @@ class DocNodeSerializer(serializers.Serializer):
depth = serializers.IntegerField() depth = serializers.IntegerField()
title = serializers.CharField(allow_blank=True, required=False) title = serializers.CharField(allow_blank=True, required=False)
des = serializers.CharField(allow_blank=True, required=False) des = serializers.CharField(allow_blank=True, required=False)
htmlContent = serializers.CharField(allow_blank=True, required=False)
createDate = serializers.DateTimeField() createDate = serializers.DateTimeField()
updateDate = serializers.DateTimeField() updateDate = serializers.DateTimeField()
delete = serializers.BooleanField() delete = serializers.BooleanField()
@ -33,6 +34,7 @@ def map_node_to_doc(node: Node) -> dict:
'depth': int(node.depth or 0), 'depth': int(node.depth or 0),
'title': node.title or '', 'title': node.title or '',
'des': (node.desc or ''), 'des': (node.desc or ''),
'htmlContent': node.html_content or '',
'createDate': node.created_at, 'createDate': node.created_at,
'updateDate': node.updated_at, 'updateDate': node.updated_at,
'delete': bool(node.deleted), 'delete': bool(node.deleted),

View File

@ -51,6 +51,12 @@ def convert_to_mindelixir_format(mindmap, nodes):
"mindmap_id": mindmap.id "mindmap_id": mindmap.id
} }
# 添加HTML内容如果存在
if node.html_content:
node_data["dangerouslySetInnerHTML"] = node.html_content
# 如果有HTML内容清空topic以避免冲突
node_data["topic"] = ""
# 添加图片信息 # 添加图片信息
if node.image_url: if node.image_url:
node_data["image"] = { node_data["image"] = {
@ -170,6 +176,8 @@ def create_mindmap(request):
children_count=0, children_count=0,
depth=0, depth=0,
deleted=False, deleted=False,
# 添加HTML内容
html_content=mindmap_data.get('dangerouslySetInnerHTML', ''),
# 添加图片信息 # 添加图片信息
image_url=mindmap_data.get('image', {}).get('url') if mindmap_data.get('image') else None, image_url=mindmap_data.get('image', {}).get('url') if mindmap_data.get('image') else None,
image_width=mindmap_data.get('image', {}).get('width') if mindmap_data.get('image') else None, image_width=mindmap_data.get('image', {}).get('width') if mindmap_data.get('image') else None,
@ -196,6 +204,8 @@ def create_mindmap(request):
children_count=0, children_count=0,
depth=0, depth=0,
deleted=False, deleted=False,
# HTML内容字段默认为空
html_content='',
# 图片字段默认为空 # 图片字段默认为空
image_url=None, image_url=None,
image_width=None, image_width=None,
@ -226,6 +236,8 @@ def create_nodes_recursively(nodes_data, mindmap, parent_id):
children_count=len(node_data.get('children', [])), children_count=len(node_data.get('children', [])),
depth=1, # 可以根据实际层级计算 depth=1, # 可以根据实际层级计算
deleted=False, deleted=False,
# 添加HTML内容
html_content=node_data.get('dangerouslySetInnerHTML', ''),
# 添加图片信息 # 添加图片信息
image_url=node_data.get('image', {}).get('url') if node_data.get('image') else None, image_url=node_data.get('image', {}).get('url') if node_data.get('image') else None,
image_width=node_data.get('image', {}).get('width') if node_data.get('image') else None, image_width=node_data.get('image', {}).get('width') if node_data.get('image') else None,
@ -296,6 +308,8 @@ def add_nodes(request):
children_count=0, # 新节点初始子节点数为0 children_count=0, # 新节点初始子节点数为0
depth=depth, depth=depth,
deleted=False, deleted=False,
# 添加HTML内容
html_content=n.get('dangerouslySetInnerHTML', ''),
) )
# 更新父节点的子节点计数 # 更新父节点的子节点计数

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -23,7 +23,7 @@
flex-direction: column; flex-direction: column;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-d78cd498.js"></script> <script type="module" crossorigin src="/assets/index-90ac6040.js"></script>
<link rel="stylesheet" href="/assets/index-fc0e370b.css"> <link rel="stylesheet" href="/assets/index-fc0e370b.css">
</head> </head>
<body> <body>

View File

@ -307,7 +307,9 @@ const generateMarkdownFromFile = async () => {
7. **重要如果原文档中包含表格请完整保留表格结构 7. **重要如果原文档中包含表格请完整保留表格结构
- 保持表格的Markdown格式 - 保持表格的Markdown格式
- 确保所有表格行都被包含 - 确保所有表格行都被包含
- 不要省略任何表格内容** - 不要省略任何表格内容
- 对于合并单元格的表格请使用HTML格式并正确使用rowspan和colspan属性
- 如果表格结构复杂优先使用HTML table标签而不是Markdown表格语法**
8. **重要确保内容完整性 8. **重要确保内容完整性
- 不要截断任何内容 - 不要截断任何内容
- 保持原文的完整性 - 保持原文的完整性
@ -1251,9 +1253,17 @@ const hasTableContent = (content) => {
return false; return false;
} }
// HTML
const hasHtmlTable = content.includes('<table') || content.includes('<tr') || content.includes('<td');
if (hasHtmlTable) {
console.log('🔍 检测到HTML表格内容');
return true;
}
// Markdown
// | // |
if (content.includes('|')) { if (content.includes('|')) {
console.log('🔍 检查表格内容:', content.substring(0, 200) + '...'); console.log('🔍 检查Markdown表格内容:', content.substring(0, 200) + '...');
} }
const lines = content.split('\n'); const lines = content.split('\n');

View File

@ -2333,11 +2333,18 @@ const createNodesRecursively = async (node, mindmapId, parentId) => {
console.log("开始创建节点:", node.topic, "父节点ID:", parentId); console.log("开始创建节点:", node.topic, "父节点ID:", parentId);
// //
const nodeResponse = await mindmapAPI.addNodes(mindmapId, { const nodeData = {
title: node.topic || node.title || "无标题", title: node.topic || node.title || "无标题",
des: node.data?.des || "", des: node.data?.des || "",
parentId: parentId parentId: parentId
}); };
// HTML
if (node.dangerouslySetInnerHTML) {
nodeData.dangerouslySetInnerHTML = node.dangerouslySetInnerHTML;
}
const nodeResponse = await mindmapAPI.addNodes(mindmapId, nodeData);
console.log("创建节点响应:", nodeResponse); console.log("创建节点响应:", nodeResponse);

View File

@ -29,15 +29,17 @@ function generateSvgText(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: num
} }
const lines = content!.split('\n') const lines = content!.split('\n')
// 计算行高,避免重复计算 // 计算行高和字体大小
const lineHeight = parseFloat(tpcStyle.lineHeight) || parseFloat(tpcStyle.fontSize) * 1.2 const lineHeight = parseFloat(tpcStyle.lineHeight) || parseFloat(tpcStyle.fontSize) * 1.2
const fontSize = parseFloat(tpcStyle.fontSize) const fontSize = parseFloat(tpcStyle.fontSize)
const paddingTop = parseInt(tpcStyle.paddingTop) || 8
const paddingLeft = parseInt(tpcStyle.paddingLeft) || 8
lines.forEach((line, index) => { lines.forEach((line, index) => {
const text = document.createElementNS(ns, 'text') const text = document.createElementNS(ns, 'text')
setAttributes(text, { setAttributes(text, {
x: x + parseInt(tpcStyle.paddingLeft) + '', x: x + paddingLeft + '',
y: y + parseInt(tpcStyle.paddingTop) + fontSize + (lineHeight * index) + '', y: y + paddingTop + fontSize + (lineHeight * index) + '',
'text-anchor': 'start', 'text-anchor': 'start',
'font-family': tpcStyle.fontFamily, 'font-family': tpcStyle.fontFamily,
'font-size': `${tpcStyle.fontSize}`, 'font-size': `${tpcStyle.fontSize}`,
@ -51,10 +53,98 @@ function generateSvgText(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: num
} }
function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: number, y: number) { function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: number, y: number) {
// 直接使用原生SVG文本渲染避免foreignObject的复杂性 const g = document.createElementNS(ns, 'g')
// 检查是否包含HTML内容表格、复杂结构等
const tpcWithText = tpc as Topic
const hasHTMLContent = !!(tpcWithText.text && tpcWithText.text.innerHTML && tpcWithText.text.innerHTML !== tpcWithText.text.textContent)
const hasTableContent = !!(tpcWithText.text && tpcWithText.text.innerHTML && tpcWithText.text.innerHTML.includes('<table'))
if (hasHTMLContent || hasTableContent) {
// 对于HTML内容使用foreignObject来正确渲染
const foreignObject = document.createElementNS(ns, 'foreignObject')
setAttributes(foreignObject, {
x: x + '',
y: y + '',
width: tpcStyle.width,
height: tpcStyle.height,
})
// 创建div容器来包含HTML内容
const div = document.createElement('div')
div.innerHTML = tpcWithText.text.innerHTML
// 应用样式,确保与思维导图显示一致
div.style.cssText = `
width: 100%;
height: 100%;
font-family: ${tpcStyle.fontFamily};
font-size: ${tpcStyle.fontSize};
color: ${tpcStyle.color};
background: ${tpcStyle.backgroundColor};
padding: ${tpcStyle.padding};
box-sizing: border-box;
overflow: visible;
text-align: center;
line-height: 1.4;
`
// 为表格添加样式
const table = div.querySelector('table') as HTMLTableElement
if (table) {
table.style.cssText = `
width: 100%;
border-collapse: collapse;
font-size: ${tpcStyle.fontSize};
font-family: ${tpcStyle.fontFamily};
margin: 0 auto;
`
// 为表格单元格添加样式
const cells = div.querySelectorAll('td, th')
cells.forEach(cell => {
const htmlCell = cell as HTMLElement
htmlCell.style.cssText = `
border: 1px solid #ccc;
padding: 4px 8px;
text-align: center;
vertical-align: top;
font-size: ${parseFloat(tpcStyle.fontSize) * 0.9}px;
`
})
}
// 为其他元素添加样式
const lists = div.querySelectorAll('ul, ol')
lists.forEach(list => {
const htmlList = list as HTMLElement
htmlList.style.cssText = `
margin: 4px 0;
padding-left: 20px;
text-align: left;
`
})
const listItems = div.querySelectorAll('li')
listItems.forEach(item => {
const htmlItem = item as HTMLElement
htmlItem.style.cssText = `
margin: 2px 0;
line-height: 1.3;
`
})
foreignObject.appendChild(div)
g.appendChild(foreignObject)
console.log('✅ 使用foreignObject渲染HTML内容')
} else {
// 对于纯文本内容使用原生SVG文本渲染
return generateSvgText(tpc, tpcStyle, x, y) return generateSvgText(tpc, tpcStyle, x, y)
} }
return g
}
function createElBox(mei: MindElixirInstance, tpc: Topic) { function createElBox(mei: MindElixirInstance, tpc: Topic) {
const tpcStyle = getComputedStyle(tpc) const tpcStyle = getComputedStyle(tpc)
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, tpc) const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, tpc)
@ -260,13 +350,20 @@ const generateSvg = async (mei: MindElixirInstance, noForeignObject = false) =>
mapDiv.querySelectorAll('.hyper-link').forEach(hl => { mapDiv.querySelectorAll('.hyper-link').forEach(hl => {
g.appendChild(convertAToSvg(mei, hl as HTMLAnchorElement)) g.appendChild(convertAToSvg(mei, hl as HTMLAnchorElement))
}) })
// 处理图片元素 // 处理图片元素 - 只处理不在节点内的独立图片
const imgPromises = Array.from(mapDiv.querySelectorAll('img')).map(async (img) => { const imgPromises = Array.from(mapDiv.querySelectorAll('img')).map(async (img) => {
// 检查图片是否在节点内,如果在节点内则跳过(因为节点已经包含了图片和文字)
const isInNode = img.closest('me-tpc')
if (isInNode) {
return null // 跳过节点内的图片,因为节点已经包含了完整内容
}
return await convertImgToSvg(mei, img) return await convertImgToSvg(mei, img)
}) })
const imgElements = await Promise.all(imgPromises) const imgElements = await Promise.all(imgPromises)
imgElements.forEach(imgEl => { imgElements.forEach(imgEl => {
if (imgEl) {
g.appendChild(imgEl) g.appendChild(imgEl)
}
}) })
setAttributes(g, { setAttributes(g, {
x: padding + '', x: padding + '',