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,13 +18,22 @@ ...@@ -18,13 +18,22 @@
<slot name="header-extra"></slot> <slot name="header-extra"></slot>
</template> </template>
<n-scrollbar :style="{ maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight }"> <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">
<slot></slot>
</div>
</n-spin>
</n-scrollbar>
</template>
<template v-else>
<n-spin :show="loading" :style="{ padding: typeof padding === 'number' ? `${padding}px` : padding }"> <n-spin :show="loading" :style="{ padding: typeof padding === 'number' ? `${padding}px` : padding }">
<div class="space-y-4"> <div class="space-y-4">
<slot></slot> <slot></slot>
</div> </div>
</n-spin> </n-spin>
</n-scrollbar> </template>
<template #footer v-if="showFooter"> <template #footer v-if="showFooter">
<n-space justify="end" align="center" class="p-[10px]"> <n-space justify="end" align="center" class="p-[10px]">
...@@ -55,6 +64,7 @@ interface Props { ...@@ -55,6 +64,7 @@ interface Props {
width?: string | number width?: string | number
maxHeight?: string | number maxHeight?: string | number
padding?: string | number padding?: string | number
scrollable?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
...@@ -67,7 +77,8 @@ const props = withDefaults(defineProps<Props>(), { ...@@ -67,7 +77,8 @@ const props = withDefaults(defineProps<Props>(), {
showFooter: true, showFooter: true,
width: '600px', width: '600px',
maxHeight: '75vh', maxHeight: '75vh',
padding: '15px' padding: '15px',
scrollable: true
}) })
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']) const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
......
...@@ -81,6 +81,18 @@ export const CB_COMPONENT_TAGS = ['CB', 'CBNAME', 'CBLOC'] ...@@ -81,6 +81,18 @@ export const CB_COMPONENT_TAGS = ['CB', 'CBNAME', 'CBLOC']
// 上下标标签 // 上下标标签
export const SUPER_SUB_TAGS = ['SUPER', 'SUPERSCRIPT', 'SUB', 'SUBSCRIPT'] 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]> = [ export const ROMAN_LOOKUP: Array<[string, number]> = [
['x', 10], ['x', 10],
......
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import { useEditorStore, nodeSelectedRefs } from '@/store/editor' import { useEditorStore, nodeSelectedRefs } from '@/store/editor'
import { LIST_ITEM_TAGS, HEADER_TAGS, ROMAN_LOOKUP } from '../constants' 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' import { useI18n } from 'vue-i18n'
// 警示块级节点集合(WARNING / CAUTION / NOTE),用于注入 insideAlert 上下文 // 警示块级节点集合(WARNING / CAUTION / NOTE),用于注入 insideAlert 上下文
...@@ -55,18 +55,19 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu ...@@ -55,18 +55,19 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
provide('insideChinese', true) provide('insideChinese', true)
} }
// 判断是否是列表容器 // 判断是否是列表容器 (需要排除表格、主题、选项以及通过复合 Map 特殊渲染的各种复合父节点/容器)
const isContainer = computed(() => { const isContainer = computed(() => {
return ( return (
!props.isInline && !props.isInline &&
props.node.children && props.node.children &&
props.node.children.length > 0 && props.node.children.length > 0 &&
props.node.tagName !== 'TABLE' && props.node.tagName !== 'TABLE' &&
props.node.tagName !== 'GRAPHIC' &&
props.node.tagName !== 'SELECTION' && props.node.tagName !== 'SELECTION' &&
props.node.tagName !== 'TOPIC' && props.node.tagName !== 'TOPIC' &&
props.node.tagName !== 'PRETOPIC' && props.node.tagName !== 'PRETOPIC' &&
props.node.tagName !== 'CBLST' props.node.tagName !== 'CBLST' &&
props.node.tagName !== 'EINLST' &&
!COMPOSITE_CONTAINER_MAP[props.node.tagName]
) )
}) })
......
export interface EditAreaContextMenuProps {
modelValue: boolean
nodeIds: string[]
}
<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( ...@@ -71,10 +71,21 @@ export function useFindReplace(
matchCase: matchCase.value, matchCase: matchCase.value,
regExp: regExp.value regExp: regExp.value
}) })
matches.value = results
if (results.length > 0) { // 💡 过滤同一个节点内的重复匹配:仅保留每个节点的第一次匹配,以此作为查找替换的独立项
// 如果旧的索引有效,保持接近 the 索引,否则从 0 开始 const uniqueResults: typeof results = []
if (currentMatchIndex.value < 0 || currentMatchIndex.value >= results.length) { 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 currentMatchIndex.value = 0
} }
// 自动聚焦到首个匹配点 // 自动聚焦到首个匹配点
......
...@@ -41,7 +41,7 @@ ...@@ -41,7 +41,7 @@
</div> </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' }"> <div :style="{ height: totalHeight + 'px', position: 'relative' }">
<!-- 仅渲染可视区块,通过 translateY 定位 --> <!-- 仅渲染可视区块,通过 translateY 定位 -->
...@@ -60,6 +60,9 @@ ...@@ -60,6 +60,9 @@
<!-- 查找与替换浮动面板 --> <!-- 查找与替换浮动面板 -->
<FindReplacePanel v-model:visible="findReplaceVisible" :sync-editor-scroll="syncEditorScroll" /> <FindReplacePanel v-model:visible="findReplaceVisible" :sync-editor-scroll="syncEditorScroll" />
<!-- 编辑区非 Table 元素右键上下文菜单弹窗 -->
<EditAreaContextMenuModal v-model="contextMenuVisible" :node-ids="contextMenuNodeIds" />
</template> </template>
<template v-else> <template v-else>
<div class="flex-1 flex flex-col items-center justify-center text-color3"> <div class="flex-1 flex flex-col items-center justify-center text-color3">
...@@ -74,6 +77,7 @@ import { SettingsOutline, SearchOutline } from '@vicons/ionicons5' ...@@ -74,6 +77,7 @@ import { SettingsOutline, SearchOutline } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import DocNodeRenderer from '../DocNodeRenderer/index.vue' import DocNodeRenderer from '../DocNodeRenderer/index.vue'
import FindReplacePanel from './components/FindReplacePanel/index.vue' import FindReplacePanel from './components/FindReplacePanel/index.vue'
import EditAreaContextMenuModal from './components/EditAreaContextMenuModal/index.vue'
import { useEditorPanel } from './functionals' import { useEditorPanel } from './functionals'
import { addNodeVisible, addNodeMode, addNodeTargetId, addNodeAllowedTags } from '../NodeTree/functionals' import { addNodeVisible, addNodeMode, addNodeTargetId, addNodeAllowedTags } from '../NodeTree/functionals'
...@@ -102,6 +106,45 @@ const handleEditSelectedNode = () => { ...@@ -102,6 +106,45 @@ const handleEditSelectedNode = () => {
addNodeAllowedTags.value = [selectedNode.value.tagName] addNodeAllowedTags.value = [selectedNode.value.tagName]
addNodeVisible.value = true 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> </script>
<style scoped> <style scoped>
......
...@@ -32,12 +32,7 @@ ...@@ -32,12 +32,7 @@
</CommonButton> </CommonButton>
<!-- 新增:插入 XML 片段按钮 --> <!-- 新增:插入 XML 片段按钮 -->
<CommonButton <CommonButton secondary size="small" class="insert-btn flex-shrink-0" @click="insertFragmentModalRef?.open(insertBelow)">
secondary
size="small"
class="insert-btn flex-shrink-0"
@click="insertFragmentModalRef?.open(insertBelow)"
>
<template #icon> <template #icon>
<n-icon><code-working-outline /></n-icon> <n-icon><code-working-outline /></n-icon>
</template> </template>
...@@ -54,7 +49,7 @@ ...@@ -54,7 +49,7 @@
size="small" size="small"
quaternary quaternary
class="util-btn" class="util-btn"
:type="batchTranslateModalRef?.completed ? 'success' : (batchTranslateModalRef?.loading ? 'primary' : 'default')" :type="batchTranslateModalRef?.completed ? 'success' : batchTranslateModalRef?.loading ? 'primary' : 'default'"
@click="handleTranslate('batch')" @click="handleTranslate('batch')"
> >
<template #icon> <template #icon>
...@@ -64,15 +59,9 @@ ...@@ -64,15 +59,9 @@
<language-outline v-else /> <language-outline v-else />
</n-icon> </n-icon>
</template> </template>
<span v-if="batchTranslateModalRef?.loading"> <span v-if="batchTranslateModalRef?.loading">批量翻译 ({{ batchTranslateModalRef.progress }}%)</span>
批量翻译 ({{ batchTranslateModalRef.progress }}%) <span v-else-if="batchTranslateModalRef?.completed">批量翻译 (已完成)</span>
</span> <span v-else>批量翻译</span>
<span v-else-if="batchTranslateModalRef?.completed">
批量翻译 (已完成)
</span>
<span v-else>
批量翻译
</span>
</CommonButton> </CommonButton>
<CommonButton size="small" quaternary class="util-btn" @click="handleTranslate('extract')"> <CommonButton size="small" quaternary class="util-btn" @click="handleTranslate('extract')">
<template #icon> <template #icon>
...@@ -104,12 +93,12 @@ ...@@ -104,12 +93,12 @@
导出 XML 导出 XML
</CommonButton> </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> <template #icon>
<n-icon><eye-outline /></n-icon> <n-icon><eye-outline /></n-icon>
</template> </template>
预览工卡 预览工卡
</CommonButton> </CommonButton> -->
<n-divider vertical class="!mx-0 flex-shrink-0" /> <n-divider vertical class="!mx-0 flex-shrink-0" />
...@@ -223,7 +212,8 @@ const { ...@@ -223,7 +212,8 @@ const {
searchTranslateModalRef searchTranslateModalRef
} = useEditorToolbar(emit) } = useEditorToolbar(emit)
const insertFragmentModalRef = ref<any>(null)</script> const insertFragmentModalRef = ref<any>(null)
</script>
<style scoped> <style scoped>
/* 消除工具栏容器本身的 focus outline */ /* 消除工具栏容器本身的 focus outline */
......
...@@ -18,20 +18,12 @@ ...@@ -18,20 +18,12 @@
<n-icon><close-circle-outline /></n-icon> <n-icon><close-circle-outline /></n-icon>
无法删除的节点 ({{ blockedNodes.length }} 个) — 违反 DTD 规则约束: 无法删除的节点 ({{ blockedNodes.length }} 个) — 违反 DTD 规则约束:
</span> </span>
<div class="bg-fill-2 border border-divider rounded p-2 flex flex-col gap-1.5 max-h-[160px] overflow-y-auto"> <CommonNodeDetailList
<div :items="formattedBlockedNodes"
v-for="item in blockedNodes" mode="readonly"
:key="item.id" max-height="160"
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="(item) => editorStore.setSelectedNodeId(item.id)"
@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>
</div> </div>
<!-- 2. 可删除的节点列表 (Safe-to-delete Nodes) --> <!-- 2. 可删除的节点列表 (Safe-to-delete Nodes) -->
...@@ -49,26 +41,13 @@ ...@@ -49,26 +41,13 @@
全选 全选
</n-checkbox> </n-checkbox>
</div> </div>
<div class="bg-fill-2 border border-divider rounded p-2 flex flex-col gap-1.5 max-h-[220px] overflow-y-auto"> <CommonNodeDetailList
<div :items="formattedSafeNodes"
v-for="item in safeNodes" mode="checkbox"
:key="item.id" max-height="220"
class="flex items-start gap-2 hover:bg-fill-3 p-1 rounded transition-colors cursor-pointer" @click="handleNodeClick"
@click="handleNodeClick(item)" @change="handleSafeNodeChange"
> />
<n-checkbox
v-model:checked="item.checked"
class="mt-0.5"
@click.stop
/>
<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> </div>
<!-- 3. 空提示(例如没有可选删除节点) --> <!-- 3. 空提示(例如没有可选删除节点) -->
...@@ -111,6 +90,32 @@ const handleNodeClick = (item: any) => { ...@@ -111,6 +90,32 @@ const handleNodeClick = (item: any) => {
editorStore.setSelectedNodeId(item.id) 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 }) defineExpose({ open })
</script> </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