Commit f2d4548f by pangchong

feat(editor): 增加编辑区非表格右键上下文菜单弹窗功能

- 新增编辑区右键菜单事件,支持非表格元素节点操作弹窗
- 实现祖先节点ID链路回溯,提供节点层级选择菜单功能
- 在 EditorPanel 组件集成 EditAreaContextMenuModal 组件
- 添加 CommonNodeDetailList 组件,实现虚拟滚动显示节点列表
- 配置化复合结构特殊容器(COMPOSITE_CONTAINER_MAP)支持子节点渲染
- 优化 DocNodeRenderer 使用复合容器映射动态渲染相关节点
- 添加 CommonModal 组件滚动封装支持 scrollable 属性
- 添加虚拟滚动列表细节,支持节点多级祖先及虚拟文本展示
- 通过样式及动画提升节点列表高亮和交互体验
- 完善 XML 节点解析与渲染规则规范的项目文档说明
parent 37792b38
# Ifar XML 标注重构编辑器 (Ifar-Xml-Editor)
本项目是一款针对航空工业 XML 结构化工卡的专业编辑器,支持实时渲染、交互定位、XML 就地修改等功能。
---
## 🛠️ XML 节点解析与渲染规则规范
为了保证编辑器的高内聚、低耦合与易维护性,所有开发人员(及 AI 助手)在新增、修改 XML 节点渲染时,**必须严格遵守以下规则**
### 1. 配置集中化原则
* **禁止硬编码**:所有 XML 标签集合、分类常量、特殊标签匹配,必须定义在 `src/configs/xmlTags.ts` 文件中。
* **禁止局部私有定义**:所有业务组件需要进行标签判定时,必须从该配置文件中导入对应的常量,严禁在业务代码中出现局部私有的硬编码判断。
### 2. 复合结构特殊容器规则 (`COMPOSITE_CONTAINER_MAP`)
当某些节点不是普通的块级节点,而是需要自定义布局、拼装表格或组合行内方式渲染其子节点(但其子节点又必须支持独立被选中、就地编辑和精确定位)时,它们被称为**复合结构特殊容器**
这些映射关系定义在 `src/configs/xmlTags.ts``COMPOSITE_CONTAINER_MAP` 对象中:
```typescript
export const COMPOSITE_CONTAINER_MAP: Record<string, string[]> = {
CBDATA: ['PAN', 'CBNAME', 'CB', 'CBLOC'], // 电路断路器行与四列子元素
TED: ['TOOLNAME', 'TOOLNBR'], // 工具名称与工具件号
CON: ['CONNAME', 'CONNBR'], // 消耗品名称与消耗品件号
GRAPHIC: ['TITLE'], // 附图与附图标题
EINDATA: ['EIN'] // 适用性组与具体功能号
}
```
#### 📌 渲染与解析规范:
1. **容器排除**:在判断一个节点是否为普通列表容器(`isContainer`)时,必须通过 `!COMPOSITE_CONTAINER_MAP[tagName]` 将这些复合容器排除,防止它们被错误渲染为普通块列表。
2. **动态渲染(禁止写死)**
在渲染这些容器下的子节点时,**必须使用 `v-for` 遍历 `COMPOSITE_CONTAINER_MAP[node.tagName]` 进行动态渲染**,严禁使用 `v-if="node.children.find(c => c.tagName === 'SPECIFIC_TAG')"` 这种死代码。
3. **高亮与定位联动**
* 必须在每一个可被编辑或选中的子节点 DOM 元素上挂载 `:data-node-id="child.id"`,以确保搜索及左侧树点击时能精确定位到该节点。
* 父级包装器(如表格的 `<tr>`)如需在子节点被选中时联动高亮,必须通过遍历 `COMPOSITE_CONTAINER_MAP[parentTag]` 并判断 `child.id === selectedNodeId` 来动态激活高亮样式,确保高亮状态同步。
......@@ -18,6 +18,7 @@
<slot name="header-extra"></slot>
</template>
<template v-if="scrollable">
<n-scrollbar :style="{ maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight }">
<n-spin :show="loading" :style="{ padding: typeof padding === 'number' ? `${padding}px` : padding }">
<div class="space-y-4">
......@@ -25,6 +26,14 @@
</div>
</n-spin>
</n-scrollbar>
</template>
<template v-else>
<n-spin :show="loading" :style="{ padding: typeof padding === 'number' ? `${padding}px` : padding }">
<div class="space-y-4">
<slot></slot>
</div>
</n-spin>
</template>
<template #footer v-if="showFooter">
<n-space justify="end" align="center" class="p-[10px]">
......@@ -55,6 +64,7 @@ interface Props {
width?: string | number
maxHeight?: string | number
padding?: string | number
scrollable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
......@@ -67,7 +77,8 @@ const props = withDefaults(defineProps<Props>(), {
showFooter: true,
width: '600px',
maxHeight: '75vh',
padding: '15px'
padding: '15px',
scrollable: true
})
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
......
<template>
<div
ref="containerRef"
class="bg-fill-2 border border-divider rounded overflow-y-auto relative scrollbar-thin"
:class="{ 'node-detail-list-dense': dense }"
:style="{ maxHeight: computedMaxHeight }"
@scroll="handleScroll"
>
<!-- 撑开高度以生成物理滚动条的占位容器 -->
<div :style="{ height: totalHeight + 'px', position: 'relative', width: '100%' }">
<!-- 实际渲染可视切片列表 neighborhood 的容器 -->
<div :style="{
transform: `translateY(${offsetY}px)`,
position: 'absolute',
left: 0,
right: 0,
display: 'flex',
flexDirection: 'column',
gap: (dense ? 4 : 6) + 'px',
padding: (dense ? 4 : 8) + 'px',
boxSizing: 'border-box'
}">
<div
v-for="item in visibleItems"
:key="item.id"
class="node-item flex items-start gap-2 rounded border transition-all cursor-pointer select-none"
:class="[
localSelectedId === item.id ? 'node-item-active' : 'hover:bg-fill-3 hover:border-primary/20 text-color2',
localSelectedId === item.id && isFlashing ? 'node-item-flash' : '',
item.disabled ? 'opacity-60 cursor-not-allowed' : ''
]"
:style="{
height: (dense ? 38 : 50) + 'px',
boxSizing: 'border-box',
padding: (dense ? '2px 6px' : '6px 8px')
}"
@click="handleItemClick(item)"
@animationend="onAnimationEnd"
>
<!-- 2. Checkbox 模式 -->
<n-checkbox
v-if="mode === 'checkbox'"
:checked="item.checked"
:disabled="item.disabled"
class="mt-0.5"
@update:checked="(val) => handleCheckboxChange(item, val)"
@click.stop
/>
<!-- 3. 内容区 -->
<div class="flex-1 min-w-0 flex flex-col select-none justify-center h-full">
<div class="flex items-center justify-between min-w-0">
<span class="node-item-title font-bold truncate text-color1" :title="item.displayName">
{{ item.displayName }}
</span>
<!-- 局部操作状态插槽,例如翻译中动画等 -->
<slot name="extra" :item="item"></slot>
</div>
<!-- DTD 违反原因(如 blocked 节点) -->
<span v-if="item.reason" class="text-danger opacity-90 scale-95 origin-left mt-0.5">
{{ item.reason }}
</span>
<!-- 祖先级联位置 -->
<span v-if="item.pathString" :title="item.pathString" class="node-item-path text-color3 opacity-80 truncate mt-0.5">位置: {{ item.pathString }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode'
interface NodeItem {
id: string
displayName?: string
pathString?: string
checked?: boolean
disabled?: boolean
reason?: string
}
interface Props {
items?: NodeItem[]
nodeIds?: string[]
flat?: boolean
dense?: boolean
mode?: 'radio' | 'checkbox' | 'readonly'
selectedId?: string
maxHeight?: number | string
activeMatchIndex?: number
}
const props = withDefaults(defineProps<Props>(), {
flat: false,
dense: false,
mode: 'readonly',
maxHeight: 240,
activeMatchIndex: -1
})
const emit = defineEmits<{
(e: 'update:selectedId', val: string): void
(e: 'change', item: NodeItem): void
(e: 'click', item: NodeItem): void
}>()
const editorStore = useEditorStore()
const localSelectedId = computed({
get: () => props.selectedId || '',
set: (val) => emit('update:selectedId', val)
})
const computedMaxHeight = computed(() => {
const val = props.maxHeight
if (typeof val === 'number') {
return `${val}px`
}
if (typeof val === 'string') {
if (/^\d+$/.test(val.trim())) {
return `${val.trim()}px`
}
return val
}
return '240px'
})
// ── 格式化节点显示文本 ──
const getNodeDisplayName = (node: XmlNode): string => {
let suffix = ''
if (node.tagName === '#text') {
const text = node.textContent?.trim() || ''
suffix = ` "${text}"`
return `文本内容${suffix}`
}
if (node.attributes.ID) {
suffix = ` (ID: ${node.attributes.ID})`
} else if (node.attributes.EFFRG) {
suffix = ` (A/C: ${node.attributes.EFFRG})`
} else {
const text = node.textContent?.trim() || ''
if (text) {
suffix = ` "${text}"`
}
}
return `<${node.tagName}>${suffix}`
}
// ── 计算节点面包屑路径 ──
const getNodeParentPath = (nodeId: string, nodeMap: Map<string, any>): string => {
const path: string[] = []
let currentId: string | null = nodeId
while (currentId) {
const item = nodeMap.get(currentId)
if (item) {
path.unshift(item.node.tagName)
currentId = item.parent?.id || null
} else {
break
}
}
return path.join(' > ')
}
// ── 处理渲染列表 ──
const processedItems = computed(() => {
const nodeMap = editorStore.nodeMap
// 💡 情况 A-1:平铺展示模式(直接列出 nodeIds 对应的所有元素,不进行周边的树链回溯)
if (props.flat && props.nodeIds && props.nodeIds.length > 0) {
const list: any[] = []
props.nodeIds.forEach((id) => {
let rid = id
if (id.includes('-txt-')) {
rid = id.split('-txt-')[0]
}
const mapped = nodeMap.get(rid)
if (mapped) {
const node = mapped.node
list.push({
id: id,
displayName: getNodeDisplayName(node),
pathString: mapped.parent ? getNodeParentPath(mapped.parent.id, nodeMap) : '无 (根节点)',
checked: false,
disabled: false
})
}
})
return list
}
// 情况 A-2:回溯整棵树链并收集其周边的兄弟节点、子孙节点以拓展操作目标
if (!props.flat && props.nodeIds && props.nodeIds.length > 0) {
let leafId = localSelectedId.value || props.nodeIds[0]
let baseRealId = leafId
if (leafId.includes('-txt-')) {
baseRealId = leafId.split('-txt-')[0]
}
let baseMapped = nodeMap.get(baseRealId)
if (!baseMapped) {
let foundAliveId = ''
for (const id of props.nodeIds) {
let rid = id
if (id.includes('-txt-')) {
rid = id.split('-txt-')[0]
}
const mapped = nodeMap.get(rid)
if (mapped) {
baseRealId = rid
baseMapped = mapped
foundAliveId = id
break
}
}
if (foundAliveId) {
const aliveId = foundAliveId
nextTick(() => {
localSelectedId.value = aliveId
})
}
}
if (!baseMapped) return []
const baseNode = baseMapped.node
const list: any[] = []
// 🟢 A. 收集有限层级的祖先节点 (最多向上限制为 3 级,避免追溯到顶层无关的大根节点)
const activeIdsSet = new Set<string>()
props.nodeIds.slice(0, 3).forEach((id) => {
let rid = id
if (id.includes('-txt-')) {
rid = id.split('-txt-')[0]
}
if (nodeMap.has(rid)) {
activeIdsSet.add(rid)
}
})
// 🟢 B. 添加自身
list.push({
id: baseRealId,
displayName: getNodeDisplayName(baseNode),
pathString: baseMapped.parent ? getNodeParentPath(baseMapped.parent.id, nodeMap) : '无 (根节点)',
checked: false,
disabled: false
})
// 🟢 C. 添加上下的兄弟节点
const parentNode = baseMapped.parent
if (parentNode) {
const siblings = parentNode.children
const selfIndex = siblings.findIndex((c) => c.id === baseRealId)
if (selfIndex !== -1) {
const siblingRange = 2
const startIdx = Math.max(0, selfIndex - siblingRange)
const endIdx = Math.min(siblings.length - 1, selfIndex + siblingRange)
for (let i = startIdx; i <= endIdx; i++) {
const sib = siblings[i]
if (sib.id !== baseRealId) {
list.push({
id: sib.id,
displayName: getNodeDisplayName(sib),
pathString: getNodeParentPath(parentNode.id, nodeMap),
checked: false,
disabled: false
})
}
}
}
}
// 🟢 D. 收集子节点和孙节点 (防止列表过长)
const childCountLimit = 3
const grandsonCountLimit = 2
baseNode.children.slice(0, childCountLimit).forEach((child) => {
list.push({
id: child.id,
displayName: getNodeDisplayName(child),
pathString: getNodeParentPath(baseRealId, nodeMap),
checked: false,
disabled: false
})
child.children.slice(0, grandsonCountLimit).forEach((grand) => {
list.push({
id: grand.id,
displayName: getNodeDisplayName(grand),
pathString: getNodeParentPath(child.id, nodeMap),
checked: false,
disabled: false
})
})
})
// 🟢 E. 添加非自身的高优先级祖先节点
for (const id of activeIdsSet) {
if (id === baseRealId) continue
const mapped = nodeMap.get(id)
if (!mapped) continue
const node = mapped.node
list.push({
id: node.id,
displayName: getNodeDisplayName(node),
pathString: mapped.parent ? getNodeParentPath(mapped.parent.id, nodeMap) : '无 (根节点)',
checked: false,
disabled: false
})
}
// 🟢 F. 虚拟文本节点还原与精确定位插入
if (leafId.includes('-txt-')) {
const textIdx = parseInt(leafId.split('-txt-')[1], 10)
const textVal = baseNode.mixedContent?.[textIdx]?.text || ''
const virtualNode = {
id: leafId,
tagName: '#text',
attributes: {},
children: [],
textContent: textVal,
mixedContent: [],
parentId: baseRealId
}
const vItem = {
id: leafId,
displayName: getNodeDisplayName(virtualNode),
pathString: getNodeParentPath(baseRealId, nodeMap),
checked: false,
disabled: false
}
const parentIdx = list.findIndex((item) => item.id === baseRealId)
if (parentIdx !== -1) {
list.splice(parentIdx + 1, 0, vItem)
} else {
list.push(vItem)
}
}
return list
}
// 情况 B:传入了并列的 items
if (props.items) {
return props.items.map((item) => {
const mapped = nodeMap.get(item.id)
const node = mapped?.node
const displayName = item.displayName || (node ? getNodeDisplayName(node) : '未知节点')
const pathString = item.pathString || (mapped?.parent ? getNodeParentPath(mapped.parent.id, nodeMap) : '')
return {
...item,
displayName,
pathString
}
})
}
return []
})
const isManualClick = ref(false)
const handleItemClick = (item: any) => {
if (item.disabled) return
isManualClick.value = true
localSelectedId.value = item.id
emit('click', item)
nextTick(() => {
isManualClick.value = false
})
}
const handleCheckboxChange = (item: any, checked: boolean) => {
item.checked = checked
emit('change', item)
}
// ── 虚拟滚动核心计算 ──
const containerRef = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const containerHeight = ref(240)
const itemHeight = computed(() => (props.dense ? 42 : 56))
const totalHeight = computed(() => {
return processedItems.value.length * itemHeight.value
})
const startIndex = ref(0)
const endIndex = ref(20)
const updateIndices = () => {
const sIdx = Math.max(0, Math.floor(scrollTop.value / itemHeight.value) - 2)
const limit = Math.ceil(containerHeight.value / itemHeight.value) + 4
const eIdx = Math.min(processedItems.value.length, sIdx + limit)
startIndex.value = sIdx
endIndex.value = eIdx
}
const offsetY = computed(() => startIndex.value * itemHeight.value)
const visibleItems = computed(() => {
return processedItems.value.slice(startIndex.value, endIndex.value)
})
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
scrollTop.value = target.scrollTop
updateIndices()
}
const updateContainerSize = () => {
if (containerRef.value) {
containerHeight.value = containerRef.value.clientHeight || 240
updateIndices()
}
}
onMounted(() => {
updateContainerSize()
if (containerRef.value && typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(() => {
updateContainerSize()
})
observer.observe(containerRef.value)
onUnmounted(() => observer.disconnect())
}
})
// 监听 dense 切换或数据源改变,实时校准高度与索引
watch([() => props.dense, () => processedItems.value], () => {
nextTick(() => {
updateContainerSize()
})
})
// ── 智能对焦滚动定位 ──
const scrollToSelected = (id: string) => {
const idx = processedItems.value.findIndex((item) => item.id === id)
if (idx !== -1 && containerRef.value) {
const targetScrollTop = idx * itemHeight.value - (containerHeight.value - itemHeight.value) / 2
containerRef.value.scrollTo({
top: Math.max(0, targetScrollTop),
behavior: 'smooth'
})
}
}
// ── 闪烁反馈动画(当索引改变时触发高亮项微动效,给用户切换确认) ──
const isFlashing = ref(false)
watch(() => props.activeMatchIndex, () => {
isFlashing.value = false
nextTick(() => {
isFlashing.value = true
})
})
const onAnimationEnd = () => {
isFlashing.value = false
}
watch(localSelectedId, (newId) => {
if (!newId) return
// 💡 如果是手动点击引起的选中项变更,则不执行滚动定位,防止画面闪动
if (isManualClick.value) return
nextTick(() => {
scrollToSelected(newId)
})
}, { immediate: true })
</script>
<style scoped>
.text-danger {
color: var(--error-color, var(--danger6, #f53f3f));
}
.node-item {
box-sizing: border-box;
background-color: var(--n-card-color, rgba(0, 0, 0, 0.015));
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.04)) !important;
}
.node-item-title {
font-size: 13px;
line-height: 1.4;
}
.node-item-path {
font-size: 11px;
line-height: 1.3;
}
.node-item-active {
background-color: rgba(24, 160, 88, 0.09) !important;
border-color: rgba(24, 160, 88, 0.35) !important;
}
.node-item-active span {
color: var(--primary-color, #18a058) !important;
}
.node-item-active span.text-color3 {
color: var(--primary-color, #18a058) !important;
opacity: 0.8;
}
.node-item-flash {
animation: flash-green-anim 0.35s ease-out;
}
@keyframes flash-green-anim {
0% {
background-color: rgba(24, 160, 88, 0.45) !important;
border-color: rgba(24, 160, 88, 0.8) !important;
}
100% {
background-color: rgba(24, 160, 88, 0.09) !important;
border-color: rgba(24, 160, 88, 0.35) !important;
}
}
/* ── 紧凑微调模式(配合小窗口查找与替换面板) ── */
.node-detail-list-dense .node-item-title {
font-size: 11px !important;
line-height: 1.3 !important;
}
.node-detail-list-dense .node-item-path {
font-size: 9px !important;
line-height: 1.2 !important;
margin-top: 1px !important;
}
.scrollbar-thin::-webkit-scrollbar {
width: 4px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: var(--n-primary-color, #18a058);
}
</style>
......@@ -81,6 +81,18 @@ export const CB_COMPONENT_TAGS = ['CB', 'CBNAME', 'CBLOC']
// 上下标标签
export const SUPER_SUB_TAGS = ['SUPER', 'SUPERSCRIPT', 'SUB', 'SUBSCRIPT']
// 复合结构特殊容器及其子节点关系映射表(键为直接父级节点标签名,值为其下需要特殊高亮/选中定位的子节点标签名)
export const COMPOSITE_CONTAINER_MAP: Record<string, string[]> = {
CBDATA: ['PAN', 'CBNAME', 'CB', 'CBLOC'],
TED: ['TOOLNAME', 'TOOLNBR'],
CON: ['CONNAME', 'CONNBR'],
GRAPHIC: ['TITLE'],
EINDATA: ['EIN']
}
// 动态派生出所有复合容器节点标签集
export const COMPOSITE_CONTAINER_TAGS = Object.keys(COMPOSITE_CONTAINER_MAP)
// 罗马数字转换表(用于列表编号)
export const ROMAN_LOOKUP: Array<[string, number]> = [
['x', 10],
......
import type { XmlNode } from '@/types/xmlNode'
import { useEditorStore, nodeSelectedRefs } from '@/store/editor'
import { LIST_ITEM_TAGS, HEADER_TAGS, ROMAN_LOOKUP } from '../constants'
import { ALERT_AND_EFF_TAGS, ALERT_BLOCK_TAGS } from '@/configs/xmlTags'
import { ALERT_AND_EFF_TAGS, ALERT_BLOCK_TAGS, COMPOSITE_CONTAINER_MAP } from '@/configs/xmlTags'
import { useI18n } from 'vue-i18n'
// 警示块级节点集合(WARNING / CAUTION / NOTE),用于注入 insideAlert 上下文
......@@ -55,18 +55,19 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
provide('insideChinese', true)
}
// 判断是否是列表容器
// 判断是否是列表容器 (需要排除表格、主题、选项以及通过复合 Map 特殊渲染的各种复合父节点/容器)
const isContainer = computed(() => {
return (
!props.isInline &&
props.node.children &&
props.node.children.length > 0 &&
props.node.tagName !== 'TABLE' &&
props.node.tagName !== 'GRAPHIC' &&
props.node.tagName !== 'SELECTION' &&
props.node.tagName !== 'TOPIC' &&
props.node.tagName !== 'PRETOPIC' &&
props.node.tagName !== 'CBLST'
props.node.tagName !== 'CBLST' &&
props.node.tagName !== 'EINLST' &&
!COMPOSITE_CONTAINER_MAP[props.node.tagName]
)
})
......
......@@ -244,17 +244,22 @@
></span>
</template>
<!-- 13. TED (工具/设备数据) 特殊处理 -->
<template v-else-if="node.tagName === 'TED'">
<span class="text-color1 cursor-pointer" @click.stop="editorStore.setSelectedNodeId(node.id)">
{{ getTedText(node) }}
<!-- 13. TED / CON (工具/消耗品数据组合) 统一配置化渲染 -->
<template v-else-if="node.tagName === 'TED' || node.tagName === 'CON'">
<span class="text-color1 cursor-pointer select-none inline-flex items-center space-x-1" @click.stop="editorStore.setSelectedNodeId(node.id)">
<template v-for="childTag in COMPOSITE_CONTAINER_MAP[node.tagName]" :key="childTag">
<template v-if="node.children.find((c) => c.tagName === childTag)">
<span v-if="childTag.endsWith('NBR')" class="text-color3 select-none mx-0.5">
{{ childTag === 'CONNBR' ? '(Material Ref. ' : '(' }}
</span>
<DocNodeRenderer
:node="node.children.find((c) => c.tagName === childTag)!"
:parent="node"
is-inline
/>
<span v-if="childTag.endsWith('NBR')" class="text-color3 select-none mx-0.5">)</span>
</template>
</template>
<!-- 14. CON (消耗品数据) 特殊处理 -->
<template v-else-if="node.tagName === 'CON'">
<span class="text-color1 cursor-pointer" @click.stop="editorStore.setSelectedNodeId(node.id)">
{{ getConText(node) }}
</span>
</template>
......@@ -312,20 +317,29 @@
<tr
:data-node-id="cbData.id"
class="h-8 hover:bg-fill-3 transition-colors cursor-pointer"
:class="[editorStore.selectedNodeId === cbData.id ? 'bg-primary/10 font-bold' : '']"
:class="[
editorStore.selectedNodeId === cbData.id ||
cbData.children.some(c => c.id === editorStore.selectedNodeId && COMPOSITE_CONTAINER_MAP['CBDATA']?.includes(c.tagName))
? 'bg-primary/5 font-bold'
: ''
]"
@click.stop="editorStore.setSelectedNodeId(cbData.id)"
>
<td class="border border-divider px-2 text-center font-mono">
{{ getCbValue(cbData, 'PAN') }}
</td>
<td class="border border-divider px-2">
{{ getCbValue(cbData, 'CBNAME') }}
</td>
<td class="border border-divider px-2 text-center font-mono font-bold text-color1">
{{ getCbValue(cbData, 'CB').replace(/-/g, '') }}
</td>
<td class="border border-divider px-2 text-center font-mono">
{{ getCbValue(cbData, 'CBLOC') }}
<td
v-for="colTag in COMPOSITE_CONTAINER_MAP['CBDATA']"
:key="colTag"
class="border border-divider px-2"
:class="{
'text-center font-mono': colTag === 'PAN' || colTag === 'CB' || colTag === 'CBLOC',
'font-bold text-color1': colTag === 'CB'
}"
>
<DocNodeRenderer
v-if="cbData.children.find((c) => c.tagName === colTag)"
:node="cbData.children.find((c) => c.tagName === colTag)!"
:parent="cbData"
is-inline
/>
</td>
</tr>
</template>
......@@ -411,14 +425,16 @@
</div>
</div>
<!-- 图片标题编辑 -->
<div class="w-full mt-2 text-center">
<span class="text-xs text-color3 italic">图标题:</span>
<span
contenteditable="true"
class="text-xs font-bold text-color2 focus:outline-none focus:bg-fill-3 px-2 py-0.5 rounded border border-dashed border-divider hover:border-primary"
@blur="handleGraphicTitleBlur"
v-text="getGraphicTitle()"
></span>
<div class="w-full mt-2 text-center select-none flex items-center justify-center">
<span class="text-xs text-color3 italic mr-1">图标题:</span>
<template v-for="tag in COMPOSITE_CONTAINER_MAP['GRAPHIC']" :key="tag">
<DocNodeRenderer
v-if="node.children.find((c) => c.tagName === tag)"
:node="node.children.find((c) => c.tagName === tag)!"
:parent="node"
is-inline
/>
</template>
</div>
</div>
</template>
......@@ -556,10 +572,16 @@
<div class="flex flex-wrap items-center gap-1.5 text-sm">
<span class="text-color2 font-medium">FIN:</span>
<template v-for="eindata in node.children" :key="eindata.id">
<template v-for="child in eindata.children.filter((c) => c.tagName === 'EIN')" :key="child.id">
<template v-for="child in eindata.children.filter((c) => COMPOSITE_CONTAINER_MAP['EINDATA']?.includes(c.tagName))" :key="child.id">
<span
class="font-mono cursor-pointer hover:underline"
style="color: blue; text-decoration: underline"
class="font-mono cursor-pointer hover:underline transition-all"
:data-node-id="child.id"
:class="[
editorStore.selectedNodeId === child.id
? 'ring-2 ring-primary ring-offset-1 rounded-sm bg-primary/20 px-0.5'
: 'underline'
]"
style="color: blue"
@click.stop="editorStore.setSelectedNodeId(child.id)"
>
{{ child.textContent }}
......@@ -580,10 +602,16 @@
:parent="eindata"
/>
<div class="pl-2">
<template v-for="child in eindata.children.filter((c) => c.tagName === 'EIN')" :key="child.id">
<template v-for="child in eindata.children.filter((c) => COMPOSITE_CONTAINER_MAP['EINDATA']?.includes(c.tagName))" :key="child.id">
<span
class="font-mono cursor-pointer hover:underline"
style="color: blue; text-decoration: underline"
class="font-mono cursor-pointer hover:underline transition-all"
:data-node-id="child.id"
:class="[
editorStore.selectedNodeId === child.id
? 'ring-2 ring-primary ring-offset-1 rounded-sm bg-primary/20 px-0.5'
: 'underline'
]"
style="color: blue"
@click.stop="editorStore.setSelectedNodeId(child.id)"
>
{{ child.textContent }}
......@@ -658,13 +686,14 @@ import { ImageOutline, GridOutline } from '@vicons/ionicons5'
import type { XmlNode } from '@/types/xmlNode'
import TableEditor from '../TableEditor/index.vue'
import { useDocNodeRenderer, getSplitListChildren, getCepTaskNumber, isAllEinDataSameEffect } from './functionals'
import { ALERT_BLOCK_TAGS, PARA_TAGS, CB_COMPONENT_TAGS, SUPER_SUB_TAGS } from '@/configs/xmlTags'
import { ALERT_BLOCK_TAGS, PARA_TAGS, CB_COMPONENT_TAGS, SUPER_SUB_TAGS, COMPOSITE_CONTAINER_TAGS, COMPOSITE_CONTAINER_MAP } from '@/configs/xmlTags'
// 预构建 Set,供模板 v-else-if 判断使用
const ALERT_BLOCK_SET = new Set(ALERT_BLOCK_TAGS)
const PARA_SET = new Set(PARA_TAGS)
const CB_COMPONENT_SET = new Set(CB_COMPONENT_TAGS)
const SUPER_SUB_SET = new Set(SUPER_SUB_TAGS)
const COMPOSITE_CONTAINER_SET = new Set(COMPOSITE_CONTAINER_TAGS)
const props = withDefaults(
defineProps<{
......@@ -703,8 +732,6 @@ const {
getNonCbDataChildren,
getCbValue
} = useDocNodeRenderer(props)
</script>
<style scoped>
......
export interface EditAreaContextMenuProps {
modelValue: boolean
nodeIds: string[]
}
import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode'
import { serializeTreeToXml } from '@/utils/xmlParser'
import { getElementRule, isMixedContentElement } from '@/utils/dtdManager'
import type { EditAreaContextMenuProps } from '../constants'
// 导入共享弹窗状态
import {
checkRuleVisible,
checkRuleData,
viewXmlVisible,
viewXmlTitle,
viewXmlContent,
addNodeVisible,
addNodeMode,
addNodeTargetId,
addNodeAllowedTags,
copyNodeCache,
translatingNodeId
} from '@/views/editor/components/NodeTree/functionals'
export function useEditAreaContextMenu(props: EditAreaContextMenuProps, emit: (event: 'update:visible', val: boolean) => void) {
const editorStore = useEditorStore()
const getEnglishSourceNode = (node: XmlNode, parent: XmlNode | null): XmlNode | null => {
if (!parent || !node.tagName.endsWith('C')) return null
const enTag = node.tagName.slice(0, -1)
if (!getElementRule(enTag)) return null
const idx = parent.children.findIndex((c) => c.id === node.id)
if (idx === -1) return null
for (let i = idx + 1; i < parent.children.length; i++) {
const sibling = parent.children[i]
if (sibling.tagName === node.tagName) break
if (sibling.tagName === enTag) {
return sibling
}
}
return null
}
const hasTranslatableText = (n: XmlNode): boolean => {
if ((n.textContent || '').trim()) return true
return n.children.some(hasTranslatableText)
}
const translateNodePairs = async (sourceNode: XmlNode, targetNode: XmlNode): Promise<boolean> => {
// ── 情形 1:混合内容节点(PARA 等含 text + inline element)──
if (sourceNode.mixedContent && sourceNode.mixedContent.length > 0 && isMixedContentElement(sourceNode.tagName)) {
const targetByTag: Record<string, XmlNode[]> = {}
for (const child of targetNode.children) {
if (!targetByTag[child.tagName]) targetByTag[child.tagName] = []
targetByTag[child.tagName].push(child)
}
const tagUsedCount: Record<string, number> = {}
const newMixedContent: import('@/types/xmlNode').MixedContentItem[] = []
let anySuccess = false
for (const item of sourceNode.mixedContent) {
if (item.type === 'text') {
const rawText = (item.text || '').trim()
if (rawText) {
const res = (await service.postJson('/translate', {
text: rawText,
search_direction: 'en_to_zh'
})) as any
if (res?.success && res?.translation) {
newMixedContent.push({ type: 'text', text: res.translation })
anySuccess = true
} else {
newMixedContent.push(item)
}
} else {
newMixedContent.push(item)
}
} else if (item.type === 'element' && item.nodeId) {
const srcChild = sourceNode.children.find((c) => c.id === item.nodeId)
if (srcChild) {
const tag = srcChild.tagName
const usedIdx = tagUsedCount[tag] || 0
tagUsedCount[tag] = usedIdx + 1
const tgtChild = (targetByTag[tag] || [])[usedIdx]
if (tgtChild) {
const ok = await translateNodePairs(srcChild, tgtChild)
if (ok) anySuccess = true
newMixedContent.push({ type: 'element', nodeId: tgtChild.id })
} else {
newMixedContent.push(item)
}
} else {
newMixedContent.push(item)
}
}
}
if (anySuccess) {
targetNode.mixedContent = newMixedContent
targetNode.textContent = newMixedContent
.filter((i) => i.type === 'text')
.map((i) => i.text || '')
.join('')
}
return anySuccess
}
// ── 情形 2:叶节点(无子元素,有直接文本)──
const sourceText = (sourceNode.textContent || '').trim()
if (sourceText && sourceNode.children.length === 0) {
const res = (await service.postJson('/translate', {
text: sourceText,
search_direction: 'en_to_zh'
})) as any
if (res?.success && res?.translation) {
targetNode.textContent = res.translation
if (isMixedContentElement(targetNode.tagName)) {
targetNode.mixedContent = [{ type: 'text', text: res.translation }]
}
return true
}
return false
}
// ── 情形 3:纯容器节点(只有子元素,无直接文本)──
if (sourceNode.children.length > 0) {
const sourceByTag: Record<string, XmlNode[]> = {}
for (const child of sourceNode.children) {
if (!sourceByTag[child.tagName]) sourceByTag[child.tagName] = []
sourceByTag[child.tagName].push(child)
}
const targetByTag: Record<string, XmlNode[]> = {}
for (const child of targetNode.children) {
if (!targetByTag[child.tagName]) targetByTag[child.tagName] = []
targetByTag[child.tagName].push(child)
}
let anySuccess = false
for (const [, srcChildren] of Object.entries(sourceByTag)) {
const tgtChildren = targetByTag[srcChildren[0].tagName] || []
for (let i = 0; i < srcChildren.length; i++) {
const tgtChild = tgtChildren[i]
if (tgtChild) {
const ok = await translateNodePairs(srcChildren[i], tgtChild)
if (ok) anySuccess = true
}
}
}
return anySuccess
}
return false
}
// 当前操作的目标节点 ID,默认是叶子节点(即列表中的最底层节点)
const activeNodeId = ref<string>('')
// 监听当前操作目标节点的变化,将其同步更新到全局 editorStore,以驱动左侧树菜单同步滚动和定位
watch(activeNodeId, (newId) => {
if (newId && editorStore.selectedNodeId !== newId) {
editorStore.setSelectedNodeId(newId)
}
})
// 当传入的 nodeIds 改变时,默认选择最底部的节点作为初始操作节点
watch(
() => props.nodeIds,
(ids) => {
if (ids && ids.length > 0) {
activeNodeId.value = ids[0]
}
},
{ immediate: true, deep: true }
)
// 当前选中节点的 XML 实体
const activeNode = computed<XmlNode | null>(() => {
if (!activeNodeId.value) return null
// 虚拟文本节点处理
if (activeNodeId.value.includes('-txt-')) {
const realId = activeNodeId.value.split('-txt-')[0]
const textIdx = parseInt(activeNodeId.value.split('-txt-')[1], 10)
const mapped = editorStore.nodeMap.get(realId)
if (mapped) {
const textVal = mapped.node.mixedContent[textIdx]?.text || ''
return {
id: activeNodeId.value,
tagName: '#text',
attributes: {},
children: [],
textContent: textVal,
mixedContent: [],
parentId: mapped.node.id
}
}
}
return editorStore.nodeMap.get(activeNodeId.value)?.node ?? null
})
// 当前选中的节点名称
const activeNodeName = computed(() => {
const node = activeNode.value
if (!node) return '未知'
if (node.tagName === '#text') return '文本内容'
return node.tagName
})
// 是否是文本节点
const isTextNode = computed(() => {
return activeNodeId.value.includes('-txt-') || activeNode.value?.tagName === '#text'
})
// 智能翻译是否可用(非纯结构节点)
const canTranslateActive = computed(() => {
const node = activeNode.value
if (!node) return false
if (node.tagName === '#text') return true
// 如果是元素,检查它是否包含文本
const text = node.textContent?.trim() || ''
if (text) return true
// 或者是混排段落
if (node.mixedContent && node.mixedContent.some((item) => item.type === 'text' && item.text?.trim())) {
return true
}
return false
})
// 是否允许删除(非根节点)
const canDeleteActive = computed(() => {
if (!activeNodeId.value || !editorStore.xmlTree) return false
if (activeNodeId.value === editorStore.xmlTree.id) return false
return true
})
// 根据 DTD 获取允许添加的子标签列表
const allowedChildrenList = computed<string[]>(() => {
const node = activeNode.value
if (!node || isTextNode.value) return []
const rule = getElementRule(node.tagName)
if (!rule || !rule.allowedChildren) return []
return rule.allowedChildren
})
// 根据 DTD 获取允许包裹或插入的父级/兄弟标签列表(取其父级的可接受子节点)
const insertableParentList = computed<string[]>(() => {
const nodeId = activeNodeId.value
if (!nodeId) return []
let realId = nodeId
if (nodeId.includes('-txt-')) {
realId = nodeId.split('-txt-')[0]
}
const mapped = editorStore.nodeMap.get(realId)
const parentNode = mapped?.parent
if (!parentNode) return []
const rule = getElementRule(parentNode.tagName)
if (!rule || !rule.allowedChildren) return []
return rule.allowedChildren
})
// 剪贴板中是否有缓存的已复制节点
const hasCopyCache = computed(() => {
return !!copyNodeCache.value
})
// 是否正在翻译中
const isTranslating = (nodeId: string) => {
return translatingNodeId.value === nodeId
}
// 核心动作处理
const runAction = async (actionName: string) => {
const nodeId = activeNodeId.value
if (!nodeId) return
let realId = nodeId
let isVirtual = false
if (nodeId.includes('-txt-')) {
realId = nodeId.split('-txt-')[0]
isVirtual = true
}
const mapped = editorStore.nodeMap.get(realId)
if (!mapped) return
// 1. 编辑节点属性
if (actionName === 'editNode') {
if (isVirtual) {
window.$message.warning('文本节点无属性可编辑,请直接在内容区双击编辑文本')
return
}
addNodeTargetId.value = realId
addNodeMode.value = 'edit'
addNodeAllowedTags.value = [mapped.node.tagName]
addNodeVisible.value = true
}
// 2. 复制节点
else if (actionName === 'copyNode') {
if (isVirtual) {
window.$message.warning('文本节点暂不支持单独复制')
return
}
copyNodeCache.value = JSON.parse(JSON.stringify(mapped.node))
window.$message.success(`已复制节点 <${mapped.node.tagName}>`)
}
// 3. 删除节点
else if (actionName === 'deleteNode') {
if (isVirtual) {
window.$message.warning('文本节点暂不支持直接删除,请直接编辑清空内容')
return
}
try {
await window.$dialog.warning({
title: '确认删除',
content: `您确定要删除选中的节点 <${mapped.node.tagName}> 吗?该操作不可撤销。`
})
const parentId = mapped.parent?.id || null
editorStore.setSelectedNodeId(realId)
editorStore.deleteSelectedNode()
window.$message.success('节点删除成功')
// 💡 智能降级定位:删除当前节点后,尝试将操作目标转移至其父节点以防止数据链路断裂空白
if (parentId && editorStore.nodeMap.has(parentId)) {
activeNodeId.value = parentId
} else {
emit('update:visible', false) // 若没有存活父级,则主动关闭当前弹窗
}
} catch (e) {
// 取消
}
}
// 4. 查看 XML 片段
else if (actionName === 'viewXml') {
if (isVirtual) {
viewXmlTitle.value = '文本内容'
viewXmlContent.value = activeNode.value?.textContent || ''
} else {
viewXmlTitle.value = `节点 <${mapped.node.tagName}> XML`
viewXmlContent.value = serializeTreeToXml(mapped.node)
}
viewXmlVisible.value = true
}
// 5. 查看 DTD 规则
else if (actionName === 'checkRule') {
if (isVirtual) {
window.$message.warning('文本内容节点没有 DTD 限制规则')
return
}
const rule = getElementRule(mapped.node.tagName)
checkRuleData.value = {
nodeName: mapped.node.tagName,
rawModel: rule?.contentModel.raw || '(#PCDATA)',
humanReadable: rule?.contentModel.humanReadable || '',
parsed: rule?.contentModel.parsed || null
}
checkRuleVisible.value = true
}
// 6. 保存为 XML 模板
else if (actionName === 'saveTemplate') {
window.$message.info('保存为模板功能开发中')
}
// 7. 智能翻译
else if (actionName === 'translateNode') {
const enSourceNode = getEnglishSourceNode(mapped.node, mapped.parent)
if (!enSourceNode) {
window.$message.warning('找不到对应的英文源节点')
return
}
if (!hasTranslatableText(enSourceNode)) {
window.$message.warning('英文原文内容为空,无需翻译')
return
}
try {
translatingNodeId.value = mapped.node.id
editorStore.saveSnapshot()
const ok = await translateNodePairs(enSourceNode, mapped.node)
if (ok) {
window.$message.success('翻译已成功填入')
editorStore.rebuildNodeMap()
} else {
window.$message.error('智能翻译失败:接口未返回有效数据')
}
} catch (err: any) {
window.$message.error('翻译失败: ' + err.message)
} finally {
translatingNodeId.value = null
}
}
// 8. 粘贴为上方/下方/内部 XML 片段
else if (actionName.startsWith('insertFragment')) {
const modeMap: Record<string, 'above' | 'below' | 'inside'> = {
insertFragmentAbove: 'above',
insertFragmentBelow: 'below',
insertFragmentInside: 'inside'
}
const mode = modeMap[actionName]
if (mode && copyNodeCache.value) {
try {
const xml = serializeTreeToXml(copyNodeCache.value)
editorStore.insertXmlFragment(xml, mode, realId)
window.$message.success('XML 片段粘贴成功')
} catch (err: any) {
window.$message.error(err.message || 'XML 片段粘贴失败')
}
}
}
// 9. 粘贴节点为上方/下方/内部
else if (actionName.startsWith('pasteNode')) {
const modeMap: Record<string, 'above' | 'below' | 'inside'> = {
pasteNodeAbove: 'above',
pasteNodeBelow: 'below',
pasteNodeInside: 'inside'
}
const mode = modeMap[actionName]
if (mode && copyNodeCache.value) {
try {
const cloneNode = JSON.parse(JSON.stringify(copyNodeCache.value))
// 递归生成全新 UUID
const regenerateIds = (n: XmlNode, pid: string | null) => {
n.id = crypto.randomUUID()
n.parentId = pid
n.children.forEach((c) => regenerateIds(c, n.id))
}
regenerateIds(cloneNode, null)
const xml = serializeTreeToXml(cloneNode)
editorStore.insertXmlFragment(xml, mode, realId)
window.$message.success('节点粘贴成功')
} catch (err: any) {
window.$message.error(err.message || '节点粘贴失败')
}
}
}
// 10. 编辑结构
else if (actionName === 'addChildNode') {
addNodeTargetId.value = realId
addNodeMode.value = 'child'
addNodeAllowedTags.value = allowedChildrenList.value
addNodeVisible.value = true
} else if (actionName === 'insertBefore') {
addNodeTargetId.value = realId
addNodeMode.value = 'before'
addNodeAllowedTags.value = insertableParentList.value
addNodeVisible.value = true
} else if (actionName === 'insertAfter') {
addNodeTargetId.value = realId
addNodeMode.value = 'after'
addNodeAllowedTags.value = insertableParentList.value
addNodeVisible.value = true
}
}
const pasteXmlOptions = computed(() => [
{ label: '粘贴到上方', key: 'insertFragmentAbove', disabled: !insertableParentList.value.length },
{ label: '粘贴到下方', key: 'insertFragmentBelow', disabled: !insertableParentList.value.length },
{ label: '粘贴为子节点', key: 'insertFragmentInside', disabled: isTextNode.value || !allowedChildrenList.value.length }
])
const editStructureOptions = computed(() => [
{ label: '添加子节点', key: 'addChildNode', disabled: isTextNode.value || !allowedChildrenList.value.length },
{ label: '插入到上方', key: 'insertBefore', disabled: !insertableParentList.value.length },
{ label: '插入到下方', key: 'insertAfter', disabled: !insertableParentList.value.length }
])
const pasteNodeOptions = computed(() => [
{ label: '粘贴到上方', key: 'pasteNodeAbove', disabled: !insertableParentList.value.length },
{ label: '粘贴到下方', key: 'pasteNodeBelow', disabled: !insertableParentList.value.length },
{ label: '粘贴为子节点', key: 'pasteNodeInside', disabled: isTextNode.value || !allowedChildrenList.value.length }
])
return {
activeNodeId,
activeNode,
activeNodeName,
isTextNode,
canTranslateActive,
canDeleteActive,
allowedChildrenList,
insertableParentList,
hasCopyCache,
isTranslating,
runAction,
pasteXmlOptions,
editStructureOptions,
pasteNodeOptions
}
}
<template>
<CommonModal v-model="show" :title="`操作节点 - ${activeNodeName}`" :width="800" :show-footer="false" :scrollable="false">
<div class="space-y-4">
<!-- 顶部节点层级选择 -->
<div>
<div class="text-xs text-color3 mb-2 font-medium">节点结构(点击切换操作目标):</div>
<CommonNodeDetailList v-model:selected-id="activeNodeId" :node-ids="nodeIds" mode="radio" max-height="500">
<template #extra="{ item }">
<!-- 局部翻译中动画 -->
<div v-if="isTranslating(item.id)" class="flex items-center space-x-1 text-primary text-xs shrink-0 pl-2">
<n-icon class="animate-spin"><sync-outline /></n-icon>
<span class="font-medium">翻译中...</span>
</div>
</template>
</CommonNodeDetailList>
</div>
<n-divider class="my-2" />
<!-- 底部操作网格 -->
<div>
<div class="text-xs text-color3 mb-2 font-medium">可用操作:</div>
<div class="space-y-2">
<div class="grid grid-cols-3 gap-2">
<!-- 1. 编辑节点 -->
<CommonButton type="success" secondary block @click="runAction('editNode')">
<template #icon>
<n-icon><create-outline /></n-icon>
</template>
编辑节点
</CommonButton>
<!-- 2. 复制节点 -->
<CommonButton type="primary" secondary block @click="runAction('copyNode')">
<template #icon>
<n-icon><copy-outline /></n-icon>
</template>
复制节点
</CommonButton>
<!-- 3. 粘贴 XML -->
<n-dropdown trigger="click" :options="pasteXmlOptions" :disabled="!hasCopyCache" @select="runAction">
<CommonButton type="success" secondary block :disabled="!hasCopyCache">
<template #icon>
<n-icon><clipboard-outline /></n-icon>
</template>
粘贴XML
</CommonButton>
</n-dropdown>
</div>
<div class="grid grid-cols-3 gap-2">
<!-- 4. 删除节点 -->
<CommonButton type="error" secondary block :disabled="!canDeleteActive" @click="runAction('deleteNode')">
<template #icon>
<n-icon><trash-outline /></n-icon>
</template>
删除节点
</CommonButton>
<!-- 5. 编辑结构 -->
<n-dropdown trigger="click" :options="editStructureOptions" :disabled="isTextNode" @select="runAction">
<CommonButton type="warning" secondary block :disabled="isTextNode">
<template #icon>
<n-icon><build-outline /></n-icon>
</template>
编辑结构
</CommonButton>
</n-dropdown>
<!-- 6. 粘贴节点 (DTD) -->
<n-dropdown trigger="click" :options="pasteNodeOptions" :disabled="!hasCopyCache" @select="runAction">
<CommonButton type="warning" secondary block :disabled="!hasCopyCache">
<template #icon>
<n-icon><clipboard-outline /></n-icon>
</template>
粘贴节点
</CommonButton>
</n-dropdown>
</div>
<div class="grid grid-cols-3 gap-2">
<!-- 7. 智能翻译 -->
<CommonButton
type="info"
secondary
block
:disabled="!canTranslateActive || isTranslating(activeNodeId)"
@click="runAction('translateNode')"
>
<template #icon>
<n-icon v-if="isTranslating(activeNodeId)" class="animate-spin"><sync-outline /></n-icon>
<n-icon v-else><language-outline /></n-icon>
</template>
智能翻译
</CommonButton>
<!-- 8. 查看 XML 片段 -->
<CommonButton type="info" secondary block @click="runAction('viewXml')">
<template #icon>
<n-icon><code-working-outline /></n-icon>
</template>
查看 XML
</CommonButton>
<!-- 9. 查看 DTD 规则 -->
<CommonButton type="info" secondary block :disabled="isTextNode" @click="runAction('checkRule')">
<template #icon>
<n-icon><eye-outline /></n-icon>
</template>
查看规则
</CommonButton>
</div>
<!-- 保存为模板 -->
<div class="grid grid-cols-3 gap-2">
<CommonButton type="primary" secondary block class="col-span-3" @click="runAction('saveTemplate')">
<template #icon>
<n-icon><save-outline /></n-icon>
</template>
保存为模板
</CommonButton>
</div>
</div>
</div>
</div>
</CommonModal>
</template>
<script setup lang="ts">
import {
CreateOutline,
CopyOutline,
ClipboardOutline,
TrashOutline,
BuildOutline,
LanguageOutline,
CodeWorkingOutline,
EyeOutline,
SaveOutline,
SyncOutline
} from '@vicons/ionicons5'
import { useEditAreaContextMenu } from './functionals/index'
import type { EditAreaContextMenuProps } from './constants'
const props = defineProps<EditAreaContextMenuProps>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
}>()
const show = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const {
activeNodeId,
activeNodeName,
isTextNode,
canTranslateActive,
canDeleteActive,
hasCopyCache,
isTranslating,
runAction,
pasteXmlOptions,
editStructureOptions,
pasteNodeOptions
} = useEditAreaContextMenu(props, (event, val) => {
if (event === 'update:visible') {
show.value = val
}
})
</script>
<style scoped></style>
......@@ -71,10 +71,21 @@ export function useFindReplace(
matchCase: matchCase.value,
regExp: regExp.value
})
matches.value = results
if (results.length > 0) {
// 如果旧的索引有效,保持接近 the 索引,否则从 0 开始
if (currentMatchIndex.value < 0 || currentMatchIndex.value >= results.length) {
// 💡 过滤同一个节点内的重复匹配:仅保留每个节点的第一次匹配,以此作为查找替换的独立项
const uniqueResults: typeof results = []
const seenIds = new Set<string>()
results.forEach((res) => {
if (!seenIds.has(res.nodeId)) {
seenIds.add(res.nodeId)
uniqueResults.push(res)
}
})
matches.value = uniqueResults
if (uniqueResults.length > 0) {
// 如果旧的索引有效,保持接近 index,否则从 0 开始
if (currentMatchIndex.value < 0 || currentMatchIndex.value >= uniqueResults.length) {
currentMatchIndex.value = 0
}
// 自动聚焦到首个匹配点
......
......@@ -3,11 +3,11 @@
<transition name="slide-fade">
<div
v-if="visible"
class="find-replace-panel absolute top-20 right-4 z-[999] bg-card/90 backdrop-blur-md border border-divider shadow-2xl rounded-xl p-4 flex flex-col space-y-3 select-none transition-all duration-300 ease-in-out"
class="find-replace-panel absolute top-20 right-4 z-[999] bg-card/90 backdrop-blur-md border border-divider shadow-2xl rounded-xl p-4 flex flex-col space-y-3 select-none"
:style="{
width: isExpanded ? '560px' : '320px',
width: isExpanded ? '800px' : '500px',
transform: `translate(${dragOffset.x}px, ${dragOffset.y}px)`,
transition: dragging ? 'none' : 'width 0.3s ease-in-out, transform 0.1s ease-out'
transition: dragging ? 'none' : 'width 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.1s ease-out'
}"
:class="{ 'is-expanded': isExpanded }"
>
......@@ -52,7 +52,7 @@
<div class="flex items-center space-x-1 text-color3">
<span
class="option-btn text-[10px] px-1.5 py-0.5 rounded cursor-pointer transition-all duration-150"
:class="{ 'active': matchCase }"
:class="{ active: matchCase }"
title="区分大小写"
@click.stop="toggleMatchCase"
>
......@@ -60,7 +60,7 @@
</span>
<span
class="option-btn text-[10px] px-1.5 py-0.5 rounded cursor-pointer transition-all duration-150"
:class="{ 'active': regExp }"
:class="{ active: regExp }"
title="正则表达式"
@click.stop="toggleRegExp"
>
......@@ -74,12 +74,7 @@
<!-- 替换行 -->
<div class="flex flex-col space-y-1">
<n-input
v-model:value="replaceQuery"
placeholder="替换为..."
size="small"
@keydown.enter="replaceCurrent"
/>
<n-input v-model:value="replaceQuery" placeholder="替换为..." size="small" @keydown.enter="replaceCurrent" />
</div>
<!-- 匹配结果及控制区 -->
......@@ -106,13 +101,7 @@
<!-- 操作按钮组 -->
<div class="grid grid-cols-2 gap-2 pt-1">
<CommonButton
size="small"
secondary
:disabled="matches.length === 0"
@click="replaceCurrent"
class="justify-center"
>
<CommonButton size="small" secondary :disabled="matches.length === 0" @click="replaceCurrent" class="justify-center">
替换当前
</CommonButton>
<CommonButton
......@@ -126,24 +115,18 @@
</CommonButton>
</div>
<!-- 匹配文本摘要预览(轻量化列表展示,极大提升体验) -->
<div
v-if="matches.length > 0"
ref="matchesListRef"
class="overflow-y-auto border border-divider/50 rounded-lg p-1 bg-fill-2 text-[10px] space-y-1 scrollbar-thin transition-all duration-300 ease-in-out"
:style="{ maxHeight: isExpanded ? '320px' : '96px' }"
>
<div
v-for="(match, idx) in matches"
:key="idx"
:ref="el => { if (el) matchItemRefs[idx] = el }"
class="match-item p-1 rounded cursor-pointer transition-all truncate hover:bg-primary/10"
:class="{ 'bg-primary/20 active-match border-l-2 border-primary': idx === currentMatchIndex }"
@click="selectMatch(idx)"
>
<span class="bg-primary/10 px-1 rounded mr-1 font-bold" :class="idx === currentMatchIndex ? 'text-primary' : 'text-color3'">{{ match.tagName }}</span>
<span>{{ getMatchContext(match) }}</span>
</div>
<!-- 匹配文本摘要预览(引入 CommonNodeDetailList 全新统一布局组件,采用 flat 平铺和响应式 dense 尺寸) -->
<div v-if="matches.length > 0" class="border border-divider/50 rounded-lg overflow-hidden">
<CommonNodeDetailList
v-model:selected-id="activeMatchNodeId"
:node-ids="matchNodeIds"
:flat="true"
:dense="!isExpanded"
mode="radio"
:max-height="isExpanded ? 480 : 240"
:active-match-index="currentMatchIndex"
:node-match-stats="nodeMatchStats"
/>
</div>
</div>
</transition>
......@@ -187,6 +170,41 @@ const {
getMatchContext,
closePanel
} = useFindReplace(props, emit)
const matchNodeIds = computed(() => {
const ids = matches.value.map((m) => m.nodeId)
return Array.from(new Set(ids))
})
const nodeMatchStats = computed(() => {
const stats: Record<string, { total: number; currentActiveIndex: number; activeOffsetInNode: number }> = {}
matches.value.forEach((m, idx) => {
if (!stats[m.nodeId]) {
stats[m.nodeId] = { total: 0, currentActiveIndex: -1, activeOffsetInNode: -1 }
}
stats[m.nodeId].total++
if (idx === currentMatchIndex.value) {
stats[m.nodeId].currentActiveIndex = idx
stats[m.nodeId].activeOffsetInNode = stats[m.nodeId].total
}
})
return stats
})
const activeMatchNodeId = computed({
get: () => {
if (currentMatchIndex.value >= 0 && currentMatchIndex.value < matches.value.length) {
return matches.value[currentMatchIndex.value].nodeId
}
return ''
},
set: (newVal) => {
const idx = matches.value.findIndex((m) => m.nodeId === newVal)
if (idx !== -1) {
selectMatch(idx)
}
}
})
</script>
<style scoped>
......
......@@ -41,7 +41,7 @@
</div>
<!-- 文档编辑区(虚拟滚动容器) -->
<div ref="viewportRef" class="flex-1 overflow-y-auto min-h-0 leading-relaxed relative" @scroll="handleScroll">
<div ref="viewportRef" class="flex-1 overflow-y-auto min-h-0 leading-relaxed relative" @scroll="handleScroll" @contextmenu="handleContextMenu">
<!-- 占位撑高,模拟全量内容总高度 -->
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<!-- 仅渲染可视区块,通过 translateY 定位 -->
......@@ -60,6 +60,9 @@
<!-- 查找与替换浮动面板 -->
<FindReplacePanel v-model:visible="findReplaceVisible" :sync-editor-scroll="syncEditorScroll" />
<!-- 编辑区非 Table 元素右键上下文菜单弹窗 -->
<EditAreaContextMenuModal v-model="contextMenuVisible" :node-ids="contextMenuNodeIds" />
</template>
<template v-else>
<div class="flex-1 flex flex-col items-center justify-center text-color3">
......@@ -74,6 +77,7 @@ import { SettingsOutline, SearchOutline } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor'
import DocNodeRenderer from '../DocNodeRenderer/index.vue'
import FindReplacePanel from './components/FindReplacePanel/index.vue'
import EditAreaContextMenuModal from './components/EditAreaContextMenuModal/index.vue'
import { useEditorPanel } from './functionals'
import { addNodeVisible, addNodeMode, addNodeTargetId, addNodeAllowedTags } from '../NodeTree/functionals'
......@@ -102,6 +106,45 @@ const handleEditSelectedNode = () => {
addNodeAllowedTags.value = [selectedNode.value.tagName]
addNodeVisible.value = true
}
const contextMenuVisible = ref(false)
const contextMenuNodeIds = ref<string[]>([])
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault()
const target = e.target as HTMLElement
const wrapper = target.closest('[data-node-id]')
if (!wrapper) return
const nodeId = wrapper.getAttribute('data-node-id')
if (!nodeId) return
// 向上回溯构建祖先节点 ID 链
const ids: string[] = []
let currentId: string | null = nodeId
let isInsideTable = false
while (currentId) {
const item = editorStore.nodeMap.get(currentId)
if (item) {
if (item.node.tagName === 'TABLE' || item.node.tagName === 'ENTRY' || item.node.tagName === 'ROW') {
isInsideTable = true
}
ids.push(currentId)
currentId = item.parent?.id || null
} else {
break
}
}
// 如果处于表格内部,采用表格专有的操作菜单,因此此处不弹出通用非 Table 菜单
if (isInsideTable) return
if (ids.length > 0) {
contextMenuNodeIds.value = ids
contextMenuVisible.value = true
}
}
</script>
<style scoped>
......
......@@ -32,12 +32,7 @@
</CommonButton>
<!-- 新增:插入 XML 片段按钮 -->
<CommonButton
secondary
size="small"
class="insert-btn flex-shrink-0"
@click="insertFragmentModalRef?.open(insertBelow)"
>
<CommonButton secondary size="small" class="insert-btn flex-shrink-0" @click="insertFragmentModalRef?.open(insertBelow)">
<template #icon>
<n-icon><code-working-outline /></n-icon>
</template>
......@@ -54,7 +49,7 @@
size="small"
quaternary
class="util-btn"
:type="batchTranslateModalRef?.completed ? 'success' : (batchTranslateModalRef?.loading ? 'primary' : 'default')"
:type="batchTranslateModalRef?.completed ? 'success' : batchTranslateModalRef?.loading ? 'primary' : 'default'"
@click="handleTranslate('batch')"
>
<template #icon>
......@@ -64,15 +59,9 @@
<language-outline v-else />
</n-icon>
</template>
<span v-if="batchTranslateModalRef?.loading">
批量翻译 ({{ batchTranslateModalRef.progress }}%)
</span>
<span v-else-if="batchTranslateModalRef?.completed">
批量翻译 (已完成)
</span>
<span v-else>
批量翻译
</span>
<span v-if="batchTranslateModalRef?.loading">批量翻译 ({{ batchTranslateModalRef.progress }}%)</span>
<span v-else-if="batchTranslateModalRef?.completed">批量翻译 (已完成)</span>
<span v-else>批量翻译</span>
</CommonButton>
<CommonButton size="small" quaternary class="util-btn" @click="handleTranslate('extract')">
<template #icon>
......@@ -104,12 +93,12 @@
导出 XML
</CommonButton>
<CommonButton size="small" type="primary" class="preview-btn flex-shrink-0" @click="emit('preview')">
<!-- <CommonButton size="small" type="primary" class="preview-btn flex-shrink-0" @click="emit('preview')">
<template #icon>
<n-icon><eye-outline /></n-icon>
</template>
预览工卡
</CommonButton>
</CommonButton> -->
<n-divider vertical class="!mx-0 flex-shrink-0" />
......@@ -223,7 +212,8 @@ const {
searchTranslateModalRef
} = useEditorToolbar(emit)
const insertFragmentModalRef = ref<any>(null)</script>
const insertFragmentModalRef = ref<any>(null)
</script>
<style scoped>
/* 消除工具栏容器本身的 focus outline */
......
......@@ -18,20 +18,12 @@
<n-icon><close-circle-outline /></n-icon>
无法删除的节点 ({{ blockedNodes.length }} 个) — 违反 DTD 规则约束:
</span>
<div class="bg-fill-2 border border-divider rounded p-2 flex flex-col gap-1.5 max-h-[160px] overflow-y-auto">
<div
v-for="item in blockedNodes"
:key="item.id"
class="flex items-start gap-1.5 text-color3 border-b border-divider/50 pb-1 last:border-0 last:pb-0 hover:bg-fill-3 p-1 rounded transition-colors cursor-pointer"
@click="editorStore.setSelectedNodeId(item.id)"
>
<n-checkbox :checked="false" disabled class="mt-0.5" />
<div class="flex flex-col">
<span class="font-bold text-color1">&lt;{{ item.tagName }}&gt;</span>
<span class="text-danger opacity-90 scale-95 origin-left">{{ item.reason }}</span>
</div>
</div>
</div>
<CommonNodeDetailList
:items="formattedBlockedNodes"
mode="readonly"
max-height="160"
@click="(item) => editorStore.setSelectedNodeId(item.id)"
/>
</div>
<!-- 2. 可删除的节点列表 (Safe-to-delete Nodes) -->
......@@ -49,26 +41,13 @@
全选
</n-checkbox>
</div>
<div class="bg-fill-2 border border-divider rounded p-2 flex flex-col gap-1.5 max-h-[220px] overflow-y-auto">
<div
v-for="item in safeNodes"
:key="item.id"
class="flex items-start gap-2 hover:bg-fill-3 p-1 rounded transition-colors cursor-pointer"
@click="handleNodeClick(item)"
>
<n-checkbox
v-model:checked="item.checked"
class="mt-0.5"
@click.stop
<CommonNodeDetailList
:items="formattedSafeNodes"
mode="checkbox"
max-height="220"
@click="handleNodeClick"
@change="handleSafeNodeChange"
/>
<div class="flex flex-col select-none flex-1 min-w-0">
<span class="font-bold text-color1 truncate">{{ item.displayName }}</span>
<span v-if="item.parentPath" class="text-color3 opacity-80 scale-95 origin-left truncate">
位置: {{ item.parentPath }}
</span>
</div>
</div>
</div>
</div>
<!-- 3. 空提示(例如没有可选删除节点) -->
......@@ -111,6 +90,32 @@ const handleNodeClick = (item: any) => {
editorStore.setSelectedNodeId(item.id)
}
// 转换 Blocked 节点属性,用于 CommonNodeDetailList 渲染
const formattedBlockedNodes = computed(() => {
return blockedNodes.value.map((n) => ({
id: n.id,
displayName: `<${n.tagName}>`,
reason: n.reason
}))
})
// 转换 Safe 节点属性,用于 CommonNodeDetailList 渲染
const formattedSafeNodes = computed(() => {
return safeNodes.value.map((n) => ({
id: n.id,
displayName: n.displayName,
pathString: n.parentPath,
checked: n.checked
}))
})
const handleSafeNodeChange = (item: any) => {
const target = safeNodes.value.find((n) => n.id === item.id)
if (target) {
target.checked = item.checked
}
}
defineExpose({ open })
</script>
......
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