Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
I
Ifar-Xml-Edtor
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
pangchong
Ifar-Xml-Edtor
Commits
9312e5ad
Commit
9312e5ad
authored
Jul 31, 2026
by
pangchong
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(editor): 增加Word风格文档大纲视图,解耦NodeTree组件三层架构并优化虚拟滚动与定位体验
parent
e84a7c4c
Hide whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
1177 additions
and
423 deletions
+1177
-423
src/views/editor/components/EditorPanel/functionals/index.ts
+25
-4
src/views/editor/components/NodeTree/components/DocOutlineView/constants/index.ts
+15
-0
src/views/editor/components/NodeTree/components/DocOutlineView/functionals/index.ts
+367
-0
src/views/editor/components/NodeTree/components/DocOutlineView/index.vue
+167
-0
src/views/editor/components/NodeTree/components/XmlTreeView/constants/index.ts
+23
-0
src/views/editor/components/NodeTree/components/XmlTreeView/functionals/index.ts
+64
-0
src/views/editor/components/NodeTree/components/XmlTreeView/index.vue
+389
-0
src/views/editor/components/NodeTree/constants/index.ts
+3
-0
src/views/editor/components/NodeTree/functionals/index.ts
+4
-1
src/views/editor/components/NodeTree/index.vue
+120
-418
No files found.
src/views/editor/components/EditorPanel/functionals/index.ts
View file @
9312e5ad
...
@@ -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
}
}
targetEl
.
scrollIntoView
({
behavior
:
'auto'
,
block
:
'center'
})
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'
})
}
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,7 +397,14 @@ export function useEditorPanel() {
...
@@ -383,7 +397,14 @@ 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
)
{
targetEl
.
scrollIntoView
({
behavior
:
'auto'
,
block
:
'center'
})
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'
})
}
}
}
})
})
})
})
...
...
src/views/editor/components/NodeTree/components/DocOutlineView/constants/index.ts
0 → 100644
View file @
9312e5ad
// 文档大纲项接口定义
export
interface
OutlineItem
{
id
:
string
tagName
:
string
seqNum
:
string
text
:
string
fullText
:
string
level
:
number
}
// 文档大纲组件 Props 定义
export
interface
DocOutlineProps
{
pattern
:
string
active
:
boolean
}
src/views/editor/components/NodeTree/components/DocOutlineView/functionals/index.ts
0 → 100644
View file @
9312e5ad
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
}
}
src/views/editor/components/NodeTree/components/DocOutlineView/index.vue
0 → 100644
View file @
9312e5ad
<
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
>
src/views/editor/components/NodeTree/components/XmlTreeView/constants/index.ts
0 → 100644
View file @
9312e5ad
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
}
src/views/editor/components/NodeTree/components/XmlTreeView/functionals/index.ts
0 → 100644
View file @
9312e5ad
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
}
}
src/views/editor/components/NodeTree/components/XmlTreeView/index.vue
0 → 100644
View file @
9312e5ad
<
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
>
src/views/editor/components/NodeTree/constants/index.ts
View file @
9312e5ad
...
@@ -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'
src/views/editor/components/NodeTree/functionals/index.ts
View file @
9312e5ad
...
@@ -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
,
...
...
src/views/editor/components/NodeTree/index.vue
View file @
9312e5ad
<
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 (展开/折叠 减号/加号) -->
<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=
"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 -->
<!-- 2. Word 风格文档大纲子组件 -->
<div
<DocOutlineView
class=
"flex-1 min-w-0 flex items-center space-x-1.5 z-10"
ref=
"docOutlineViewRef"
:title=
"
v-show=
"viewMode === 'outline'"
item.fullSubtitle || item.subtitle || item.attrSummary
:pattern=
"pattern"
? `${item.tagName} ${item.attrSummary ? '[' + item.attrSummary + '] ' : ''}${item.fullSubtitle || item.subtitle}`
:active=
"viewMode === 'outline'"
: item.tagName
@
scroll=
"handleScroll"
"
/>
>
<!-- 节点名称高亮 -->
<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
,
Navigate
Outline
}
from
'@vicons/ionicons5'
import
{
SearchOutline
,
ListOutline
,
TrashOutline
,
CloseOutline
,
ArrowUp
Outline
}
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 {
}
}
)
)
watch
(
()
=>
xmlTreeViewRef
.
value
?.
viewportRef
,
(
el
)
=>
{
if
(
el
)
{
viewportRef
.
value
=
el
viewportHeight
.
value
=
el
.
clientHeight
}
},
{
immediate
:
true
}
)
watch
(
viewMode
,
(
newMode
)
=>
{
if
(
newMode
===
'tree'
)
{
nextTick
(()
=>
{
const
vp
=
xmlTreeViewRef
.
value
?.
viewportRef
||
viewportRef
.
value
if
(
vp
)
{
viewportRef
.
value
=
vp
viewportHeight
.
value
=
vp
.
clientHeight
scrollTop
.
value
=
vp
.
scrollTop
}
if
(
editorStore
.
selectedNodeId
)
{
syncTreeSelection
(
editorStore
.
selectedNodeId
,
true
)
}
})
}
else
if
(
newMode
===
'outline'
)
{
nextTick
(()
=>
{
const
vp
=
docOutlineViewRef
.
value
?.
outlineViewportRef
if
(
vp
)
{
scrollTop
.
value
=
vp
.
scrollTop
}
})
}
})
const
scrollToTop
=
()
=>
{
const
scrollToTop
=
()
=>
{
if
(
viewportRef
.
value
)
{
if
(
viewMode
.
value
===
'tree'
)
{
viewportRef
.
value
.
scrollTop
=
0
const
vp
=
xmlTreeViewRef
.
value
?.
viewportRef
||
viewportRef
.
value
if
(
vp
)
{
vp
.
scrollTo
({
top
:
0
,
behavior
:
'smooth'
})
}
}
else
{
const
vp
=
docOutlineViewRef
.
value
?.
outlineViewportRef
if
(
vp
)
{
vp
.
scrollTo
({
top
:
0
,
behavior
:
'smooth'
})
}
}
}
}
}
const
batchDeleteConfirmModalRef
=
ref
<
any
>
(
null
)
</
script
>
</
script
>
<
style
scoped
>
<
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
);
}
}
.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
>
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment