Commit 9312e5ad by pangchong

feat(editor): 增加Word风格文档大纲视图,解耦NodeTree组件三层架构并优化虚拟滚动与定位体验

parent e84a7c4c
...@@ -328,16 +328,25 @@ export function useEditorPanel() { ...@@ -328,16 +328,25 @@ export function useEditorPanel() {
const containerCenter = containerRect.top + containerRect.height / 2 const containerCenter = containerRect.top + containerRect.height / 2
const diff = Math.abs(targetCenter - containerCenter) const diff = Math.abs(targetCenter - containerCenter)
// 若节点高度超过视口的 70%,采用顶端预留 24px 呼吸距离对齐
const isTall = rect.height >= containerRect.height * 0.7
// 已在可视范围内且非强制,不做处理 // 已在可视范围内且非强制,不做处理
if (!force && rect.top >= containerRect.top + 20 && rect.bottom <= containerRect.bottom - 20) { if (!force && rect.top >= containerRect.top + 20 && rect.bottom <= containerRect.bottom - 20) {
return return
} }
// 如果当前节点已基本居中 (偏差 <= 35px),不再重复触发 scrollIntoView,防止画面二次抖动与闪烁 // 如果当前节点已基本居中/靠顶 (偏差 <= 35px),不再重复触发 scrollIntoView,防止画面二次抖动与闪烁
if (diff <= 35 && rect.top >= containerRect.top && rect.bottom <= containerRect.bottom) { if (diff <= 35 && rect.top >= containerRect.top && rect.bottom <= containerRect.bottom) {
return return
} }
if (isTall) {
const currentScrollTop = viewportRef.value.scrollTop
const targetScrollTop = Math.max(0, currentScrollTop + (rect.top - containerRect.top) - 24)
viewportRef.value.scrollTo({ top: targetScrollTop, behavior: 'smooth' })
} else {
targetEl.scrollIntoView({ behavior: 'auto', block: 'center' }) targetEl.scrollIntoView({ behavior: 'auto', block: 'center' })
}
return return
} }
...@@ -357,7 +366,12 @@ export function useEditorPanel() { ...@@ -357,7 +366,12 @@ export function useEditorPanel() {
} }
const estimatedTargetTop = pos.top + internalOffset const estimatedTargetTop = pos.top + internalOffset
const targetScrollTop = Math.max(0, estimatedTargetTop - vHeight / 2) const targetNode = editorStore.nodeMap.get(realId)?.node
const estimatedHeight = targetNode ? getNodeEstimatedHeight(targetNode) : 200
const isTall = estimatedHeight >= vHeight * 0.7
const targetScrollTop = isTall
? Math.max(0, estimatedTargetTop - 24)
: Math.max(0, estimatedTargetTop - vHeight / 2)
viewportRef.value.scrollTo({ viewportRef.value.scrollTo({
top: targetScrollTop, top: targetScrollTop,
...@@ -383,8 +397,15 @@ export function useEditorPanel() { ...@@ -383,8 +397,15 @@ export function useEditorPanel() {
const containerCenter = containerRect.top + containerRect.height / 2 const containerCenter = containerRect.top + containerRect.height / 2
if (Math.abs(targetCenter - containerCenter) > 45) { if (Math.abs(targetCenter - containerCenter) > 45) {
const isTallNode = rect.height >= containerRect.height * 0.7
if (isTallNode) {
const currentScrollTop = viewportRef.value.scrollTop
const targetScrollTop = Math.max(0, currentScrollTop + (rect.top - containerRect.top) - 24)
viewportRef.value.scrollTo({ top: targetScrollTop, behavior: 'smooth' })
} else {
targetEl.scrollIntoView({ behavior: 'auto', block: 'center' }) targetEl.scrollIntoView({ behavior: 'auto', block: 'center' })
} }
}
}) })
}) })
} }
......
// 文档大纲项接口定义
export interface OutlineItem {
id: string
tagName: string
seqNum: string
text: string
fullText: string
level: number
}
// 文档大纲组件 Props 定义
export interface DocOutlineProps {
pattern: string
active: boolean
}
import { useEditorStore } from '@/store/editor'
import type { DocOutlineProps, OutlineItem } from '../constants'
export function useDocOutlineView(props: DocOutlineProps) {
const editorStore = useEditorStore()
const outlineViewportRef = ref<HTMLElement | null>(null)
const OUTLINE_ITEM_HEIGHT = 27
const scrollTop = ref(0)
const viewportHeight = ref(600)
const getCepTaskNumber = (node: any): string => {
if (!node || !node.attributes) return ''
const { CHAPPREF, CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR } = node.attributes
const parts = [CHAPPREF, CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR].filter(Boolean)
if (parts.length > 0) return parts.join('-')
return node.attributes.TASKNBR || node.attributes.KEY || node.attributes.ID || ''
}
const outlineList = computed<OutlineItem[]>(() => {
try {
if (!editorStore.xmlTree) return []
const outline: OutlineItem[] = []
const getPreviewText = (n: any): string => {
if (!n) return ''
if (n.tagName === 'TOPIC' || n.tagName === 'PRETOPIC' || n.tagName === 'GRAPHIC' || n.tagName === 'TABLE') {
const titleNode = Array.isArray(n.children) ? n.children.find((c: any) => c && (c.tagName === 'TITLE' || c.tagName === 'TITLEC')) : null
if (titleNode && titleNode.textContent) return titleNode.textContent.trim()
}
if (n.textContent && typeof n.textContent === 'string') {
const clean = n.textContent.trim().replace(/\s+/g, ' ')
if (clean) return clean
}
if (Array.isArray(n.children) && n.children.length > 0) {
for (const c of n.children) {
const txt = getPreviewText(c)
if (txt) return txt
}
}
return ''
}
const topicSeqObj = { val: 0 }
const traverse = (node: any, currentLevel: number) => {
if (!node) return
let isOutline = false
let level = currentLevel
let seqNum = ''
let text = ''
const parentTag = editorStore.nodeMap?.get(node.id)?.parent?.tagName
if (node.tagName === 'CEP' || node.tagName === 'TASK' || node.tagName === 'JC-TASK') {
isOutline = true
level = 0
const taskNo = getCepTaskNumber(node)
text = taskNo ? `任务 ${taskNo}` : node.tagName
} else if ((node.tagName === 'TITLE' || node.tagName === 'TITLEC') && (parentTag === 'CEP' || parentTag === 'TASK' || parentTag === 'JC-TASK')) {
isOutline = true
level = 1
text = getPreviewText(node) || '无标题'
} else if (node.tagName === 'TOPIC' || node.tagName === 'PRETOPIC' || node.tagName === 'SECTION' || node.tagName === 'CHTR') {
isOutline = true
level = 1
topicSeqObj.val++
// 对齐 DocNodeRenderer 的 TOPIC 序号规则
let calcSeq = ''
let rootNode: any = null
let isAlphaFormat = false
let curr = editorStore.nodeMap?.get(node.id)
while (curr) {
if (curr.node.tagName === 'CEP' || curr.node.tagName === 'TASK') {
rootNode = curr.node
isAlphaFormat = false
break
}
if (curr.node.tagName === 'JC-TASK') {
rootNode = curr.node
isAlphaFormat = true
break
}
curr = curr.parent ? editorStore.nodeMap?.get(curr.parent.id) : undefined
}
if (rootNode) {
const collected: any[] = []
const trav = (n: any) => {
if (!n) return
if (n.tagName === 'TOPIC') {
const parentItem = editorStore.nodeMap?.get(n.id)?.parent
if (parentItem && (parentItem.tagName === 'CEP' || parentItem.tagName === 'TASK' || parentItem.tagName === 'JC-TASK')) {
collected.push(n)
}
} else if (n.tagName === 'PRETOPIC') {
const parentItem = editorStore.nodeMap?.get(n.id)?.parent
if (parentItem && parentItem.tagName === 'TFMATR') {
const grandParentItem = editorStore.nodeMap?.get(parentItem.id)?.parent
if (grandParentItem && (grandParentItem.tagName === 'CEP' || grandParentItem.tagName === 'TASK' || grandParentItem.tagName === 'JC-TASK')) {
collected.push(n)
}
}
}
if (Array.isArray(n.children)) {
n.children.forEach(trav)
}
}
trav(rootNode)
const idx = collected.findIndex((n) => n.id === node.id)
if (idx !== -1) {
calcSeq = isAlphaFormat ? `${String.fromCharCode(65 + idx)}. ` : `${idx + 1}. `
}
}
seqNum = calcSeq || `${topicSeqObj.val}. `
text = getPreviewText(node) || '无标题主题'
} else if (node.tagName === 'SUBTASK' || node.tagName === 'SUBSECT') {
isOutline = true
level = 2
const taskNo = getCepTaskNumber(node)
text = taskNo ? `子任务 ${taskNo}` : node.tagName
const preview = getPreviewText(node)
if (preview && !preview.includes(taskNo)) {
text += ` (${preview})`
}
} else if (node.tagName === 'L1ITEM') {
isOutline = true
level = 2
// 对齐 DocNodeRenderer 的 L1ITEM 编号规则:按 TOPIC 下的 SUBTASK 去重编号
let subtaskNode: any = null
let topicNode: any = null
let curr = editorStore.nodeMap?.get(node.id)
while (curr) {
if (curr.node.tagName === 'SUBTASK') {
subtaskNode = curr.node
} else if (curr.node.tagName === 'TOPIC') {
topicNode = curr.node
break
}
curr = curr.parent ? editorStore.nodeMap?.get(curr.parent.id) : undefined
}
let calcSeq = ''
if (subtaskNode && topicNode && Array.isArray(topicNode.children)) {
const subtasks = topicNode.children.filter((c: any) => c && c.tagName === 'SUBTASK')
const uniqueKeys: string[] = []
for (const sub of subtasks) {
const func = sub.attributes?.FUNC || ''
const seq = sub.attributes?.SEQ || ''
const key = `${func}-${seq}`
if (!uniqueKeys.includes(key)) {
uniqueKeys.push(key)
}
}
const currentFunc = subtaskNode.attributes?.FUNC || ''
const currentSeq = subtaskNode.attributes?.SEQ || ''
const currentKey = `${currentFunc}-${currentSeq}`
const idx = uniqueKeys.indexOf(currentKey)
if (idx !== -1) {
calcSeq = `${String.fromCharCode(65 + idx)}. `
}
}
if (!calcSeq) {
const parent = editorStore.nodeMap?.get(node.id)?.parent
const siblings = parent && Array.isArray(parent.children) ? parent.children.filter((c: any) => c && c.tagName === 'L1ITEM') : []
const idx = siblings.findIndex((c: any) => c && c.id === node.id)
const num = idx >= 0 ? idx : 0
calcSeq = `${String.fromCharCode(65 + num)}. `
}
seqNum = calcSeq
text = getPreviewText(node) || '—'
} else if (node.tagName === 'L2ITEM') {
isOutline = true
level = 3
const parent = editorStore.nodeMap?.get(node.id)?.parent
const siblings = parent && Array.isArray(parent.children) ? parent.children.filter((c: any) => c && c.tagName === 'L2ITEM') : []
const idx = siblings.findIndex((c: any) => c && c.id === node.id)
const num = idx >= 0 ? idx : 0
seqNum = `(${num + 1}) `
text = getPreviewText(node) || '—'
} else if (node.tagName === 'L3ITEM') {
isOutline = true
level = 4
const parent = editorStore.nodeMap?.get(node.id)?.parent
const siblings = parent && Array.isArray(parent.children) ? parent.children.filter((c: any) => c && c.tagName === 'L3ITEM') : []
const idx = siblings.findIndex((c: any) => c && c.id === node.id)
const num = idx >= 0 ? idx : 0
seqNum = `(${String.fromCharCode(97 + num)}) `
text = getPreviewText(node) || '—'
} else if (node.tagName === 'WARNING') {
isOutline = true
level = 3
seqNum = '警告: '
text = getPreviewText(node) || '—'
} else if (node.tagName === 'CAUTION') {
isOutline = true
level = 3
seqNum = '注意: '
text = getPreviewText(node) || '—'
} else if (node.tagName === 'NOTE') {
isOutline = true
level = 3
seqNum = '提示: '
text = getPreviewText(node) || '—'
} else if (node.tagName === 'GRAPHIC') {
isOutline = true
level = 3
const key = node.attributes?.KEY || node.attributes?.GRAPHICKEY || ''
seqNum = '插图: '
text = getPreviewText(node) || key || 'GRAPHIC'
} else if (node.tagName === 'TABLE') {
isOutline = true
level = 3
seqNum = '表格: '
text = getPreviewText(node) || 'TABLE'
}
if (isOutline) {
outline.push({
id: node.id,
tagName: node.tagName,
seqNum,
text: text,
fullText: `${seqNum}${text}`,
level
})
}
if (Array.isArray(node.children) && node.children.length > 0) {
const nextLevel = isOutline ? level + 1 : currentLevel
node.children.forEach((child: any) => traverse(child, nextLevel))
}
}
traverse(editorStore.xmlTree, 0)
return outline
} catch (e) {
console.error('Failed to compute outline list:', e)
return []
}
})
const filteredOutlineList = computed(() => {
if (!props.pattern.trim()) return outlineList.value
const kw = props.pattern.toLowerCase()
return outlineList.value.filter((item) => item.fullText.toLowerCase().includes(kw) || item.tagName.toLowerCase().includes(kw))
})
// ── 虚拟列表计算(针对巨型 XML 文件高性能渲染) ──
const totalHeight = computed(() => filteredOutlineList.value.length * OUTLINE_ITEM_HEIGHT)
const startIndex = computed(() => Math.max(0, Math.floor(scrollTop.value / OUTLINE_ITEM_HEIGHT) - 5))
const endIndex = computed(() =>
Math.min(
filteredOutlineList.value.length,
Math.ceil((scrollTop.value + viewportHeight.value) / OUTLINE_ITEM_HEIGHT) + 5
)
)
const visibleOutlineItems = computed(() =>
filteredOutlineList.value.slice(startIndex.value, endIndex.value)
)
const startOffset = computed(() => startIndex.value * OUTLINE_ITEM_HEIGHT)
const handleOutlineScroll = (e: Event) => {
const target = e.target as HTMLElement
if (target) {
scrollTop.value = target.scrollTop
}
}
onMounted(() => {
if (outlineViewportRef.value) {
viewportHeight.value = outlineViewportRef.value.clientHeight
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
viewportHeight.value = entry.contentRect.height
}
})
observer.observe(outlineViewportRef.value)
}
})
const activeOutlineId = computed<string | null>(() => {
let selectedId = editorStore.selectedNodeId
if (!selectedId) return null
if (selectedId.includes('-txt-')) {
selectedId = selectedId.split('-txt-')[0]
}
const outlineIdSet = new Set(outlineList.value.map((i) => i.id))
let currId: string | null = selectedId
while (currId) {
if (outlineIdSet.has(currId)) {
return currId
}
const item = editorStore.nodeMap?.get(currId)
currId = item?.parent?.id || null
}
return null
})
let isSelfClick = false
const handleOutlineClick = (id: string) => {
isSelfClick = true
editorStore.setSelectedNodeId(id, true)
setTimeout(() => {
isSelfClick = false
}, 300)
}
const scrollToActiveOutline = () => {
if (isSelfClick) return
if (!props.active || !outlineViewportRef.value) return
nextTick(() => {
if (!outlineViewportRef.value) return
viewportHeight.value = outlineViewportRef.value.clientHeight || 600
scrollTop.value = outlineViewportRef.value.scrollTop
if (activeOutlineId.value) {
const idx = filteredOutlineList.value.findIndex((i) => i.id === activeOutlineId.value)
if (idx !== -1) {
const itemTop = idx * OUTLINE_ITEM_HEIGHT
const vHeight = viewportHeight.value
const targetTop = Math.max(0, itemTop - vHeight / 2 + 14)
outlineViewportRef.value.scrollTo({
top: targetTop,
behavior: 'smooth'
})
scrollTop.value = targetTop
}
}
})
}
watch(
() => activeOutlineId.value,
() => {
scrollToActiveOutline()
}
)
watch(
() => props.active,
(isActive) => {
if (isActive) {
scrollToActiveOutline()
}
}
)
return {
editorStore,
outlineViewportRef,
filteredOutlineList,
activeOutlineId,
handleOutlineClick,
handleOutlineScroll,
totalHeight,
startOffset,
visibleOutlineItems
}
}
<template>
<div ref="outlineViewportRef" class="flex-1 overflow-y-auto p-2 pb-14 relative select-none" @scroll="handleScroll">
<div v-if="filteredOutlineList.length > 0" :style="{ height: totalHeight + 'px', position: 'relative' }">
<div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 right-0">
<div
v-for="item in visibleOutlineItems"
:key="item.id"
:data-outline-id="item.id"
class="relative h-[27px] px-2 rounded-md text-xs cursor-pointer flex items-center justify-between transition-all duration-150 group/outline"
:style="{ paddingLeft: getItemPaddingLeft(item.level) }"
:class="[
activeOutlineId === item.id
? 'bg-primary text-white font-semibold shadow-sm'
: item.level === 0
? 'bg-primary/10 dark:bg-primary/20 border border-primary/30 text-color1 dark:text-white hover:bg-primary/15'
: item.level === 1
? 'hover:bg-fill-3 text-color1 font-bold'
: 'hover:bg-fill-3 text-color2 font-normal'
]"
@click="handleOutlineClick(item.id)"
>
<!-- 顶层主任务/文档卡片左侧修饰条 -->
<div
v-if="item.level === 0 && activeOutlineId !== item.id"
class="absolute left-0 top-1.5 bottom-1.5 w-[3px] bg-primary rounded-r"
></div>
<div class="flex items-center space-x-1.5 min-w-0 flex-1 mr-1.5">
<!-- 大纲类型图标 -->
<n-icon
v-if="getItemIcon(item.tagName)"
size="14"
class="shrink-0 transition-transform group-hover/outline:scale-110"
:class="[
activeOutlineId === item.id
? 'text-white'
: item.level === 0
? 'text-primary dark:text-primary-hover font-bold'
: item.tagName === 'WARNING' || item.tagName === 'CAUTION'
? 'text-amber-500 dark:text-amber-400'
: item.tagName === 'GRAPHIC'
? 'text-indigo-500 dark:text-indigo-400'
: item.tagName === 'TABLE'
? 'text-emerald-500 dark:text-emerald-400'
: 'text-primary/70'
]"
>
<component :is="getItemIcon(item.tagName)" />
</n-icon>
<!-- 序号徽标 (如 3., A., (1), (2)) -->
<span
v-if="item.seqNum && !item.seqNum.includes('🖼️') && !item.seqNum.includes('📊') && !item.seqNum.includes('⚡') && !item.seqNum.includes('📝')"
class="font-mono font-bold shrink-0 text-[11px]"
:class="[activeOutlineId === item.id ? 'text-white' : 'text-primary dark:text-primary-hover']"
>
{{ item.seqNum }}
</span>
<!-- 标题与节点文本预览 -->
<span
class="truncate"
:class="[
item.level === 0 ? 'font-bold text-xs tracking-wide text-color1 dark:text-white' : '',
item.level === 1 ? 'font-bold text-xs text-color1' : '',
item.tagName === 'SUBTASK' ? 'font-semibold text-color1' : ''
]"
:title="item.fullText"
>
{{ item.text }}
</span>
</div>
<!-- 定位指示 / 悬浮跳转图标 -->
<n-icon
size="13"
class="shrink-0 transition-all duration-150"
:class="[
activeOutlineId === item.id
? 'text-white opacity-100 translate-x-0'
: 'opacity-0 -translate-x-1 group-hover/outline:opacity-80 group-hover/outline:translate-x-0 text-primary'
]"
>
<NavigateOutline />
</n-icon>
</div>
</div>
</div>
<div v-else class="h-full flex items-center justify-center text-color3 py-8">
<n-empty description="暂无匹配的大纲项" />
</div>
</div>
</template>
<script setup lang="ts">
import {
DocumentTextOutline,
BookmarkOutline,
ListOutline,
ImageOutline,
GridOutline,
WarningOutline,
InformationCircleOutline,
NavigateOutline
} from '@vicons/ionicons5'
import type { DocOutlineProps } from './constants'
import { useDocOutlineView } from './functionals'
const props = defineProps<DocOutlineProps>()
const emit = defineEmits<{
(e: 'scroll', event: Event): void
}>()
const {
outlineViewportRef,
filteredOutlineList,
activeOutlineId,
handleOutlineClick,
handleOutlineScroll,
totalHeight,
startOffset,
visibleOutlineItems
} = useDocOutlineView(props)
const handleScroll = (e: Event) => {
handleOutlineScroll(e)
emit('scroll', e)
}
const getItemIcon = (tagName: string) => {
switch (tagName) {
case 'CEP':
case 'TASK':
case 'JC-TASK':
return DocumentTextOutline
case 'TOPIC':
case 'PRETOPIC':
case 'SECTION':
return BookmarkOutline
case 'SUBTASK':
case 'SUBSECT':
return ListOutline
case 'GRAPHIC':
return ImageOutline
case 'TABLE':
return GridOutline
case 'WARNING':
case 'CAUTION':
return WarningOutline
case 'NOTE':
return InformationCircleOutline
default:
return null
}
}
const getItemPaddingLeft = (level: number): string => {
if (level === 0) return '8px'
const pad = Math.max(8, Math.min((level - 1) * 10 + 8, 48))
return `${pad}px`
}
defineExpose({
outlineViewportRef
})
</script>
import type { FlatNode } from '@/views/editor/components/NodeTree/constants'
export type { FlatNode }
export interface XmlTreeViewProps {
pattern: string
isBatchMode: boolean
selectedKeys: Set<string>
translatingNodeId: string | null
isAnyModalVisible: boolean
flatList: FlatNode[]
totalHeight: number
visibleVerticalLines: any[]
startOffset: number
visibleItems: FlatNode[]
effectiveSelectedId: string | null
showDropdown: boolean
contextNodeId: string | null
getNodeIcon: (item: FlatNode) => any
highlightText: (text: string, pattern: string) => string
hasCustomColor: (item: FlatNode) => boolean
getNodeStyle: (item: FlatNode) => any
}
import { useEditorStore } from '@/store/editor'
import type { XmlTreeViewProps } from '../constants'
export function useXmlTreeView(_props: XmlTreeViewProps) {
const editorStore = useEditorStore()
const viewportRef = ref<HTMLElement | null>(null)
const getGraphicKey = (node: any): string => {
let key = node.attributes?.KEY || node.attributes?.GRAPHICKEY || node.attributes?.ID || ''
if (!key && node.children) {
const sheet = node.children.find((c: any) => c.tagName === 'SHEET')
if (sheet && sheet.attributes) {
key = sheet.attributes.GNBR || sheet.attributes.KEY || sheet.attributes.ID || ''
}
}
return key
}
const getTreeItemRefNodes = (node: any): any[] => {
const key = getGraphicKey(node)
if (!key || !editorStore.nodeMap) return []
const refs: any[] = []
for (const item of editorStore.nodeMap.values()) {
const childNode = item.node
const attrs = childNode.attributes || {}
if (attrs.REFID === key || attrs.GRAPHICKEY === key || attrs.STRUCTID === key || attrs.GNBR === key) {
refs.push(childNode)
}
}
return refs
}
const getTreeItemTargetId = (node: any): string | null => {
let refId = node.attributes?.REFID || node.attributes?.STRUCTID || node.attributes?.GRAPHICKEY || node.attributes?.GNBR
if (!refId && node.textContent) {
refId = node.textContent
.trim()
.replace(/^\(?(?:Ref:\s*|参考:\s*)?|\)?$/gi, '')
.replace(/\[Sh\.\d+\]/gi, '')
.trim()
}
if (refId) {
const target = editorStore.findNodeByRef(refId, 'GRAPHIC')
return target ? target.id : null
}
return null
}
const handleTreeRefJump = (node: any) => {
const refs = getTreeItemRefNodes(node)
if (refs.length > 0) {
editorStore.setSelectedNodeId(refs[0].id, true)
}
}
return {
editorStore,
viewportRef,
getGraphicKey,
getTreeItemRefNodes,
getTreeItemTargetId,
handleTreeRefJump
}
}
<template>
<div ref="viewportRef" class="flex-1 overflow-y-auto p-2 pb-14 relative select-none virtual-tree-container" @scroll="emit('scroll', $event)">
<div v-if="flatList.length > 0" :style="{ height: totalHeight + 'px', position: 'relative' }">
<!-- 背景连接线层 - 绘制连续的垂直虚线 -->
<div class="tree-lines-layer">
<div
v-for="line in visibleVerticalLines"
:key="line.key"
class="tree-vertical-line"
:style="{
left: `${line.left}px`,
top: `${line.top}px`,
height: `${line.height}px`
}"
></div>
</div>
<!-- 可见列表节点 -->
<div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 right-0">
<div
v-for="item in visibleItems"
:key="item.id"
class="flex items-center h-[32px] px-2 rounded cursor-pointer transition-colors group/row tree-node-content"
:class="[
effectiveSelectedId === item.id
? 'bg-primary text-white tree-node-selected'
: (showDropdown || isAnyModalVisible) && contextNodeId === item.id
? 'tree-node-context-active'
: hasCustomColor(item)
? 'hover:bg-fill-3'
: 'hover:bg-fill-3 text-color2',
VIRTUAL_LAYOUT_TAGS.includes(item.tagName) ? 'tree-node-pagebreak' : ''
]"
:style="[
{
paddingLeft: item.depth * 20 + 8 + (isBatchMode ? 24 : 0) + 'px',
'--tree-level': item.depth,
'--batch-shift': isBatchMode ? '24px' : '0px'
},
getNodeStyle(item)
]"
@click="emit('select', item.id)"
@contextmenu.prevent="(e) => emit('contextmenu', e, item)"
>
<!-- 局部翻译加载状态 -->
<Transition name="translate-loading">
<div v-if="translatingNodeId === item.id" class="translate-loading-mask">
<div class="translate-loading-inner">
<n-icon size="13" class="translate-loading-icon">
<SyncOutline />
</n-icon>
<span class="translate-loading-text">
正在智能翻译
<span class="translate-dots">
<span>.</span>
<span>.</span>
<span>.</span>
</span>
</span>
</div>
</div>
</Transition>
<!-- Checkbox (批量管理模式) -->
<CommonCheckboxSingle
v-if="isBatchMode"
:checked="selectedKeys.has(item.id)"
class="absolute left-2.5 shrink-0 z-10"
@update:checked="(val: boolean) => emit('toggleCheck', item.id, val)"
@click.stop
/>
<!-- Switcher (展开/折叠 减号/加号) -->
<div
v-if="item.hasChildren"
class="w-4 h-4 flex items-center justify-center mr-1 text-color3 hover:text-color1 cursor-pointer transition-colors z-10"
:class="[effectiveSelectedId === item.id ? 'text-white/80 hover:text-white' : 'text-primary']"
@click.stop="emit('toggleExpand', item.id)"
>
<!-- 展开状态:减号 -->
<svg v-if="item.isExpanded" class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M3 8h10v1H3V8z" />
</svg>
<!-- 折叠状态:加号 -->
<svg v-else class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
</svg>
</div>
<!-- 占位符 (无子节点时填充宽度以便对齐) -->
<div v-else class="w-4 h-4 mr-1"></div>
<!-- Icon -->
<div
class="mr-1.5 flex items-center justify-center shrink-0 z-10"
:class="[effectiveSelectedId === item.id ? 'text-white' : '']"
>
<n-icon size="16">
<component :is="getNodeIcon(item)" />
</n-icon>
</div>
<!-- 快捷定位引用 / 定位目标按钮 (在图标右侧直观展示) -->
<template v-if="item.tagName === 'GRAPHIC' && getTreeItemRefNodes(item.rawNode).length > 0">
<div
class="w-5 h-5 rounded hover:bg-primary/30 flex items-center justify-center mr-1 text-primary cursor-pointer transition-transform hover:scale-110 z-20 shrink-0 border border-primary/30"
:class="[effectiveSelectedId === item.id ? 'text-white border-white/50 bg-white/20' : 'bg-primary/10']"
title="点击直接定位至引用当前插图的位置"
@click.stop="handleTreeRefJump(item.rawNode)"
>
<n-icon size="12"><NavigateOutline /></n-icon>
</div>
</template>
<template v-else-if="(item.tagName === 'GRPHCREF' || item.tagName === 'REFINT') && getTreeItemTargetId(item.rawNode)">
<div
class="w-5 h-5 rounded hover:bg-primary/30 flex items-center justify-center mr-1 text-primary cursor-pointer transition-transform hover:scale-110 z-20 shrink-0 border border-primary/30"
:class="[effectiveSelectedId === item.id ? 'text-white border-white/50 bg-white/20' : 'bg-primary/10']"
title="点击直接定位至关联的插图/目标节点"
@click.stop="editorStore.setSelectedNodeId(getTreeItemTargetId(item.rawNode)!, true)"
>
<n-icon size="12"><NavigateOutline /></n-icon>
</div>
</template>
<!-- Label & Subtitle & AttrSummary -->
<div
class="flex-1 min-w-0 flex items-center space-x-1.5 z-10"
:title="
item.fullSubtitle || item.subtitle || item.attrSummary
? `${item.tagName} ${item.attrSummary ? '[' + item.attrSummary + '] ' : ''}${item.fullSubtitle || item.subtitle}`
: item.tagName
"
>
<!-- 节点名称高亮 -->
<span class="font-bold text-sm flex-shrink-0" v-html="highlightText(item.tagName, pattern)"></span>
<!-- 属性摘要 Badge (包含淡色背景与框线,与文本预览彻底区分) -->
<span
v-if="item.attrSummary"
class="text-[10px] font-mono px-1.5 py-0.5 rounded shrink-0 leading-none font-medium border"
:class="[
effectiveSelectedId === item.id
? 'bg-white/20 text-white border-white/30'
: 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:border-primary/30'
]"
v-html="highlightText(item.attrSummary, pattern)"
></span>
<!-- 节点文本内容预览高亮 -->
<span
v-if="item.subtitle"
class="text-xs truncate font-normal"
:title="item.fullSubtitle || item.subtitle"
:class="[
effectiveSelectedId === item.id
? 'text-white/70'
: (showDropdown || isAnyModalVisible) && contextNodeId === item.id
? 'tree-node-context-active-subtitle'
: hasCustomColor(item)
? 'opacity-80'
: 'text-color3'
]"
v-html="highlightText(item.subtitle, pattern)"
></span>
</div>
</div>
</div>
</div>
<div v-else class="h-full flex items-center justify-center text-color3">
<n-empty description="暂无节点数据" />
</div>
</div>
</template>
<script setup lang="ts">
import { SyncOutline, NavigateOutline } from '@vicons/ionicons5'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
import type { FlatNode, XmlTreeViewProps } from './constants'
import { useXmlTreeView } from './functionals'
const props = defineProps<XmlTreeViewProps>()
const emit = defineEmits<{
(e: 'scroll', event: Event): void
(e: 'select', id: string): void
(e: 'contextmenu', event: MouseEvent, item: FlatNode): void
(e: 'toggleExpand', id: string): void
(e: 'toggleCheck', id: string, checked: boolean): void
}>()
const { editorStore, viewportRef, getTreeItemRefNodes, getTreeItemTargetId, handleTreeRefJump } = useXmlTreeView(props)
defineExpose({
viewportRef
})
</script>
<style scoped>
.virtual-tree-container {
position: relative;
overflow-y: auto;
overflow-x: hidden;
}
/* 树形连接线背景层 */
.tree-lines-layer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 0;
}
/* 垂直连接虚线 */
.tree-vertical-line {
position: absolute;
width: 1px;
border-left: 1px dashed var(--divider-color, rgba(0, 0, 0, 0.15));
}
/* 树节点内容 */
.tree-node-content {
position: relative;
z-index: 1;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
/* 水平连接虚线 */
.tree-node-content::after {
content: '';
position: absolute;
top: 16px;
height: 1px;
width: 12px;
border-top: 1px dashed var(--divider-color, rgba(0, 0, 0, 0.15));
pointer-events: none;
left: calc(var(--tree-level, 0) * 20px + 8px + var(--batch-shift, 0px));
}
/* 根级节点不显示水平虚线 */
.tree-node-content[style*='--tree-level: 0']::after {
display: none;
}
/* 搜索高亮标记样式 */
:deep(.highlight-mark) {
background-color: var(--primary-color-hover);
color: var(--primary-color);
padding: 0 2px;
border-radius: 2px;
font-weight: 600;
}
/* 选中节点时的标记高亮样式 */
.tree-node-selected :deep(.highlight-mark) {
background-color: rgba(255, 255, 255, 0.3) !important;
color: #fff !important;
}
/* 右键或弹窗高亮时的节点样式 */
.tree-node-context-active {
background-color: var(--primary1) !important;
outline: 1px solid var(--primary3) !important;
}
.tree-node-context-active :deep(span),
.tree-node-context-active :deep(.n-icon),
.tree-node-context-active :deep(svg) {
color: var(--primary-color) !important;
}
/* 右键或弹窗高亮时的子标题样式 */
.tree-node-context-active-subtitle {
color: var(--primary-color) !important;
opacity: 0.8;
}
/* ── 智能翻译 局部加载遮罩 ── */
.translate-loading-mask {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
border-radius: 4px;
display: flex;
align-items: center;
overflow: hidden;
/* 从左向右渐变的主色条纹 */
background: linear-gradient(
90deg,
var(--primary-color) 0%,
color-mix(in srgb, var(--primary-color) 85%, transparent) 60%,
color-mix(in srgb, var(--primary-color) 50%, transparent) 100%
);
}
.translate-loading-inner {
display: flex;
align-items: center;
gap: 5px;
padding: 0 10px;
width: 100%;
}
.translate-loading-icon {
color: #fff;
flex-shrink: 0;
animation: translate-spin 0.8s linear infinite;
}
.translate-loading-text {
font-size: 12px;
font-weight: 600;
color: #fff;
letter-spacing: 0.02em;
white-space: nowrap;
display: flex;
align-items: baseline;
gap: 1px;
}
/* 三点跳动 */
.translate-dots span {
display: inline-block;
animation: translate-bounce 1.2s ease-in-out infinite;
font-weight: 900;
}
.translate-dots span:nth-child(1) {
animation-delay: 0s;
}
.translate-dots span:nth-child(2) {
animation-delay: 0.2s;
}
.translate-dots span:nth-child(3) {
animation-delay: 0.4s;
}
/* 入场 / 离场过渡 */
.translate-loading-enter-active,
.translate-loading-leave-active {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.translate-loading-enter-from,
.translate-loading-leave-to {
opacity: 0;
transform: scaleX(0.9);
transform-origin: left center;
}
@keyframes translate-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes translate-bounce {
0%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-3px);
}
}
/* ── 虚拟排版辅助节点(如 PAGEBREAK)在树中的凸显样式 ── */
.tree-node-pagebreak {
color: var(--primary-color) !important;
background-color: color-mix(in srgb, var(--primary-color) 6%, transparent) !important;
border: 1px dashed color-mix(in srgb, var(--primary-color) 30%, transparent) !important;
margin: 2px 0;
border-radius: 4px;
}
.tree-node-pagebreak:hover {
background-color: color-mix(in srgb, var(--primary-color) 12%, transparent) !important;
}
.tree-node-selected.tree-node-pagebreak {
background-color: var(--primary-color) !important;
color: #ffffff !important;
border: 1px dashed color-mix(in srgb, var(--card-color, #ffffff) 40%, transparent) !important;
}
</style>
...@@ -72,3 +72,6 @@ export interface TranslationResponse { ...@@ -72,3 +72,6 @@ export interface TranslationResponse {
fuzzy_score: number | null fuzzy_score: number | null
reference_count: number reference_count: number
} }
// 文档大纲项接口定义
export type { OutlineItem } from '../components/DocOutlineView/constants'
...@@ -693,10 +693,12 @@ export function useNodeTree( ...@@ -693,10 +693,12 @@ export function useNodeTree(
const itemTop = idx * ITEM_HEIGHT const itemTop = idx * ITEM_HEIGHT
const vHeight = viewportRef.value.clientHeight const vHeight = viewportRef.value.clientHeight
const targetTop = Math.max(0, itemTop - vHeight / 2)
viewportRef.value.scrollTo({ viewportRef.value.scrollTo({
top: Math.max(0, itemTop - vHeight / 2), top: targetTop,
behavior: 'auto' behavior: 'auto'
}) })
scrollTop.value = targetTop
} }
}) })
} }
...@@ -1673,6 +1675,7 @@ export function useNodeTree( ...@@ -1673,6 +1675,7 @@ export function useNodeTree(
handleContextMenu, handleContextMenu,
handleDropdownSelect, handleDropdownSelect,
handleScroll, handleScroll,
syncTreeSelection,
hasCustomColor, hasCustomColor,
getNodeStyle, getNodeStyle,
effectiveSelectedId, effectiveSelectedId,
......
<template> <template>
<div class="flex flex-col h-full border-r border-divider relative bg-card"> <div class="flex flex-col h-full border-r border-divider relative bg-card">
<!-- 视图模式切换栏:XML 节点树 vs Word 风格大纲 -->
<div class="px-2.5 py-2 border-b border-divider bg-fill-2 flex items-center justify-between shrink-0">
<div class="flex items-center space-x-1 w-full bg-fill-3 p-1 rounded-lg border border-divider">
<button
class="flex-1 py-1.5 text-xs rounded-md transition-all flex items-center justify-center space-x-1 select-none"
:class="[
viewMode === 'tree'
? 'bg-primary text-white shadow-sm font-bold'
: 'text-color2 hover:text-color1 hover:bg-fill-4 font-medium'
]"
@click="viewMode = 'tree'"
>
<span>🌳 XML 节点树</span>
</button>
<button
class="flex-1 py-1.5 text-xs rounded-md transition-all flex items-center justify-center space-x-1 select-none"
:class="[
viewMode === 'outline'
? 'bg-primary text-white shadow-sm font-bold'
: 'text-color2 hover:text-color1 hover:bg-fill-4 font-medium'
]"
@click="viewMode = 'outline'"
>
<span>📑 Word 大纲</span>
</button>
</div>
</div>
<!-- 搜索过滤与批量管理 --> <!-- 搜索过滤与批量管理 -->
<div class="p-3 border-b border-divider flex items-center space-x-2"> <div class="p-3 border-b border-divider flex items-center space-x-2 shrink-0">
<n-input v-model:value="pattern" placeholder="搜索节点名称..." size="small" class="flex-1"> <n-input v-model:value="pattern" :placeholder="viewMode === 'tree' ? '搜索节点名称...' : '搜索大纲标题/序号...'" size="small" class="flex-1">
<template #prefix> <template #prefix>
<n-icon><search-outline /></n-icon> <n-icon><search-outline /></n-icon>
</template> </template>
</n-input> </n-input>
<CommonButton size="small" :type="isBatchMode ? 'primary' : 'default'" @click="toggleBatchMode"> <CommonButton v-if="viewMode === 'tree'" size="small" :type="isBatchMode ? 'primary' : 'default'" @click="toggleBatchMode">
<template #icon> <template #icon>
<n-icon><list-outline /></n-icon> <n-icon><list-outline /></n-icon>
</template> </template>
...@@ -16,7 +44,7 @@ ...@@ -16,7 +44,7 @@
</div> </div>
<!-- 批量操作管理栏 --> <!-- 批量操作管理栏 -->
<div v-if="isBatchMode" class="px-3 py-2 bg-fill-2 border-b border-divider flex items-center justify-between text-xs"> <div v-if="isBatchMode && viewMode === 'tree'" class="px-3 py-2 bg-fill-2 border-b border-divider flex items-center justify-between text-xs shrink-0">
<span class="text-color2"> <span class="text-color2">
已选择 已选择
<strong class="text-primary">{{ selectedKeys.size }}</strong> <strong class="text-primary">{{ selectedKeys.size }}</strong>
...@@ -38,175 +66,42 @@ ...@@ -38,175 +66,42 @@
</div> </div>
</div> </div>
<!-- 虚拟滚动树组件容器 --> <!-- 1. XML 节点树子组件 -->
<div ref="viewportRef" class="flex-1 overflow-y-auto p-2 relative select-none virtual-tree-container" @scroll="handleScroll"> <XmlTreeView
<div v-if="flatList.length > 0" :style="{ height: totalHeight + 'px', position: 'relative' }"> v-show="viewMode === 'tree'"
<!-- 背景连接线层 - 绘制连续的垂直虚线 --> ref="xmlTreeViewRef"
<div class="tree-lines-layer"> :pattern="pattern"
<div :is-batch-mode="isBatchMode"
v-for="line in visibleVerticalLines" :selected-keys="selectedKeys"
:key="line.key" :translating-node-id="translatingNodeId"
class="tree-vertical-line" :is-any-modal-visible="isAnyModalVisible"
:style="{ :flat-list="flatList"
left: `${line.left}px`, :total-height="totalHeight"
top: `${line.top}px`, :visible-vertical-lines="visibleVerticalLines"
height: `${line.height}px` :start-offset="startOffset"
}" :visible-items="visibleItems"
></div> :effective-selected-id="effectiveSelectedId"
</div> :show-dropdown="showDropdown"
:context-node-id="contextNodeId"
<!-- 可见列表节点 --> :get-node-icon="getNodeIcon"
<div :style="{ transform: `translateY(${startOffset}px)` }" class="absolute top-0 left-0 right-0"> :highlight-text="highlightText"
<div :has-custom-color="hasCustomColor"
v-for="item in visibleItems" :get-node-style="getNodeStyle"
:key="item.id" @scroll="handleScroll"
class="flex items-center h-[32px] px-2 rounded cursor-pointer transition-colors group/row tree-node-content" @select="handleSelect"
:class="[ @contextmenu="handleContextMenu"
effectiveSelectedId === item.id @toggle-expand="toggleExpand"
? 'bg-primary text-white tree-node-selected' @toggle-check="handleToggleCheck"
: (showDropdown || isAnyModalVisible) && contextNodeId === item.id
? 'tree-node-context-active'
: hasCustomColor(item)
? 'hover:bg-fill-3'
: 'hover:bg-fill-3 text-color2',
VIRTUAL_LAYOUT_TAGS.includes(item.tagName) ? 'tree-node-pagebreak' : ''
]"
:style="[
{
paddingLeft: item.depth * 20 + 8 + (isBatchMode ? 24 : 0) + 'px',
'--tree-level': item.depth,
'--batch-shift': isBatchMode ? '24px' : '0px'
},
getNodeStyle(item)
]"
@click="handleSelect(item.id)"
@contextmenu.prevent="(e) => handleContextMenu(e, item)"
>
<!-- 局部翻译加载状态 -->
<Transition name="translate-loading">
<div v-if="translatingNodeId === item.id" class="translate-loading-mask">
<div class="translate-loading-inner">
<n-icon size="13" class="translate-loading-icon">
<SyncOutline />
</n-icon>
<span class="translate-loading-text">
正在智能翻译
<span class="translate-dots">
<span>.</span>
<span>.</span>
<span>.</span>
</span>
</span>
</div>
</div>
</Transition>
<!-- Checkbox (批量管理模式) -->
<CommonCheckboxSingle
v-if="isBatchMode"
:checked="selectedKeys.has(item.id)"
class="absolute left-2.5 shrink-0 z-10"
@update:checked="(val: boolean) => handleToggleCheck(item.id, val)"
@click.stop
/> />
<!-- Switcher (展开/折叠 减号/加号) --> <!-- 2. Word 风格文档大纲子组件 -->
<div <DocOutlineView
v-if="item.hasChildren" ref="docOutlineViewRef"
class="w-4 h-4 flex items-center justify-center mr-1 text-color3 hover:text-color1 cursor-pointer transition-colors z-10" v-show="viewMode === 'outline'"
:class="[effectiveSelectedId === item.id ? 'text-white/80 hover:text-white' : 'text-primary']" :pattern="pattern"
@click.stop="toggleExpand(item.id)" :active="viewMode === 'outline'"
> @scroll="handleScroll"
<!-- 展开状态:减号 --> />
<svg v-if="item.isExpanded" class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M3 8h10v1H3V8z" />
</svg>
<!-- 折叠状态:加号 -->
<svg v-else class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
</svg>
</div>
<!-- 占位符 (无子节点时填充宽度以便对齐) -->
<div v-else class="w-4 h-4 mr-1"></div>
<!-- Icon -->
<div
class="mr-1.5 flex items-center justify-center shrink-0 z-10"
:class="[effectiveSelectedId === item.id ? 'text-white' : '']"
>
<n-icon size="16">
<component :is="getNodeIcon(item)" />
</n-icon>
</div>
<!-- 快捷定位引用 / 定位目标按钮 (在图标右侧直观展示) -->
<template v-if="item.tagName === 'GRAPHIC' && getTreeItemRefNodes(item.rawNode).length > 0">
<div
class="w-5 h-5 rounded hover:bg-primary/30 flex items-center justify-center mr-1 text-primary cursor-pointer transition-transform hover:scale-110 z-20 shrink-0 border border-primary/30"
:class="[effectiveSelectedId === item.id ? 'text-white border-white/50 bg-white/20' : 'bg-primary/10']"
title="点击直接定位至引用当前插图的位置"
@click.stop="handleTreeRefJump(item.rawNode)"
>
<n-icon size="12"><NavigateOutline /></n-icon>
</div>
</template>
<template v-else-if="(item.tagName === 'GRPHCREF' || item.tagName === 'REFINT') && getTreeItemTargetId(item.rawNode)">
<div
class="w-5 h-5 rounded hover:bg-primary/30 flex items-center justify-center mr-1 text-primary cursor-pointer transition-transform hover:scale-110 z-20 shrink-0 border border-primary/30"
:class="[effectiveSelectedId === item.id ? 'text-white border-white/50 bg-white/20' : 'bg-primary/10']"
title="点击直接定位至关联的插图/目标节点"
@click.stop="editorStore.setSelectedNodeId(getTreeItemTargetId(item.rawNode)!, true)"
>
<n-icon size="12"><NavigateOutline /></n-icon>
</div>
</template>
<!-- Label & Subtitle & AttrSummary -->
<div
class="flex-1 min-w-0 flex items-center space-x-1.5 z-10"
:title="
item.fullSubtitle || item.subtitle || item.attrSummary
? `${item.tagName} ${item.attrSummary ? '[' + item.attrSummary + '] ' : ''}${item.fullSubtitle || item.subtitle}`
: item.tagName
"
>
<!-- 节点名称高亮 -->
<span class="font-bold text-sm flex-shrink-0" v-html="highlightText(item.tagName, pattern)"></span>
<!-- 属性摘要 Badge (包含淡色背景与框线,与文本预览彻底区分) -->
<span
v-if="item.attrSummary"
class="text-[10px] font-mono px-1.5 py-0.5 rounded shrink-0 leading-none font-medium border"
:class="[
effectiveSelectedId === item.id
? 'bg-white/20 text-white border-white/30'
: 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:border-primary/30'
]"
v-html="highlightText(item.attrSummary, pattern)"
></span>
<!-- 节点文本内容预览高亮 -->
<span
v-if="item.subtitle"
class="text-xs truncate font-normal"
:title="item.fullSubtitle || item.subtitle"
:class="[
effectiveSelectedId === item.id
? 'text-white/70'
: (showDropdown || isAnyModalVisible) && contextNodeId === item.id
? 'tree-node-context-active-subtitle'
: hasCustomColor(item)
? 'opacity-80'
: 'text-color3'
]"
v-html="highlightText(item.subtitle, pattern)"
></span>
</div>
</div>
</div>
</div>
<div v-else class="h-full flex items-center justify-center text-color3">
<n-empty description="暂无节点数据" />
</div>
</div>
<!-- 右键下拉菜单 --> <!-- 右键下拉菜单 -->
<n-dropdown <n-dropdown
...@@ -252,9 +147,8 @@ ...@@ -252,9 +147,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SearchOutline, ListOutline, TrashOutline, CloseOutline, SyncOutline, ArrowUpOutline, NavigateOutline } from '@vicons/ionicons5' import { SearchOutline, ListOutline, TrashOutline, CloseOutline, ArrowUpOutline } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
import { import {
useNodeTree, useNodeTree,
checkRuleVisible, checkRuleVisible,
...@@ -272,6 +166,8 @@ import ViewXmlModal from './components/ViewXmlModal/index.vue' ...@@ -272,6 +166,8 @@ import ViewXmlModal from './components/ViewXmlModal/index.vue'
import InsertFragmentModal from '../EditorToolbar/components/InsertFragmentModal/index.vue' import InsertFragmentModal from '../EditorToolbar/components/InsertFragmentModal/index.vue'
import BatchDeleteConfirmModal from './components/BatchDeleteConfirmModal/index.vue' import BatchDeleteConfirmModal from './components/BatchDeleteConfirmModal/index.vue'
import StashNodeModal from './components/StashNodeModal/index.vue' import StashNodeModal from './components/StashNodeModal/index.vue'
import XmlTreeView from './components/XmlTreeView/index.vue'
import DocOutlineView from './components/DocOutlineView/index.vue'
const props = defineProps<{ const props = defineProps<{
expandedKeys: string[] expandedKeys: string[]
...@@ -281,53 +177,10 @@ const emit = defineEmits(['update:expandedKeys']) ...@@ -281,53 +177,10 @@ const emit = defineEmits(['update:expandedKeys'])
const editorStore = useEditorStore() const editorStore = useEditorStore()
const getGraphicKey = (node: any): string => { const viewMode = ref<'tree' | 'outline'>('tree')
let key = node.attributes?.KEY || node.attributes?.GRAPHICKEY || node.attributes?.ID || '' const xmlTreeViewRef = ref<any>(null)
if (!key && node.children) { const docOutlineViewRef = ref<any>(null)
const sheet = node.children.find((c: any) => c.tagName === 'SHEET') const batchDeleteConfirmModalRef = ref<any>(null)
if (sheet && sheet.attributes) {
key = sheet.attributes.GNBR || sheet.attributes.KEY || sheet.attributes.ID || ''
}
}
return key
}
const getTreeItemRefNodes = (node: any): any[] => {
const key = getGraphicKey(node)
if (!key || !editorStore.nodeMap) return []
const refs: any[] = []
for (const item of editorStore.nodeMap.values()) {
const childNode = item.node
const attrs = childNode.attributes || {}
if (attrs.REFID === key || attrs.GRAPHICKEY === key || attrs.STRUCTID === key || attrs.GNBR === key) {
refs.push(childNode)
}
}
return refs
}
const getTreeItemTargetId = (node: any): string | null => {
let refId = node.attributes?.REFID || node.attributes?.STRUCTID || node.attributes?.GRAPHICKEY || node.attributes?.GNBR
if (!refId && node.textContent) {
refId = node.textContent
.trim()
.replace(/^\(?(?:Ref:\s*|参考:\s*)?|\)?$/gi, '')
.replace(/\[Sh\.\d+\]/gi, '')
.trim()
}
if (refId) {
const target = editorStore.findNodeByRef(refId, 'GRAPHIC')
return target ? target.id : null
}
return null
}
const handleTreeRefJump = (node: any) => {
const refs = getTreeItemRefNodes(node)
if (refs.length > 0) {
editorStore.setSelectedNodeId(refs[0].id, true)
}
}
const insertFragmentModalRef = ref<any>(null) const insertFragmentModalRef = ref<any>(null)
const isAnyModalVisible = computed(() => { const isAnyModalVisible = computed(() => {
...@@ -361,6 +214,8 @@ const { ...@@ -361,6 +214,8 @@ const {
getNodeIcon, getNodeIcon,
highlightText, highlightText,
handleDropdownSelect, handleDropdownSelect,
syncTreeSelection,
viewportHeight,
hasCustomColor, hasCustomColor,
getNodeStyle, getNodeStyle,
effectiveSelectedId, effectiveSelectedId,
...@@ -382,192 +237,56 @@ const { ...@@ -382,192 +237,56 @@ const {
} }
) )
const scrollToTop = () => { watch(
if (viewportRef.value) { () => xmlTreeViewRef.value?.viewportRef,
viewportRef.value.scrollTop = 0 (el) => {
if (el) {
viewportRef.value = el
viewportHeight.value = el.clientHeight
} }
} },
{ immediate: true }
const batchDeleteConfirmModalRef = ref<any>(null) )
</script>
<style scoped>
.virtual-tree-container {
position: relative;
overflow-y: auto;
overflow-x: hidden;
}
/* 树形连接线背景层 */
.tree-lines-layer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 0;
}
/* 垂直连接虚线 */
.tree-vertical-line {
position: absolute;
width: 1px;
border-left: 1px dashed var(--divider-color, rgba(0, 0, 0, 0.15));
}
/* 树节点内容 */
.tree-node-content {
position: relative;
z-index: 1;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
/* 水平连接虚线 */
.tree-node-content::after {
content: '';
position: absolute;
top: 16px;
height: 1px;
width: 12px;
border-top: 1px dashed var(--divider-color, rgba(0, 0, 0, 0.15));
pointer-events: none;
left: calc(var(--tree-level, 0) * 20px + 8px + var(--batch-shift, 0px));
}
/* 根级节点不显示水平虚线 */
.tree-node-content[style*='--tree-level: 0']::after {
display: none;
}
/* 搜索高亮标记样式 */
:deep(.highlight-mark) {
background-color: var(--primary-color-hover);
color: var(--primary-color);
padding: 0 2px;
border-radius: 2px;
font-weight: 600;
}
/* 选中节点时的标记高亮样式 */
.tree-node-selected :deep(.highlight-mark) {
background-color: rgba(255, 255, 255, 0.3) !important;
color: #fff !important;
}
/* 右键或弹窗高亮时的节点样式 */
.tree-node-context-active {
background-color: var(--primary1) !important;
outline: 1px solid var(--primary3) !important;
}
.tree-node-context-active :deep(span),
.tree-node-context-active :deep(.n-icon),
.tree-node-context-active :deep(svg) {
color: var(--primary-color) !important;
}
/* 右键或弹窗高亮时的子标题样式 */
.tree-node-context-active-subtitle {
color: var(--primary-color) !important;
opacity: 0.8;
}
/* ── 智能翻译 局部加载遮罩 ── */
.translate-loading-mask {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
border-radius: 4px;
display: flex;
align-items: center;
overflow: hidden;
/* 从左向右渐变的主色条纹 */
background: linear-gradient(
90deg,
var(--primary-color) 0%,
color-mix(in srgb, var(--primary-color) 85%, transparent) 60%,
color-mix(in srgb, var(--primary-color) 50%, transparent) 100%
);
}
.translate-loading-inner {
display: flex;
align-items: center;
gap: 5px;
padding: 0 10px;
width: 100%;
}
.translate-loading-icon {
color: #fff;
flex-shrink: 0;
animation: translate-spin 0.8s linear infinite;
}
.translate-loading-text {
font-size: 12px;
font-weight: 600;
color: #fff;
letter-spacing: 0.02em;
white-space: nowrap;
display: flex;
align-items: baseline;
gap: 1px;
}
/* 三点跳动 */
.translate-dots span {
display: inline-block;
animation: translate-bounce 1.2s ease-in-out infinite;
font-weight: 900;
}
.translate-dots span:nth-child(1) {
animation-delay: 0s;
}
.translate-dots span:nth-child(2) {
animation-delay: 0.2s;
}
.translate-dots span:nth-child(3) {
animation-delay: 0.4s;
}
/* 入场 / 离场过渡 */
.translate-loading-enter-active,
.translate-loading-leave-active {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.translate-loading-enter-from,
.translate-loading-leave-to {
opacity: 0;
transform: scaleX(0.9);
transform-origin: left center;
}
@keyframes translate-spin { watch(viewMode, (newMode) => {
from { if (newMode === 'tree') {
transform: rotate(0deg); nextTick(() => {
const vp = xmlTreeViewRef.value?.viewportRef || viewportRef.value
if (vp) {
viewportRef.value = vp
viewportHeight.value = vp.clientHeight
scrollTop.value = vp.scrollTop
} }
to { if (editorStore.selectedNodeId) {
transform: rotate(360deg); syncTreeSelection(editorStore.selectedNodeId, true)
} }
} })
} else if (newMode === 'outline') {
nextTick(() => {
const vp = docOutlineViewRef.value?.outlineViewportRef
if (vp) {
scrollTop.value = vp.scrollTop
}
})
}
})
@keyframes translate-bounce { const scrollToTop = () => {
0%, if (viewMode.value === 'tree') {
80%, const vp = xmlTreeViewRef.value?.viewportRef || viewportRef.value
100% { if (vp) {
transform: translateY(0); vp.scrollTo({ top: 0, behavior: 'smooth' })
}
} else {
const vp = docOutlineViewRef.value?.outlineViewportRef
if (vp) {
vp.scrollTo({ top: 0, behavior: 'smooth' })
} }
40% {
transform: translateY(-3px);
} }
} }
</script>
<style scoped>
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: transition:
...@@ -579,21 +298,4 @@ const batchDeleteConfirmModalRef = ref<any>(null) ...@@ -579,21 +298,4 @@ const batchDeleteConfirmModalRef = ref<any>(null)
opacity: 0; opacity: 0;
transform: translateY(10px) scale(0.9); transform: translateY(10px) scale(0.9);
} }
/* ── 虚拟排版辅助节点(如 PAGEBREAK)在树中的凸显样式 ── */
.tree-node-pagebreak {
color: var(--primary-color) !important;
background-color: color-mix(in srgb, var(--primary-color) 6%, transparent) !important;
border: 1px dashed color-mix(in srgb, var(--primary-color) 30%, transparent) !important;
margin: 2px 0;
border-radius: 4px;
}
.tree-node-pagebreak:hover {
background-color: color-mix(in srgb, var(--primary-color) 12%, transparent) !important;
}
.tree-node-selected.tree-node-pagebreak {
background-color: var(--primary-color) !important;
color: #ffffff !important;
border: 1px dashed color-mix(in srgb, var(--card-color, #ffffff) 40%, transparent) !important;
}
</style> </style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment