Commit 5af0d0fb by pangchong

feat: 完善对比工卡逻辑

parent d278bc3b
...@@ -34,6 +34,13 @@ import { history, defaultKeymap, historyKeymap } from '@codemirror/commands' ...@@ -34,6 +34,13 @@ import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
import { searchKeymap, highlightSelectionMatches, openSearchPanel, search, SearchQuery, getSearchQuery } from '@codemirror/search' import { searchKeymap, highlightSelectionMatches, openSearchPanel, search, SearchQuery, getSearchQuery } from '@codemirror/search'
import { lintKeymap } from '@codemirror/lint' import { lintKeymap } from '@codemirror/lint'
interface InlineDiff {
line: number
from: number
to: number
class: string
}
interface Props { interface Props {
modelValue: string modelValue: string
disabled?: boolean disabled?: boolean
...@@ -42,6 +49,7 @@ interface Props { ...@@ -42,6 +49,7 @@ interface Props {
minHeight?: string | number minHeight?: string | number
lineClasses?: string[] lineClasses?: string[]
lineNumbers?: (string | number)[] lineNumbers?: (string | number)[]
inlineDiffs?: InlineDiff[]
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
...@@ -49,19 +57,33 @@ const props = withDefaults(defineProps<Props>(), { ...@@ -49,19 +57,33 @@ const props = withDefaults(defineProps<Props>(), {
disabled: false, disabled: false,
minHeight: 200, minHeight: 200,
lineClasses: () => [], lineClasses: () => [],
lineNumbers: () => [] lineNumbers: () => [],
inlineDiffs: () => []
}) })
// ── 差异高亮与自定义行号的 CodeMirror 6 拓展实现 ── // ── 差异高亮与自定义行号的 CodeMirror 6 拓展实现 ──
const setLineClassesEffect = StateEffect.define<string[]>() const setLineClassesEffect = StateEffect.define<{ lineClasses: string[]; inlineDiffs: InlineDiff[] }>()
function getLineDecorations(lineClasses: string[], doc: any) { function getLineDecorations(lineClasses: string[], inlineDiffs: InlineDiff[], doc: any) {
const builder = new RangeSetBuilder<Decoration>() const builder = new RangeSetBuilder<Decoration>()
// 将 inlineDiffs 按 line 分组
const diffsByLine = new Map<number, InlineDiff[]>()
if (inlineDiffs && inlineDiffs.length > 0) {
for (const item of inlineDiffs) {
if (!diffsByLine.has(item.line)) {
diffsByLine.set(item.line, [])
}
diffsByLine.get(item.line)!.push(item)
}
}
for (let i = 1; i <= doc.lines; i++) { for (let i = 1; i <= doc.lines; i++) {
const cls = lineClasses[i - 1] const cls = lineClasses[i - 1]
if (cls) {
const line = doc.line(i) const line = doc.line(i)
if (cls) {
builder.add( builder.add(
line.from, line.from,
line.from, line.from,
...@@ -70,19 +92,37 @@ function getLineDecorations(lineClasses: string[], doc: any) { ...@@ -70,19 +92,37 @@ function getLineDecorations(lineClasses: string[], doc: any) {
}) })
) )
} }
const lineDiffs = diffsByLine.get(i)
if (lineDiffs && lineDiffs.length > 0) {
lineDiffs.sort((a, b) => a.from - b.from)
for (const diff of lineDiffs) {
const startPos = Math.min(line.from + diff.from, line.to)
const endPos = Math.min(line.from + diff.to, line.to)
if (startPos < endPos) {
builder.add(
startPos,
endPos,
Decoration.mark({
class: diff.class
})
)
}
}
}
} }
return builder.finish() return builder.finish()
} }
const lineClassesField = StateField.define<DecorationSet>({ const lineClassesField = StateField.define<DecorationSet>({
create(state) { create(state) {
return getLineDecorations(props.lineClasses || [], state.doc) return getLineDecorations(props.lineClasses || [], props.inlineDiffs || [], state.doc)
}, },
update(deco, tr) { update(deco, tr) {
deco = deco.map(tr.changes) deco = deco.map(tr.changes)
for (const effect of tr.effects) { for (const effect of tr.effects) {
if (effect.is(setLineClassesEffect)) { if (effect.is(setLineClassesEffect)) {
deco = getLineDecorations(effect.value, tr.state.doc) deco = getLineDecorations(effect.value.lineClasses, effect.value.inlineDiffs, tr.state.doc)
} }
} }
return deco return deco
...@@ -675,7 +715,10 @@ watch( ...@@ -675,7 +715,10 @@ watch(
}) })
if (props.lineClasses && props.lineClasses.length > 0) { if (props.lineClasses && props.lineClasses.length > 0) {
editorView.dispatch({ editorView.dispatch({
effects: setLineClassesEffect.of(props.lineClasses) effects: setLineClassesEffect.of({
lineClasses: props.lineClasses,
inlineDiffs: props.inlineDiffs || []
})
}) })
} }
} }
...@@ -693,11 +736,14 @@ watch( ...@@ -693,11 +736,14 @@ watch(
) )
watch( watch(
() => props.lineClasses, [() => props.lineClasses, () => props.inlineDiffs],
(newClasses) => { ([newClasses, newDiffs]) => {
if (editorView && newClasses) { if (editorView) {
editorView.dispatch({ editorView.dispatch({
effects: setLineClassesEffect.of(newClasses) effects: setLineClassesEffect.of({
lineClasses: newClasses || [],
inlineDiffs: newDiffs || []
})
}) })
} }
}, },
......
...@@ -19,6 +19,10 @@ export interface XmlNode { ...@@ -19,6 +19,10 @@ export interface XmlNode {
parentId: string | null parentId: string | null
/** 比对差异状态 */ /** 比对差异状态 */
diffStatus?: 'added' | 'removed' | 'modified' | 'none' diffStatus?: 'added' | 'removed' | 'modified' | 'none'
/** 是否包含属性变更 */
attrModified?: boolean
/** 具体变更的属性列表 */
attrDiffs?: { key: string; oldVal: string; newVal: string }[]
} }
/** /**
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
:data-node-id="node.id" :data-node-id="node.id"
:data-tag-name="node.tagName" :data-tag-name="node.tagName"
:data-diff-status="node.diffStatus" :data-diff-status="node.diffStatus"
:data-attr-modified="node.attrModified ? 'true' : undefined"
class="doc-node-wrapper relative transition-all" class="doc-node-wrapper relative transition-all"
:class="[ :class="[
renderInline ? 'inline align-baseline mx-0.5' : 'block my-1', renderInline ? 'inline align-baseline mx-0.5' : 'block my-1',
...@@ -27,6 +28,29 @@ ...@@ -27,6 +28,29 @@
</div> </div>
</div> </div>
</Transition> </Transition>
<!-- 属性变更徽标:仅块级节点用 absolute 定位,行内节点已由黄色背景/diff边框标示 -->
<n-popover v-if="!renderInline && node.attrDiffs && node.attrDiffs.length > 0" trigger="hover" placement="top-end" style="max-width: 380px">
<template #trigger>
<span
class="attr-diff-badge absolute -top-2 right-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] font-bold font-mono cursor-pointer select-none shadow-md z-20 pointer-events-auto transition-all hover:opacity-90"
style="background: #f59e0b; color: #fff; line-height: 1.2"
@click.stop
>
🏷️ {{ node.tagName }}
</span>
</template>
<div class="p-2 space-y-1.5 text-xs font-mono select-none" @click.stop>
<div class="font-bold border-b border-divider pb-1 flex items-center gap-1">
<span>🏷️ &lt;{{ node.tagName }}&gt; 属性变更明细</span>
</div>
<div v-for="diff in node.attrDiffs" :key="diff.key" class="flex items-center gap-1.5 flex-wrap">
<span class="font-semibold">{{ diff.key }}:</span>
<span class="line-through text-red-500 px-1 bg-red-500/10 rounded">{{ diff.oldVal || '(空)' }}</span>
<span class="text-amber-500 font-bold"></span>
<span class="text-emerald-600 dark:text-emerald-400 font-bold px-1 bg-emerald-500/10 rounded">{{ diff.newVal || '(空)' }}</span>
</div>
</div>
</n-popover>
<!-- 0. 各类 HEADER 节点特殊处理 (不展示) --> <!-- 0. 各类 HEADER 节点特殊处理 (不展示) -->
<template v-if="isHeaderTag"></template> <template v-if="isHeaderTag"></template>
...@@ -1326,6 +1350,29 @@ ...@@ -1326,6 +1350,29 @@
v-text="node.textContent || node.tagName" v-text="node.textContent || node.tagName"
></div> ></div>
</template> </template>
<!-- 行内节点属性变更上标徽标:渲染在内容之后,不遮挡任何文字 -->
<n-popover v-if="renderInline && node.attrDiffs && node.attrDiffs.length > 0" trigger="hover" placement="top" style="max-width: 380px">
<template #trigger>
<sup
class="inline-flex items-center justify-center cursor-pointer select-none rounded-full shadow-sm transition-transform hover:scale-125 ml-0.5"
style="background: #f59e0b; color: #fff; font-size: 8px; width: 14px; height: 14px; vertical-align: super; line-height: 1"
@click.stop
>
🏷
</sup>
</template>
<div class="p-2 space-y-1.5 text-xs font-mono select-none" @click.stop>
<div class="font-bold border-b border-divider pb-1 flex items-center gap-1">
<span>🏷️ &lt;{{ node.tagName }}&gt; 属性变更明细</span>
</div>
<div v-for="diff in node.attrDiffs" :key="diff.key" class="flex items-center gap-1.5 flex-wrap">
<span class="font-semibold">{{ diff.key }}:</span>
<span class="line-through text-red-500 px-1 bg-red-500/10 rounded">{{ diff.oldVal || '(空)' }}</span>
<span class="text-amber-500 font-bold"></span>
<span class="text-emerald-600 dark:text-emerald-400 font-bold px-1 bg-emerald-500/10 rounded">{{ diff.newVal || '(空)' }}</span>
</div>
</div>
</n-popover>
</component> </component>
</template> </template>
......
...@@ -15,6 +15,6 @@ export interface RenderDiffHunk { ...@@ -15,6 +15,6 @@ export interface RenderDiffHunk {
// XML源码比对差异段样式模型 // XML源码比对差异段样式模型
export interface XmlDiffHunk { export interface XmlDiffHunk {
type: 'added' | 'removed' type: 'added' | 'removed' | 'modified'
style: Record<string, string> style: Record<string, string>
} }
...@@ -42,6 +42,7 @@ function cloneWithDiff(node: XmlNode, status: DiffStatusType = 'none'): XmlNode ...@@ -42,6 +42,7 @@ function cloneWithDiff(node: XmlNode, status: DiffStatusType = 'none'): XmlNode
...node, ...node,
id: node.id + '_' + status, // 避免 id 冲突 id: node.id + '_' + status, // 避免 id 冲突
diffStatus: status as any, diffStatus: status as any,
attrModified: false,
children, children,
mixedContent mixedContent
} }
...@@ -121,10 +122,14 @@ function diffXmlTrees(oldNode: XmlNode | null, newNode: XmlNode | null): { oldDi ...@@ -121,10 +122,14 @@ function diffXmlTrees(oldNode: XmlNode | null, newNode: XmlNode | null): { oldDi
const newAttrs = { ...newNode!.attributes } const newAttrs = { ...newNode!.attributes }
let attrsChanged = false let attrsChanged = false
const attrDiffs: { key: string; oldVal: string; newVal: string }[] = []
const allAttrKeys = new Set([...Object.keys(oldAttrs), ...Object.keys(newAttrs)]) const allAttrKeys = new Set([...Object.keys(oldAttrs), ...Object.keys(newAttrs)])
for (const key of allAttrKeys) { for (const key of allAttrKeys) {
if (oldAttrs[key] !== newAttrs[key]) { const oVal = oldAttrs[key] || ''
const nVal = newAttrs[key] || ''
if (oVal !== nVal) {
attrsChanged = true attrsChanged = true
attrDiffs.push({ key, oldVal: oVal, newVal: nVal })
} }
} }
...@@ -315,6 +320,8 @@ function diffXmlTrees(oldNode: XmlNode | null, newNode: XmlNode | null): { oldDi ...@@ -315,6 +320,8 @@ function diffXmlTrees(oldNode: XmlNode | null, newNode: XmlNode | null): { oldDi
...oldNode!, ...oldNode!,
id: oldNode!.id + '_diff', id: oldNode!.id + '_diff',
diffStatus: diffStatus as any, diffStatus: diffStatus as any,
attrModified: attrsChanged,
attrDiffs,
children: oldChildrenDiff, children: oldChildrenDiff,
mixedContent: oldMixedContent, mixedContent: oldMixedContent,
attributes: oldAttrs, attributes: oldAttrs,
...@@ -325,6 +332,8 @@ function diffXmlTrees(oldNode: XmlNode | null, newNode: XmlNode | null): { oldDi ...@@ -325,6 +332,8 @@ function diffXmlTrees(oldNode: XmlNode | null, newNode: XmlNode | null): { oldDi
...newNode!, ...newNode!,
id: newNode!.id + '_diff', id: newNode!.id + '_diff',
diffStatus: diffStatus as any, diffStatus: diffStatus as any,
attrModified: attrsChanged,
attrDiffs,
children: newChildrenDiff, children: newChildrenDiff,
mixedContent: newMixedContent, mixedContent: newMixedContent,
attributes: newAttrs, attributes: newAttrs,
...@@ -350,6 +359,10 @@ export function useCompareModal() { ...@@ -350,6 +359,10 @@ export function useCompareModal() {
const leftLineClasses = ref<string[]>([]) const leftLineClasses = ref<string[]>([])
const rightLineClasses = ref<string[]>([]) const rightLineClasses = ref<string[]>([])
// XML 内部单词/字符级的细粒度 Diff 标记
const leftInlineDiffs = ref<{ line: number; from: number; to: number; class: string }[]>([])
const rightInlineDiffs = ref<{ line: number; from: number; to: number; class: string }[]>([])
// XML 的行号数组 // XML 的行号数组
const leftLineNumbers = ref<(string | number)[]>([]) const leftLineNumbers = ref<(string | number)[]>([])
const rightLineNumbers = ref<(string | number)[]>([]) const rightLineNumbers = ref<(string | number)[]>([])
...@@ -453,6 +466,127 @@ export function useCompareModal() { ...@@ -453,6 +466,127 @@ export function useCompareModal() {
return { added, removed, modified } return { added, removed, modified }
}) })
// 按类型收集渲染区中所有 diff 节点的 ID 列表,用于顺序导航
const diffNodeIds = computed(() => {
const added: string[] = []
const removed: string[] = []
const modified: string[] = []
const visitOld = (node: XmlNode) => {
if (!node) return
if (node.diffStatus === 'removed') {
removed.push(node.id)
return
}
if (node.diffStatus === 'modified') modified.push(node.id)
if (node.children) node.children.forEach(visitOld)
}
const visitNew = (node: XmlNode) => {
if (!node) return
if (node.diffStatus === 'added') {
added.push(node.id)
return
}
if (node.children) node.children.forEach(visitNew)
}
if (leftRenderTree.value) visitOld(leftRenderTree.value)
if (rightRenderTree.value) visitNew(rightRenderTree.value)
return { added, removed, modified }
})
// 各类型当前导航索引
const navIndex = reactive({ added: 0, removed: 0, modified: 0 })
/**
* 滚动渲染区到指定 node.id 对应的 DOM 元素
* side: 'left' 滚动左侧(删除、变更),'right' 滚动右侧(新增、变更)
*/
const scrollRenderToNode = (nodeId: string, side: 'left' | 'right') => {
const container = side === 'left' ? leftRenderContainer.value : rightRenderContainer.value
if (!container) return
const el = container.querySelector(`[data-node-id="${nodeId}"]`) as HTMLElement | null
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
// 短暂闪烁高亮
const prev = el.style.outline
el.style.outline = '2px solid #f59e0b'
el.style.borderRadius = '2px'
setTimeout(() => {
el.style.outline = prev
}, 1400)
}
/** 按类型导航到上一个或下一个 diff 节点 */
const navigateDiff = (type: 'added' | 'removed' | 'modified', direction: 'prev' | 'next') => {
const ids = diffNodeIds.value[type]
if (!ids || ids.length === 0) return
if (direction === 'next') {
navIndex[type] = (navIndex[type] + 1) % ids.length
} else {
navIndex[type] = (navIndex[type] - 1 + ids.length) % ids.length
}
const nodeId = ids[navIndex[type]]
// 1. 滚动渲染区
const scrollRender = (side: 'left' | 'right') => scrollRenderToNode(nodeId, side)
if (type === 'added') {
scrollRender('right')
} else if (type === 'removed') {
scrollRender('left')
} else {
scrollRender('left')
scrollRender('right')
}
// 2. 同步高亮定位 XML 编辑器(复用点击渲染区时的高亮逻辑)
const highlightXmlNode = (rawNodeId: string) => {
const cleanId = rawNodeId.replace(/_(diff|added|removed|unchanged|modified)/g, '').replace(/-txt-\d+/g, '')
// 左侧 XML
const leftRange = leftNodeRanges.value.get(cleanId)
if (leftRange) {
const leftDisplayLine = leftOriginalToEditor.value.get(leftRange.startLine)
if (leftDisplayLine) {
leftActiveLine.value = leftDisplayLine
leftEditorRef.value?.highlightAndScrollToLine(leftDisplayLine)
}
}
// 右侧 XML
const rightRange = rightNodeRanges.value.get(cleanId)
if (rightRange) {
const rightDisplayLine = rightOriginalToEditor.value.get(rightRange.startLine)
if (rightDisplayLine) {
rightActiveLine.value = rightDisplayLine
rightEditorRef.value?.highlightAndScrollToLine(rightDisplayLine)
}
}
}
highlightXmlNode(nodeId)
}
const navigateToNextDiff = (type: 'added' | 'removed' | 'modified') => navigateDiff(type, 'next')
const navigateToPrevDiff = (type: 'added' | 'removed' | 'modified') => navigateDiff(type, 'prev')
// 格式化展示导航进度(如 "1/2"、"2/2" 或 "0")
const navCurrentDisplay = computed(() => {
const addedCount = diffNodeIds.value.added.length
const removedCount = diffNodeIds.value.removed.length
const modifiedCount = diffNodeIds.value.modified.length
return {
added: addedCount > 0 ? `${(navIndex.added % addedCount) + 1}/${addedCount}` : '0',
removed: removedCount > 0 ? `${(navIndex.removed % removedCount) + 1}/${removedCount}` : '0',
modified: modifiedCount > 0 ? `${(navIndex.modified % modifiedCount) + 1}/${modifiedCount}` : '0'
}
})
const showXmlCompare = ref(true) const showXmlCompare = ref(true)
const showRenderCompare = ref(true) const showRenderCompare = ref(true)
...@@ -487,6 +621,8 @@ export function useCompareModal() { ...@@ -487,6 +621,8 @@ export function useCompareModal() {
rightDisplayXml.value = '' rightDisplayXml.value = ''
leftLineClasses.value = [] leftLineClasses.value = []
rightLineClasses.value = [] rightLineClasses.value = []
leftInlineDiffs.value = []
rightInlineDiffs.value = []
leftLineNumbers.value = [] leftLineNumbers.value = []
rightLineNumbers.value = [] rightLineNumbers.value = []
leftRenderTree.value = null leftRenderTree.value = null
...@@ -573,12 +709,124 @@ export function useCompareModal() { ...@@ -573,12 +709,124 @@ export function useCompareModal() {
const rClasses: string[] = [] const rClasses: string[] = []
const lNums: (string | number)[] = [] const lNums: (string | number)[] = []
const rNums: (string | number)[] = [] const rNums: (string | number)[] = []
const lInlineDiffs: { line: number; from: number; to: number; class: string }[] = []
const rInlineDiffs: { line: number; from: number; to: number; class: string }[] = []
let leftRealLine = 1 let leftRealLine = 1
let rightRealLine = 1 let rightRealLine = 1
let displayLineIndex = 1 let displayLineIndex = 1
for (const change of diffResults) { let idx = 0
while (idx < diffResults.length) {
const current = diffResults[idx]
const next = diffResults[idx + 1]
// 检查是否为成对的「修改行」(前面删、后面增,或前面增、后面删)
if (next && ((current.removed && next.added) || (current.added && next.removed))) {
const removedChange = current.removed ? current : next
const addedChange = current.added ? current : next
const removedLines = removedChange.value.split('\n')
if (removedLines.length > 1 && removedLines[removedLines.length - 1] === '') {
removedLines.pop()
}
const addedLines = addedChange.value.split('\n')
if (addedLines.length > 1 && addedLines[addedLines.length - 1] === '') {
addedLines.pop()
}
const maxPair = Math.max(removedLines.length, addedLines.length)
for (let p = 0; p < maxPair; p++) {
const rLine = removedLines[p]
const aLine = addedLines[p]
if (rLine !== undefined && aLine !== undefined) {
// 同一行都有内容:说明是同一行的改动,直接同行侧重对比!
lLines.push(rLine)
lClasses.push('diff-modified-left')
lNums.push(leftRealLine)
rLines.push(aLine)
rClasses.push('diff-modified-right')
rNums.push(rightRealLine)
// 详细计算该行内部单词/字符级别的 Inline Word Diff
const wordDiffs = Diff.diffWords(rLine, aLine)
let leftCharOffset = 0
let rightCharOffset = 0
for (const w of wordDiffs) {
const len = w.value.length
if (w.removed) {
lInlineDiffs.push({
line: displayLineIndex,
from: leftCharOffset,
to: leftCharOffset + len,
class: 'cm-diff-inline-removed'
})
leftCharOffset += len
} else if (w.added) {
rInlineDiffs.push({
line: displayLineIndex,
from: rightCharOffset,
to: rightCharOffset + len,
class: 'cm-diff-inline-added'
})
rightCharOffset += len
} else {
leftCharOffset += len
rightCharOffset += len
}
}
leftEditorToOriginal.value.set(displayLineIndex, leftRealLine)
leftOriginalToEditor.value.set(leftRealLine, displayLineIndex)
rightEditorToOriginal.value.set(displayLineIndex, rightRealLine)
rightOriginalToEditor.value.set(rightRealLine, displayLineIndex)
leftRealLine++
rightRealLine++
displayLineIndex++
} else if (rLine !== undefined) {
// 多出来的旧行
lLines.push(rLine)
lClasses.push('diff-removed')
lNums.push(leftRealLine)
rLines.push('')
rClasses.push('diff-removed-placeholder')
rNums.push('')
leftEditorToOriginal.value.set(displayLineIndex, leftRealLine)
leftOriginalToEditor.value.set(leftRealLine, displayLineIndex)
leftRealLine++
displayLineIndex++
} else if (aLine !== undefined) {
// 多出来的增行
lLines.push('')
lClasses.push('diff-added-placeholder')
lNums.push('')
rLines.push(aLine)
rClasses.push('diff-added')
rNums.push(rightRealLine)
rightEditorToOriginal.value.set(displayLineIndex, rightRealLine)
rightOriginalToEditor.value.set(rightRealLine, displayLineIndex)
rightRealLine++
displayLineIndex++
}
}
idx += 2
continue
}
// 普通单块处理
const change = current
const lines = change.value.split('\n') const lines = change.value.split('\n')
if (lines.length > 1 && lines[lines.length - 1] === '') { if (lines.length > 1 && lines[lines.length - 1] === '') {
lines.pop() lines.pop()
...@@ -594,7 +842,6 @@ export function useCompareModal() { ...@@ -594,7 +842,6 @@ export function useCompareModal() {
rClasses.push('diff-added') rClasses.push('diff-added')
rNums.push(rightRealLine) rNums.push(rightRealLine)
// 记录右侧编辑器到原始格式化行号的对应关系
rightEditorToOriginal.value.set(displayLineIndex, rightRealLine) rightEditorToOriginal.value.set(displayLineIndex, rightRealLine)
rightOriginalToEditor.value.set(rightRealLine, displayLineIndex) rightOriginalToEditor.value.set(rightRealLine, displayLineIndex)
...@@ -611,7 +858,6 @@ export function useCompareModal() { ...@@ -611,7 +858,6 @@ export function useCompareModal() {
rClasses.push('diff-removed-placeholder') rClasses.push('diff-removed-placeholder')
rNums.push('') rNums.push('')
// 记录左侧编辑器到原始格式化行号的对应关系
leftEditorToOriginal.value.set(displayLineIndex, leftRealLine) leftEditorToOriginal.value.set(displayLineIndex, leftRealLine)
leftOriginalToEditor.value.set(leftRealLine, displayLineIndex) leftOriginalToEditor.value.set(leftRealLine, displayLineIndex)
...@@ -628,7 +874,6 @@ export function useCompareModal() { ...@@ -628,7 +874,6 @@ export function useCompareModal() {
rClasses.push('') rClasses.push('')
rNums.push(rightRealLine) rNums.push(rightRealLine)
// 记录两侧编辑器到原始格式化行号的对应关系
leftEditorToOriginal.value.set(displayLineIndex, leftRealLine) leftEditorToOriginal.value.set(displayLineIndex, leftRealLine)
leftOriginalToEditor.value.set(leftRealLine, displayLineIndex) leftOriginalToEditor.value.set(leftRealLine, displayLineIndex)
...@@ -640,12 +885,16 @@ export function useCompareModal() { ...@@ -640,12 +885,16 @@ export function useCompareModal() {
displayLineIndex++ displayLineIndex++
} }
} }
idx++
} }
leftDisplayXml.value = lLines.join('\n') leftDisplayXml.value = lLines.join('\n')
rightDisplayXml.value = rLines.join('\n') rightDisplayXml.value = rLines.join('\n')
leftLineClasses.value = lClasses leftLineClasses.value = lClasses
rightLineClasses.value = rClasses rightLineClasses.value = rClasses
leftInlineDiffs.value = lInlineDiffs
rightInlineDiffs.value = rInlineDiffs
leftLineNumbers.value = lNums leftLineNumbers.value = lNums
rightLineNumbers.value = rNums rightLineNumbers.value = rNums
...@@ -661,9 +910,11 @@ export function useCompareModal() { ...@@ -661,9 +910,11 @@ export function useCompareModal() {
compared.value = true compared.value = true
window.$message?.success('比对成功') window.$message?.success('比对成功')
// 比对成功后同步滚动绑定 // 比对成功后同步滚动绑定,并分两轮补齐左右渲染节点高度差
nextTick(() => { nextTick(() => {
bindScrollSync() bindScrollSync()
setTimeout(syncRenderHeights, 300)
setTimeout(syncRenderHeights, 800)
}) })
} catch (err: any) { } catch (err: any) {
window.$message?.error(`比对发生错误: ${err.message || err}`) window.$message?.error(`比对发生错误: ${err.message || err}`)
...@@ -672,6 +923,67 @@ export function useCompareModal() { ...@@ -672,6 +923,67 @@ export function useCompareModal() {
} }
} }
/**
* 高度对齐补偿(并行遍历两棵虚拟数据树):
* ⚠️ doc-node-wrapper 有 transition-all,paddingBottom 变化会触发 150ms 动画。
* 直接读 offsetHeight 会读到动画中间值,导致父节点重复叠加补偿。
* 修复:测量前临时关闭所有节点的 transition,补偿完成后恢复。
*/
const syncRenderHeights = () => {
const lc = leftRenderContainer.value
const rc = rightRenderContainer.value
if (!lc || !rc) return
if (!leftRenderTree.value || !rightRenderTree.value) return
// 1. 收集所有块级 doc-node-wrapper
const leftAll = Array.from(lc.querySelectorAll<HTMLElement>('.doc-node-wrapper:not(.inline)'))
const rightAll = Array.from(rc.querySelectorAll<HTMLElement>('.doc-node-wrapper:not(.inline)'))
// 2. 临时关闭 transition(避免 paddingBottom 动画导致 offsetHeight 读到中间值)
const allNodes = [...leftAll, ...rightAll]
allNodes.forEach((el) => { el.style.transition = 'none' })
// 强制 reflow,使 transition:none 立即生效
void lc.offsetHeight
// 3. 清除所有旧的 paddingBottom
leftAll.forEach((el) => { el.style.paddingBottom = '' })
rightAll.forEach((el) => { el.style.paddingBottom = '' })
// 再次强制 reflow,确保清除立即生效
void lc.offsetHeight
// 4. 后序并行遍历两棵虚拟树,用各自节点 id 在各自容器中找 DOM 元素
const walk = (leftNode: XmlNode, rightNode: XmlNode) => {
const lc_children = leftNode.children || []
const rc_children = rightNode.children || []
const len = Math.min(lc_children.length, rc_children.length)
for (let i = 0; i < len; i++) {
walk(lc_children[i], rc_children[i])
}
const leftEl = lc.querySelector<HTMLElement>(`[data-node-id="${leftNode.id}"]`)
const rightEl = rc.querySelector<HTMLElement>(`[data-node-id="${rightNode.id}"]`)
if (!leftEl || !rightEl) return
if (leftEl.classList.contains('inline') || rightEl.classList.contains('inline')) return
const hL = leftEl.offsetHeight
const hR = rightEl.offsetHeight
const diff = hR - hL
if (diff > 2) {
leftEl.style.paddingBottom = `${diff}px`
} else if (diff < -2) {
rightEl.style.paddingBottom = `${Math.abs(diff)}px`
}
}
walk(leftRenderTree.value, rightRenderTree.value)
// 5. 补偿完成后在下一帧恢复 transition
requestAnimationFrame(() => {
allNodes.forEach((el) => { el.style.transition = '' })
})
}
// 同步 XML 编辑器滚动 // 同步 XML 编辑器滚动
// 用位置相等判断阻断回路:设置 other.scrollTop 后 other 的 scroll 事件触发时两侧已相等 → 直接返回 // 用位置相等判断阻断回路:设置 other.scrollTop 后 other 的 scroll 事件触发时两侧已相等 → 直接返回
// 不使用锁标志,确保每次用户滚动事件都能即时同步对侧,不产生位置滞后 // 不使用锁标志,确保每次用户滚动事件都能即时同步对侧,不产生位置滞后
...@@ -917,8 +1229,8 @@ export function useCompareModal() { ...@@ -917,8 +1229,8 @@ export function useCompareModal() {
} }
}) })
// 将左侧 lineClasses 拆解为纵向标记段 // 将左侧 lineClasses 拆解为纵向小地图标记段
const diffHunks = computed<XmlDiffHunk[]>(() => { const leftDiffHunks = computed<XmlDiffHunk[]>(() => {
const cls = leftLineClasses.value const cls = leftLineClasses.value
const n = cls.length const n = cls.length
if (n === 0) return [] if (n === 0) return []
...@@ -941,6 +1253,14 @@ export function useCompareModal() { ...@@ -941,6 +1253,14 @@ export function useCompareModal() {
type: 'added', type: 'added',
style: { top: `${(start / n) * 100}%`, height: `${Math.max(0.5, (count / n) * 100)}%`, minHeight: '3px' } style: { top: `${(start / n) * 100}%`, height: `${Math.max(0.5, (count / n) * 100)}%`, minHeight: '3px' }
}) })
} else if (cls[i] === 'diff-modified-left') {
const start = i
while (i < n && cls[i] === 'diff-modified-left') i++
const count = i - start
result.push({
type: 'modified',
style: { top: `${(start / n) * 100}%`, height: `${Math.max(0.5, (count / n) * 100)}%`, minHeight: '3px' }
})
} else { } else {
i++ i++
} }
...@@ -948,6 +1268,47 @@ export function useCompareModal() { ...@@ -948,6 +1268,47 @@ export function useCompareModal() {
return result return result
}) })
// 将右侧 lineClasses 拆解为纵向小地图标记段
const rightDiffHunks = computed<XmlDiffHunk[]>(() => {
const cls = rightLineClasses.value
const n = cls.length
if (n === 0) return []
const result: XmlDiffHunk[] = []
let i = 0
while (i < n) {
if (cls[i] === 'diff-added') {
const start = i
while (i < n && cls[i] === 'diff-added') i++
const count = i - start
result.push({
type: 'added',
style: { top: `${(start / n) * 100}%`, height: `${Math.max(0.5, (count / n) * 100)}%`, minHeight: '3px' }
})
} else if (cls[i] === 'diff-removed-placeholder') {
const start = i
while (i < n && cls[i] === 'diff-removed-placeholder') i++
const count = i - start
result.push({
type: 'removed',
style: { top: `${(start / n) * 100}%`, height: `${Math.max(0.5, (count / n) * 100)}%`, minHeight: '3px' }
})
} else if (cls[i] === 'diff-modified-right') {
const start = i
while (i < n && cls[i] === 'diff-modified-right') i++
const count = i - start
result.push({
type: 'modified',
style: { top: `${(start / n) * 100}%`, height: `${Math.max(0.5, (count / n) * 100)}%`, minHeight: '3px' }
})
} else {
i++
}
}
return result
})
const diffHunks = leftDiffHunks
const updateGutterScroll = () => { const updateGutterScroll = () => {
if (leftScrollerEl) { if (leftScrollerEl) {
gutterScrollTop.value = leftScrollerEl.scrollTop gutterScrollTop.value = leftScrollerEl.scrollTop
...@@ -1250,13 +1611,6 @@ export function useCompareModal() { ...@@ -1250,13 +1611,6 @@ export function useCompareModal() {
} }
} }
console.log('[DEBUG Compare] handleXmlLineSelect:', {
side,
origLine,
targetNodeId,
minSpan
})
if (!targetNodeId) return if (!targetNodeId) return
activeNodeId.value = targetNodeId activeNodeId.value = targetNodeId
...@@ -1265,44 +1619,44 @@ export function useCompareModal() { ...@@ -1265,44 +1619,44 @@ export function useCompareModal() {
el.classList.remove('render-active-highlight') el.classList.remove('render-active-highlight')
}) })
// 查找并高亮左侧与右侧排版区域中匹配的元素 // 查找并高亮左侧与右侧排版区域中匹配的元素(精确匹配 nodeId,防止 node_1 误选 node_10)
const targetLeft = leftRenderContainer.value?.querySelector(`[data-node-id^="${targetNodeId}"]`) as HTMLElement const findRenderNodeElement = (container: HTMLElement | null, nodeId: string): HTMLElement | null => {
console.log( if (!container || !nodeId) return null
'[DEBUG Compare] targetLeft element:', const els = container.querySelectorAll('[data-node-id]')
targetLeft for (let i = 0; i < els.length; i++) {
? { const el = els[i] as HTMLElement
tagName: targetLeft.tagName, const idAttr = el.getAttribute('data-node-id') || ''
nodeIdAttr: targetLeft.getAttribute('data-node-id'), if (idAttr === nodeId || idAttr.startsWith(`${nodeId}_`)) {
outerHTML: targetLeft.outerHTML.slice(0, 200) return el
} }
: 'null' }
) return null
}
const targetLeft = findRenderNodeElement(leftRenderContainer.value, targetNodeId)
const targetRight = findRenderNodeElement(rightRenderContainer.value, targetNodeId)
if (targetLeft) { if (targetLeft) {
targetLeft.classList.add('render-active-highlight') targetLeft.classList.add('render-active-highlight')
} }
const targetRight = rightRenderContainer.value?.querySelector(`[data-node-id^="${targetNodeId}"]`) as HTMLElement
console.log(
'[DEBUG Compare] targetRight element:',
targetRight
? {
tagName: targetRight.tagName,
nodeIdAttr: targetRight.getAttribute('data-node-id'),
outerHTML: targetRight.outerHTML.slice(0, 200)
}
: 'null'
)
if (targetRight) { if (targetRight) {
targetRight.classList.add('render-active-highlight') targetRight.classList.add('render-active-highlight')
} }
// 智能定位:大尺寸容器(如 JOBCARD, CHP)滚动至顶部 start,小尺寸节点滚动至中央 center
const scrollToElement = (targetEl: HTMLElement, containerEl: HTMLElement | null) => {
if (!targetEl || !containerEl) return
const containerHeight = containerEl.clientHeight || 400
const targetHeight = targetEl.clientHeight || 0
const blockAlign = targetHeight > containerHeight * 0.35 ? 'start' : 'center'
targetEl.scrollIntoView({ behavior: 'smooth', block: blockAlign })
}
// 根据触发源,让对应的渲染区域容器进行滚动定位 // 根据触发源,让对应的渲染区域容器进行滚动定位
if (side === 'left' && targetLeft) { if (side === 'left' && targetLeft) {
targetLeft.scrollIntoView({ behavior: 'smooth', block: 'center' }) scrollToElement(targetLeft, leftRenderContainer.value)
} else if (side === 'right' && targetRight) { } else if (side === 'right' && targetRight) {
targetRight.scrollIntoView({ behavior: 'smooth', block: 'center' }) scrollToElement(targetRight, rightRenderContainer.value)
} }
} }
...@@ -1321,7 +1675,7 @@ export function useCompareModal() { ...@@ -1321,7 +1675,7 @@ export function useCompareModal() {
document.querySelectorAll('.render-active-highlight').forEach((el) => { document.querySelectorAll('.render-active-highlight').forEach((el) => {
el.classList.remove('render-active-highlight') el.classList.remove('render-active-highlight')
}) })
document.querySelectorAll(`[data-node-id^="${nodeId}"]`).forEach((el) => { document.querySelectorAll(`[data-node-id="${nodeId}"], [data-node-id^="${nodeId}_"]`).forEach((el) => {
el.classList.add('render-active-highlight') el.classList.add('render-active-highlight')
}) })
...@@ -1329,13 +1683,10 @@ export function useCompareModal() { ...@@ -1329,13 +1683,10 @@ export function useCompareModal() {
leftActiveLine.value = null leftActiveLine.value = null
rightActiveLine.value = null rightActiveLine.value = null
// 高亮定位 XML 编辑器 // 高亮定位 XML 编辑器
console.log('[CompareModal] Clicked side:', side, 'nodeId:', nodeId)
const leftRange = leftNodeRanges.value.get(nodeId) const leftRange = leftNodeRanges.value.get(nodeId)
console.log('[CompareModal] leftRange:', leftRange)
if (leftRange) { if (leftRange) {
const leftDisplayLine = leftOriginalToEditor.value.get(leftRange.startLine) const leftDisplayLine = leftOriginalToEditor.value.get(leftRange.startLine)
console.log('[CompareModal] leftDisplayLine:', leftDisplayLine)
if (leftDisplayLine) { if (leftDisplayLine) {
leftActiveLine.value = leftDisplayLine leftActiveLine.value = leftDisplayLine
leftEditorRef.value?.highlightAndScrollToLine(leftDisplayLine) leftEditorRef.value?.highlightAndScrollToLine(leftDisplayLine)
...@@ -1343,10 +1694,8 @@ export function useCompareModal() { ...@@ -1343,10 +1694,8 @@ export function useCompareModal() {
} }
const rightRange = rightNodeRanges.value.get(nodeId) const rightRange = rightNodeRanges.value.get(nodeId)
console.log('[CompareModal] rightRange:', rightRange)
if (rightRange) { if (rightRange) {
const rightDisplayLine = rightOriginalToEditor.value.get(rightRange.startLine) const rightDisplayLine = rightOriginalToEditor.value.get(rightRange.startLine)
console.log('[CompareModal] rightDisplayLine:', rightDisplayLine)
if (rightDisplayLine) { if (rightDisplayLine) {
rightActiveLine.value = rightDisplayLine rightActiveLine.value = rightDisplayLine
rightEditorRef.value?.highlightAndScrollToLine(rightDisplayLine) rightEditorRef.value?.highlightAndScrollToLine(rightDisplayLine)
...@@ -1424,6 +1773,67 @@ export function useCompareModal() { ...@@ -1424,6 +1773,67 @@ export function useCompareModal() {
} }
}) })
// ── 拖拽调整区域高度 (Vertical Splitter) ──────────────────
const mainContainerRef = ref<HTMLElement | null>(null)
const textZoneHeight = ref<number | null>(null)
const isResizingVertical = ref(false)
// 上方 XML 对比区域动态样式
const textZoneStyle = computed(() => {
if (!showXmlCompare.value) return {}
if (!showRenderCompare.value) return { flex: '1 1 0% !important' }
if (textZoneHeight.value !== null) {
return {
flex: `0 0 ${textZoneHeight.value}px !important`,
minHeight: '80px !important'
}
}
return {
flex: '0 0 40% !important',
minHeight: '80px !important'
}
})
// 下方渲染对比区域动态样式
const renderZoneStyle = computed(() => {
if (!showRenderCompare.value) return {}
if (!showXmlCompare.value) return { flex: '1 1 0% !important' }
return {
flex: '1 1 0% !important',
minHeight: '100px !important'
}
})
// 1. 上下高度拖拽 (Vertical Drag)
const startVerticalDrag = (e: PointerEvent) => {
e.preventDefault()
isResizingVertical.value = true
const startY = e.clientY
const containerEl = mainContainerRef.value
if (!containerEl) return
const textZoneEl = containerEl.querySelector('.compare-text-zone') as HTMLElement | null
const startHeight = textZoneEl ? textZoneEl.getBoundingClientRect().height : textZoneHeight.value || 280
const containerHeight = containerEl.getBoundingClientRect().height
const onPointerMove = (moveEv: PointerEvent) => {
if (!isResizingVertical.value) return
const deltaY = moveEv.clientY - startY
const newHeight = Math.max(80, Math.min(containerHeight - 120, startHeight + deltaY))
textZoneHeight.value = newHeight
}
const onPointerUp = () => {
isResizingVertical.value = false
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
return { return {
visible, visible,
leftXml, leftXml,
...@@ -1432,6 +1842,8 @@ export function useCompareModal() { ...@@ -1432,6 +1842,8 @@ export function useCompareModal() {
rightDisplayXml, rightDisplayXml,
leftLineClasses, leftLineClasses,
rightLineClasses, rightLineClasses,
leftInlineDiffs,
rightInlineDiffs,
leftLineNumbers, leftLineNumbers,
rightLineNumbers, rightLineNumbers,
leftRenderTree, leftRenderTree,
...@@ -1445,6 +1857,9 @@ export function useCompareModal() { ...@@ -1445,6 +1857,9 @@ export function useCompareModal() {
compared, compared,
comparing, comparing,
diffCounts, diffCounts,
navCurrentDisplay,
navigateToNextDiff,
navigateToPrevDiff,
showXmlCompare, showXmlCompare,
showRenderCompare, showRenderCompare,
leftRenderContainer, leftRenderContainer,
...@@ -1452,6 +1867,7 @@ export function useCompareModal() { ...@@ -1452,6 +1867,7 @@ export function useCompareModal() {
open, open,
handleUseCurrent, handleUseCurrent,
handleCompare, handleCompare,
syncRenderHeights,
unbindScrollSync, unbindScrollSync,
// 自定义横向滚动 // 自定义横向滚动
isLeftHorizontalScrollable, isLeftHorizontalScrollable,
...@@ -1468,6 +1884,8 @@ export function useCompareModal() { ...@@ -1468,6 +1884,8 @@ export function useCompareModal() {
isDragging, isDragging,
viewportStyle, viewportStyle,
diffHunks, diffHunks,
leftDiffHunks,
rightDiffHunks,
handleThumbPointerdown, handleThumbPointerdown,
handleScrollbarClick, handleScrollbarClick,
renderScrollerBound, renderScrollerBound,
...@@ -1489,6 +1907,13 @@ export function useCompareModal() { ...@@ -1489,6 +1907,13 @@ export function useCompareModal() {
handleXmlLineSelect, handleXmlLineSelect,
handleRenderClick, handleRenderClick,
handleTextExpandedChange, handleTextExpandedChange,
handleRenderExpandedChange handleRenderExpandedChange,
// 上下拖拽调节
mainContainerRef,
textZoneHeight,
isResizingVertical,
textZoneStyle,
renderZoneStyle,
startVerticalDrag
} }
} }
...@@ -22,12 +22,27 @@ ...@@ -22,12 +22,27 @@
+ +
</span> </span>
<span class="text-success font-medium">新增内容</span> <span class="text-success font-medium">新增内容</span>
<template v-if="compared">
<button
class="flex items-center justify-center w-4 h-4 rounded text-success hover:bg-success/20 transition-colors cursor-pointer select-none text-[11px] font-bold"
title="上一处新增"
@click="navigateToPrevDiff('added')"
>
</button>
<span <span
v-if="compared" class="px-1.5 py-0.5 text-[10px] font-bold rounded bg-success/10 border border-success/30 text-success leading-none select-none min-w-[18px] text-center"
class="px-1.5 py-0.5 text-[10px] font-bold rounded bg-success/10 border border-success/30 text-success leading-none"
> >
{{ diffCounts.added }} {{ navCurrentDisplay.added }}
</span> </span>
<button
class="flex items-center justify-center w-4 h-4 rounded text-success hover:bg-success/20 transition-colors cursor-pointer select-none text-[11px] font-bold"
title="下一处新增"
@click="navigateToNextDiff('added')"
>
</button>
</template>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span <span
...@@ -36,12 +51,27 @@ ...@@ -36,12 +51,27 @@
- -
</span> </span>
<span class="text-error font-medium line-through">删除内容</span> <span class="text-error font-medium line-through">删除内容</span>
<template v-if="compared">
<button
class="flex items-center justify-center w-4 h-4 rounded text-error hover:bg-error/20 transition-colors cursor-pointer select-none text-[11px] font-bold"
title="上一处删除"
@click="navigateToPrevDiff('removed')"
>
</button>
<span <span
v-if="compared" class="px-1.5 py-0.5 text-[10px] font-bold rounded bg-error/10 border border-error/30 text-error leading-none select-none min-w-[18px] text-center"
class="px-1.5 py-0.5 text-[10px] font-bold rounded bg-error/10 border border-error/30 text-error leading-none"
> >
{{ diffCounts.removed }} {{ navCurrentDisplay.removed }}
</span> </span>
<button
class="flex items-center justify-center w-4 h-4 rounded text-error hover:bg-error/20 transition-colors cursor-pointer select-none text-[11px] font-bold"
title="下一处删除"
@click="navigateToNextDiff('removed')"
>
</button>
</template>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span <span
...@@ -50,12 +80,27 @@ ...@@ -50,12 +80,27 @@
~ ~
</span> </span>
<span class="text-warning font-medium">属性/文本变更</span> <span class="text-warning font-medium">属性/文本变更</span>
<template v-if="compared">
<button
class="flex items-center justify-center w-4 h-4 rounded text-warning hover:bg-warning/20 transition-colors cursor-pointer select-none text-[11px] font-bold"
title="上一处属性/文本变更"
@click="navigateToPrevDiff('modified')"
>
</button>
<span <span
v-if="compared" class="px-1.5 py-0.5 text-[10px] font-bold rounded bg-warning/10 border border-warning/30 text-warning leading-none select-none min-w-[18px] text-center"
class="px-1.5 py-0.5 text-[10px] font-bold rounded bg-warning/10 border border-warning/30 text-warning leading-none"
> >
{{ diffCounts.modified }} {{ navCurrentDisplay.modified }}
</span> </span>
<button
class="flex items-center justify-center w-4 h-4 rounded text-warning hover:bg-warning/20 transition-colors cursor-pointer select-none text-[11px] font-bold"
title="下一处属性/文本变更"
@click="navigateToNextDiff('modified')"
>
</button>
</template>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span class="w-3.5 h-3.5 rounded bg-fill-3 border border-divider/60 repeating-stripes"></span> <span class="w-3.5 h-3.5 rounded bg-fill-3 border border-divider/60 repeating-stripes"></span>
...@@ -89,12 +134,16 @@ ...@@ -89,12 +134,16 @@
</div> </div>
<!-- 主内容区域:双栏展示 --> <!-- 主内容区域:双栏展示 -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden bg-fill-2/30 p-4 gap-4 relative"> <div
ref="mainContainerRef"
class="flex-1 flex flex-col min-h-0 overflow-hidden bg-fill-2/30 p-4 gap-2 relative"
:class="{ 'select-none': isResizingVertical }"
>
<!-- 加载遮罩(比对进行中) --> <!-- 加载遮罩(比对进行中) -->
<transition name="fade"> <transition name="fade">
<div v-if="comparing" class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-card/80 backdrop-blur-sm"> <div v-if="comparing" class="compare-loading-overlay absolute inset-0 z-20 flex flex-col items-center justify-center gap-3">
<n-spin size="large" /> <div class="compare-spinner"></div>
<span class="mt-3 text-sm text-color2 font-medium">正在分析对比,请稍候...</span> <span class="compare-loading-text">正在分析对比,请稍候...</span>
</div> </div>
</transition> </transition>
<!-- 上方:XML 源代码对比区域 --> <!-- 上方:XML 源代码对比区域 -->
...@@ -102,10 +151,12 @@ ...@@ -102,10 +151,12 @@
class="flex flex-col compare-text-zone shrink-0 gap-1.5" class="flex flex-col compare-text-zone shrink-0 gap-1.5"
:class="{ :class="{
'is-collapsed': !showXmlCompare, 'is-collapsed': !showXmlCompare,
'expanded-full': !showRenderCompare 'expanded-full': !showRenderCompare,
'is-resizing': isResizingVertical
}" }"
:style="textZoneStyle"
> >
<div class="flex items-center justify-between text-xs font-bold text-color2 px-1"> <div class="flex items-center justify-between text-xs font-bold text-color2 px-1 shrink-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span>源工卡 XML (旧版本)</span> <span>源工卡 XML (旧版本)</span>
<CommonButton size="tiny" quaternary type="info" @click="handleUseCurrent">使用当前打开的工卡</CommonButton> <CommonButton size="tiny" quaternary type="info" @click="handleUseCurrent">使用当前打开的工卡</CommonButton>
...@@ -127,6 +178,7 @@ ...@@ -127,6 +178,7 @@
min-height="100%" min-height="100%"
:line-classes="computedLeftLineClasses" :line-classes="computedLeftLineClasses"
:line-numbers="leftLineNumbers" :line-numbers="leftLineNumbers"
:inline-diffs="leftInlineDiffs"
@line-select="handleXmlLineSelect($event, 'left')" @line-select="handleXmlLineSelect($event, 'left')"
/> />
</div> </div>
...@@ -138,12 +190,19 @@ ...@@ -138,12 +190,19 @@
<!-- diff 差异标记(仅对比后显示) --> <!-- diff 差异标记(仅对比后显示) -->
<template v-if="compared"> <template v-if="compared">
<div <div
v-for="(hunk, i) in diffHunks" v-for="(hunk, i) in leftDiffHunks"
:key="i" :key="i"
class="absolute left-0.5 right-0.5 rounded-sm" class="absolute left-0.5 right-0.5 rounded-sm"
:style="[ :style="[
hunk.style, hunk.style,
{ background: hunk.type === 'removed' ? 'rgba(239,83,80,0.8)' : 'rgba(102,187,106,0.8)' } {
background:
hunk.type === 'removed'
? 'rgba(239,83,80,0.85)'
: hunk.type === 'added'
? 'rgba(102,187,106,0.85)'
: 'rgba(245,158,11,0.9)'
}
]" ]"
/> />
</template> </template>
...@@ -183,6 +242,7 @@ ...@@ -183,6 +242,7 @@
min-height="100%" min-height="100%"
:line-classes="computedRightLineClasses" :line-classes="computedRightLineClasses"
:line-numbers="rightLineNumbers" :line-numbers="rightLineNumbers"
:inline-diffs="rightInlineDiffs"
@line-select="handleXmlLineSelect($event, 'right')" @line-select="handleXmlLineSelect($event, 'right')"
/> />
</div> </div>
...@@ -194,12 +254,19 @@ ...@@ -194,12 +254,19 @@
<!-- diff 差异标记(仅对比后显示) --> <!-- diff 差异标记(仅对比后显示) -->
<template v-if="compared"> <template v-if="compared">
<div <div
v-for="(hunk, i) in diffHunks" v-for="(hunk, i) in rightDiffHunks"
:key="i" :key="i"
class="absolute left-0.5 right-0.5 rounded-sm" class="absolute left-0.5 right-0.5 rounded-sm"
:style="[ :style="[
hunk.style, hunk.style,
{ background: hunk.type === 'removed' ? 'rgba(239,83,80,0.8)' : 'rgba(102,187,106,0.8)' } {
background:
hunk.type === 'removed'
? 'rgba(239,83,80,0.85)'
: hunk.type === 'added'
? 'rgba(102,187,106,0.85)'
: 'rgba(245,158,11,0.9)'
}
]" ]"
/> />
</template> </template>
...@@ -233,19 +300,39 @@ ...@@ -233,19 +300,39 @@
</div> </div>
</div> </div>
<!-- 上下拖拽分割条 (Resizer) -->
<div
v-if="showXmlCompare && showRenderCompare"
class="compare-split-divider group shrink-0 relative flex items-center justify-center cursor-row-resize select-none z-10 -my-0.5 h-3 rounded"
:class="{ 'is-dragging': isResizingVertical }"
@pointerdown="startVerticalDrag"
>
<!-- 可视指示线 -->
<div class="compare-divider-line"></div>
<!-- 手柄圆点组 -->
<div class="compare-divider-handle">
<div class="handle-dot"></div>
<div class="handle-dot"></div>
<div class="handle-dot"></div>
</div>
</div>
<!-- 下方:排版样式渲染对比区域 --> <!-- 下方:排版样式渲染对比区域 -->
<div <div
class="flex-grow flex flex-col compare-render-zone min-h-0 gap-1.5" class="flex-grow flex flex-col compare-render-zone min-h-0 gap-1.5"
:class="{ :class="{
'is-collapsed': !showRenderCompare 'is-collapsed': !showRenderCompare,
'is-resizing': isResizingVertical
}" }"
:style="renderZoneStyle"
> >
<div class="flex items-center justify-between text-xs font-bold text-color2 px-1 shrink-0"> <div class="flex items-center justify-between text-xs font-bold text-color2 px-1 shrink-0">
<span>源工卡 渲染 (旧版本)</span> <span>源工卡 渲染 (旧版本)</span>
<span>目标工卡 渲染 (新版本)</span> <span>目标工卡 渲染 (新版本)</span>
</div> </div>
<div class="flex-grow flex gap-4 min-h-0"> <div class="flex-grow flex gap-2 min-h-0">
<!-- 左:源工卡渲染 --> <!-- 左:源工卡渲染 -->
<div class="flex-1 min-w-0 h-full flex border border-divider rounded-xl bg-card overflow-hidden"> <div class="flex-1 min-w-0 h-full flex border border-divider rounded-xl bg-card overflow-hidden">
<div <div
...@@ -371,6 +458,8 @@ const { ...@@ -371,6 +458,8 @@ const {
rightDisplayXml, rightDisplayXml,
leftLineClasses, leftLineClasses,
rightLineClasses, rightLineClasses,
leftInlineDiffs,
rightInlineDiffs,
leftLineNumbers, leftLineNumbers,
rightLineNumbers, rightLineNumbers,
leftRenderTree, leftRenderTree,
...@@ -384,6 +473,9 @@ const { ...@@ -384,6 +473,9 @@ const {
compared, compared,
comparing, comparing,
diffCounts, diffCounts,
navCurrentDisplay,
navigateToNextDiff,
navigateToPrevDiff,
showXmlCompare, showXmlCompare,
showRenderCompare, showRenderCompare,
leftRenderContainer, leftRenderContainer,
...@@ -407,6 +499,8 @@ const { ...@@ -407,6 +499,8 @@ const {
isDragging, isDragging,
viewportStyle, viewportStyle,
diffHunks, diffHunks,
leftDiffHunks,
rightDiffHunks,
handleThumbPointerdown, handleThumbPointerdown,
handleScrollbarClick, handleScrollbarClick,
renderScrollerBound, renderScrollerBound,
...@@ -425,7 +519,14 @@ const { ...@@ -425,7 +519,14 @@ const {
handleXmlLineSelect, handleXmlLineSelect,
handleRenderClick, handleRenderClick,
handleTextExpandedChange, handleTextExpandedChange,
handleRenderExpandedChange handleRenderExpandedChange,
// 拖拽控制面板与状态
mainContainerRef,
textZoneHeight,
isResizingVertical,
textZoneStyle,
renderZoneStyle,
startVerticalDrag
} = useCompareModal() } = useCompareModal()
// 注入只读对比模式状态 // 注入只读对比模式状态
...@@ -549,73 +650,192 @@ defineExpose({ ...@@ -549,73 +650,192 @@ defineExpose({
) !important; ) !important;
} }
// 排版渲染结果比对样式 // 排版渲染结果比对样式(完全不侵入和改变原工卡的任何 PaddingMargin 和缩进样式)
:deep([data-diff-status='added']) { :deep([data-diff-status='added']) {
background-color: rgba(102, 187, 106, 0.08); background-color: rgba(102, 187, 106, 0.1) !important;
outline: 1.5px dashed rgba(102, 187, 106, 0.45) !important; outline: 1.5px dashed rgba(102, 187, 106, 0.5) !important;
outline-offset: 1px; outline-offset: 1px;
position: relative; position: relative;
border-radius: 4px; border-radius: 4px;
&::before { &::before {
content: '+'; content: '+';
position: absolute; position: absolute;
left: 2px; left: -18px;
top: 2px; top: 2px;
color: #2e7d32; color: #2e7d32;
font-weight: bold; font-weight: bold;
font-size: 11px; font-size: 12px;
line-height: 1; line-height: 1;
z-index: 10; z-index: 10;
background-color: rgba(102, 187, 106, 0.2); background-color: rgba(102, 187, 106, 0.25);
padding: 1px 3px; padding: 1px 4px;
border-radius: 2px; border-radius: 3px;
} }
} }
:deep([data-diff-status='removed']) { :deep([data-diff-status='removed']) {
background-color: rgba(239, 83, 80, 0.08); background-color: rgba(239, 83, 80, 0.1) !important;
outline: 1.5px dashed rgba(239, 83, 80, 0.45) !important; outline: 1.5px dashed rgba(239, 83, 80, 0.5) !important;
outline-offset: 1px; outline-offset: 1px;
text-decoration: line-through; text-decoration: line-through !important;
opacity: 0.72; opacity: 0.75;
position: relative; position: relative;
border-radius: 4px; border-radius: 4px;
&::before { &::before {
content: '-'; content: '-';
position: absolute; position: absolute;
left: 2px; left: -18px;
top: 2px; top: 2px;
color: #c62828; color: #c62828;
font-weight: bold; font-weight: bold;
font-size: 11px; font-size: 12px;
line-height: 1; line-height: 1;
z-index: 10; z-index: 10;
background-color: rgba(239, 83, 80, 0.2); background-color: rgba(239, 83, 80, 0.25);
padding: 1px 4px; padding: 1px 4px;
border-radius: 2px; border-radius: 3px;
} }
} }
:deep([data-diff-status='modified']) { :deep([data-diff-status='modified']) {
background-color: rgba(255, 193, 7, 0.06); background-color: rgba(255, 193, 7, 0.08) !important;
outline: 1.5px dashed rgba(255, 193, 7, 0.4) !important; outline: 1.5px dashed rgba(255, 193, 7, 0.45) !important;
outline-offset: 1px; outline-offset: 1px;
border-radius: 4px; border-radius: 4px;
} }
// 隐藏空白对齐占位节点 :deep([data-attr-modified='true']) {
:deep([data-diff-status='added-placeholder']), /* 属性具体变更点由 DocNodeRenderer 内部 attr-diff-badge 明确呈现 */
}
// XML 源码编辑区同一行属性/内容修改侧重高亮
:deep(.diff-modified-left) {
background-color: rgba(255, 193, 7, 0.14) !important;
}
:deep(.diff-modified-right) {
background-color: rgba(255, 193, 7, 0.18) !important;
}
// XML 源码编辑区行内细粒度 (Word-level) 变动字符强醒目标记
:deep(.cm-diff-inline-removed) {
background-color: rgba(239, 68, 68, 0.22) !important;
color: #b91c1c !important;
font-weight: 700 !important;
border-radius: 3px !important;
padding: 0 3px !important;
outline: 1px solid rgba(220, 38, 38, 0.5) !important;
}
:deep(.cm-diff-inline-added) {
background-color: rgba(34, 197, 94, 0.22) !important;
color: #15803d !important;
font-weight: 700 !important;
border-radius: 3px !important;
padding: 0 3px !important;
outline: 1px solid rgba(22, 163, 74, 0.5) !important;
}
// ── 暗色主题 (Dark Mode) 文本与高亮适应性优化 ──
:deep(.dark) .cm-diff-inline-removed,
html.dark :deep(.cm-diff-inline-removed) {
background-color: rgba(239, 68, 68, 0.35) !important;
color: #fca5a5 !important;
outline-color: rgba(248, 113, 113, 0.6) !important;
}
:deep(.dark) .cm-diff-inline-added,
html.dark :deep(.cm-diff-inline-added) {
background-color: rgba(34, 197, 94, 0.35) !important;
color: #86efac !important;
outline-color: rgba(74, 222, 128, 0.6) !important;
}
:deep(.dark) [data-diff-status='added']::before,
html.dark :deep([data-diff-status='added']::before) {
color: #86efac !important;
background-color: rgba(34, 197, 94, 0.35) !important;
}
:deep(.dark) [data-diff-status='removed']::before,
html.dark :deep([data-diff-status='removed']::before) {
color: #fca5a5 !important;
background-color: rgba(239, 68, 68, 0.35) !important;
}
// 占位排版比对结果(保持原工卡缩进与同行 1-to-1 完全对齐)
:deep([data-diff-status='removed-placeholder']) { :deep([data-diff-status='removed-placeholder']) {
opacity: 0 !important; background-color: rgba(239, 83, 80, 0.05) !important;
visibility: hidden !important; background-image: repeating-linear-gradient(
pointer-events: none !important; -45deg,
* { transparent,
opacity: 0 !important; transparent 5px,
visibility: hidden !important; rgba(239, 83, 80, 0.08) 5px,
pointer-events: none !important; rgba(239, 83, 80, 0.08) 10px
) !important;
outline: 1.5px dashed rgba(239, 83, 80, 0.4) !important;
outline-offset: 1px;
text-decoration: line-through !important;
opacity: 0.72;
position: relative;
border-radius: 4px;
&::before {
content: '-';
position: absolute;
left: -18px;
top: 2px;
color: #c62828;
font-weight: bold;
font-size: 12px;
line-height: 1;
z-index: 10;
background-color: rgba(239, 83, 80, 0.25);
padding: 1px 4px;
border-radius: 3px;
} }
} }
:deep([data-diff-status='added-placeholder']) {
background-color: rgba(102, 187, 106, 0.05) !important;
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 5px,
rgba(102, 187, 106, 0.08) 5px,
rgba(102, 187, 106, 0.08) 10px
) !important;
outline: 1.5px dashed rgba(102, 187, 106, 0.4) !important;
outline-offset: 1px;
opacity: 0.72;
position: relative;
border-radius: 4px;
&::before {
content: '+';
position: absolute;
left: -18px;
top: 2px;
color: #2e7d32;
font-weight: bold;
font-size: 12px;
line-height: 1;
z-index: 10;
background-color: rgba(102, 187, 106, 0.25);
padding: 1px 4px;
border-radius: 3px;
}
}
:deep(.dark) [data-diff-status='removed-placeholder']::before,
html.dark :deep([data-diff-status='removed-placeholder']::before) {
color: #fca5a5 !important;
background-color: rgba(239, 68, 68, 0.3) !important;
}
:deep(.dark) [data-diff-status='added-placeholder']::before,
html.dark :deep([data-diff-status='added-placeholder']::before) {
color: #86efac !important;
background-color: rgba(34, 197, 94, 0.3) !important;
}
// 单词级高亮显示 // 单词级高亮显示
:deep(.text-success) { :deep(.text-success) {
color: #2e7d32 !important; color: #2e7d32 !important;
...@@ -633,6 +853,17 @@ defineExpose({ ...@@ -633,6 +853,17 @@ defineExpose({
border-radius: 2px; border-radius: 2px;
} }
:deep(.dark) .text-success,
html.dark :deep(.text-success) {
color: #4ade80 !important;
background-color: rgba(34, 197, 94, 0.25) !important;
}
:deep(.dark) .text-error,
html.dark :deep(.text-error) {
color: #f87171 !important;
background-color: rgba(239, 68, 68, 0.25) !important;
}
// 选中排版节点双向联动高亮样式 // 选中排版节点双向联动高亮样式
:deep(.render-active-highlight) { :deep(.render-active-highlight) {
outline: 2px dashed var(--primary-color, #165dff) !important; outline: 2px dashed var(--primary-color, #165dff) !important;
...@@ -662,64 +893,156 @@ defineExpose({ ...@@ -662,64 +893,156 @@ defineExpose({
/* XML 文本区默认样式与展开/收起过渡 */ /* XML 文本区默认样式与展开/收起过渡 */
.compare-text-zone { .compare-text-zone {
flex: 1 1 0% !important; flex: 0 0 40%;
min-height: 180px !important; min-height: 80px;
max-height: 320px !important; margin-bottom: 0px;
margin-bottom: 0px !important; opacity: 1;
opacity: 1 !important;
overflow: hidden; overflow: hidden;
/* 展开过渡:弹性分配比和高度立即变化,不透明度延迟 0.15s 淡入 */
transition: transition:
flex 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s, flex 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
max-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s, min-height 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
min-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s, opacity 0.25s linear;
opacity 0.2s linear 0.15s !important; }
/* 拖拽过程中禁用 CSS 过渡,保证 60FPS 实时极速响应 */
.compare-text-zone.is-resizing,
.compare-render-zone.is-resizing {
transition: none !important;
} }
/* 当渲染区收起时,文本区独占全部剩余高度 */ /* 当渲染区收起时,文本区独占全部剩余高度 */
.compare-text-zone.expanded-full { .compare-text-zone.expanded-full {
flex: 1 1 0% !important; flex: 1 1 0% !important;
min-height: 0 !important; min-height: 0 !important;
max-height: 100% !important;
} }
/* 当文本区收起时:不透明度立即淡出,尺寸延迟 0.08s 收折 */ /* 当文本区收起时 */
.compare-text-zone.is-collapsed { .compare-text-zone.is-collapsed {
flex: 0 0 0% !important; flex: 0 0 0% !important;
max-height: 0 !important; min-height: 0px !important;
min-height: 0 !important;
opacity: 0 !important; opacity: 0 !important;
pointer-events: none; pointer-events: none;
transition: transition:
opacity 0.15s linear 0s, opacity 0.2s linear,
flex 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s, flex 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
max-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s, min-height 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) !important;
min-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s !important;
} }
/* 渲染区默认样式与展开/收起过渡 */ /* 渲染区默认样式与展开/收起过渡 */
.compare-render-zone { .compare-render-zone {
flex: 2 1 0% !important; flex: 1 1 0%;
min-height: 0 !important; min-height: 100px;
max-height: 2000px !important; opacity: 1;
opacity: 1 !important;
overflow: hidden; overflow: hidden;
/* 展开过渡:弹性大小和高度立即变化,不透明度延迟 0.15s 淡入 */
transition: transition:
flex 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s, flex 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
max-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0s, min-height 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
opacity 0.2s linear 0.15s !important; opacity 0.25s linear;
} }
/* 当渲染区收起时:不透明度立即淡出,弹性大小延迟 0.08s 收折 */ /* 当渲染区收起时:纯靠 flex 平滑收缩到 0% + opacity 淡出 */
.compare-render-zone.is-collapsed { .compare-render-zone.is-collapsed {
flex: 0 0 0% !important; flex: 0 0 0% !important;
max-height: 0 !important; min-height: 0px !important;
opacity: 0 !important; opacity: 0 !important;
pointer-events: none; pointer-events: none;
transition: transition:
opacity 0.15s linear 0s, opacity 0.2s linear,
flex 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s, flex 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
max-height 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.08s !important; min-height 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) !important;
}
/* ── 上下拖拽分割条(完全对齐主框架 Splitter 组件视觉风格) ── */
.compare-split-divider {
transition: background-color 0.2s;
&:hover,
&.is-dragging {
background-color: color-mix(in srgb, var(--primary-color, #165dff) 10%, transparent);
}
}
.compare-divider-line {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 1px;
transform: translateY(-50%);
background-color: var(--divider-color, rgba(0, 0, 0, 0.08));
transition:
background-color 0.2s,
height 0.2s;
}
.compare-split-divider:hover .compare-divider-line,
.compare-split-divider.is-dragging .compare-divider-line {
background-color: var(--primary-color, #165dff);
height: 2px;
}
.compare-divider-handle {
position: relative;
z-index: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 3px;
padding: 3px 6px;
border-radius: 8px;
background: var(--colorFill2, rgba(0, 0, 0, 0.04));
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.08));
opacity: 0;
transform: scaleX(0.8);
transition:
opacity 0.2s,
transform 0.2s,
background 0.2s;
}
.compare-split-divider:hover .compare-divider-handle,
.compare-split-divider.is-dragging .compare-divider-handle {
opacity: 1;
transform: scaleX(1);
background: var(--primary-color, #165dff);
border-color: var(--primary-color, #165dff);
}
.handle-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background-color: var(--textColor3, rgba(0, 0, 0, 0.25));
transition: background-color 0.2s;
}
.compare-split-divider:hover .handle-dot,
.compare-split-divider.is-dragging .handle-dot {
background-color: white;
}
/* ── 加载遮罩:纯 GPU 加速动画,无 backdrop-filter ── */
.compare-loading-overlay {
background-color: color-mix(in srgb, var(--card-color, #fff) 92%, transparent);
}
.compare-spinner {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2.5px solid color-mix(in srgb, var(--primary-color, #7c5cfc) 20%, transparent);
border-top-color: var(--primary-color, #7c5cfc);
will-change: transform;
animation: compare-spin 0.9s linear infinite;
}
.compare-loading-text {
font-size: 12px;
font-weight: 500;
}
@keyframes compare-spin {
to {
transform: rotate(360deg);
}
} }
</style> </style>
...@@ -78,6 +78,8 @@ ...@@ -78,6 +78,8 @@
:rowspan="cell.rowspan" :rowspan="cell.rowspan"
:data-node-id="cell.id" :data-node-id="cell.id"
data-tag-name="ENTRY" data-tag-name="ENTRY"
:data-diff-status="cell.rawNode?.diffStatus"
:data-attr-modified="cell.rawNode?.attrModified ? 'true' : undefined"
class="p-2 text-left font-bold bg-fill-4 border border-divider text-color1 transition-colors relative" class="p-2 text-left font-bold bg-fill-4 border border-divider text-color1 transition-colors relative"
:class="[ :class="[
isDiffMode ? 'cursor-default' : 'cursor-pointer', isDiffMode ? 'cursor-default' : 'cursor-pointer',
...@@ -91,6 +93,29 @@ ...@@ -91,6 +93,29 @@
@click.stop="isDiffMode ? null : handleCellClick(cell, $event)" @click.stop="isDiffMode ? null : handleCellClick(cell, $event)"
@contextmenu.prevent="isDiffMode ? null : handleCellContextMenu(cell, row.id, $event)" @contextmenu.prevent="isDiffMode ? null : handleCellContextMenu(cell, row.id, $event)"
> >
<!-- 属性变更徽标:absolute 定位在单元格右上角,不侵入内容流 -->
<n-popover v-if="cell.rawNode?.attrDiffs && cell.rawNode.attrDiffs.length > 0" trigger="hover" placement="top-end" style="max-width: 380px">
<template #trigger>
<span
class="attr-diff-badge absolute -top-2 right-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] font-bold font-mono cursor-pointer select-none shadow-md z-20 pointer-events-auto transition-all hover:opacity-90"
style="background: #f59e0b; color: #fff; line-height: 1.2;"
@click.stop
>
🏷️ {{ cell.rawNode.tagName }}
</span>
</template>
<div class="p-2 space-y-1.5 text-xs font-mono select-none" @click.stop>
<div class="font-bold border-b border-divider pb-1 flex items-center gap-1">
<span>🏷️ &lt;{{ cell.rawNode.tagName }}&gt; 属性变更明细</span>
</div>
<div v-for="diff in cell.rawNode.attrDiffs" :key="diff.key" class="flex items-center gap-1.5 flex-wrap">
<span class="font-semibold">{{ diff.key }}:</span>
<span class="line-through text-red-500 px-1 bg-red-500/10 rounded">{{ diff.oldVal || '(空)' }}</span>
<span class="text-amber-500 font-bold"></span>
<span class="text-emerald-600 dark:text-emerald-400 font-bold px-1 bg-emerald-500/10 rounded">{{ diff.newVal || '(空)' }}</span>
</div>
</div>
</n-popover>
<template v-if="cell.rawNode && cell.rawNode.children && cell.rawNode.children.length > 0"> <template v-if="cell.rawNode && cell.rawNode.children && cell.rawNode.children.length > 0">
<DocNodeRenderer v-for="child in cell.rawNode!.children" :key="child.id" :node="child" :parent="cell.rawNode" /> <DocNodeRenderer v-for="child in cell.rawNode!.children" :key="child.id" :node="child" :parent="cell.rawNode" />
</template> </template>
...@@ -190,6 +215,8 @@ ...@@ -190,6 +215,8 @@
:rowspan="cell.rowspan" :rowspan="cell.rowspan"
:data-node-id="cell.id" :data-node-id="cell.id"
data-tag-name="ENTRY" data-tag-name="ENTRY"
:data-diff-status="cell.rawNode?.diffStatus"
:data-attr-modified="cell.rawNode?.attrModified ? 'true' : undefined"
class="p-2 border border-divider text-color2 transition-colors relative" class="p-2 border border-divider text-color2 transition-colors relative"
:class="[ :class="[
isDiffMode ? 'cursor-default' : 'cursor-pointer', isDiffMode ? 'cursor-default' : 'cursor-pointer',
...@@ -203,6 +230,29 @@ ...@@ -203,6 +230,29 @@
@click.stop="isDiffMode ? null : handleCellClick(cell, $event)" @click.stop="isDiffMode ? null : handleCellClick(cell, $event)"
@contextmenu.prevent="isDiffMode ? null : handleCellContextMenu(cell, row.id, $event)" @contextmenu.prevent="isDiffMode ? null : handleCellContextMenu(cell, row.id, $event)"
> >
<!-- 属性变更徽标:absolute 定位在单元格右上角,不侵入内容流 -->
<n-popover v-if="cell.rawNode?.attrDiffs && cell.rawNode.attrDiffs.length > 0" trigger="hover" placement="top-end" style="max-width: 380px">
<template #trigger>
<span
class="attr-diff-badge absolute -top-2 right-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] font-bold font-mono cursor-pointer select-none shadow-md z-20 pointer-events-auto transition-all hover:opacity-90"
style="background: #f59e0b; color: #fff; line-height: 1.2;"
@click.stop
>
🏷️ {{ cell.rawNode.tagName }}
</span>
</template>
<div class="p-2 space-y-1.5 text-xs font-mono select-none" @click.stop>
<div class="font-bold border-b border-divider pb-1 flex items-center gap-1">
<span>🏷️ &lt;{{ cell.rawNode.tagName }}&gt; 属性变更明细</span>
</div>
<div v-for="diff in cell.rawNode.attrDiffs" :key="diff.key" class="flex items-center gap-1.5 flex-wrap">
<span class="font-semibold">{{ diff.key }}:</span>
<span class="line-through text-red-500 px-1 bg-red-500/10 rounded">{{ diff.oldVal || '(空)' }}</span>
<span class="text-amber-500 font-bold"></span>
<span class="text-emerald-600 dark:text-emerald-400 font-bold px-1 bg-emerald-500/10 rounded">{{ diff.newVal || '(空)' }}</span>
</div>
</div>
</n-popover>
<template v-if="cell.rawNode && cell.rawNode.children && cell.rawNode.children.length > 0"> <template v-if="cell.rawNode && cell.rawNode.children && cell.rawNode.children.length > 0">
<DocNodeRenderer v-for="child in cell.rawNode!.children" :key="child.id" :node="child" :parent="cell.rawNode" /> <DocNodeRenderer v-for="child in cell.rawNode!.children" :key="child.id" :node="child" :parent="cell.rawNode" />
</template> </template>
......
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