Commit f4b7cfd2 by pangchong

feat: 重新写工卡对比

parent a65ea933
export interface DiffMixedContentItem {
type: 'text' | 'element'
text?: string
nodeId?: string
diffStatus?: 'added' | 'removed' | 'modified' | 'none'
diffWords?: { value: string; added?: boolean; removed?: boolean }[]
}
export interface DiffXmlNode {
id: string
tagName: string
attributes: Record<string, string>
children: DiffXmlNode[]
textContent: string
mixedContent: DiffMixedContentItem[]
parentId: string | null
diffStatus: 'added' | 'removed' | 'modified' | 'none'
}
export interface DiffLine {
type: 'added' | 'removed' | 'normal' | 'empty'
num?: number
content: string
}
import { type DiffXmlNode, type DiffMixedContentItem, type DiffLine } from '../constants'
import type { XmlNode } from '@/types/xmlNode'
import * as Diff from 'diff'
import { useEditorStore } from '@/store/editor'
import { serializeTreeToXml, formatXmlText } from '@/utils/xmlParser'
import { openUploadModal } from '@/utils/render'
import { EFFECT_METADATA_TAGS } from '@/configs/xmlTags'
export function useCompareModal() {
const editorStore = useEditorStore()
const showModal = ref(false)
const loading = ref(false)
const oldXmlText = ref('')
const newXmlText = ref('')
const oldDiffTree = ref<DiffXmlNode | null>(null)
const newDiffTree = ref<DiffXmlNode | null>(null)
// 支持 XML 文本差异和编辑状态控制
const isEditingOld = ref(true)
const isEditingNew = ref(true)
const oldDiffLines = ref<DiffLine[]>([])
const newDiffLines = ref<DiffLine[]>([])
const editingOldRange = ref<{ start: number; end: number } | null>(null)
const editingNewRange = ref<{ start: number; end: number } | null>(null)
const editingOldRangeText = ref('')
const editingNewRangeText = ref('')
const updateTextDiff = () => {
const { leftLines, rightLines } = generateDoubleFolderDiff(oldXmlText.value, newXmlText.value)
oldDiffLines.value = leftLines
newDiffLines.value = rightLines
}
// 滚动条同步逻辑(XML文本 + 工卡渲染,四向强联同步)
const leftScrollContainer = ref<HTMLElement | null>(null)
const rightScrollContainer = ref<HTMLElement | null>(null)
const leftTextScrollContainer = ref<HTMLElement | null>(null)
const rightTextScrollContainer = ref<HTMLElement | null>(null)
let scrollSource: HTMLElement | null = null
let clearSourceTimer: any = null
const handleScroll = (source: HTMLElement, targets: (HTMLElement | null)[]) => {
if (scrollSource && scrollSource !== source) return
scrollSource = source
if (clearSourceTimer) clearTimeout(clearSourceTimer)
const ratio = source.scrollHeight - source.clientHeight > 0 ? source.scrollTop / (source.scrollHeight - source.clientHeight) : 0
for (const target of targets) {
if (target && target !== source) {
const targetMax = target.scrollHeight - target.clientHeight
target.scrollTop = Math.round(ratio * targetMax)
}
}
clearSourceTimer = setTimeout(() => {
scrollSource = null
}, 80)
}
const handleLeftScroll = () => {
if (!leftScrollContainer.value) return
handleScroll(leftScrollContainer.value, [rightScrollContainer.value])
}
const handleRightScroll = () => {
if (!rightScrollContainer.value) return
handleScroll(rightScrollContainer.value, [leftScrollContainer.value])
}
const handleLeftTextScroll = () => {
if (!leftTextScrollContainer.value) return
handleScroll(leftTextScrollContainer.value, [rightTextScrollContainer.value])
}
const handleRightTextScroll = () => {
if (!rightTextScrollContainer.value) return
handleScroll(rightTextScrollContainer.value, [leftTextScrollContainer.value])
}
// 载入当前编辑器 XML
const loadCurrentXml = () => {
if (!editorStore.xmlTree) {
window.$message.warning('当前编辑器内无正在编辑的工卡')
return
}
oldXmlText.value = serializeTreeToXml(editorStore.xmlTree, 0, false)
window.$message.success('已自动载入当前正在编辑的工卡 XML')
updateTextDiff()
}
// 重新开始对比
const resetDiff = () => {
oldDiffTree.value = null
newDiffTree.value = null
editingOldRange.value = null
editingNewRange.value = null
}
const open = (currentXml?: string) => {
showModal.value = true
loading.value = false
oldXmlText.value = currentXml || ''
newXmlText.value = ''
oldDiffTree.value = null
newDiffTree.value = null
isEditingOld.value = true
isEditingNew.value = true
oldDiffLines.value = []
newDiffLines.value = []
editingOldRange.value = null
editingNewRange.value = null
// 自动拉取当前编辑器内容填充为旧版本
if (editorStore.xmlTree) {
oldXmlText.value = serializeTreeToXml(editorStore.xmlTree, 0, false)
}
updateTextDiff()
}
const handleCompare = async (showWarning = false) => {
if (!oldXmlText.value.trim() || !newXmlText.value.trim()) {
if (showWarning) {
window.$message.warning('请确保两份工卡的 XML 内容都不为空')
}
return
}
loading.value = true
try {
// 解析 XML 树
const oldTree = parseXmlToTree(oldXmlText.value)
const newTree = parseXmlToTree(newXmlText.value)
// 比对并生成左右两侧差异树
oldDiffTree.value = buildOldDiffTree(oldTree, newTree)
newDiffTree.value = buildNewDiffTree(oldTree, newTree)
window.$message.success('对比成功,已生成差异视图')
} catch (err: any) {
window.$message.error('XML 解析或对比失败: ' + err.message)
oldDiffTree.value = null
newDiffTree.value = null
} finally {
loading.value = false
}
}
const handleXmlImportClick = () => {
openUploadModal({
title: '导入目标工卡 XML',
accept: '.xml',
uploadFunc: (file: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
resolve(e.target?.result as string)
}
reader.onerror = (err) => reject(err)
reader.readAsText(file)
})
},
onSuccess: (res: string) => {
newXmlText.value = formatXmlText(res)
updateTextDiff()
}
})
}
const leftParentContainer = ref<HTMLElement | null>(null)
const rightParentContainer = ref<HTMLElement | null>(null)
const editingOldLineNum = ref<number | null>(null)
const editingNewLineNum = ref<number | null>(null)
const editingOldText = ref('')
const editingNewText = ref('')
// 监听切换编辑模式时的滚动条定位还原,防止滚回顶部
watch(isEditingOld, (newVal) => {
if (newVal) {
const scrollVal = leftTextScrollContainer.value ? leftTextScrollContainer.value.scrollTop : 0
nextTick(() => {
const textarea = leftParentContainer.value?.querySelector('textarea')
if (textarea) textarea.scrollTop = scrollVal
})
} else {
const textarea = leftParentContainer.value?.querySelector('textarea')
const scrollVal = textarea ? textarea.scrollTop : 0
nextTick(() => {
if (leftTextScrollContainer.value) {
leftTextScrollContainer.value.scrollTop = scrollVal
}
})
}
})
watch(isEditingNew, (newVal) => {
if (newVal) {
const scrollVal = rightTextScrollContainer.value ? rightTextScrollContainer.value.scrollTop : 0
nextTick(() => {
const textarea = rightParentContainer.value?.querySelector('textarea')
if (textarea) textarea.scrollTop = scrollVal
})
} else {
const textarea = rightParentContainer.value?.querySelector('textarea')
const scrollVal = textarea ? textarea.scrollTop : 0
nextTick(() => {
if (rightTextScrollContainer.value) {
rightTextScrollContainer.value.scrollTop = scrollVal
}
})
}
})
const startEditLine = (line: any, isNewSide: boolean) => {
if (line.type === 'empty' || !line.num) return
if (isNewSide) {
editingNewLineNum.value = line.num
editingNewText.value = line.content
nextTick(() => {
const input = document.getElementById(`new-line-input-${line.num}`) as HTMLInputElement
if (input) input.focus()
})
} else {
editingOldLineNum.value = line.num
editingOldText.value = line.content
nextTick(() => {
const input = document.getElementById(`old-line-input-${line.num}`) as HTMLInputElement
if (input) input.focus()
})
}
}
const saveEditLine = (isNewSide: boolean) => {
if (isNewSide) {
if (editingNewLineNum.value === null) return
const lines = newXmlText.value.split('\n')
lines[editingNewLineNum.value - 1] = editingNewText.value
newXmlText.value = lines.join('\n')
// 立即执行一次同步比对,防止防抖延迟导致页面文字旧值闪烁
updateTextDiff()
handleCompare()
editingNewLineNum.value = null
} else {
if (editingOldLineNum.value === null) return
const lines = oldXmlText.value.split('\n')
lines[editingOldLineNum.value - 1] = editingOldText.value
oldXmlText.value = lines.join('\n')
// 立即执行一次同步比对,防止防抖延迟导致页面文字旧值闪烁
updateTextDiff()
handleCompare()
editingOldLineNum.value = null
}
}
const cancelEditLine = (isNewSide: boolean) => {
if (isNewSide) {
editingNewLineNum.value = null
} else {
editingOldLineNum.value = null
}
}
const handleOldXmlBlur = () => {
oldXmlText.value = formatXmlText(oldXmlText.value)
updateTextDiff()
}
const handleNewXmlBlur = () => {
newXmlText.value = formatXmlText(newXmlText.value)
updateTextDiff()
}
const getSelectedLineRange = (isNewSide: boolean): { start: number; end: number } | null => {
const selection = window.getSelection()
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null
const range = selection.getRangeAt(0)
const getLineNumFromNode = (node: Node | null): number | null => {
let el = node as HTMLElement | null
if (node && node.nodeType === Node.TEXT_NODE) {
el = node.parentElement
}
while (el) {
if (el.classList && el.classList.contains('diff-line-row')) {
const numAttr = el.getAttribute('data-line-num')
if (numAttr) {
const num = parseInt(numAttr, 10)
if (!isNaN(num)) return num
}
}
el = el.parentElement
}
return null
}
const startNum = getLineNumFromNode(range.startContainer)
const endNum = getLineNumFromNode(range.endContainer)
if (startNum !== null && endNum !== null) {
return {
start: Math.min(startNum, endNum),
end: Math.max(startNum, endNum)
}
}
return null
}
const toggleEditOld = () => {
if (isEditingOld.value) {
isEditingOld.value = false
return
}
if (editingOldRange.value) {
saveEditRange(false)
} else {
const range = getSelectedLineRange(false)
if (range) {
editingOldRange.value = range
const lines = oldXmlText.value.split('\n')
editingOldRangeText.value = lines.slice(range.start - 1, range.end).join('\n')
nextTick(() => {
const el = document.getElementById(`old-range-input-${range.start}`)
el?.focus()
})
} else {
window.$message.warning('请先在上方 XML 文本对比区用鼠标划词选中需要编辑的区域')
}
}
}
const toggleEditNew = () => {
if (isEditingNew.value) {
isEditingNew.value = false
return
}
if (editingNewRange.value) {
saveEditRange(true)
} else {
const range = getSelectedLineRange(true)
if (range) {
editingNewRange.value = range
const lines = newXmlText.value.split('\n')
editingNewRangeText.value = lines.slice(range.start - 1, range.end).join('\n')
nextTick(() => {
const el = document.getElementById(`new-range-input-${range.start}`)
el?.focus()
})
} else {
window.$message.warning('请先在上方 XML 文本对比区用鼠标划词选中需要编辑的区域')
}
}
}
const saveEditRange = (isNewSide: boolean) => {
if (isNewSide) {
if (!editingNewRange.value) return
const lines = newXmlText.value.split('\n')
const startIdx = editingNewRange.value.start - 1
const endIdx = editingNewRange.value.end - 1
const newSubLines = editingNewRangeText.value.split('\n')
lines.splice(startIdx, endIdx - startIdx + 1, ...newSubLines)
newXmlText.value = lines.join('\n')
editingNewRange.value = null
updateTextDiff()
handleCompare()
} else {
if (!editingOldRange.value) return
const lines = oldXmlText.value.split('\n')
const startIdx = editingOldRange.value.start - 1
const endIdx = editingOldRange.value.end - 1
const newSubLines = editingOldRangeText.value.split('\n')
lines.splice(startIdx, endIdx - startIdx + 1, ...newSubLines)
oldXmlText.value = lines.join('\n')
editingOldRange.value = null
updateTextDiff()
handleCompare()
}
}
const cancelEditRange = (isNewSide: boolean) => {
if (isNewSide) {
editingNewRange.value = null
} else {
editingOldRange.value = null
}
}
// 实时防抖比对与状态切换侦听
const debounce = <T extends (...args: any[]) => any>(fn: T, delay: number) => {
let timer: any = null
return function (this: any, ...args: Parameters<T>) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
watch(
[oldXmlText, newXmlText],
debounce(() => {
updateTextDiff()
handleCompare()
// 核心交互:如果两边都有值,自动将显示状态切为 Diff 高亮
if (oldXmlText.value.trim() && newXmlText.value.trim()) {
isEditingOld.value = false
isEditingNew.value = false
} else {
if (!oldXmlText.value.trim()) isEditingOld.value = true
if (!newXmlText.value.trim()) isEditingNew.value = true
}
}, 300),
{ immediate: true }
)
// 寻找下方对应标签和文本最相似的 DOM 节点并高亮(空间相对比例对齐法)
const highlightTargetNode = (line: DiffLine, isNewSide: boolean) => {
const lineContent = line?.content
if (!lineContent || lineContent.trim().startsWith('<!--')) return
// 1. 提取出当前行的标签名 (支持开始与自闭合)
const tagMatch = lineContent.match(/<\/?([a-zA-Z0-9\-]+)/)
if (!tagMatch) return
const lineTagName = tagMatch[1].toUpperCase()
// 2. 确定我们要搜索的容器
const container = isNewSide ? rightScrollContainer.value : leftScrollContainer.value
if (!container) return
// 3. 计算当前点击行在文本中的相对高度比例
const diffLines = isNewSide ? newDiffLines.value : oldDiffLines.value
const clickedIndex = diffLines.indexOf(line)
if (clickedIndex === -1) return
const lineRatio = clickedIndex / diffLines.length
// 4. 获取当前侧容器下的所有 [data-node-id] 元素
const elements = Array.from(container.querySelectorAll('[data-node-id]')) as HTMLElement[]
if (elements.length === 0) return
let bestScore = -1
let bestCandidates: { el: HTMLElement; domRatio: number }[] = []
for (let i = 0; i < elements.length; i++) {
const el = elements[i]
const elTagName = (el.getAttribute('data-tag-name') || '').toUpperCase()
// 标签名必须完全一致
if (elTagName !== lineTagName) continue
let score = 10 // 基础分
// 匹配属性值
const attrValues = lineContent.match(/="([^"]+)"/g)
if (attrValues) {
for (const val of attrValues) {
const cleanVal = val.replace(/="|"$/g, '')
if (el.outerHTML.includes(cleanVal)) {
score += 20
}
}
}
// 匹配去标签后的文本内容
const cleanLineText = lineContent.replace(/<[^>]+>/g, '').trim()
if (cleanLineText) {
const elText = el.textContent || ''
if (elText.includes(cleanLineText)) {
score += cleanLineText.length * 2
}
}
// 维护得分最高候选者
const domRatio = i / elements.length
if (score > bestScore) {
bestScore = score
bestCandidates = [{ el, domRatio }]
} else if (score === bestScore) {
bestCandidates.push({ el, domRatio })
}
}
// 5. 在所有最高分的候选节点中,寻找其在 DOM 树中的相对百分比位置最接近当前点击行的节点
let bestMatch: HTMLElement | null = null
let minDistance = Infinity
for (const cand of bestCandidates) {
const distance = Math.abs(cand.domRatio - lineRatio)
if (distance < minDistance) {
minDistance = distance
bestMatch = cand.el
}
}
// 6. 执行闪烁高亮与居中滚动
if (bestMatch) {
// 清理当前侧容器中的所有已高亮节点
container.querySelectorAll('.highlight-focused-node').forEach((item) => {
item.classList.remove('highlight-focused-node')
})
bestMatch.classList.add('highlight-focused-node')
bestMatch.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
// ─── 差异指示条(minimap)高精度物理对齐 ───
// 核心逻辑:为了实现“滚轴中间压着指示条”,我们需要在考虑滑块最小高度限制(例如 8%)的情况下,
// 使得滑块中点对应的百分比和元素在视口中点对应的百分比进行线性匹配。
//
// 设轨道总百分比高度为 H_track = 100,滑块百分比高度为 hSlider。
// 当 scrollTop = 0 时,滑块顶部在 0,中点在 hSlider / 2。
// 当 scrollTop = maxScrollTop 时,滑块底部在 100,顶部在 100 - hSlider,中点在 100 - hSlider / 2。
//
// 因此,当 scrollTop 在 [0, maxScrollTop] 变化时,滑块顶部的映射公式为:
// sliderTop% = (scrollTop / maxScrollTop) * (100 - hSlider)
// 某一行在 scrollTop = offsetTop - clientHeight / 2 时刚好处于视口垂直正中。
// 我们让这一行对应的 marker 刚好等于此时滑块中点的位置:
// markerTop% = (targetScrollTopClamped / maxScrollTop) * (100 - hSlider) + hSlider / 2
// 辅助映射:将元素的物理 offsetTop 转换为与滑块中心严格对齐的百分比
const mapOffsetToPercent = (y: number, sh: number, ch: number, hSlider: number): number => {
const maxScrollTop = sh - ch
if (maxScrollTop <= 0) {
return (y / sh) * 100
}
// 目标滚动位置:使该元素刚好处于可视区域的正中间
const targetScrollTop = y - ch / 2
const targetScrollTopClamped = Math.max(0, Math.min(targetScrollTop, maxScrollTop))
return (targetScrollTopClamped / maxScrollTop) * (100 - hSlider) + hSlider / 2
}
// 辅助逆映射:点击指示条某处,反推目标 scrollTop 以使该位置的内容处于视口正中
const getScrollTopFromRatio = (ratio: number, sh: number, ch: number, hSlider: number): number => {
const maxScrollTop = sh - ch
if (maxScrollTop <= 0) return 0
const clickPercent = ratio * 100
// 反推:clickPercent = (targetScrollTop / maxScrollTop) * (100 - hSlider) + hSlider / 2
const targetScrollTop = ((clickPercent - hSlider / 2) / (100 - hSlider)) * maxScrollTop
return Math.max(0, Math.min(targetScrollTop, maxScrollTop))
}
// 递归累加 offsetTop,获取元素相对于指定相对定位容器的物理偏移
const getOffsetTop = (el: HTMLElement, container: HTMLElement): number => {
let top = 0
let cur: HTMLElement | null = el
while (cur && cur !== container) {
top += cur.offsetTop
cur = cur.offsetParent as HTMLElement | null
}
return top
}
// 文本差异指示条 markers(高精度中点匹配)
const leftTextDiffMarkers = ref<{ top: number }[]>([])
const rightTextDiffMarkers = ref<{ top: number }[]>([])
const updateTextDiffMarkers = () => {
const lc = leftTextScrollContainer.value
if (lc && lc.scrollHeight > 0) {
const sh = lc.scrollHeight
const ch = lc.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
leftTextDiffMarkers.value = (Array.from(lc.querySelectorAll('.line-row-removed')) as HTMLElement[]).map((row) => ({
top: mapOffsetToPercent(getOffsetTop(row, lc), sh, ch, hSlider)
}))
} else {
leftTextDiffMarkers.value = []
}
const rc = rightTextScrollContainer.value
if (rc && rc.scrollHeight > 0) {
const sh = rc.scrollHeight
const ch = rc.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
rightTextDiffMarkers.value = (Array.from(rc.querySelectorAll('.line-row-added')) as HTMLElement[]).map((row) => ({
top: mapOffsetToPercent(getOffsetTop(row, rc), sh, ch, hSlider)
}))
} else {
rightTextDiffMarkers.value = []
}
}
// 渲染差异指示条 markers(高精度中点匹配)
const leftRenderDiffMarkers = ref<{ type: 'removed' | 'modified'; top: number }[]>([])
const rightRenderDiffMarkers = ref<{ type: 'added' | 'modified'; top: number }[]>([])
const updateRenderDiffMarkers = () => {
const lc = leftScrollContainer.value
if (lc && lc.scrollHeight > 0) {
const sh = lc.scrollHeight
const ch = lc.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
leftRenderDiffMarkers.value = (
Array.from(lc.querySelectorAll('[data-diff-status="removed"],[data-diff-status="modified"]')) as HTMLElement[]
).flatMap((el) => {
const s = el.getAttribute('data-diff-status')
if (!s || s === 'none' || s === 'added') return []
return [{ type: s as 'removed' | 'modified', top: mapOffsetToPercent(getOffsetTop(el, lc), sh, ch, hSlider) }]
})
} else {
leftRenderDiffMarkers.value = []
}
const rc = rightScrollContainer.value
if (rc && rc.scrollHeight > 0) {
const sh = rc.scrollHeight
const ch = rc.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
rightRenderDiffMarkers.value = (
Array.from(rc.querySelectorAll('[data-diff-status="added"],[data-diff-status="modified"]')) as HTMLElement[]
).flatMap((el) => {
const s = el.getAttribute('data-diff-status')
if (!s || s === 'none' || s === 'removed') return []
return [{ type: s as 'added' | 'modified', top: mapOffsetToPercent(getOffsetTop(el, rc), sh, ch, hSlider) }]
})
} else {
rightRenderDiffMarkers.value = []
}
}
// 指示条点击跳转事件处理器(使目标差异完美居中于视口,滚轴中点压住指示条)
const handleLeftTextMinimapClick = (e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const clickY = e.clientY - rect.top
const ratio = clickY / rect.height
if (leftTextScrollContainer.value) {
const sh = leftTextScrollContainer.value.scrollHeight
const ch = leftTextScrollContainer.value.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
leftTextScrollContainer.value.scrollTop = getScrollTopFromRatio(ratio, sh, ch, hSlider)
}
}
const handleRightTextMinimapClick = (e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const clickY = e.clientY - rect.top
const ratio = clickY / rect.height
if (rightTextScrollContainer.value) {
const sh = rightTextScrollContainer.value.scrollHeight
const ch = rightTextScrollContainer.value.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
rightTextScrollContainer.value.scrollTop = getScrollTopFromRatio(ratio, sh, ch, hSlider)
}
}
const handleLeftRenderMinimapClick = (e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const clickY = e.clientY - rect.top
const ratio = clickY / rect.height
if (leftScrollContainer.value) {
const sh = leftScrollContainer.value.scrollHeight
const ch = leftScrollContainer.value.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
leftScrollContainer.value.scrollTop = getScrollTopFromRatio(ratio, sh, ch, hSlider)
}
}
const handleRightRenderMinimapClick = (e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const clickY = e.clientY - rect.top
const ratio = clickY / rect.height
if (rightScrollContainer.value) {
const sh = rightScrollContainer.value.scrollHeight
const ch = rightScrollContainer.value.clientHeight
const hSlider = Math.max(8, (ch / sh) * 100)
rightScrollContainer.value.scrollTop = getScrollTopFromRatio(ratio, sh, ch, hSlider)
}
}
// ─── 模拟滚动滑块与拖拽同步 ───
const leftTextScrollTopPercent = ref(0)
const leftTextScrollHeightPercent = ref(100)
const rightTextScrollTopPercent = ref(0)
const rightTextScrollHeightPercent = ref(100)
const leftRenderScrollTopPercent = ref(0)
const leftRenderScrollHeightPercent = ref(100)
const rightRenderScrollTopPercent = ref(0)
const rightRenderScrollHeightPercent = ref(100)
const updateScrollPercentages = () => {
// 1. 左文本
const ltc = leftTextScrollContainer.value
if (ltc && ltc.scrollHeight > 0) {
const sh = ltc.scrollHeight
const ch = ltc.clientHeight
const maxScrollTop = sh - ch
const hSlider = Math.max(8, (ch / sh) * 100)
leftTextScrollHeightPercent.value = hSlider
leftTextScrollTopPercent.value = maxScrollTop > 0 ? (ltc.scrollTop / maxScrollTop) * (100 - hSlider) : 0
}
// 2. 右文本
const rtc = rightTextScrollContainer.value
if (rtc && rtc.scrollHeight > 0) {
const sh = rtc.scrollHeight
const ch = rtc.clientHeight
const maxScrollTop = sh - ch
const hSlider = Math.max(8, (ch / sh) * 100)
rightTextScrollHeightPercent.value = hSlider
rightTextScrollTopPercent.value = maxScrollTop > 0 ? (rtc.scrollTop / maxScrollTop) * (100 - hSlider) : 0
}
// 3. 左渲染
const lrc = leftScrollContainer.value
if (lrc && lrc.scrollHeight > 0) {
const sh = lrc.scrollHeight
const ch = lrc.clientHeight
const maxScrollTop = sh - ch
const hSlider = Math.max(8, (ch / sh) * 100)
leftRenderScrollHeightPercent.value = hSlider
leftRenderScrollTopPercent.value = maxScrollTop > 0 ? (lrc.scrollTop / maxScrollTop) * (100 - hSlider) : 0
}
// 4. 右渲染
const rrc = rightScrollContainer.value
if (rrc && rrc.scrollHeight > 0) {
const sh = rrc.scrollHeight
const ch = rrc.clientHeight
const maxScrollTop = sh - ch
const hSlider = Math.max(8, (ch / sh) * 100)
rightRenderScrollHeightPercent.value = hSlider
rightRenderScrollTopPercent.value = maxScrollTop > 0 ? (rrc.scrollTop / maxScrollTop) * (100 - hSlider) : 0
}
}
// 模拟滚动滑块拖拽逻辑 (根据精确的缩放比映射 scrollTop)
const startDragScroll = (e: MouseEvent, type: 'leftText' | 'rightText' | 'leftRender' | 'rightRender') => {
e.preventDefault()
const container =
type === 'leftText'
? leftTextScrollContainer.value
: type === 'rightText'
? rightTextScrollContainer.value
: type === 'leftRender'
? leftScrollContainer.value
: rightScrollContainer.value
if (!container) return
const startY = e.clientY
const startScrollTop = container.scrollTop
const sh = container.scrollHeight
const ch = container.clientHeight
const maxScrollTop = sh - ch
const hSlider = Math.max(8, (ch / sh) * 100)
const track = (e.currentTarget as HTMLElement).parentElement
if (!track) return
const trackHeight = track.clientHeight
const onMouseMove = (moveEvent: MouseEvent) => {
const deltaY = moveEvent.clientY - startY
const deltaRatio = deltaY / trackHeight
if (maxScrollTop > 0) {
// 在限制最小滑块高度后,滑块顶部实际的活动百分比区间是 100 - hSlider
// 故 deltaScroll = (deltaRatio * 100 / (100 - hSlider)) * maxScrollTop
const deltaScroll = ((deltaRatio * 100) / (100 - hSlider)) * maxScrollTop
container.scrollTop = Math.max(0, Math.min(startScrollTop + deltaScroll, maxScrollTop))
}
// 同步联锁其它容器
if (type === 'leftText') handleLeftTextScroll()
if (type === 'rightText') handleRightTextScroll()
if (type === 'leftRender') handleLeftScroll()
if (type === 'rightRender') handleRightScroll()
updateScrollPercentages()
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// 包装原生的 @scroll 监听器,确保每次滚动时同步更新模拟滚动滑块的物理占比
const onLeftTextScroll = () => {
handleLeftTextScroll()
updateScrollPercentages()
}
const onRightTextScroll = () => {
handleRightTextScroll()
updateScrollPercentages()
}
const onLeftScroll = () => {
handleLeftScroll()
updateScrollPercentages()
}
const onRightScroll = () => {
handleRightScroll()
updateScrollPercentages()
}
// 侦听渲染树变化 → 重算渲染区 marker
watch(
[oldDiffTree, newDiffTree],
() => {
nextTick(() => {
setTimeout(() => {
updateRenderDiffMarkers()
updateScrollPercentages()
}, 200)
})
},
{ deep: true, immediate: true }
)
// 侦听文本行变化 → 重算文本 marker
watch(
[oldDiffLines, newDiffLines],
() => {
nextTick(() => {
setTimeout(() => {
updateTextDiffMarkers()
updateScrollPercentages()
}, 200)
})
},
{ immediate: true }
)
// 侦听模态框打开,在动画完成后触发一次完整的重算,确保 DOM clientHeight/scrollHeight 能被正确读取
watch(showModal, (newVal) => {
if (newVal) {
nextTick(() => {
setTimeout(() => {
updateTextDiffMarkers()
updateRenderDiffMarkers()
updateScrollPercentages()
}, 350)
})
}
})
return {
showModal,
loading,
oldXmlText,
newXmlText,
oldDiffTree,
newDiffTree,
leftScrollContainer,
rightScrollContainer,
handleLeftScroll,
handleRightScroll,
loadCurrentXml,
resetDiff,
open,
handleCompare,
handleXmlImportClick,
formatXmlText,
isEditingOld,
isEditingNew,
oldDiffLines,
newDiffLines,
updateTextDiff,
leftTextScrollContainer,
rightTextScrollContainer,
handleLeftTextScroll,
handleRightTextScroll,
leftParentContainer,
rightParentContainer,
editingOldLineNum,
editingNewLineNum,
editingOldText,
editingNewText,
startEditLine,
saveEditLine,
cancelEditLine,
handleOldXmlBlur,
handleNewXmlBlur,
highlightTargetNode,
leftTextDiffMarkers,
rightTextDiffMarkers,
leftRenderDiffMarkers,
rightRenderDiffMarkers,
handleLeftTextMinimapClick,
handleRightTextMinimapClick,
handleLeftRenderMinimapClick,
handleRightRenderMinimapClick,
leftTextScrollTopPercent,
leftTextScrollHeightPercent,
rightTextScrollTopPercent,
rightTextScrollHeightPercent,
leftRenderScrollTopPercent,
leftRenderScrollHeightPercent,
rightRenderScrollTopPercent,
rightRenderScrollHeightPercent,
startDragScroll,
onLeftTextScroll,
onRightTextScroll,
onLeftScroll,
onRightScroll,
editingOldRange,
editingNewRange,
editingOldRangeText,
editingNewRangeText,
toggleEditOld,
toggleEditNew,
saveEditRange,
cancelEditRange
}
}
// ── 内部辅助比对函数 ──
function alignNodes(oldChildren: XmlNode[], newChildren: XmlNode[]): { oldNode: XmlNode | null; newNode: XmlNode | null }[] {
const aligned: { oldNode: XmlNode | null; newNode: XmlNode | null }[] = []
let i = 0
let j = 0
while (i < oldChildren.length || j < newChildren.length) {
if (i >= oldChildren.length) {
aligned.push({ oldNode: null, newNode: newChildren[j] })
j++
} else if (j >= newChildren.length) {
aligned.push({ oldNode: oldChildren[i], newNode: null })
i++
} else {
const oldChild = oldChildren[i]
const newChild = newChildren[j]
if (oldChild.tagName === newChild.tagName) {
aligned.push({ oldNode: oldChild, newNode: newChild })
i++
j++
} else {
// 查找前方是否有匹配的标签
let foundMatch = false
for (let k = i + 1; k < oldChildren.length; k++) {
if (oldChildren[k].tagName === newChild.tagName) {
for (let m = i; m < k; m++) {
aligned.push({ oldNode: oldChildren[m], newNode: null })
}
aligned.push({ oldNode: oldChildren[k], newNode: newChild })
i = k + 1
j++
foundMatch = true
break
}
}
if (!foundMatch) {
aligned.push({ oldNode: null, newNode: newChild })
j++
}
}
}
}
return aligned
}
/**
* 构建旧工卡差异树(仅保留未变更和已被删除的节点,删除的以红色高亮)
*/
function buildOldDiffTree(oldNode: XmlNode | null, newNode: XmlNode | null): DiffXmlNode | null {
if (!oldNode) return null
// 只有旧节点(说明是被删除的元素)
if (oldNode && !newNode) {
return {
id: oldNode.id,
tagName: oldNode.tagName,
attributes: { ...oldNode.attributes },
textContent: oldNode.textContent,
parentId: oldNode.parentId,
diffStatus: 'removed',
mixedContent: (oldNode.mixedContent || []).map((item) => ({
...item,
diffStatus: 'removed'
})),
children: oldNode.children.map((child) => buildOldDiffTree(child, null)).filter((c): c is DiffXmlNode => c !== null)
}
}
// 两者都有
const node = oldNode!
const other = newNode!
let diffStatus: 'added' | 'removed' | 'modified' | 'none' = 'none'
const attrsEqual = JSON.stringify(node.attributes) === JSON.stringify(other.attributes)
const isMetadataTag = EFFECT_METADATA_TAGS.includes(node.tagName)
const oldText = isMetadataTag ? getMetadataRenderedText(node) : node.textContent || ''
const newText = isMetadataTag ? getMetadataRenderedText(other) : other.textContent || ''
const textEqual = oldText === newText
if (!attrsEqual || !textEqual) {
diffStatus = 'modified'
}
const mixedContent: DiffMixedContentItem[] = []
if (isMetadataTag) {
// 对于元数据标签,总是把渲染文本放入 mixedContent,这样渲染器就可以走 word diff
const words = Diff.diffWords(oldText, newText)
mixedContent.push({
type: 'text',
text: oldText,
diffWords: words
.filter((w) => !w.added)
.map((w) => ({
value: w.value,
removed: w.removed
}))
})
} else if (node.textContent || other.textContent) {
const words = Diff.diffWords(node.textContent || '', other.textContent || '')
mixedContent.push({
type: 'text',
text: node.textContent || '',
diffWords: words
.filter((w) => !w.added) // 过滤掉新增的单词,旧卡只显示删除的/未改动的
.map((w) => ({
value: w.value,
removed: w.removed
}))
})
}
const aligned = alignNodes(node.children, other.children)
const children = aligned.map((pair) => buildOldDiffTree(pair.oldNode, pair.newNode)).filter((c): c is DiffXmlNode => c !== null)
return {
id: node.id,
tagName: node.tagName,
attributes: { ...node.attributes },
textContent: node.textContent,
parentId: node.parentId,
diffStatus,
mixedContent,
children
}
}
/**
* 构建新工卡差异树(仅保留未变更和新增的节点,新增的以绿色高亮)
*/
function buildNewDiffTree(oldNode: XmlNode | null, newNode: XmlNode | null): DiffXmlNode | null {
if (!newNode) return null
// 只有新节点(说明是新增的元素)
if (!oldNode && newNode) {
return {
id: newNode.id,
tagName: newNode.tagName,
attributes: { ...newNode.attributes },
textContent: newNode.textContent,
parentId: newNode.parentId,
diffStatus: 'added',
mixedContent: (newNode.mixedContent || []).map((item) => ({
...item,
diffStatus: 'added'
})),
children: newNode.children.map((child) => buildNewDiffTree(null, child)).filter((c): c is DiffXmlNode => c !== null)
}
}
// 两者都有
const node = oldNode!
const other = newNode!
let diffStatus: 'added' | 'removed' | 'modified' | 'none' = 'none'
const attrsEqual = JSON.stringify(node.attributes) === JSON.stringify(other.attributes)
const isMetadataTag = EFFECT_METADATA_TAGS.includes(node.tagName)
const oldText = isMetadataTag ? getMetadataRenderedText(node) : node.textContent || ''
const newText = isMetadataTag ? getMetadataRenderedText(other) : other.textContent || ''
const textEqual = oldText === newText
if (!attrsEqual || !textEqual) {
diffStatus = 'modified'
}
const mixedContent: DiffMixedContentItem[] = []
if (isMetadataTag) {
const words = Diff.diffWords(oldText, newText)
mixedContent.push({
type: 'text',
text: newText,
diffWords: words
.filter((w) => !w.removed)
.map((w) => ({
value: w.value,
added: w.added
}))
})
} else if (node.textContent || other.textContent) {
const words = Diff.diffWords(node.textContent || '', other.textContent || '')
mixedContent.push({
type: 'text',
text: other.textContent || '',
diffWords: words
.filter((w) => !w.removed) // 过滤掉被删除的单词,新卡只显示新增的/未改动的
.map((w) => ({
value: w.value,
added: w.added
}))
})
}
const aligned = alignNodes(node.children, other.children)
const children = aligned.map((pair) => buildNewDiffTree(pair.oldNode, pair.newNode)).filter((c): c is DiffXmlNode => c !== null)
return {
id: other.id,
tagName: other.tagName,
attributes: { ...other.attributes },
textContent: other.textContent,
parentId: other.parentId,
diffStatus,
mixedContent,
children
}
}
function formatEff(eff: string | undefined): string {
if (!eff) return 'ALL'
const cleaned = eff.replace(/\s+/g, '')
if (cleaned === '001999') return 'ALL'
if (/^\d+$/.test(cleaned) && cleaned.length % 6 === 0) {
const parts: string[] = []
for (let i = 0; i < cleaned.length; i += 6) {
const block = cleaned.substring(i, i + 6)
parts.push(block === '001999' ? 'ALL' : block)
}
return parts.join(', ')
}
return eff
}
function getMetadataRenderedText(n: XmlNode): string {
if (n.tagName === 'EFFECT' || n.tagName === 'CONEFFECT') {
const type = n.tagName === 'EFFECT' ? 'ON A/C' : 'CONF'
const val = n.attributes.EFFRG || n.textContent || ''
return `** ${type}: ${formatEff(val)}`
}
if (n.tagName === 'SBEFF' || n.tagName === 'SBEFFC') {
const cond = n.attributes.SBCOND || ''
const nbr = n.attributes.SBNBR || ''
const eff = formatEff(n.attributes.EFFRG)
return `** SB: ${cond} SB ${nbr} for A/C ${eff}`
}
return ''
}
export function generateDoubleFolderDiff(oldText: string, newText: string) {
const diff = Diff.diffLines(oldText, newText)
const leftLines: DiffLine[] = []
const rightLines: DiffLine[] = []
let leftNum = 1
let rightNum = 1
let i = 0
while (i < diff.length) {
const current = diff[i]
if (!current.added && !current.removed) {
const lines = current.value.split('\n')
if (lines[lines.length - 1] === '') lines.pop()
for (const line of lines) {
leftLines.push({ type: 'normal', num: leftNum++, content: line })
rightLines.push({ type: 'normal', num: rightNum++, content: line })
}
i++
} else {
const leftBlock: string[] = []
const rightBlock: string[] = []
while (i < diff.length && (diff[i].added || diff[i].removed)) {
const block = diff[i]
const lines = block.value.split('\n')
if (lines[lines.length - 1] === '') lines.pop()
if (block.removed) {
leftBlock.push(...lines)
} else {
rightBlock.push(...lines)
}
i++
}
const maxLen = Math.max(leftBlock.length, rightBlock.length)
for (let k = 0; k < maxLen; k++) {
if (k < leftBlock.length) {
leftLines.push({ type: 'removed', num: leftNum++, content: leftBlock[k] })
} else {
leftLines.push({ type: 'empty', content: '' })
}
if (k < rightBlock.length) {
rightLines.push({ type: 'added', num: rightNum++, content: rightBlock[k] })
} else {
rightLines.push({ type: 'empty', content: '' })
}
}
}
}
return { leftLines, rightLines }
}
<template>
<CommonModal
v-model="showModal"
title="工卡 XML 差异实时对比"
fullscreen
:padding="16"
:scrollable="false"
:showFooter="true"
:loading="loading"
:show-confirm="false"
cancel-text="关闭"
class="compare-modal-nobar"
>
<div class="compare-modal-content flex-1 flex flex-col min-h-0 h-full">
<!-- 顶栏:图例说明 -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-3 p-3 bg-fill-2 border border-divider rounded-lg shrink-0">
<div class="flex items-center space-x-4 text-xs font-semibold select-none">
<span class="text-color3">差异图例:</span>
<span class="flex items-center space-x-1">
<span class="w-3 h-3 rounded bg-success-1 border border-success-3"></span>
<span class="text-success-6 dark:text-success-5">新增内容</span>
</span>
<span class="flex items-center space-x-1">
<span class="w-3 h-3 rounded bg-danger-1 border border-danger-3"></span>
<span class="text-danger-6 dark:text-danger-5 line-through">删除内容</span>
</span>
<span class="flex items-center space-x-1">
<span class="w-1 h-3 bg-warning rounded-sm"></span>
<span class="text-warning-6 dark:text-warning-5">属性变更 / 空白占位</span>
</span>
</div>
<div class="flex items-center gap-4">
<div class="text-xs text-color3 italic">上方为 XML 文本对比,下方为工卡实时渲染差异</div>
<div class="w-[1px] h-4 bg-divider"></div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<span class="text-xs font-semibold text-color2">XML 对比</span>
<n-switch :value="isTextExpanded" size="small" @update:value="handleTextExpandedChange" />
</div>
<div class="w-[1px] h-3 bg-divider"></div>
<div class="flex items-center gap-1.5">
<span class="text-xs font-semibold text-color2">渲染对比</span>
<n-switch :value="isRenderExpanded" size="small" @update:value="handleRenderExpandedChange" />
</div>
</div>
</div>
</div>
<!-- 主内容区:左右分栏 -->
<div class="flex-1 flex gap-4 min-h-0 h-full">
<!-- 左侧栏:旧工卡输入与渲染 -->
<div class="flex-1 flex flex-col min-w-0 min-h-0">
<!-- 输入区 -->
<div
class="flex flex-col shrink-0 compare-text-zone"
:class="{
'is-collapsed': !isTextExpanded,
'expanded-full': !isRenderExpanded
}"
>
<div class="flex items-center justify-between mb-2 shrink-0">
<span class="text-sm font-bold text-color2 flex items-center space-x-1.5 whitespace-nowrap">
<span class="w-2 h-2 rounded-full bg-warning"></span>
<span>源工卡 XML (旧版本)</span>
</span>
<div class="flex items-center space-x-1.5">
<CommonButton
v-if="oldXmlText"
size="tiny"
secondary
:type="isEditingOld || editingOldRange ? 'warning' : 'default'"
@click="toggleEditOld"
>
{{ isEditingOld || editingOldRange ? '查看对比' : '编辑 XML' }}
</CommonButton>
<CommonButton size="tiny" secondary type="warning" @click="loadCurrentXml">使用当前打开的工卡</CommonButton>
</div>
</div>
<div ref="leftParentContainer" class="flex-1 flex flex-col min-h-0 relative">
<n-input
v-if="isEditingOld"
v-model:value="oldXmlText"
type="textarea"
placeholder="请粘贴源工卡的完整 XML 内容..."
class="flex-1 font-mono text-xs"
:input-props="{ style: { height: '100%' } }"
@blur="handleOldXmlBlur"
/>
<div
v-else
ref="leftTextScrollContainer"
class="flex-1 border border-divider rounded-lg bg-card overflow-auto p-3 font-mono text-[11px] leading-5 select-text whitespace-pre relative scroll-container text-color1"
title="双击行内容可编辑这一行"
@scroll="onLeftTextScroll"
>
<div
v-for="(line, idx) in oldDiffLines"
:key="idx"
v-show="
!editingOldRange ||
!line.num ||
line.num < editingOldRange.start ||
line.num > editingOldRange.end ||
line.num === editingOldRange.start
"
class="flex w-full group hover:bg-fill-2 min-h-[20px] cursor-pointer diff-line-row"
:class="{
'line-row-removed': line.type === 'removed',
'line-row-empty': line.type === 'empty'
}"
:data-line-num="line.num"
@click="highlightTargetNode(line, false)"
@dblclick.stop="startEditLine(line, false)"
>
<span class="line-num select-none w-10 text-right pr-2 border-r border-divider opacity-50">
{{ line.num || '' }}
</span>
<textarea
v-if="editingOldRange && line.num === editingOldRange.start"
:id="`old-range-input-${line.num}`"
v-model="editingOldRangeText"
:rows="Math.max(2, editingOldRange.end - editingOldRange.start + 1)"
class="flex-1 px-2 py-1 bg-fill-2 border border-primary text-[11px] font-mono text-color1 outline-none rounded resize-y"
@blur="saveEditRange(false)"
@keydown.enter.ctrl.stop="saveEditRange(false)"
@keydown.esc.stop="cancelEditRange(false)"
@click.stop
/>
<input
v-else-if="editingOldLineNum === line.num && line.num"
:id="`old-line-input-${line.num}`"
v-model="editingOldText"
class="flex-1 px-2 py-0.5 bg-fill-2 border border-primary text-[11px] font-mono text-color1 outline-none h-[18px] leading-[18px] rounded"
@blur="saveEditLine(false)"
@keyup.enter="saveEditLine(false)"
@keyup.esc="cancelEditLine(false)"
@click.stop
/>
<span v-else class="line-content pl-2 break-all whitespace-pre-wrap flex-1">{{ line.content }}</span>
</div>
</div>
<!-- 左侧文本差异指示条:放在滚动容器外部右侧,加宽并支持点击跳转 and 模拟拖拽 -->
<div
v-if="!isEditingOld"
class="absolute right-[2px] top-0 bottom-0 w-[12px] bg-transparent hover:bg-black/[0.04] cursor-pointer z-50 select-none transition-colors"
title="点击定位或拖动滑块"
@click="handleLeftTextMinimapClick"
>
<!-- 差异 markers -->
<div
v-for="(marker, mIdx) in leftTextDiffMarkers"
:key="mIdx"
class="absolute left-0 right-0 h-[3px] bg-danger-6"
:style="{ top: marker.top + '%' }"
></div>
<!-- 模拟滑块 -->
<div
class="absolute left-[1px] right-[1px] compare-minimap-slider cursor-grab active:cursor-grabbing"
:style="{
top: leftTextScrollTopPercent + '%',
height: leftTextScrollHeightPercent + '%'
}"
@mousedown.stop="startDragScroll($event, 'leftText')"
></div>
</div>
</div>
</div>
<!-- 渲染区 -->
<div
class="flex-grow flex flex-col compare-render-zone"
:class="{
'is-collapsed': !isRenderExpanded
}"
>
<div class="text-xs font-bold text-color3 mb-2 px-1 flex items-center space-x-1.5 shrink-0">
<span class="w-2 h-2 rounded-full bg-danger"></span>
<span>源工卡渲染 (旧版本)</span>
</div>
<!-- 滚动容器 + 指示条共用一个 relative 包裹 -->
<div class="flex-1 min-h-0 relative">
<div
ref="leftScrollContainer"
class="is-diff-mode h-full overflow-auto border border-divider rounded-lg bg-card p-6 leading-relaxed relative scroll-container"
@scroll="onLeftScroll"
>
<DocNodeRenderer v-if="oldDiffTree" :node="oldDiffTree" :parent="null" />
<div v-else class="h-full flex items-center justify-center text-xs text-color3 select-none">
暂无源工卡数据,请在上方输入框中粘贴或导入
</div>
</div>
<!-- 左侧渲染差异指示条:滚动容器外部右侧,加宽并支持点击跳转和模拟拖拽 -->
<div
v-if="oldDiffTree"
class="absolute right-[2px] top-0 bottom-0 w-[12px] bg-transparent hover:bg-black/[0.04] cursor-pointer z-50 select-none transition-colors"
title="点击定位或拖动滑块"
@click="handleLeftRenderMinimapClick"
>
<!-- 差异 markers -->
<div
v-for="(marker, mIdx) in leftRenderDiffMarkers"
:key="mIdx"
class="absolute left-0 right-0 h-[3px]"
:class="marker.type === 'removed' ? 'bg-danger-6' : 'bg-warning-6'"
:style="{ top: marker.top + '%' }"
></div>
<!-- 模拟滑块 -->
<div
class="absolute left-[1px] right-[1px] compare-minimap-slider cursor-grab active:cursor-grabbing"
:style="{
top: leftRenderScrollTopPercent + '%',
height: leftRenderScrollHeightPercent + '%'
}"
@mousedown.stop="startDragScroll($event, 'leftRender')"
></div>
</div>
</div>
</div>
</div>
<!-- 右侧栏:新工卡输入与渲染 -->
<div class="flex-1 flex flex-col min-w-0 min-h-0">
<!-- 输入区 -->
<div
class="flex flex-col shrink-0 compare-text-zone"
:class="{
'is-collapsed': !isTextExpanded,
'expanded-full': !isRenderExpanded
}"
>
<div class="flex items-center justify-between mb-2 shrink-0">
<span class="text-sm font-bold text-color2 flex items-center space-x-1.5 whitespace-nowrap">
<span class="w-2 h-2 rounded-full bg-primary"></span>
<span>目标工卡 XML (新版本)</span>
</span>
<div class="flex items-center space-x-1.5">
<CommonButton
v-if="newXmlText"
size="tiny"
secondary
:type="isEditingNew || editingNewRange ? 'primary' : 'default'"
@click="toggleEditNew"
>
{{ isEditingNew || editingNewRange ? '查看对比' : '编辑 XML' }}
</CommonButton>
<CommonButton size="tiny" secondary type="primary" @click="handleXmlImportClick">选择 XML 文件导入</CommonButton>
</div>
</div>
<div ref="rightParentContainer" class="flex-1 flex flex-col min-h-0 relative">
<n-input
v-if="isEditingNew"
v-model:value="newXmlText"
type="textarea"
placeholder="请粘贴目标工卡的完整 XML 内容,或者点击上方按钮导入文件..."
class="flex-1 font-mono text-xs"
:input-props="{ style: { height: '100%' } }"
@blur="handleNewXmlBlur"
/>
<div
v-else
ref="rightTextScrollContainer"
class="flex-1 border border-divider rounded-lg bg-card overflow-auto p-3 font-mono text-[11px] leading-5 select-text whitespace-pre relative scroll-container text-color1"
title="双击行内容可编辑这一行"
@scroll="onRightTextScroll"
>
<div
v-for="(line, idx) in newDiffLines"
:key="idx"
v-show="
!editingNewRange ||
!line.num ||
line.num < editingNewRange.start ||
line.num > editingNewRange.end ||
line.num === editingNewRange.start
"
class="flex w-full group hover:bg-fill-2 min-h-[20px] cursor-pointer diff-line-row"
:class="{
'line-row-added': line.type === 'added',
'line-row-empty': line.type === 'empty'
}"
:data-line-num="line.num"
@click="highlightTargetNode(line, true)"
@dblclick.stop="startEditLine(line, true)"
>
<span class="line-num select-none w-10 text-right pr-2 border-r border-divider opacity-50">
{{ line.num || '' }}
</span>
<textarea
v-if="editingNewRange && line.num === editingNewRange.start"
:id="`new-range-input-${line.num}`"
v-model="editingNewRangeText"
:rows="Math.max(2, editingNewRange.end - editingNewRange.start + 1)"
class="flex-1 px-2 py-1 bg-fill-2 border border-primary text-[11px] font-mono text-color1 outline-none rounded resize-y"
@blur="saveEditRange(true)"
@keydown.enter.ctrl.stop="saveEditRange(true)"
@keydown.esc.stop="cancelEditRange(true)"
@click.stop
/>
<input
v-else-if="editingNewLineNum === line.num && line.num"
:id="`new-line-input-${line.num}`"
v-model="editingNewText"
class="flex-1 px-2 py-0.5 bg-fill-2 border border-primary text-[11px] font-mono text-color1 outline-none h-[18px] leading-[18px] rounded"
@blur="saveEditLine(true)"
@keyup.enter="saveEditLine(true)"
@keyup.esc="cancelEditLine(true)"
@click.stop
/>
<span v-else class="line-content pl-2 break-all whitespace-pre-wrap flex-1">{{ line.content }}</span>
</div>
</div>
<!-- 右侧文本差异指示条:放在滚动容器外部左侧,加宽并支持点击跳转和模拟拖拽 -->
<div
v-if="!isEditingNew"
class="absolute left-[2px] top-0 bottom-0 w-[12px] bg-transparent hover:bg-black/[0.04] cursor-pointer z-50 select-none transition-colors"
title="点击定位或拖动滑块"
@click="handleRightTextMinimapClick"
>
<!-- 差异 markers -->
<div
v-for="(marker, mIdx) in rightTextDiffMarkers"
:key="mIdx"
class="absolute left-0 right-0 h-[3px] bg-success-6"
:style="{ top: marker.top + '%' }"
></div>
<!-- 模拟滑块 -->
<div
class="absolute left-[1px] right-[1px] compare-minimap-slider cursor-grab active:cursor-grabbing"
:style="{
top: rightTextScrollTopPercent + '%',
height: rightTextScrollHeightPercent + '%'
}"
@mousedown.stop="startDragScroll($event, 'rightText')"
></div>
</div>
</div>
</div>
<!-- 渲染区 -->
<div
class="flex-grow flex flex-col compare-render-zone"
:class="{
'is-collapsed': !isRenderExpanded
}"
>
<div class="text-xs font-bold text-color3 mb-2 px-1 flex items-center space-x-1.5 shrink-0">
<span class="w-2 h-2 rounded-full bg-success"></span>
<span>目标工卡渲染 (新版本)</span>
</div>
<!-- 滚动容器 + 指示条共用一个 relative 包裹 -->
<div class="flex-1 min-h-0 relative">
<div
ref="rightScrollContainer"
class="is-diff-mode h-full overflow-auto border border-divider rounded-lg bg-card p-6 leading-relaxed relative scroll-container"
@scroll="onRightScroll"
>
<DocNodeRenderer v-if="newDiffTree" :node="newDiffTree" :parent="null" />
<div v-else class="h-full flex items-center justify-center text-xs text-color3 select-none">
暂无目标工卡数据,请在上方输入框中粘贴或导入
</div>
</div>
<!-- 右侧渲染差异指示条:滚动容器外部左侧,加宽并支持点击跳转和模拟拖拽 -->
<div
v-if="newDiffTree"
class="absolute left-[2px] top-0 bottom-0 w-[12px] bg-transparent hover:bg-black/[0.04] cursor-pointer z-50 select-none transition-colors"
title="点击定位或拖动滑块"
@click="handleRightRenderMinimapClick"
>
<!-- 差异 markers -->
<div
v-for="(marker, mIdx) in rightRenderDiffMarkers"
:key="mIdx"
class="absolute left-0 right-0 h-[3px]"
:class="marker.type === 'added' ? 'bg-success-6' : 'bg-warning-6'"
:style="{ top: marker.top + '%' }"
></div>
<!-- 模拟滑块 -->
<div
class="absolute left-[1px] right-[1px] compare-minimap-slider cursor-grab active:cursor-grabbing"
:style="{
top: rightRenderScrollTopPercent + '%',
height: rightRenderScrollHeightPercent + '%'
}"
@mousedown.stop="startDragScroll($event, 'rightRender')"
></div>
</div>
</div>
</div>
</div>
</div>
</div>
</CommonModal>
<div></div>
</template>
<script setup lang="ts">
import { ref, provide } from 'vue'
import { useCompareModal } from './functionals'
import DocNodeRenderer from '../../../DocNodeRenderer/index.vue'
const {
showModal,
loading,
oldXmlText,
newXmlText,
oldDiffTree,
newDiffTree,
leftScrollContainer,
rightScrollContainer,
handleLeftScroll,
handleRightScroll,
loadCurrentXml,
resetDiff,
open,
handleCompare,
handleXmlImportClick,
formatXmlText,
isEditingOld,
isEditingNew,
oldDiffLines,
newDiffLines,
updateTextDiff,
leftTextScrollContainer,
rightTextScrollContainer,
handleLeftTextScroll,
handleRightTextScroll,
leftParentContainer,
rightParentContainer,
editingOldLineNum,
editingNewLineNum,
editingOldText,
editingNewText,
startEditLine,
saveEditLine,
cancelEditLine,
handleOldXmlBlur,
handleNewXmlBlur,
highlightTargetNode,
leftTextDiffMarkers,
rightTextDiffMarkers,
leftRenderDiffMarkers,
rightRenderDiffMarkers,
handleLeftTextMinimapClick,
handleRightTextMinimapClick,
handleLeftRenderMinimapClick,
handleRightRenderMinimapClick,
leftTextScrollTopPercent,
leftTextScrollHeightPercent,
rightTextScrollTopPercent,
rightTextScrollHeightPercent,
leftRenderScrollTopPercent,
leftRenderScrollHeightPercent,
rightRenderScrollTopPercent,
rightRenderScrollHeightPercent,
startDragScroll,
onLeftTextScroll,
onRightTextScroll,
onLeftScroll,
onRightScroll,
editingOldRange,
editingNewRange,
editingOldRangeText,
editingNewRangeText,
toggleEditOld,
toggleEditNew,
saveEditRange,
cancelEditRange
} = useCompareModal()
const isTextExpanded = ref(true)
const isRenderExpanded = ref(true)
const handleTextExpandedChange = (val: boolean) => {
if (!val && !isRenderExpanded.value) {
window.$message.warning('请至少保留一个对比区域')
return
}
isTextExpanded.value = val
}
const handleRenderExpandedChange = (val: boolean) => {
if (!val && !isTextExpanded.value) {
window.$message.warning('请至少保留一个对比区域')
return
}
isRenderExpanded.value = val
}
// 注入 diffMode: true 到下级所有的 DocNodeRenderer 中
provide('diffMode', true)
defineExpose({
open
})
</script>
<style>
.app-modal.is-fullscreen .scroll-container {
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.015) 1px, transparent 1px);
background-size: 24px 24px;
}
/* 覆盖 n-input 文本域的高度以填充容器 */
.app-modal.is-fullscreen .n-input.n-input--textarea {
height: 100% !important;
}
.app-modal.is-fullscreen .n-input.n-input--textarea .n-input-wrapper {
height: 100% !important;
}
.app-modal.is-fullscreen .n-input.n-input--textarea textarea {
height: 100% !important;
flex: 1 !important;
}
/* 比对模式样式覆写 */
.is-diff-mode [contenteditable] {
pointer-events: none !important;
user-select: text !important;
}
.is-diff-mode .ring-2,
.is-diff-mode .ring-primary,
.is-diff-mode .focus\:bg-fill-3 {
box-shadow: none !important;
background-color: transparent !important;
}
/* 移除 hover 背景色 */
.is-diff-mode .doc-node-wrapper:hover {
background-color: transparent !important;
}
/* 隐藏对比弹窗内滚动容器的原生垂直和水平滚动条,只保留我们自定义的模拟滑块 */
.compare-modal-content .scroll-container {
scrollbar-width: none !important; /* Firefox */
-ms-overflow-style: none !important; /* IE/Edge */
}
.compare-modal-content .scroll-container::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important; /* Chrome/Safari */
}
/* 差异标记样式 */
.is-diff-mode [data-diff-status='added'] {
background-color: color-mix(in srgb, var(--success-color) 8%, transparent) !important;
border-left: 3px solid var(--success-color) !important;
padding-left: 4px !important;
margin-left: -7px !important;
}
.is-diff-mode [data-diff-status='removed'] {
background-color: color-mix(in srgb, var(--error-color) 8%, transparent) !important;
border-left: 3px solid var(--error-color) !important;
text-decoration: line-through !important;
opacity: 0.75 !important;
padding-left: 4px !important;
margin-left: -7px !important;
}
.is-diff-mode [data-diff-status='modified'] {
background-color: color-mix(in srgb, var(--warning-color) 12%, transparent) !important;
box-shadow:
inset 3px 0 0 var(--warning-color),
0 0 0 1.5px color-mix(in srgb, var(--warning-color) 40%, transparent) !important;
border-radius: 2px !important;
padding: 0 2px !important;
}
/* block 元素补充左侧缩进,避免 box-shadow 被裁剪 */
div.doc-node-wrapper[data-diff-status='modified'] {
margin-left: -5px !important;
padding-left: 5px !important;
}
/* 新的 XML 文本比对行级差异高亮 */
.line-row-removed {
background-color: color-mix(in srgb, var(--error-color) 10%, transparent) !important;
border-left: 3px solid var(--error-color) !important;
color: color-mix(in srgb, var(--error-color) 90%, var(--text-color-1)) !important;
}
.line-row-added {
background-color: color-mix(in srgb, var(--success-color) 10%, transparent) !important;
border-left: 3px solid var(--success-color) !important;
color: color-mix(in srgb, var(--success-color) 90%, var(--text-color-1)) !important;
}
.line-row-empty {
background-color: color-mix(in srgb, var(--warning-color) 5%, transparent) !important;
background-image: repeating-linear-gradient(
-45deg,
color-mix(in srgb, var(--warning-color) 15%, transparent) 0,
color-mix(in srgb, var(--warning-color) 15%, transparent) 1.5px,
transparent 0,
transparent 8px
) !important;
background-size: 12px 12px;
user-select: none !important;
}
/* XML行选中对应的工卡渲染节点高亮闪烁效果 */
@keyframes highlight-glow {
0% {
box-shadow: 0 0 0 0px color-mix(in srgb, var(--warning-color) 40%, transparent);
background-color: color-mix(in srgb, var(--warning-color) 10%, transparent) !important;
}
50% {
box-shadow: 0 0 10px 4px color-mix(in srgb, var(--warning-color) 60%, transparent);
background-color: color-mix(in srgb, var(--warning-color) 25%, transparent) !important;
}
100% {
box-shadow: 0 0 0 0px color-mix(in srgb, var(--warning-color) 40%, transparent);
background-color: color-mix(in srgb, var(--warning-color) 10%, transparent) !important;
}
}
.highlight-focused-node {
animation: highlight-glow 1.5s ease-in-out infinite;
border-radius: 4px;
transition: all 0.3s ease;
}
/* 隐藏比对弹框的头部 */
.app-modal.compare-modal-nobar > .n-card-header {
display: none !important;
}
/* XML 文本区默认样式与展开/收起过渡 */
.compare-text-zone {
flex: 1 1 0% !important;
min-height: 180px !important;
max-height: 320px !important;
margin-bottom: 12px !important;
opacity: 1 !important;
overflow: hidden;
/* 展开过渡:弹性分配比和外边距立即变化,不透明度延迟 0.15s 淡入 */
transition:
flex 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s,
max-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s,
min-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s,
margin-bottom 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s,
opacity 0.2s linear 0.15s !important;
}
/* 当渲染区收起时,文本区独占全部剩余高度 */
.compare-text-zone.expanded-full {
flex: 1 1 0% !important;
min-height: 0 !important;
max-height: 100% !important;
margin-bottom: 0 !important;
}
/* 当文本区收起时:不透明度立即淡出,尺寸延迟 0.08s 收折 */
.compare-text-zone.is-collapsed {
flex: 0 0 0% !important;
max-height: 0 !important;
min-height: 0 !important;
margin-bottom: 0 !important;
opacity: 0 !important;
pointer-events: none;
transition:
opacity 0.15s linear 0s,
flex 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s,
max-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s,
min-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s,
margin-bottom 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s !important;
}
/* 渲染区默认样式与展开/收起过渡 */
.compare-render-zone {
flex: 2 1 0% !important;
min-height: 0 !important;
max-height: 2000px !important;
opacity: 1 !important;
overflow: hidden;
/* 展开过渡:弹性大小和高度立即变化,不透明度延迟 0.15s 淡入 */
transition:
flex 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s,
max-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s,
opacity 0.2s linear 0.15s !important;
}
/* 当渲染区收起时:不透明度立即淡出,弹性大小延迟 0.08s 收折 */
.compare-render-zone.is-collapsed {
flex: 0 0 0% !important;
max-height: 0 !important;
opacity: 0 !important;
pointer-events: none;
transition:
opacity 0.15s linear 0s,
flex 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s,
max-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s !important;
}
.compare-minimap-slider {
background-color: color-mix(in srgb, var(--primary-color) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--primary-color) 20%, transparent);
border-radius: 4px;
transition:
background-color 0.15s,
border-color 0.15s;
}
.compare-minimap-slider:hover {
background-color: color-mix(in srgb, var(--primary-color) 25%, transparent);
}
.compare-minimap-slider:active {
background-color: color-mix(in srgb, var(--primary-color) 35%, transparent);
}
</style>
<script setup lang="ts"></script>
<style lang="less" scoped></style>
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