Commit 5af0d0fb by pangchong

feat: 完善对比工卡逻辑

parent d278bc3b
......@@ -34,6 +34,13 @@ import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
import { searchKeymap, highlightSelectionMatches, openSearchPanel, search, SearchQuery, getSearchQuery } from '@codemirror/search'
import { lintKeymap } from '@codemirror/lint'
interface InlineDiff {
line: number
from: number
to: number
class: string
}
interface Props {
modelValue: string
disabled?: boolean
......@@ -42,6 +49,7 @@ interface Props {
minHeight?: string | number
lineClasses?: string[]
lineNumbers?: (string | number)[]
inlineDiffs?: InlineDiff[]
}
const props = withDefaults(defineProps<Props>(), {
......@@ -49,19 +57,33 @@ const props = withDefaults(defineProps<Props>(), {
disabled: false,
minHeight: 200,
lineClasses: () => [],
lineNumbers: () => []
lineNumbers: () => [],
inlineDiffs: () => []
})
// ── 差异高亮与自定义行号的 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>()
// 将 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++) {
const cls = lineClasses[i - 1]
const line = doc.line(i)
if (cls) {
const line = doc.line(i)
builder.add(
line.from,
line.from,
......@@ -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()
}
const lineClassesField = StateField.define<DecorationSet>({
create(state) {
return getLineDecorations(props.lineClasses || [], state.doc)
return getLineDecorations(props.lineClasses || [], props.inlineDiffs || [], state.doc)
},
update(deco, tr) {
deco = deco.map(tr.changes)
for (const effect of tr.effects) {
if (effect.is(setLineClassesEffect)) {
deco = getLineDecorations(effect.value, tr.state.doc)
deco = getLineDecorations(effect.value.lineClasses, effect.value.inlineDiffs, tr.state.doc)
}
}
return deco
......@@ -675,7 +715,10 @@ watch(
})
if (props.lineClasses && props.lineClasses.length > 0) {
editorView.dispatch({
effects: setLineClassesEffect.of(props.lineClasses)
effects: setLineClassesEffect.of({
lineClasses: props.lineClasses,
inlineDiffs: props.inlineDiffs || []
})
})
}
}
......@@ -693,11 +736,14 @@ watch(
)
watch(
() => props.lineClasses,
(newClasses) => {
if (editorView && newClasses) {
[() => props.lineClasses, () => props.inlineDiffs],
([newClasses, newDiffs]) => {
if (editorView) {
editorView.dispatch({
effects: setLineClassesEffect.of(newClasses)
effects: setLineClassesEffect.of({
lineClasses: newClasses || [],
inlineDiffs: newDiffs || []
})
})
}
},
......
......@@ -19,6 +19,10 @@ export interface XmlNode {
parentId: string | null
/** 比对差异状态 */
diffStatus?: 'added' | 'removed' | 'modified' | 'none'
/** 是否包含属性变更 */
attrModified?: boolean
/** 具体变更的属性列表 */
attrDiffs?: { key: string; oldVal: string; newVal: string }[]
}
/**
......
......@@ -4,6 +4,7 @@
:data-node-id="node.id"
:data-tag-name="node.tagName"
:data-diff-status="node.diffStatus"
:data-attr-modified="node.attrModified ? 'true' : undefined"
class="doc-node-wrapper relative transition-all"
:class="[
renderInline ? 'inline align-baseline mx-0.5' : 'block my-1',
......@@ -27,6 +28,29 @@
</div>
</div>
</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 节点特殊处理 (不展示) -->
<template v-if="isHeaderTag"></template>
......@@ -1326,6 +1350,29 @@
v-text="node.textContent || node.tagName"
></div>
</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>
</template>
......
......@@ -15,6 +15,6 @@ export interface RenderDiffHunk {
// XML源码比对差异段样式模型
export interface XmlDiffHunk {
type: 'added' | 'removed'
type: 'added' | 'removed' | 'modified'
style: Record<string, string>
}
......@@ -42,6 +42,7 @@ function cloneWithDiff(node: XmlNode, status: DiffStatusType = 'none'): XmlNode
...node,
id: node.id + '_' + status, // 避免 id 冲突
diffStatus: status as any,
attrModified: false,
children,
mixedContent
}
......@@ -121,10 +122,14 @@ function diffXmlTrees(oldNode: XmlNode | null, newNode: XmlNode | null): { oldDi
const newAttrs = { ...newNode!.attributes }
let attrsChanged = false
const attrDiffs: { key: string; oldVal: string; newVal: string }[] = []
const allAttrKeys = new Set([...Object.keys(oldAttrs), ...Object.keys(newAttrs)])
for (const key of allAttrKeys) {
if (oldAttrs[key] !== newAttrs[key]) {
const oVal = oldAttrs[key] || ''
const nVal = newAttrs[key] || ''
if (oVal !== nVal) {
attrsChanged = true
attrDiffs.push({ key, oldVal: oVal, newVal: nVal })
}
}
......@@ -315,6 +320,8 @@ function diffXmlTrees(oldNode: XmlNode | null, newNode: XmlNode | null): { oldDi
...oldNode!,
id: oldNode!.id + '_diff',
diffStatus: diffStatus as any,
attrModified: attrsChanged,
attrDiffs,
children: oldChildrenDiff,
mixedContent: oldMixedContent,
attributes: oldAttrs,
......@@ -325,6 +332,8 @@ function diffXmlTrees(oldNode: XmlNode | null, newNode: XmlNode | null): { oldDi
...newNode!,
id: newNode!.id + '_diff',
diffStatus: diffStatus as any,
attrModified: attrsChanged,
attrDiffs,
children: newChildrenDiff,
mixedContent: newMixedContent,
attributes: newAttrs,
......@@ -350,6 +359,10 @@ export function useCompareModal() {
const leftLineClasses = 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 的行号数组
const leftLineNumbers = ref<(string | number)[]>([])
const rightLineNumbers = ref<(string | number)[]>([])
......@@ -453,6 +466,127 @@ export function useCompareModal() {
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 showRenderCompare = ref(true)
......@@ -487,6 +621,8 @@ export function useCompareModal() {
rightDisplayXml.value = ''
leftLineClasses.value = []
rightLineClasses.value = []
leftInlineDiffs.value = []
rightInlineDiffs.value = []
leftLineNumbers.value = []
rightLineNumbers.value = []
leftRenderTree.value = null
......@@ -573,12 +709,124 @@ export function useCompareModal() {
const rClasses: string[] = []
const lNums: (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 rightRealLine = 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')
if (lines.length > 1 && lines[lines.length - 1] === '') {
lines.pop()
......@@ -594,7 +842,6 @@ export function useCompareModal() {
rClasses.push('diff-added')
rNums.push(rightRealLine)
// 记录右侧编辑器到原始格式化行号的对应关系
rightEditorToOriginal.value.set(displayLineIndex, rightRealLine)
rightOriginalToEditor.value.set(rightRealLine, displayLineIndex)
......@@ -611,7 +858,6 @@ export function useCompareModal() {
rClasses.push('diff-removed-placeholder')
rNums.push('')
// 记录左侧编辑器到原始格式化行号的对应关系
leftEditorToOriginal.value.set(displayLineIndex, leftRealLine)
leftOriginalToEditor.value.set(leftRealLine, displayLineIndex)
......@@ -628,7 +874,6 @@ export function useCompareModal() {
rClasses.push('')
rNums.push(rightRealLine)
// 记录两侧编辑器到原始格式化行号的对应关系
leftEditorToOriginal.value.set(displayLineIndex, leftRealLine)
leftOriginalToEditor.value.set(leftRealLine, displayLineIndex)
......@@ -640,12 +885,16 @@ export function useCompareModal() {
displayLineIndex++
}
}
idx++
}
leftDisplayXml.value = lLines.join('\n')
rightDisplayXml.value = rLines.join('\n')
leftLineClasses.value = lClasses
rightLineClasses.value = rClasses
leftInlineDiffs.value = lInlineDiffs
rightInlineDiffs.value = rInlineDiffs
leftLineNumbers.value = lNums
rightLineNumbers.value = rNums
......@@ -661,9 +910,11 @@ export function useCompareModal() {
compared.value = true
window.$message?.success('比对成功')
// 比对成功后同步滚动绑定
// 比对成功后同步滚动绑定,并分两轮补齐左右渲染节点高度差
nextTick(() => {
bindScrollSync()
setTimeout(syncRenderHeights, 300)
setTimeout(syncRenderHeights, 800)
})
} catch (err: any) {
window.$message?.error(`比对发生错误: ${err.message || err}`)
......@@ -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 编辑器滚动
// 用位置相等判断阻断回路:设置 other.scrollTop 后 other 的 scroll 事件触发时两侧已相等 → 直接返回
// 不使用锁标志,确保每次用户滚动事件都能即时同步对侧,不产生位置滞后
......@@ -917,8 +1229,8 @@ export function useCompareModal() {
}
})
// 将左侧 lineClasses 拆解为纵向标记段
const diffHunks = computed<XmlDiffHunk[]>(() => {
// 将左侧 lineClasses 拆解为纵向小地图标记段
const leftDiffHunks = computed<XmlDiffHunk[]>(() => {
const cls = leftLineClasses.value
const n = cls.length
if (n === 0) return []
......@@ -941,6 +1253,53 @@ export function useCompareModal() {
type: 'added',
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 {
i++
}
}
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++
}
......@@ -948,6 +1307,8 @@ export function useCompareModal() {
return result
})
const diffHunks = leftDiffHunks
const updateGutterScroll = () => {
if (leftScrollerEl) {
gutterScrollTop.value = leftScrollerEl.scrollTop
......@@ -1250,13 +1611,6 @@ export function useCompareModal() {
}
}
console.log('[DEBUG Compare] handleXmlLineSelect:', {
side,
origLine,
targetNodeId,
minSpan
})
if (!targetNodeId) return
activeNodeId.value = targetNodeId
......@@ -1265,44 +1619,44 @@ export function useCompareModal() {
el.classList.remove('render-active-highlight')
})
// 查找并高亮左侧与右侧排版区域中匹配的元素
const targetLeft = leftRenderContainer.value?.querySelector(`[data-node-id^="${targetNodeId}"]`) as HTMLElement
console.log(
'[DEBUG Compare] targetLeft element:',
targetLeft
? {
tagName: targetLeft.tagName,
nodeIdAttr: targetLeft.getAttribute('data-node-id'),
outerHTML: targetLeft.outerHTML.slice(0, 200)
}
: 'null'
)
// 查找并高亮左侧与右侧排版区域中匹配的元素(精确匹配 nodeId,防止 node_1 误选 node_10)
const findRenderNodeElement = (container: HTMLElement | null, nodeId: string): HTMLElement | null => {
if (!container || !nodeId) return null
const els = container.querySelectorAll('[data-node-id]')
for (let i = 0; i < els.length; i++) {
const el = els[i] as HTMLElement
const idAttr = el.getAttribute('data-node-id') || ''
if (idAttr === nodeId || idAttr.startsWith(`${nodeId}_`)) {
return el
}
}
return null
}
const targetLeft = findRenderNodeElement(leftRenderContainer.value, targetNodeId)
const targetRight = findRenderNodeElement(rightRenderContainer.value, targetNodeId)
if (targetLeft) {
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) {
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) {
targetLeft.scrollIntoView({ behavior: 'smooth', block: 'center' })
scrollToElement(targetLeft, leftRenderContainer.value)
} else if (side === 'right' && targetRight) {
targetRight.scrollIntoView({ behavior: 'smooth', block: 'center' })
scrollToElement(targetRight, rightRenderContainer.value)
}
}
......@@ -1321,7 +1675,7 @@ export function useCompareModal() {
document.querySelectorAll('.render-active-highlight').forEach((el) => {
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')
})
......@@ -1329,13 +1683,10 @@ export function useCompareModal() {
leftActiveLine.value = null
rightActiveLine.value = null
// 高亮定位 XML 编辑器
console.log('[CompareModal] Clicked side:', side, 'nodeId:', nodeId)
const leftRange = leftNodeRanges.value.get(nodeId)
console.log('[CompareModal] leftRange:', leftRange)
if (leftRange) {
const leftDisplayLine = leftOriginalToEditor.value.get(leftRange.startLine)
console.log('[CompareModal] leftDisplayLine:', leftDisplayLine)
if (leftDisplayLine) {
leftActiveLine.value = leftDisplayLine
leftEditorRef.value?.highlightAndScrollToLine(leftDisplayLine)
......@@ -1343,10 +1694,8 @@ export function useCompareModal() {
}
const rightRange = rightNodeRanges.value.get(nodeId)
console.log('[CompareModal] rightRange:', rightRange)
if (rightRange) {
const rightDisplayLine = rightOriginalToEditor.value.get(rightRange.startLine)
console.log('[CompareModal] rightDisplayLine:', rightDisplayLine)
if (rightDisplayLine) {
rightActiveLine.value = rightDisplayLine
rightEditorRef.value?.highlightAndScrollToLine(rightDisplayLine)
......@@ -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 {
visible,
leftXml,
......@@ -1432,6 +1842,8 @@ export function useCompareModal() {
rightDisplayXml,
leftLineClasses,
rightLineClasses,
leftInlineDiffs,
rightInlineDiffs,
leftLineNumbers,
rightLineNumbers,
leftRenderTree,
......@@ -1445,6 +1857,9 @@ export function useCompareModal() {
compared,
comparing,
diffCounts,
navCurrentDisplay,
navigateToNextDiff,
navigateToPrevDiff,
showXmlCompare,
showRenderCompare,
leftRenderContainer,
......@@ -1452,6 +1867,7 @@ export function useCompareModal() {
open,
handleUseCurrent,
handleCompare,
syncRenderHeights,
unbindScrollSync,
// 自定义横向滚动
isLeftHorizontalScrollable,
......@@ -1468,6 +1884,8 @@ export function useCompareModal() {
isDragging,
viewportStyle,
diffHunks,
leftDiffHunks,
rightDiffHunks,
handleThumbPointerdown,
handleScrollbarClick,
renderScrollerBound,
......@@ -1489,6 +1907,13 @@ export function useCompareModal() {
handleXmlLineSelect,
handleRenderClick,
handleTextExpandedChange,
handleRenderExpandedChange
handleRenderExpandedChange,
// 上下拖拽调节
mainContainerRef,
textZoneHeight,
isResizingVertical,
textZoneStyle,
renderZoneStyle,
startVerticalDrag
}
}
......@@ -22,12 +22,27 @@
+
</span>
<span class="text-success font-medium">新增内容</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"
>
{{ diffCounts.added }}
</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
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"
>
{{ navCurrentDisplay.added }}
</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 class="flex items-center gap-1.5">
<span
......@@ -36,12 +51,27 @@
-
</span>
<span class="text-error font-medium line-through">删除内容</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"
>
{{ diffCounts.removed }}
</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
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"
>
{{ navCurrentDisplay.removed }}
</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 class="flex items-center gap-1.5">
<span
......@@ -50,12 +80,27 @@
~
</span>
<span class="text-warning font-medium">属性/文本变更</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"
>
{{ diffCounts.modified }}
</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
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"
>
{{ navCurrentDisplay.modified }}
</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 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>
......@@ -89,12 +134,16 @@
</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">
<div v-if="comparing" class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-card/80 backdrop-blur-sm">
<n-spin size="large" />
<span class="mt-3 text-sm text-color2 font-medium">正在分析对比,请稍候...</span>
<div v-if="comparing" class="compare-loading-overlay absolute inset-0 z-20 flex flex-col items-center justify-center gap-3">
<div class="compare-spinner"></div>
<span class="compare-loading-text">正在分析对比,请稍候...</span>
</div>
</transition>
<!-- 上方:XML 源代码对比区域 -->
......@@ -102,10 +151,12 @@
class="flex flex-col compare-text-zone shrink-0 gap-1.5"
:class="{
'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">
<span>源工卡 XML (旧版本)</span>
<CommonButton size="tiny" quaternary type="info" @click="handleUseCurrent">使用当前打开的工卡</CommonButton>
......@@ -127,6 +178,7 @@
min-height="100%"
:line-classes="computedLeftLineClasses"
:line-numbers="leftLineNumbers"
:inline-diffs="leftInlineDiffs"
@line-select="handleXmlLineSelect($event, 'left')"
/>
</div>
......@@ -138,12 +190,19 @@
<!-- diff 差异标记(仅对比后显示) -->
<template v-if="compared">
<div
v-for="(hunk, i) in diffHunks"
v-for="(hunk, i) in leftDiffHunks"
:key="i"
class="absolute left-0.5 right-0.5 rounded-sm"
: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>
......@@ -183,6 +242,7 @@
min-height="100%"
:line-classes="computedRightLineClasses"
:line-numbers="rightLineNumbers"
:inline-diffs="rightInlineDiffs"
@line-select="handleXmlLineSelect($event, 'right')"
/>
</div>
......@@ -194,12 +254,19 @@
<!-- diff 差异标记(仅对比后显示) -->
<template v-if="compared">
<div
v-for="(hunk, i) in diffHunks"
v-for="(hunk, i) in rightDiffHunks"
:key="i"
class="absolute left-0.5 right-0.5 rounded-sm"
: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>
......@@ -233,19 +300,39 @@
</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
class="flex-grow flex flex-col compare-render-zone min-h-0 gap-1.5"
: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">
<span>源工卡 渲染 (旧版本)</span>
<span>目标工卡 渲染 (新版本)</span>
</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
......@@ -371,6 +458,8 @@ const {
rightDisplayXml,
leftLineClasses,
rightLineClasses,
leftInlineDiffs,
rightInlineDiffs,
leftLineNumbers,
rightLineNumbers,
leftRenderTree,
......@@ -384,6 +473,9 @@ const {
compared,
comparing,
diffCounts,
navCurrentDisplay,
navigateToNextDiff,
navigateToPrevDiff,
showXmlCompare,
showRenderCompare,
leftRenderContainer,
......@@ -407,6 +499,8 @@ const {
isDragging,
viewportStyle,
diffHunks,
leftDiffHunks,
rightDiffHunks,
handleThumbPointerdown,
handleScrollbarClick,
renderScrollerBound,
......@@ -425,7 +519,14 @@ const {
handleXmlLineSelect,
handleRenderClick,
handleTextExpandedChange,
handleRenderExpandedChange
handleRenderExpandedChange,
// 拖拽控制面板与状态
mainContainerRef,
textZoneHeight,
isResizingVertical,
textZoneStyle,
renderZoneStyle,
startVerticalDrag
} = useCompareModal()
// 注入只读对比模式状态
......@@ -549,73 +650,192 @@ defineExpose({
) !important;
}
// 排版渲染结果比对样式
// 排版渲染结果比对样式(完全不侵入和改变原工卡的任何 PaddingMargin 和缩进样式)
:deep([data-diff-status='added']) {
background-color: rgba(102, 187, 106, 0.08);
outline: 1.5px dashed rgba(102, 187, 106, 0.45) !important;
background-color: rgba(102, 187, 106, 0.1) !important;
outline: 1.5px dashed rgba(102, 187, 106, 0.5) !important;
outline-offset: 1px;
position: relative;
border-radius: 4px;
&::before {
content: '+';
position: absolute;
left: 2px;
left: -18px;
top: 2px;
color: #2e7d32;
font-weight: bold;
font-size: 11px;
font-size: 12px;
line-height: 1;
z-index: 10;
background-color: rgba(102, 187, 106, 0.2);
padding: 1px 3px;
border-radius: 2px;
background-color: rgba(102, 187, 106, 0.25);
padding: 1px 4px;
border-radius: 3px;
}
}
:deep([data-diff-status='removed']) {
background-color: rgba(239, 83, 80, 0.08);
outline: 1.5px dashed rgba(239, 83, 80, 0.45) !important;
background-color: rgba(239, 83, 80, 0.1) !important;
outline: 1.5px dashed rgba(239, 83, 80, 0.5) !important;
outline-offset: 1px;
text-decoration: line-through;
opacity: 0.72;
text-decoration: line-through !important;
opacity: 0.75;
position: relative;
border-radius: 4px;
&::before {
content: '-';
position: absolute;
left: 2px;
left: -18px;
top: 2px;
color: #c62828;
font-weight: bold;
font-size: 11px;
font-size: 12px;
line-height: 1;
z-index: 10;
background-color: rgba(239, 83, 80, 0.2);
background-color: rgba(239, 83, 80, 0.25);
padding: 1px 4px;
border-radius: 2px;
border-radius: 3px;
}
}
:deep([data-diff-status='modified']) {
background-color: rgba(255, 193, 7, 0.06);
outline: 1.5px dashed rgba(255, 193, 7, 0.4) !important;
background-color: rgba(255, 193, 7, 0.08) !important;
outline: 1.5px dashed rgba(255, 193, 7, 0.45) !important;
outline-offset: 1px;
border-radius: 4px;
}
// 隐藏空白对齐占位节点
:deep([data-diff-status='added-placeholder']),
:deep([data-attr-modified='true']) {
/* 属性具体变更点由 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']) {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
* {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
background-color: rgba(239, 83, 80, 0.05) !important;
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 5px,
rgba(239, 83, 80, 0.08) 5px,
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) {
color: #2e7d32 !important;
......@@ -633,6 +853,17 @@ defineExpose({
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) {
outline: 2px dashed var(--primary-color, #165dff) !important;
......@@ -662,64 +893,156 @@ defineExpose({
/* XML 文本区默认样式与展开/收起过渡 */
.compare-text-zone {
flex: 1 1 0% !important;
min-height: 180px !important;
max-height: 320px !important;
margin-bottom: 0px !important;
opacity: 1 !important;
flex: 0 0 40%;
min-height: 80px;
margin-bottom: 0px;
opacity: 1;
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,
opacity 0.2s linear 0.15s !important;
flex 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
min-height 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
opacity 0.25s linear;
}
/* 拖拽过程中禁用 CSS 过渡,保证 60FPS 实时极速响应 */
.compare-text-zone.is-resizing,
.compare-render-zone.is-resizing {
transition: none !important;
}
/* 当渲染区收起时,文本区独占全部剩余高度 */
.compare-text-zone.expanded-full {
flex: 1 1 0% !important;
min-height: 0 !important;
max-height: 100% !important;
}
/* 当文本区收起时:不透明度立即淡出,尺寸延迟 0.08s 收折 */
/* 当文本区收起时 */
.compare-text-zone.is-collapsed {
flex: 0 0 0% !important;
max-height: 0 !important;
min-height: 0 !important;
min-height: 0px !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 !important;
opacity 0.2s linear,
flex 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
min-height 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) !important;
}
/* 渲染区默认样式与展开/收起过渡 */
.compare-render-zone {
flex: 2 1 0% !important;
min-height: 0 !important;
max-height: 2000px !important;
opacity: 1 !important;
flex: 1 1 0%;
min-height: 100px;
opacity: 1;
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;
flex 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
min-height 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
opacity 0.25s linear;
}
/* 当渲染区收起时:不透明度立即淡出,弹性大小延迟 0.08s 收折 */
/* 当渲染区收起时:纯靠 flex 平滑收缩到 0% + opacity 淡出 */
.compare-render-zone.is-collapsed {
flex: 0 0 0% !important;
max-height: 0 !important;
min-height: 0px !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;
opacity 0.2s linear,
flex 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
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>
......@@ -78,6 +78,8 @@
:rowspan="cell.rowspan"
:data-node-id="cell.id"
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="[
isDiffMode ? 'cursor-default' : 'cursor-pointer',
......@@ -91,6 +93,29 @@
@click.stop="isDiffMode ? null : handleCellClick(cell, $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">
<DocNodeRenderer v-for="child in cell.rawNode!.children" :key="child.id" :node="child" :parent="cell.rawNode" />
</template>
......@@ -190,6 +215,8 @@
:rowspan="cell.rowspan"
:data-node-id="cell.id"
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="[
isDiffMode ? 'cursor-default' : 'cursor-pointer',
......@@ -203,6 +230,29 @@
@click.stop="isDiffMode ? null : handleCellClick(cell, $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">
<DocNodeRenderer v-for="child in cell.rawNode!.children" :key="child.id" :node="child" :parent="cell.rawNode" />
</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