feat: 修复表格节点显示和SVG导出问题
- 添加html_content字段到Django Node模型,支持dangerouslySetInnerHTML内容持久化 - 修复表格节点无法正常显示的问题,确保HTML内容正确保存到数据库 - 优化SVG导出功能,支持表格和图片的正确渲染 - 改进AI提示词,支持复杂表格结构(rowspan/colspan) - 增强表格检测逻辑,支持HTML表格和Markdown表格格式 - 修复节点文本居中对齐问题,避免与连线错位 - 更新前端节点创建逻辑,确保HTML内容正确传递到后端
This commit is contained in:
parent
bbddf200cf
commit
ef1b94d959
Binary file not shown.
|
|
@ -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=''),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,8 +53,96 @@ 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')
|
||||||
return generateSvgText(tpc, tpcStyle, x, y)
|
|
||||||
|
// 检查是否包含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 g
|
||||||
}
|
}
|
||||||
|
|
||||||
function createElBox(mei: MindElixirInstance, tpc: Topic) {
|
function createElBox(mei: MindElixirInstance, tpc: Topic) {
|
||||||
|
|
@ -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 => {
|
||||||
g.appendChild(imgEl)
|
if (imgEl) {
|
||||||
|
g.appendChild(imgEl)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
setAttributes(g, {
|
setAttributes(g, {
|
||||||
x: padding + '',
|
x: padding + '',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue