Commit 3c28c342 by pangchong

feat(compare): 新增工卡版本对比功能模块

- 添加CompareModal组件,实现工卡XML与排版渲染结果双栏对比
- 集成了自定义滚动条及差异高亮显示,支持增删改标记
- 支持XML文本区与渲染区的展开收起与同步滚动联动
- CommonXmlEditor新增行号选中事件,并支持光标智能补全
- CommonModal新增showHeader属性控制标题栏显示隐藏
- xmlParser新增XML序列化映射,用于节点行号关联
- 优化XML文本内容解析,统一规范化空白字符处理
- 依赖新增overlayscrollbars及vue绑定样式库,用于滚动条美化
- View层新增比对图例与模式切换控件提升用户体验
- 增加排版渲染对比节点高亮及隐藏空白占位标记样式
- 细化滚动条交互状态及最小滑块尺寸常量定义
parent f4b7cfd2
......@@ -25,6 +25,8 @@
"mammoth": "^1.11.0",
"mitt": "^3.0.1",
"naive-ui": "^2.43.2",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-vue": "^0.5.10",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"vfonts": "^0.0.3",
......@@ -3792,6 +3794,22 @@
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/overlayscrollbars": {
"version": "2.16.0",
"resolved": "https://registry.npmmirror.com/overlayscrollbars/-/overlayscrollbars-2.16.0.tgz",
"integrity": "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==",
"license": "MIT"
},
"node_modules/overlayscrollbars-vue": {
"version": "0.5.10",
"resolved": "https://registry.npmmirror.com/overlayscrollbars-vue/-/overlayscrollbars-vue-0.5.10.tgz",
"integrity": "sha512-vu35CJj/kzGBgkxsQyXCCCRRBz/3AvXhkDckuiRHphRE6CwXjbWATY0PVbn2KRy30NbaZhHqueH5yqPo8ItqTA==",
"license": "MIT",
"peerDependencies": {
"overlayscrollbars": "^2.0.0",
"vue": "^3.2.25"
}
},
"node_modules/p-limit": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-4.0.0.tgz",
......
......@@ -33,6 +33,8 @@
"mammoth": "^1.11.0",
"mitt": "^3.0.1",
"naive-ui": "^2.43.2",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-vue": "^0.5.10",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"vfonts": "^0.0.3",
......
......@@ -13,6 +13,7 @@
header-class="!px-[20px] !py-[15px]"
content-class="!p-0"
footer-class="!m-0 !p-0"
:header-style="showHeader ? {} : { display: 'none' }"
>
<template #header-extra>
<slot name="header-extra"></slot>
......@@ -66,6 +67,7 @@ interface Props {
padding?: string | number
scrollable?: boolean
fullscreen?: boolean
showHeader?: boolean
}
const props = withDefaults(defineProps<Props>(), {
......@@ -80,7 +82,8 @@ const props = withDefaults(defineProps<Props>(), {
maxHeight: '75vh',
padding: '15px',
scrollable: true,
fullscreen: false
fullscreen: false,
showHeader: true
})
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
......
......@@ -99,7 +99,7 @@ const customLineNumbers = lineNumbers({
}
})
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits(['update:modelValue', 'line-select'])
const editorStyle = computed(() => {
const styles: Record<string, string> = {}
......@@ -627,21 +627,30 @@ const initCodeMirror = () => {
// 如果光标位置改变,或者编辑器聚焦且选区为空(普通闪烁光标)
if (update.selectionSet || update.focusChanged) {
const mainSelection = update.state.selection.main
if (mainSelection.empty && update.view.hasFocus) {
if (mainSelection.empty) {
const pos = mainSelection.head
const beforeStr = update.state.sliceDoc(Math.max(0, pos - 50), pos)
const afterStr = update.state.sliceDoc(pos, Math.min(update.state.doc.length, pos + 50))
// 检查光标是否正好落在两个引号之间 "" 或 ''
const isBetweenQuotes =
(beforeStr.endsWith('"') && afterStr.startsWith('"')) || (beforeStr.endsWith("'") && afterStr.startsWith("'"))
if (update.selectionSet && update.view.hasFocus) {
try {
const line = update.state.doc.lineAt(pos)
emit('line-select', line.number)
} catch (e) {}
}
if (isBetweenQuotes) {
const matchAttr = beforeStr.match(/([a-zA-Z0-9_-]+)\s*=\s*['"]$/)
if (matchAttr) {
setTimeout(() => {
startCompletion(update.view)
}, 0)
if (update.view.hasFocus) {
const beforeStr = update.state.sliceDoc(Math.max(0, pos - 50), pos)
const afterStr = update.state.sliceDoc(pos, Math.min(update.state.doc.length, pos + 50))
// 检查光标是否正好落在两个引号之间 "" 或 ''
const isBetweenQuotes =
(beforeStr.endsWith('"') && afterStr.startsWith('"')) || (beforeStr.endsWith("'") && afterStr.startsWith("'"))
if (isBetweenQuotes) {
const matchAttr = beforeStr.match(/([a-zA-Z0-9_-]+)\s*=\s*['"]$/)
if (matchAttr) {
setTimeout(() => {
startCompletion(update.view)
}, 0)
}
}
}
}
......@@ -753,6 +762,17 @@ const handleGlobalCtrlF = (event: KeyboardEvent) => {
openSearchPanel(editorView)
}
const highlightAndScrollToLine = (lineNum: number) => {
if (!editorView || !lineNum) return
try {
const line = editorView.state.doc.line(lineNum)
editorView.dispatch({
selection: { anchor: line.from },
scrollIntoView: true
})
} catch (e) {}
}
onMounted(() => {
initCodeMirror()
window.addEventListener('keydown', handleGlobalCtrlF, true)
......@@ -766,6 +786,7 @@ onBeforeUnmount(() => {
})
defineExpose({
handleFormat
handleFormat,
highlightAndScrollToLine
})
</script>
......@@ -74,10 +74,15 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode
let textContentCollector = ''
for (const child of childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent || ''
if (text) {
mixedContent.push({ type: 'text', text })
textContentCollector += text
const rawText = child.textContent || ''
if (rawText) {
// 规范化空白字符:将所有换行符及其周围的连续空白规范化为一个单空格,并 trim 首尾空白
// trim() 消除格式化缩进导致的尾部空格(如 "text\n " → "text"),避免比对时误判差异
const text = rawText.replace(/\s*[\r\n]\s*/g, ' ').trim()
if (text) {
mixedContent.push({ type: 'text', text })
textContentCollector += text
}
}
} else if (child.nodeType === Node.ELEMENT_NODE) {
const childNode = domElementToXmlNode(child as Element, id)
......@@ -94,8 +99,9 @@ function domElementToXmlNode(element: Element, parentId: string | null): XmlNode
}
}
} else {
// 纯文本内容
textContent = element.textContent || ''
// 纯文本内容,同样对换行与缩进做规范化合并,并 trim 首尾空白避免格式化差异
const rawText = element.textContent || ''
textContent = rawText.replace(/\s*[\r\n]\s*/g, ' ').trim()
}
return {
......@@ -151,6 +157,90 @@ export function serializeTreeToXml(node: XmlNode, indent: number = 0, compact: b
return `${pad}<${openTag}>${newline}${childrenXml}${newline}${pad}</${node.tagName}>`
}
interface LineRange {
startLine: number
endLine: number
}
/**
* 将 XmlNode 树序列化回 XML 字符串,并同时计算记录每一个节点在最终序列化纯净文本中的起始和结束行号范围(1-indexed)
*/
export function serializeTreeToXmlWithMapping(
node: XmlNode,
indent: number = 0,
compact: boolean = false,
state = { currentLine: 1 },
mapping = new Map<string, LineRange>()
): { xml: string; mapping: Map<string, LineRange> } {
const pad = compact ? '' : ' '.repeat(indent)
const newline = compact ? '' : '\n'
const attrs = Object.entries(node.attributes)
.map(([k, v]) => `${k}="${escapeXmlAttr(v)}"`)
.join(' ')
const openTag = attrs ? `${node.tagName} ${attrs}` : node.tagName
const startLine = state.currentLine
let xml = ''
// 空元素(无子节点、无文本、无混合内容)
if (node.children.length === 0 && !node.textContent && node.mixedContent.length === 0) {
xml = `${pad}<${openTag}/>`
mapping.set(node.id, { startLine, endLine: startLine })
return { xml, mapping }
}
// 混合内容节点
if (node.mixedContent.length > 0) {
let content = ''
for (const item of node.mixedContent) {
if (item.type === 'text') {
const escapedText = escapeXmlText(item.text || '')
content += escapedText
const newlines = (escapedText.match(/\n/g) || []).length
state.currentLine += newlines
} else if (item.type === 'element' && item.nodeId) {
const child = node.children.find((c) => c.id === item.nodeId)
if (child) {
const res = serializeTreeToXmlWithMapping(child, 0, true, state, mapping)
content += res.xml
}
}
}
xml = `${pad}<${openTag}>${content}</${node.tagName}>`
mapping.set(node.id, { startLine, endLine: state.currentLine })
return { xml, mapping }
}
// 纯文本节点
if (node.children.length === 0 && node.textContent) {
const escapedText = escapeXmlText(node.textContent)
xml = `${pad}<${openTag}>${escapedText}</${node.tagName}>`
const newlines = (escapedText.match(/\n/g) || []).length
state.currentLine += newlines
mapping.set(node.id, { startLine, endLine: state.currentLine })
return { xml, mapping }
}
// 纯元素子节点
state.currentLine++
const childrenParts: string[] = []
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i]
const res = serializeTreeToXmlWithMapping(child, indent + 1, compact, state, mapping)
childrenParts.push(res.xml)
if (i < node.children.length - 1) {
state.currentLine++
}
}
state.currentLine++
const childrenXml = childrenParts.join(newline)
xml = `${pad}<${openTag}>${newline}${childrenXml}${newline}${pad}</${node.tagName}>`
mapping.set(node.id, { startLine, endLine: state.currentLine })
return { xml, mapping }
}
/**
* 格式化 XML 字符串(转换为带换行和缩进的排版形式)
*/
......
......@@ -10,7 +10,7 @@
isSelected ? 'ring-2 ring-primary ring-offset-1 rounded-sm bg-primary/5' : '',
!renderInline && isContainer ? 'py-1 px-1' : ''
]"
:style="!isDiffMode && node.attributes?.MERGED === 'TRUE' ? { backgroundColor: 'yellow' } : {}"
:style="node.attributes?.MERGED === 'TRUE' ? { backgroundColor: 'yellow' } : {}"
@click.stop="editorStore.setSelectedNodeId(node.id)"
>
<!-- 智能翻译加载遮罩 -->
......
// 比对差异状态类型定义
export type DiffStatusType = 'added' | 'removed' | 'modified' | 'none' | 'added-placeholder' | 'removed-placeholder'
// 滚动条交互所使用的侧边类型
export type ScrollSide = 'left' | 'right'
// 自定义横向/纵向滚动条滑块的最小宽度/高度百分比 (%)
export const MIN_THUMB_SIZE_PCT = 8
// 渲染区域比对差异段样式模型
export interface RenderDiffHunk {
type: 'added' | 'removed' | 'modified'
style: Record<string, string>
}
// XML源码比对差异段样式模型
export interface XmlDiffHunk {
type: 'added' | 'removed'
style: Record<string, string>
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment