Commit 48fdf105 by pangchong

feat(core): 集中管理XML标签并优化编辑器选中与快捷键逻辑

- 新增 src/configs/xmlTags.ts,统一定义和导出所有XML节点标签集合,禁止业务代码中硬编码
- 优化 DocNodeRenderer 组件逻辑,改用统一标签集合判断节点类型简化模板条件
- 编辑器状态增加 renderedSelectedNodeId,支持处理富文本选中节点的真正渲染ID映射
- 编辑器store新增批量删除多节点方法,提升节点删除效率
- 完善undo重做逻辑,确保选中节点状态同步准确
- 用dtdManager增强删除子节点和排序逻辑,支持严格DTD规则校验与子节点顺序排序
- useKeyboardShortcuts钩子封装全局与局部快捷键,MainLayout中移除冗余键盘事件处理
- 视图组件内段落、特殊标签内容统一可编辑状态,增加交互体验一致性
- 删除无用scratch_test.js文件,移除遗留调试代码
- 完善Gemini规则文档,新增XML标签一元化管理规范说明
- 依赖中新增@vueuse/core提升组合式API开发效率,相关类型包同步安装
parent 16a92c5e
...@@ -49,8 +49,8 @@ ...@@ -49,8 +49,8 @@
--- ---
## 4. 自动导入 (Auto Import) 规范 ## 4. 自动导入 (Auto Import) 规范
- **禁止手动导入 Vue 常用 API**:如 `ref`, `computed`, `reactive`, `h`, `nextTick`, `onMounted` 等,以及 `useTableSearch`, `useDictStore`, `service`, `toCamelCase`, `openUploadModal` 等常用 Hooks 与 Utils,严禁手动编写 `import` 语句 - **禁止手动导入常用 API、Hooks 与 Utils**:项目已配置 `unplugin-auto-import`。对 `vue``vue-router``pinia``naive-ui` 的常用 API,以及位于 `src/hooks``src/utils``src/api` 目录下的所有自定义 Hooks、工具函数和 API 实例(例如 `useKeyboardShortcuts``service``formatDate``useTableSearch` 等),**均不需要且严禁手动编写 `import` 语句**,直接全局使用即可
- **禁止手动导入 formatDate**:全局工具函数 `formatDate` 已通过自动导入机制全局生效,在所有 `.vue``.ts` 文件中直接调用即可。
--- ---
...@@ -195,3 +195,10 @@ ...@@ -195,3 +195,10 @@
- 在编写 `CommonSelect``n-input` 组件时,对于普通属性、枚举选择或内容输入,**禁止**手动添加 `placeholder="请选择"``placeholder="选择"``placeholder="请输入"` 等冗余的占位字符。 - 在编写 `CommonSelect``n-input` 组件时,对于普通属性、枚举选择或内容输入,**禁止**手动添加 `placeholder="请选择"``placeholder="选择"``placeholder="请输入"` 等冗余的占位字符。
- 只在具有明确指示性特指文案的业务场景下(如“请选择切换动画”等特有场景),方可添加具体的占位文本。 - 只在具有明确指示性特指文案的业务场景下(如“请选择切换动画”等特有场景),方可添加具体的占位文本。
- **事件监听规范**:对 `CommonSelect` 组件监听值变更回调时,**必须**绑定组件导出的 **`@change`** 事件(如 `@change="onTagChange"`),**禁止**使用 `@update:value` 监听器,以保障数据联动更新的生命周期一致性。 - **事件监听规范**:对 `CommonSelect` 组件监听值变更回调时,**必须**绑定组件导出的 **`@change`** 事件(如 `@change="onTagChange"`),**禁止**使用 `@update:value` 监听器,以保障数据联动更新的生命周期一致性。
---
## 17. XML 节点与标签使用规范 (一元化管理)
- **禁止直接在业务代码或常量中硬编码 XML 节点标签数组或映射**(如 `['PARA', 'PARAC', 'TITLE']` 等元素集合)。
- **强制要求统一收拢管理**:所有关于 DTD 定义的各类 XML 标签、节点分类(如文档型标签、列表条目标签、复杂容器标签、头部元数据标签等)必须统一在 [xmlTags.ts](file:///e:/refactor-Editor/Ifar-Xml-Editor/src/configs/xmlTags.ts) 文件中进行定义和导出。
- **使用规范**:在任何业务组件、逻辑 Hook 或常量模块中需要判断或使用节点标签集合时,必须先在 `xmlTags.ts` 中维护,随后在对应的代码文件中通过 `import` 引用,严禁私自在局部硬编码私有数组,以确保 DTD 分类规则的一致性与后期维护的便捷度。
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
"@alova/scene-vue": "^1.6.2", "@alova/scene-vue": "^1.6.2",
"@bpmn-io/properties-panel": "^3.40.4", "@bpmn-io/properties-panel": "^3.40.4",
"@vicons/ionicons5": "^0.13.0", "@vicons/ionicons5": "^0.13.0",
"@vueuse/core": "^14.3.0",
"alova": "^3.5.0", "alova": "^3.5.0",
"bpmn-js": "^17.11.1", "bpmn-js": "^17.11.1",
"bpmn-js-properties-panel": "^5.53.0", "bpmn-js-properties-panel": "^5.53.0",
...@@ -1704,6 +1705,12 @@ ...@@ -1704,6 +1705,12 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vicons/ionicons5": { "node_modules/@vicons/ionicons5": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmmirror.com/@vicons/ionicons5/-/ionicons5-0.13.0.tgz", "resolved": "https://registry.npmmirror.com/@vicons/ionicons5/-/ionicons5-0.13.0.tgz",
...@@ -1951,6 +1958,44 @@ ...@@ -1951,6 +1958,44 @@
} }
} }
}, },
"node_modules/@vueuse/core": {
"version": "14.3.0",
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz",
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.3.0",
"@vueuse/shared": "14.3.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.3.0",
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz",
"integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.3.0",
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz",
"integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@xmldom/xmldom": { "node_modules/@xmldom/xmldom": {
"version": "0.8.11", "version": "0.8.11",
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz", "resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
......
...@@ -18,11 +18,13 @@ ...@@ -18,11 +18,13 @@
"@alova/scene-vue": "^1.6.2", "@alova/scene-vue": "^1.6.2",
"@bpmn-io/properties-panel": "^3.40.4", "@bpmn-io/properties-panel": "^3.40.4",
"@vicons/ionicons5": "^0.13.0", "@vicons/ionicons5": "^0.13.0",
"@vueuse/core": "^14.3.0",
"alova": "^3.5.0", "alova": "^3.5.0",
"bpmn-js": "^17.11.1", "bpmn-js": "^17.11.1",
"bpmn-js-properties-panel": "^5.53.0", "bpmn-js-properties-panel": "^5.53.0",
"camunda-bpmn-moddle": "^7.0.1", "camunda-bpmn-moddle": "^7.0.1",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"diff": "^7.0.0",
"docx-preview": "^0.3.7", "docx-preview": "^0.3.7",
"less": "^4.5.1", "less": "^4.5.1",
"lodash-es": "^4.18.1", "lodash-es": "^4.18.1",
...@@ -36,8 +38,7 @@ ...@@ -36,8 +38,7 @@
"vue-i18n": "^11.2.8", "vue-i18n": "^11.2.8",
"vue-router": "^5.0.3", "vue-router": "^5.0.3",
"vuedraggable": "^4.1.0", "vuedraggable": "^4.1.0",
"xlsx": "^0.18.5", "xlsx": "^0.18.5"
"diff": "^7.0.0"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^19.8.0", "@commitlint/cli": "^19.8.0",
......
import fs from 'fs';
const xml = fs.readFileSync('e:/refactor-Editor/Ifar-Xml-Editor/src/assets/file/WORK_CARD_20260318095803.xml', 'utf-8');
// Count tags using regex
const matches = xml.match(/<[a-zA-Z0-9_-]+/g);
console.log('Total tags count:', matches ? matches.length : 0);
// Let's count specific tags to see if there is anything huge
const tagCounts = {};
if (matches) {
for (const match of matches) {
const tag = match.slice(1);
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
}
}
console.log('Tag counts:', Object.entries(tagCounts).sort((a, b) => b[1] - a[1]).slice(0, 20));
/**
* XML 标签与分类常量全局配置文件
* ⚠️ 规范:所有 XML 节点标签集合必须在此文件中定义,禁止在业务代码中硬编码。
*/
// ─── 基础原子标签集 ──────────────────────────────────────────────────────────
/**
* 头部元数据展示节点标签(这些节点一般不独立在右侧生成内容容器)
* 包含 SMJC/LMJC/NRCJC 等各类工卡头部节点。
*/
export const HEADER_TAGS = [
'SMJC-HEADER', 'LMJC-HEADER', 'NRCJC-HEADER',
'TCJC-HEADER', 'QECJC-HEADER', 'EOTK-HEADER',
'DRJC-HEADER', 'CMJC-HEADER', 'EOJC-HEADER', 'MAOJC-HEADER'
]
/**
* 行内元素集合(可以嵌套在 PARA/PARAC/TITLE/TITLEC 等混合内容节点中的子元素)
* 来源:PARA 和 TITLE 的 allowedChildren 交集分析 + 实际渲染经验
*/
export const INLINE_ELEMENTS = [
// 引用类
'REFBLOCK', 'REFINT', 'REFEXT', 'GRPHCREF',
// 零件编号类
'EIN', 'EINMFR', 'PAN', 'SBNBR',
// 工具类
'TOOLNBR', 'TOOLNAME', 'TED',
// 标识类
'STDNAME', 'STDNBR',
// 区域
'ZONE',
// 适用性标记
'EFFECT', 'CONEFFECT',
// CB 类(工具箱)
'CB', 'CBNAME', 'CBLOC',
// 上下标
'SUPER', 'SUPERSCRIPT', 'SUB', 'SUBSCRIPT',
// 其他常见行内
'SSI', 'ACRO', 'SP', 'KWD', 'CSN', 'CON', 'NCON',
'REVST', 'REVEND'
]
// 段落类节点标签(PARA 为英文段落,PARAC 为中文段落)
export const PARA_TAGS = ['PARA', 'PARAC']
// 警示块级节点标签(作为独立块渲染,注入 insideAlert 上下文)
export const ALERT_BLOCK_TAGS = ['WARNING', 'CAUTION', 'NOTE']
// 有序列表容器节点标签(LIST1-LIST7)
export const ORDERED_LIST_TAGS = ['LIST1', 'LIST2', 'LIST3', 'LIST4', 'LIST5', 'LIST6', 'LIST7']
// 有序列表条目标签(L1ITEM-L7ITEM)
export const ORDERED_LIST_ITEM_TAGS = ['L1ITEM', 'L2ITEM', 'L3ITEM', 'L4ITEM', 'L5ITEM', 'L6ITEM', 'L7ITEM']
// 无序列表条目标签
export const UNORDERED_LIST_ITEM_TAGS = ['UNLITEM', 'NUMLITEM']
// 所有列表条目标签
export const LIST_ITEM_TAGS = [...ORDERED_LIST_ITEM_TAGS, ...UNORDERED_LIST_ITEM_TAGS]
// 红色警示类节点标签(树节点着色、导出样式等)
// 注意:SBEFF / CONEFFECT 在 DTD 中是 EMPTY 节点,嵌在段落中作为适用性标记
export const WARNING_LIKE_TAGS = ['WARNING', 'EFFECT', 'CONEFFECT', 'SBEFF', 'SBEFFC']
// 蓝色引用/注意类节点标签(树节点着色、导出样式等)
export const NOTE_AND_REF_TAGS = ['NOTE', 'REFBLOCK', 'REFINT', 'REFEXT', 'GRPHCREF', 'EIN']
// 容器穿透节点(不独立分块,直接穿透子节点进行虚拟滚动细粒度分块)
export const TRANSPARENT_TAGS = ['JOBCARD', 'CEP', 'TASK', 'TFMATR']
// CALS 表格核心结构标签
export const CALS_TABLE_TAGS = ['TABLE', 'TGROUP']
// CALS 表格完整结构标签(含内部分组)
export const CALS_TABLE_ALL_TAGS = [...CALS_TABLE_TAGS, 'THEAD', 'TFOOT', 'TBODY', 'ROW', 'ENTRY']
// CB 类组件标签(工具箱编号、名称、位置)
export const CB_COMPONENT_TAGS = ['CB', 'CBNAME', 'CBLOC']
// 上下标标签
export const SUPER_SUB_TAGS = ['SUPER', 'SUPERSCRIPT', 'SUB', 'SUBSCRIPT']
// 罗马数字转换表(用于列表编号)
export const ROMAN_LOOKUP: Array<[string, number]> = [
['x', 10],
['ix', 9],
['v', 5],
['iv', 4],
['i', 1]
]
// ─── 派生组合标签集(由以上原子集合组合而成)────────────────────────────────
// 文档型叶子节点(用于在左侧树提取文本副标题展示)
export const DOCUMENT_LIKE_TAGS = [
'PARA', 'PARAC', 'TITLE', 'TITLEC', 'REGULATION',
'REFBLOCK', 'GRPHCREF', 'REFINT', 'EQU'
]
// 所有列表容器标签(有序 LIST1-7 + 无序 UNLIST/NUMLIST)
export const ALL_LIST_TAGS = [...ORDERED_LIST_TAGS, 'UNLIST', 'NUMLIST']
// 警示及适用性关联标签(WARNING_LIKE + CAUTION + NOTE,在列表拆分等场景用作前置拦截)
export const ALERT_AND_EFF_TAGS = [...WARNING_LIKE_TAGS, 'CAUTION', 'NOTE']
// 所有需要自定义颜色显示的特殊节点(WARNING_LIKE + CAUTION + NOTE_AND_REF)
export const COLORED_TAGS = [...WARNING_LIKE_TAGS, 'CAUTION', ...NOTE_AND_REF_TAGS]
// 表格单元格下的复杂内容容器标签(当包含这些标签时使用 DocNodeRenderer 渲染)
export const COMPLEX_ENTRY_TAGS = [
...ORDERED_LIST_TAGS, 'UNLIST', 'NUMLIST',
'WARNING', 'CAUTION', 'NOTE',
'GRAPHIC', 'TABLE'
]
// 树结构容器节点集合(在获取文本预览等场景中跳过这些节点的文本提取)
export const STRUCTURAL_TAGS = [
...TRANSPARENT_TAGS,
'PRETOPIC', 'TOPIC', 'SUBTASK', 'STEP',
...ORDERED_LIST_TAGS,
...LIST_ITEM_TAGS,
'UNLIST', 'NUMLIST',
...CALS_TABLE_ALL_TAGS,
'WARNING', 'CAUTION', 'NOTE', 'HNANOTE',
'CBSUBLST', 'GRAPHIC', 'FTNOTE', 'APPEND'
]
import { onKeyStroke, useEventListener } from '@vueuse/core'
import { useEditorStore } from '@/store/editor/index'
import { useAppStore } from '@/store/app/index'
interface ShortcutOptions {
onFind?: () => void
onEscape?: () => void
}
export function useKeyboardShortcuts(
options: ShortcutOptions = {},
enableGlobal = false
) {
const appStore = useAppStore()
const editorStore = useEditorStore()
// 1. 全局快捷键(使用 useEventListener 并开启 capture: true 捕获事件)
if (enableGlobal) {
useEventListener('keydown', (e: KeyboardEvent) => {
const ctrl = e.ctrlKey || e.metaKey
const shift = e.shiftKey
// Ctrl+Shift+D: 切换深色/浅色主题
if (ctrl && shift && e.key.toLowerCase() === 'd') {
e.preventDefault()
appStore.isDark = !appStore.isDark
return
}
const target = e.target as HTMLElement | null
const isInput = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
if (isInput) return
if (ctrl) {
const key = e.key.toLowerCase()
if (key === 'z') {
e.preventDefault()
const activeEl = document.activeElement as HTMLElement | null
if (activeEl && activeEl.isContentEditable) {
activeEl.blur()
}
setTimeout(() => {
if (shift) {
editorStore.redo()
} else {
editorStore.undo()
}
}, 0)
} else if (key === 'y') {
e.preventDefault()
const activeEl = document.activeElement as HTMLElement | null
if (activeEl && activeEl.isContentEditable) {
activeEl.blur()
}
setTimeout(() => {
editorStore.redo()
}, 0)
}
}
}, { capture: true })
}
// 2. 局部/上下文快捷键(使用 onKeyStroke,在冒泡阶段响应)
if (options.onFind) {
onKeyStroke(['f', 'F'], (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
options.onFind!()
}
})
}
if (options.onEscape) {
onKeyStroke('Escape', (e: KeyboardEvent) => {
e.preventDefault()
options.onEscape!()
})
}
}
...@@ -24,7 +24,6 @@ import CommonDownloadModal from '@/components/CommonDownloadModal.vue' ...@@ -24,7 +24,6 @@ import CommonDownloadModal from '@/components/CommonDownloadModal.vue'
const themeVars = useThemeVars() const themeVars = useThemeVars()
const appStore = useAppStore() const appStore = useAppStore()
const editorStore = useEditorStore()
const globalImportModalRef = ref() const globalImportModalRef = ref()
const globalExportModalRef = ref() const globalExportModalRef = ref()
const globalAttachmentModalRef = ref() const globalAttachmentModalRef = ref()
...@@ -32,51 +31,10 @@ const globalUploadModalRef = ref() ...@@ -32,51 +31,10 @@ const globalUploadModalRef = ref()
const globalPreviewModalRef = ref() const globalPreviewModalRef = ref()
const globalDownloadModalRef = ref() const globalDownloadModalRef = ref()
// 全局快捷键处理 // 启用全局快捷键
const handleGlobalKeydown = (e: KeyboardEvent) => { useKeyboardShortcuts({}, true)
const ctrl = e.ctrlKey || e.metaKey
const shift = e.shiftKey
// 如果当前焦点在普通文本输入框中,保留其原生的行内打字撤销行为
const target = e.target as HTMLElement
const isInput = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
// Ctrl+Shift+D: 切换深色/浅色主题
if (ctrl && shift && e.key.toLowerCase() === 'd') {
e.preventDefault()
appStore.isDark = !appStore.isDark
return
}
if (!isInput) {
// Ctrl+Z: 全局撤销
if (ctrl && !shift && e.key.toLowerCase() === 'z') {
e.preventDefault()
if (document.activeElement && (document.activeElement as HTMLElement).isContentEditable) {
;(document.activeElement as HTMLElement).blur()
}
setTimeout(() => {
editorStore.undo()
}, 0)
return
}
// Ctrl+Y 或 Ctrl+Shift+Z: 全局重做
if ((ctrl && e.key.toLowerCase() === 'y') || (ctrl && shift && e.key.toLowerCase() === 'z')) {
e.preventDefault()
if (document.activeElement && (document.activeElement as HTMLElement).isContentEditable) {
;(document.activeElement as HTMLElement).blur()
}
setTimeout(() => {
editorStore.redo()
}, 0)
return
}
}
}
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', handleGlobalKeydown, true)
window.$importModal = globalImportModalRef.value window.$importModal = globalImportModalRef.value
window.$exportModal = globalExportModalRef.value window.$exportModal = globalExportModalRef.value
window.$attachmentModal = globalAttachmentModalRef.value window.$attachmentModal = globalAttachmentModalRef.value
...@@ -87,10 +45,6 @@ onMounted(() => { ...@@ -87,10 +45,6 @@ onMounted(() => {
// 初始化应用主题 // 初始化应用主题
appStore.applyTheme() appStore.applyTheme()
}) })
onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalKeydown, true)
})
</script> </script>
<style scoped> <style scoped>
......
...@@ -295,6 +295,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -295,6 +295,7 @@ export const useEditorStore = defineStore('editor', {
state: (): EditorState => ({ state: (): EditorState => ({
xmlTree: null, xmlTree: null,
selectedNodeId: null, selectedNodeId: null,
renderedSelectedNodeId: null,
nodeMap: markRaw(new Map()), nodeMap: markRaw(new Map()),
undoStack: [], undoStack: [],
redoStack: [], redoStack: [],
...@@ -411,10 +412,10 @@ export const useEditorStore = defineStore('editor', { ...@@ -411,10 +412,10 @@ export const useEditorStore = defineStore('editor', {
} }
// 3. 严格校验 DTD 规则约束 // 3. 严格校验 DTD 规则约束
const existingTags = destParentNode.children.map(c => c.tagName) const existingTags = destParentNode.children.map((c) => c.tagName)
for (const node of newNodes) { for (const node of newNodes) {
const tagName = node.tagName const tagName = node.tagName
const currentCount = existingTags.filter(t => t === tagName).length const currentCount = existingTags.filter((t) => t === tagName).length
if (!canAddChild(destParentNode.tagName, tagName, currentCount)) { if (!canAddChild(destParentNode.tagName, tagName, currentCount)) {
throw new Error(`DTD 校验失败: 节点 <${destParentNode.tagName}> 无法接受子元素 <${tagName}>`) throw new Error(`DTD 校验失败: 节点 <${destParentNode.tagName}> 无法接受子元素 <${tagName}>`)
} }
...@@ -424,23 +425,23 @@ export const useEditorStore = defineStore('editor', { ...@@ -424,23 +425,23 @@ export const useEditorStore = defineStore('editor', {
// 4. 递归修正插入子节点的 parentId 关系 // 4. 递归修正插入子节点的 parentId 关系
const setParentRecursive = (n: XmlNode, pid: string) => { const setParentRecursive = (n: XmlNode, pid: string) => {
n.parentId = pid n.parentId = pid
n.children.forEach(c => setParentRecursive(c, n.id)) n.children.forEach((c) => setParentRecursive(c, n.id))
} }
this.saveSnapshot() this.saveSnapshot()
newNodes.forEach(node => setParentRecursive(node, destParentNode.id)) newNodes.forEach((node) => setParentRecursive(node, destParentNode.id))
// 5. 将解析出来的新节点追加到指定位置,并重置选中焦点 // 5. 将解析出来的新节点追加到指定位置,并重置选中焦点
if (mode === 'above') { if (mode === 'above') {
const index = destParentNode.children.findIndex(c => c.id === targetId) const index = destParentNode.children.findIndex((c) => c.id === targetId)
if (index !== -1) { if (index !== -1) {
destParentNode.children.splice(index, 0, ...newNodes) destParentNode.children.splice(index, 0, ...newNodes)
} else { } else {
destParentNode.children.unshift(...newNodes) destParentNode.children.unshift(...newNodes)
} }
} else if (mode === 'below') { } else if (mode === 'below') {
const index = destParentNode.children.findIndex(c => c.id === targetId) const index = destParentNode.children.findIndex((c) => c.id === targetId)
if (index !== -1) { if (index !== -1) {
destParentNode.children.splice(index + 1, 0, ...newNodes) destParentNode.children.splice(index + 1, 0, ...newNodes)
} else { } else {
...@@ -466,14 +467,38 @@ export const useEditorStore = defineStore('editor', { ...@@ -466,14 +467,38 @@ export const useEditorStore = defineStore('editor', {
}, },
setSelectedNodeId(id: string | null) { setSelectedNodeId(id: string | null) {
const oldId = this.selectedNodeId
this.selectedNodeId = id this.selectedNodeId = id
if (oldId && nodeSelectedRefs.has(oldId)) {
const r = nodeSelectedRefs.get(oldId) let renderedId: string | null = null
if (id) {
let realId = id
if (id.includes('-txt-')) {
realId = id.split('-txt-')[0]
}
let curr = this.nodeMap.get(realId)
while (curr) {
if (nodeSelectedRefs.has(curr.node.id)) {
renderedId = curr.node.id
break
}
curr = curr.parent ? this.nodeMap.get(curr.parent.id) : undefined
}
if (!renderedId) {
renderedId = realId
}
}
const oldRenderedId = this.renderedSelectedNodeId
this.renderedSelectedNodeId = renderedId
if (oldRenderedId && nodeSelectedRefs.has(oldRenderedId)) {
const r = nodeSelectedRefs.get(oldRenderedId)
if (r) r.value = false if (r) r.value = false
} }
if (id && nodeSelectedRefs.has(id)) { if (renderedId && nodeSelectedRefs.has(renderedId)) {
const r = nodeSelectedRefs.get(id) const r = nodeSelectedRefs.get(renderedId)
if (r) r.value = true if (r) r.value = true
} }
}, },
...@@ -508,15 +533,15 @@ export const useEditorStore = defineStore('editor', { ...@@ -508,15 +533,15 @@ export const useEditorStore = defineStore('editor', {
undo() { undo() {
if (this.undoStack.length === 0 || !this.xmlTree) return if (this.undoStack.length === 0 || !this.xmlTree) return
const previousSnapshot = this.undoStack.pop()!
const currentClone = JSON.parse(JSON.stringify(this.xmlTree)) const currentClone = JSON.parse(JSON.stringify(this.xmlTree))
const currentSnapshot = { const currentSnapshot = {
tree: currentClone, tree: currentClone,
selectedNodeId: this.selectedNodeId selectedNodeId: previousSnapshot.selectedNodeId || this.selectedNodeId
} }
this.redoStack.push(currentSnapshot) this.redoStack.push(currentSnapshot)
const previousSnapshot = this.undoStack.pop()!
this.xmlTree = previousSnapshot.tree this.xmlTree = previousSnapshot.tree
this.rebuildNodeMap() this.rebuildNodeMap()
if (previousSnapshot.selectedNodeId && this.nodeMap.has(previousSnapshot.selectedNodeId)) { if (previousSnapshot.selectedNodeId && this.nodeMap.has(previousSnapshot.selectedNodeId)) {
...@@ -628,12 +653,51 @@ export const useEditorStore = defineStore('editor', { ...@@ -628,12 +653,51 @@ export const useEditorStore = defineStore('editor', {
// 处理混合内容 (mixedContent) 中的对应项 // 处理混合内容 (mixedContent) 中的对应项
parent.mixedContent = parent.mixedContent.filter((item: any) => item.nodeId !== this.selectedNodeId) parent.mixedContent = parent.mixedContent.filter((item: any) => item.nodeId !== this.selectedNodeId)
// 退化校验:如果子元素为空,直接收归为纯文本并清空混合数组
if (parent.children.length === 0) {
const mergedText = parent.mixedContent
.map((item: any) => (item.type === 'text' ? item.text || '' : ''))
.join('')
parent.textContent = mergedText
parent.mixedContent = []
} else {
parent.textContent = parent.mixedContent
.map((item: any) => (item.type === 'text' ? item.text || '' : ''))
.join('')
}
// 选中父节点 // 选中父节点
this.selectedNodeId = parent.id this.selectedNodeId = parent.id
} }
this.rebuildNodeMap() this.rebuildNodeMap()
}, },
/**
* 批量删除指定的多个节点(支持跨层级跨父节点)
*/
batchDeleteMultipleNodes(nodeIds: string[]) {
if (!this.xmlTree || nodeIds.length === 0) return
this.saveSnapshot()
const deleteSet = new Set(nodeIds)
const removeNodes = (node: XmlNode) => {
node.children = node.children.filter((c) => !deleteSet.has(c.id))
node.mixedContent = node.mixedContent.filter((item: any) => !deleteSet.has(item.nodeId || ''))
node.children.forEach(removeNodes)
}
removeNodes(this.xmlTree)
if (deleteSet.has(this.selectedNodeId || '')) {
this.selectedNodeId = this.xmlTree.id
}
this.rebuildNodeMap()
},
/** /**
* 移动子节点位置 * 移动子节点位置
*/ */
...@@ -798,7 +862,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -798,7 +862,7 @@ export const useEditorStore = defineStore('editor', {
// 按照从后往前的顺序对同一个节点内的文本进行替换,防止位置偏移 // 按照从后往前的顺序对同一个节点内的文本进行替换,防止位置偏移
const nodeGroups: Record<string, typeof matches> = {} const nodeGroups: Record<string, typeof matches> = {}
matches.forEach(m => { matches.forEach((m) => {
const key = `${m.nodeId}-${m.type}-${m.mixedIndex ?? 'none'}` const key = `${m.nodeId}-${m.type}-${m.mixedIndex ?? 'none'}`
if (!nodeGroups[key]) { if (!nodeGroups[key]) {
nodeGroups[key] = [] nodeGroups[key] = []
...@@ -809,7 +873,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -809,7 +873,7 @@ export const useEditorStore = defineStore('editor', {
let replaceCount = 0 let replaceCount = 0
// 针对每个文本单元,自后向前替换 // 针对每个文本单元,自后向前替换
Object.keys(nodeGroups).forEach(key => { Object.keys(nodeGroups).forEach((key) => {
const group = nodeGroups[key] const group = nodeGroups[key]
// 按 start 降序排列 // 按 start 降序排列
group.sort((a, b) => b.start - a.start) group.sort((a, b) => b.start - a.start)
...@@ -820,7 +884,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -820,7 +884,7 @@ export const useEditorStore = defineStore('editor', {
if (firstMatch.type === 'textContent') { if (firstMatch.type === 'textContent') {
let text = node.textContent || '' let text = node.textContent || ''
group.forEach(m => { group.forEach((m) => {
const prefix = text.substring(0, m.start) const prefix = text.substring(0, m.start)
const suffix = text.substring(m.start + m.length) const suffix = text.substring(m.start + m.length)
text = prefix + replacement + suffix text = prefix + replacement + suffix
...@@ -831,7 +895,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -831,7 +895,7 @@ export const useEditorStore = defineStore('editor', {
const item = node.mixedContent[firstMatch.mixedIndex] const item = node.mixedContent[firstMatch.mixedIndex]
if (item && item.type === 'text') { if (item && item.type === 'text') {
let text = item.text || '' let text = item.text || ''
group.forEach(m => { group.forEach((m) => {
const prefix = text.substring(0, m.start) const prefix = text.substring(0, m.start)
const suffix = text.substring(m.start + m.length) const suffix = text.substring(m.start + m.length)
text = prefix + replacement + suffix text = prefix + replacement + suffix
......
...@@ -8,6 +8,7 @@ export interface Snapshot { ...@@ -8,6 +8,7 @@ export interface Snapshot {
export interface EditorState { export interface EditorState {
xmlTree: XmlNode | null xmlTree: XmlNode | null
selectedNodeId: string | null selectedNodeId: string | null
renderedSelectedNodeId: string | null // 实际渲染的被选中节点 ID
nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }> nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>
undoStack: Snapshot[] // 保存 Snapshot 历史快照 undoStack: Snapshot[] // 保存 Snapshot 历史快照
redoStack: Snapshot[] // 保存 Snapshot 重做快照 redoStack: Snapshot[] // 保存 Snapshot 重做快照
......
...@@ -107,19 +107,82 @@ export function canAddChild(parentTagName: string, childTagName: string, current ...@@ -107,19 +107,82 @@ export function canAddChild(parentTagName: string, childTagName: string, current
/** /**
* 判断某个子节点是否可以被删除(考虑 occurrence: once 约束) * 判断某个子节点是否可以被删除(考虑 occurrence: once 约束)
*/ */
export function canDeleteChild(parentTagName: string, childTagName: string, currentChildCount: number): boolean { export function canDeleteChild(parentTagName: string, childTagName: string, existingChildTags: string[]): boolean {
const rule = getElementRule(parentTagName) const rule = getElementRule(parentTagName)
if (!rule) return true if (!rule || !rule.contentModel || !rule.contentModel.parsed) return true
// 模拟删除该子节点一个实例后的标签列表
const index = existingChildTags.indexOf(childTagName)
const remainingTags = [...existingChildTags]
if (index !== -1) {
remainingTags.splice(index, 1)
}
return validateTagsAgainstModel(rule.contentModel.parsed, remainingTags)
}
/**
* 递归验证标签列表是否能匹配 DtdContentModel 所定义的最小出现约束与组合关系
*/
function validateTagsAgainstModel(model: DtdContentModel, tags: string[]): boolean {
const occurrence = model.occurrence || 'once'
// 1. 如果是叶子节点元素引用
if (model.type === 'elementRef' && model.name) {
const count = tags.filter((t) => t === model.name).length
if (occurrence === 'once' || occurrence === 'oneOrMore') {
return count >= 1
}
return true
}
// 2. 如果是混合纯文本
if (model.type === 'pcdata') {
return true
}
// 3. 如果是选择组 (choice)
if (model.type === 'choice') {
const isRequired = occurrence === 'once' || occurrence === 'oneOrMore'
if (!isRequired) return true
// 既然是必填 choice 组,则其子项中只要有任意一个在 tags 中得到了满足,该 choice 组即被视为满足
if (model.children && model.children.length > 0) {
return model.children.some((child) => validateTagsAgainstModel(child, tags))
}
return true
}
// 4. 如果是顺序组 (sequence)
if (model.type === 'sequence') {
const isRequired = occurrence === 'once' || occurrence === 'oneOrMore'
if (!isRequired) return true
// 既然是必填 sequence 组,则其中所有的必填子项都必须在 tags 中得到满足
if (model.children) {
return model.children.every((child) => validateTagsAgainstModel(child, tags))
}
return true
}
return true
}
/**
* 获取某个子节点所允许的最小子节点数量(基于 DTD 约束)
*/
export function getMinChildCount(parentTagName: string, childTagName: string): number {
const rule = getElementRule(parentTagName)
if (!rule) return 0
const model = rule.contentModel.parsed const model = rule.contentModel.parsed
const childOccurrence = findChildOccurrence(model, childTagName) const childOccurrence = findChildOccurrence(model, childTagName)
// 'once' 或 'oneOrMore' 的子元素至少需要一个 if (childOccurrence === 'once' || childOccurrence === 'oneOrMore') {
if ((childOccurrence === 'once' || childOccurrence === 'oneOrMore') && currentChildCount <= 1) { return 1
return false
} }
return true return 0
} }
/** /**
...@@ -151,8 +214,8 @@ export function getInsertableChildren(parentTagName: string, existingChildren: s ...@@ -151,8 +214,8 @@ export function getInsertableChildren(parentTagName: string, existingChildren: s
const model = rule.contentModel.parsed const model = rule.contentModel.parsed
return allowed.filter(childTag => { return allowed.filter((childTag) => {
const count = existingChildren.filter(t => t === childTag).length const count = existingChildren.filter((t) => t === childTag).length
const occurrence = findChildOccurrence(model, childTag) const occurrence = findChildOccurrence(model, childTag)
if (occurrence === 'once' && count >= 1) return false if (occurrence === 'once' && count >= 1) return false
...@@ -186,3 +249,46 @@ export function getAllElementNames(): string[] { ...@@ -186,3 +249,46 @@ export function getAllElementNames(): string[] {
if (!_schema) return [] if (!_schema) return []
return Object.keys(_schema.elements) return Object.keys(_schema.elements)
} }
/**
* 获取 DTD 中定义的子元素顺序(包含 #PCDATA -> #text)
*/
export function getDtdDefinedOrder(tagName: string): string[] {
const rule = getElementRule(tagName)
if (!rule || !rule.contentModel || !rule.contentModel.parsed) return []
const order: string[] = []
const collect = (model: DtdContentModel) => {
if (model.type === 'elementRef' && model.name) {
if (!order.includes(model.name)) {
order.push(model.name)
}
} else if (model.type === 'pcdata') {
if (!order.includes('#text')) {
order.push('#text')
}
}
if (model.children) {
model.children.forEach(collect)
}
}
collect(rule.contentModel.parsed)
return order
}
/**
* 根据父节点 DTD 定义的子节点顺序进行排序
*/
export function sortChildrenByDtd(parentTagName: string, childTags: string[]): string[] {
const order = getDtdDefinedOrder(parentTagName)
if (order.length === 0) return childTags
return [...childTags].sort((a, b) => {
let idxA = order.indexOf(a)
let idxB = order.indexOf(b)
if (idxA === -1) idxA = 9999
if (idxB === -1) idxB = 9999
return idxA - idxB
})
}
import type { XmlNode, MixedContentItem } from '@/types/xmlNode' import type { XmlNode, MixedContentItem } from '@/types/xmlNode'
import { INLINE_ELEMENTS } from '@/configs/xmlTags'
/** /**
* 生成唯一 ID * 生成唯一 ID
...@@ -7,18 +8,10 @@ function generateId(): string { ...@@ -7,18 +8,10 @@ function generateId(): string {
return crypto.randomUUID ? crypto.randomUUID() : `node_${Date.now()}_${Math.random().toString(36).slice(2, 9)}` return crypto.randomUUID ? crypto.randomUUID() : `node_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
} }
/** const INLINE_ELEMENTS_SET = new Set(INLINE_ELEMENTS)
* 判断节点是否为行内元素(嵌在文本中的元素)
*/
const INLINE_ELEMENTS = new Set([
'REFBLOCK', 'REFINT', 'REFEXT', 'EIN', 'PAN', 'STDNAME', 'TED',
'TOOLNBR', 'TOOLNAME', 'ZONE', 'EFFECT', 'CONEFFECT',
'CB', 'CBNAME', 'CBLOC', 'GRPHCREF',
'SUPER', 'SUPERSCRIPT', 'SUB', 'SUBSCRIPT'
])
export function isInlineElement(tagName: string): boolean { export function isInlineElement(tagName: string): boolean {
return INLINE_ELEMENTS.has(tagName) return INLINE_ELEMENTS_SET.has(tagName)
} }
/** /**
......
export const LIST_ITEM_TAGS = [ export { LIST_ITEM_TAGS, HEADER_TAGS, ROMAN_LOOKUP } from '@/configs/xmlTags'
'L1ITEM',
'L2ITEM',
'L3ITEM',
'L4ITEM',
'L5ITEM',
'L6ITEM',
'L7ITEM',
'UNLITEM',
'NUMLITEM'
]
export const HEADER_TAGS = [
'SMJC-HEADER',
'LMJC-HEADER',
'NRCJC-HEADER',
'TCJC-HEADER',
'QECJC-HEADER',
'EOTK-HEADER',
'DRJC-HEADER'
]
export const ROMAN_LOOKUP: Array<[string, number]> = [
['x', 10],
['ix', 9],
['v', 5],
['iv', 4],
['i', 1]
]
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 { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
// 警示块级节点集合(WARNING / CAUTION / NOTE),用于注入 insideAlert 上下文
const ALERT_ROOT_TAGS = new Set(ALERT_BLOCK_TAGS)
export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | null; isInline?: boolean; insideRefBlock?: boolean }) { export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | null; isInline?: boolean; insideRefBlock?: boolean }) {
const editorStore = useEditorStore() const editorStore = useEditorStore()
const { locale } = useI18n() const { locale } = useI18n()
const isSelected = ref(editorStore.selectedNodeId === props.node.id) const isSelected = ref(editorStore.renderedSelectedNodeId === props.node.id)
onMounted(() => { onMounted(() => {
nodeSelectedRefs.set(props.node.id, isSelected) nodeSelectedRefs.set(props.node.id, isSelected)
// 自动溯源刷新选中:若发现当前选中节点的祖先链中包含当前节点,则触发状态重算,使新挂载的组件正确取得高亮
if (editorStore.selectedNodeId) {
let realId = editorStore.selectedNodeId
if (realId.includes('-txt-')) {
realId = realId.split('-txt-')[0]
}
let curr = editorStore.nodeMap.get(realId)
while (curr) {
if (curr.node.id === props.node.id) {
editorStore.setSelectedNodeId(editorStore.selectedNodeId)
break
}
curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined
}
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
...@@ -20,7 +39,7 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu ...@@ -20,7 +39,7 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
const parentAlert = inject('insideAlert', false) const parentAlert = inject('insideAlert', false)
const isInsideAlert = computed(() => parentAlert) const isInsideAlert = computed(() => parentAlert)
if (['WARNING', 'CAUTION', 'NOTE'].includes(props.node.tagName) || parentAlert) { if (ALERT_ROOT_TAGS.has(props.node.tagName) || parentAlert) {
provide('insideAlert', true) provide('insideAlert', true)
} }
...@@ -290,3 +309,39 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu ...@@ -290,3 +309,39 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
getHeaderValue getHeaderValue
} }
} }
export const getSplitListChildren = (children?: XmlNode[]) => {
if (!children) return { alerts: [], normals: [] }
const alertTagsSet = new Set(ALERT_AND_EFF_TAGS)
const alerts: XmlNode[] = []
const normals: XmlNode[] = []
let foundNormal = false
for (const child of children) {
if (!foundNormal && alertTagsSet.has(child.tagName)) {
alerts.push(child)
} else {
foundNormal = true
normals.push(child)
}
}
return { alerts, normals }
}
export const getCepTaskNumber = (node: XmlNode): string => {
const { CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR } = node.attributes || {}
const parts = [CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR].filter(Boolean)
return parts.join('-')
}
export const isAllEinDataSameEffect = (einlst: XmlNode): boolean => {
if (!einlst.children || einlst.children.length === 0) return true
const getEffString = (eindata: XmlNode): string => {
const effNode = eindata.children.find((c) => c.tagName === 'EFFECT' || c.tagName === 'CONEFFECT')
if (!effNode) return 'ALL'
return effNode.attributes?.EFFRG || effNode.textContent || 'ALL'
}
const firstEff = getEffString(einlst.children[0])
return einlst.children.every((c) => getEffString(c) === firstEff)
}
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
<template v-if="isHeaderTag"></template> <template v-if="isHeaderTag"></template>
<!-- 1. WARNING / CAUTION / NOTE 结构特殊处理 (仿照 PDF) --> <!-- 1. WARNING / CAUTION / NOTE 结构特殊处理 (仿照 PDF) -->
<template v-else-if="node.tagName === 'WARNING' || node.tagName === 'CAUTION' || node.tagName === 'NOTE'"> <template v-else-if="ALERT_BLOCK_SET.has(node.tagName)">
<div <div
class="relative my-3 text-sm" class="relative my-3 text-sm"
:class="[ :class="[
...@@ -93,34 +93,11 @@ ...@@ -93,34 +93,11 @@
</template> </template>
<!-- 6. PARA / PARAC 段落文本处理 --> <!-- 6. PARA / PARAC 段落文本处理 -->
<template v-else-if="node.tagName === 'PARA' || node.tagName === 'PARAC'"> <template v-else-if="PARA_SET.has(node.tagName)">
<!-- 如果有混合内容,则渲染混合片段 --> <!-- 如果有子节点,说明属于容器段落,就不在 PARA 中展示或编辑文本内容,只展示其子元素节点 -->
<div <div
v-if="node.mixedContent && node.mixedContent.length > 0" v-if="node.children && node.children.length > 0"
class="flex flex-wrap items-center leading-relaxed text-sm" class="text-sm leading-relaxed py-1 flex flex-wrap items-center"
:class="[isInsideAlert ? 'text-inherit' : 'text-color2']"
>
<template v-for="(item, idx) in node.mixedContent" :key="idx">
<span
v-if="item.type === 'text'"
contenteditable="true"
class="focus:outline-none focus:bg-fill-3 px-0.5 rounded"
@blur="(e) => handleMixedTextBlur(idx, e)"
v-text="item.text"
></span>
<DocNodeRenderer
v-else-if="item.type === 'element' && item.nodeId && getChildNode(item.nodeId)"
:node="getChildNode(item.nodeId)!"
:parent="node"
is-inline
:inside-ref-block="insideRefBlock"
/>
</template>
</div>
<!-- 如果没有混合内容但有子节点,则直接渲染子节点 -->
<div
v-else-if="node.children && node.children.length > 0"
class="text-sm leading-relaxed py-1"
:class="[isInsideAlert ? 'text-inherit' : 'text-color2']" :class="[isInsideAlert ? 'text-inherit' : 'text-color2']"
> >
<DocNodeRenderer <DocNodeRenderer
...@@ -132,6 +109,7 @@ ...@@ -132,6 +109,7 @@
:inside-ref-block="insideRefBlock" :inside-ref-block="insideRefBlock"
/> />
</div> </div>
<!-- 否则,如果是一个纯文本的段落,直接展示和编辑文本 -->
<div <div
v-else v-else
contenteditable="true" contenteditable="true"
...@@ -221,30 +199,49 @@ ...@@ -221,30 +199,49 @@
<!-- 9. EIN (功能号) 特殊处理 --> <!-- 9. EIN (功能号) 特殊处理 -->
<template v-else-if="node.tagName === 'EIN'"> <template v-else-if="node.tagName === 'EIN'">
<span class="font-mono cursor-pointer underline" style="color: blue" @click.stop="editorStore.setSelectedNodeId(node.id)"> <span
FIN {{ (node.textContent || '').replace(/-/g, '') }} class="font-mono cursor-pointer underline inline-flex items-baseline"
style="color: blue"
@click.stop="editorStore.setSelectedNodeId(node.id)"
>
<span class="mr-1 select-none">FIN</span>
<span
contenteditable="true"
class="focus:outline-none focus:bg-fill-3 px-0.5 rounded"
@blur="handleTextBlur"
v-text="node.textContent"
></span>
</span> </span>
</template> </template>
<!-- 10. PAN (盖板/面板) 特殊处理 --> <!-- 10. PAN (盖板/面板) 特殊处理 -->
<template v-else-if="node.tagName === 'PAN'"> <template v-else-if="node.tagName === 'PAN'">
<span class="text-color1 cursor-pointer" @click.stop="editorStore.setSelectedNodeId(node.id)"> <span
{{ node.textContent }} contenteditable="true"
</span> class="text-color1 cursor-pointer focus:outline-none focus:bg-fill-3 px-0.5 rounded"
@blur="handleTextBlur"
v-text="node.textContent"
></span>
</template> </template>
<!-- 11. STDNAME (标准名称) 特殊处理 --> <!-- 11. STDNAME (标准名称) 特殊处理 -->
<template v-else-if="node.tagName === 'STDNAME'"> <template v-else-if="node.tagName === 'STDNAME'">
<span class="text-color1 cursor-pointer" @click.stop="editorStore.setSelectedNodeId(node.id)"> <span
{{ node.textContent }} contenteditable="true"
</span> class="text-color1 cursor-pointer focus:outline-none focus:bg-fill-3 px-0.5 rounded"
@blur="handleTextBlur"
v-text="node.textContent"
></span>
</template> </template>
<!-- 12. ZONE (区域) 特殊处理 --> <!-- 12. ZONE (区域) 特殊处理 -->
<template v-else-if="node.tagName === 'ZONE'"> <template v-else-if="node.tagName === 'ZONE'">
<span class="font-mono text-color1 cursor-pointer" @click.stop="editorStore.setSelectedNodeId(node.id)"> <span
{{ node.textContent }} contenteditable="true"
</span> class="font-mono text-color1 cursor-pointer focus:outline-none focus:bg-fill-3 px-0.5 rounded"
@blur="handleTextBlur"
v-text="node.textContent"
></span>
</template> </template>
<!-- 13. TED (工具/设备数据) 特殊处理 --> <!-- 13. TED (工具/设备数据) 特殊处理 -->
...@@ -262,10 +259,13 @@ ...@@ -262,10 +259,13 @@
</template> </template>
<!-- 15. CB / CBNAME / CBLOC (断路器信息) 特殊处理 --> <!-- 15. CB / CBNAME / CBLOC (断路器信息) 特殊处理 -->
<template v-else-if="node.tagName === 'CB' || node.tagName === 'CBNAME' || node.tagName === 'CBLOC'"> <template v-else-if="CB_COMPONENT_SET.has(node.tagName)">
<span class="font-mono text-color1 cursor-pointer" @click.stop="editorStore.setSelectedNodeId(node.id)"> <span
{{ node.textContent }} contenteditable="true"
</span> class="font-mono text-color1 cursor-pointer focus:outline-none focus:bg-fill-3 px-0.5 rounded"
@blur="handleTextBlur"
v-text="node.textContent"
></span>
</template> </template>
<!-- 16. CBLST (电路断路器列表) 特殊处理 (仿照 PDF 样式表格) --> <!-- 16. CBLST (电路断路器列表) 特殊处理 (仿照 PDF 样式表格) -->
...@@ -542,17 +542,69 @@ ...@@ -542,17 +542,69 @@
</div> </div>
</template> </template>
<!-- 25. ASSODATA 隐藏处理 (PDF 隐藏) --> <!-- 25. ZONELST 隐藏处理 (只在被选中时渲染灰色提示框) -->
<template v-else-if="node.tagName === 'ASSODATA'"> <template v-else-if="node.tagName === 'ZONELST'">
<div <div v-if="isSelected" class="p-2 border border-dashed border-divider rounded bg-fill-2 text-center text-xs text-color3 select-none">
v-if="isSelected" [ 区域列表 &lt;ZONELST&gt; PDF 渲染中已设为隐藏,不予展示内容 ]
class="p-4 border border-dashed border-divider rounded bg-fill-2 text-center text-xs text-color3 select-none animate-pulse" </div>
</template>
<!-- 25.2 EINLST 特殊处理 (展示 FIN: 12QM 13QM ) -->
<template v-else-if="node.tagName === 'EINLST'">
<div class="my-2 py-1 select-text">
<template v-if="isAllEinDataSameEffect(node)">
<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">
<span
class="font-mono cursor-pointer hover:underline"
style="color: blue; text-decoration: underline"
@click.stop="editorStore.setSelectedNodeId(child.id)"
> >
[ 节点 &lt;ASSODATA&gt; PDF 渲染中已设为隐藏,不予展示内容 ] {{ child.textContent }}
</span>
</template>
</template>
</div>
</template>
<template v-else>
<div class="text-sm">
<span class="text-color2 font-medium block mb-1">FIN:</span>
<div class="space-y-1 pl-4">
<div v-for="eindata in node.children" :key="eindata.id" class="block">
<DocNodeRenderer
v-for="eff in eindata.children.filter((c) => c.tagName === 'EFFECT' || c.tagName === 'CONEFFECT')"
:key="eff.id"
:node="eff"
:parent="eindata"
/>
<div class="pl-2">
<template v-for="child in eindata.children.filter((c) => c.tagName === 'EIN')" :key="child.id">
<span
class="font-mono cursor-pointer hover:underline"
style="color: blue; text-decoration: underline"
@click.stop="editorStore.setSelectedNodeId(child.id)"
>
{{ child.textContent }}
</span>
</template>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<!-- 25.5 CEP-HEADER 虚拟节点(任务号)展现 -->
<template v-else-if="node.tagName === 'CEP-HEADER'">
<div class="my-2">
<div class="text-sm font-bold text-color1">任务 {{ getCepTaskNumber(node) }}</div>
</div> </div>
</template> </template>
<!-- 26. 其它所有容器节点(如 JOBCARD, CEP, TFMATR, SUBTASK, LIST1... --> <!-- 26. 其它所有容器节点(如 JOBCARD, TFMATR, SUBTASK, LIST1... -->
<template v-else-if="isContainer"> <template v-else-if="isContainer">
<div class="space-y-1"> <div class="space-y-1">
<DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" :parent="node" /> <DocNodeRenderer v-for="child in node.children" :key="child.id" :node="child" :parent="node" />
...@@ -560,7 +612,7 @@ ...@@ -560,7 +612,7 @@
</template> </template>
<!-- 26.5 上标与下标 (SUPER / SUPERSCRIPT / SUB / SUBSCRIPT) 特殊处理 --> <!-- 26.5 上标与下标 (SUPER / SUPERSCRIPT / SUB / SUBSCRIPT) 特殊处理 -->
<template v-else-if="node.tagName === 'SUPER' || node.tagName === 'SUPERSCRIPT' || node.tagName === 'SUB' || node.tagName === 'SUBSCRIPT'"> <template v-else-if="SUPER_SUB_SET.has(node.tagName)">
<span <span
class="inline select-all text-[0.75em]" class="inline select-all text-[0.75em]"
:class="[node.tagName === 'SUPER' || node.tagName === 'SUPERSCRIPT' ? 'align-super' : 'align-sub']" :class="[node.tagName === 'SUPER' || node.tagName === 'SUPERSCRIPT' ? 'align-super' : 'align-sub']"
...@@ -605,7 +657,14 @@ import DocNodeRenderer from './index.vue' ...@@ -605,7 +657,14 @@ import DocNodeRenderer from './index.vue'
import { ImageOutline, GridOutline } from '@vicons/ionicons5' import { ImageOutline, GridOutline } from '@vicons/ionicons5'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import TableEditor from '../TableEditor/index.vue' import TableEditor from '../TableEditor/index.vue'
import { useDocNodeRenderer } from './functionals' import { useDocNodeRenderer, getSplitListChildren, getCepTaskNumber, isAllEinDataSameEffect } from './functionals'
import { ALERT_BLOCK_TAGS, PARA_TAGS, CB_COMPONENT_TAGS, SUPER_SUB_TAGS } 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 props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
...@@ -634,10 +693,8 @@ const { ...@@ -634,10 +693,8 @@ const {
getTopicSeqNum, getTopicSeqNum,
getTopicTitleNodes, getTopicTitleNodes,
getTopicContentNodes, getTopicContentNodes,
getChildNode,
handleTextBlur, handleTextBlur,
handleChildTextBlur, handleChildTextBlur,
handleMixedTextBlur,
getGraphicTitle, getGraphicTitle,
handleGraphicTitleBlur, handleGraphicTitleBlur,
getTedText, getTedText,
...@@ -647,22 +704,7 @@ const { ...@@ -647,22 +704,7 @@ const {
getCbValue getCbValue
} = useDocNodeRenderer(props) } = useDocNodeRenderer(props)
const getSplitListChildren = (children?: XmlNode[]) => {
if (!children) return { alerts: [], normals: [] }
const alertTags = new Set(['WARNING', 'CAUTION', 'NOTE', 'EFFECT', 'CONEFFECT', 'SBEFF', 'SBEFFC'])
const alerts: XmlNode[] = []
const normals: XmlNode[] = []
let foundNormal = false
for (const child of children) {
if (!foundNormal && alertTags.has(child.tagName)) {
alerts.push(child)
} else {
foundNormal = true
normals.push(child)
}
}
return { alerts, normals }
}
</script> </script>
<style scoped> <style scoped>
......
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import { useMessage } from 'naive-ui'
export function useFindReplace( export function useFindReplace(
props: { visible: boolean; syncEditorScroll: (nodeId: string, force: boolean) => void }, props: { visible: boolean; syncEditorScroll: (nodeId: string, force: boolean) => void },
emit: (event: 'update:visible', value: boolean) => void emit: (event: 'update:visible', value: boolean) => void
) { ) {
const editorStore = useEditorStore() const editorStore = useEditorStore()
const message = useMessage()
const isExpanded = ref(false) const isExpanded = ref(false)
const findQuery = ref('') const findQuery = ref('')
...@@ -123,7 +121,7 @@ export function useFindReplace( ...@@ -123,7 +121,7 @@ export function useFindReplace(
const success = editorStore.replaceMatch(match, replaceQuery.value) const success = editorStore.replaceMatch(match, replaceQuery.value)
if (success) { if (success) {
message.success('替换成功') window.$message?.success('替换成功')
// 重新查找以保持精准的位置和剩余结果统计 // 重新查找以保持精准的位置和剩余结果统计
const prevIndex = currentMatchIndex.value const prevIndex = currentMatchIndex.value
doSearch() doSearch()
...@@ -134,7 +132,7 @@ export function useFindReplace( ...@@ -134,7 +132,7 @@ export function useFindReplace(
focusMatch(nextIdx) focusMatch(nextIdx)
} }
} else { } else {
message.error('替换失败') window.$message?.error('替换失败')
} }
} }
...@@ -147,12 +145,12 @@ export function useFindReplace( ...@@ -147,12 +145,12 @@ export function useFindReplace(
}) })
if (count > 0) { if (count > 0) {
message.success(`成功替换了 ${count} 处匹配`) window.$message?.success(`成功替换了 ${count} 处匹配`)
matches.value = [] matches.value = []
currentMatchIndex.value = -1 currentMatchIndex.value = -1
findQuery.value = '' findQuery.value = ''
} else { } else {
message.info('未找到可替换的匹配项') window.$message?.info('未找到可替换的匹配项')
} }
} }
...@@ -175,6 +173,15 @@ export function useFindReplace( ...@@ -175,6 +173,15 @@ export function useFindReplace(
emit('update:visible', false) emit('update:visible', false)
} }
// ESC 键关闭面板
useKeyboardShortcuts({
onEscape: () => {
if (props.visible) {
closePanel()
}
}
})
// 自动对焦查找框 // 自动对焦查找框
watch( watch(
() => props.visible, () => props.visible,
...@@ -215,3 +222,50 @@ export function useFindReplace( ...@@ -215,3 +222,50 @@ export function useFindReplace(
closePanel closePanel
} }
} }
/**
* 使面板可以拖动的 Hook
*/
export function useDraggable(visible: Ref<boolean>) {
const dragOffset = ref({ x: 0, y: 0 })
const dragging = ref(false)
const onDragStart = (e: MouseEvent) => {
if (e.button !== 0) return
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('a') || target.closest('.n-button') || target.closest('.n-input')) {
return
}
dragging.value = true
const startX = e.clientX
const startY = e.clientY
const initialX = dragOffset.value.x
const initialY = dragOffset.value.y
const onMouseMove = (moveEvt: MouseEvent) => {
if (!dragging.value) return
dragOffset.value = {
x: initialX + moveEvt.clientX - startX,
y: initialY + moveEvt.clientY - startY
}
}
const onMouseUp = () => {
dragging.value = false
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
}
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
}
watch(visible, (val) => {
if (!val) {
dragOffset.value = { x: 0, y: 0 }
}
})
return { dragOffset, dragging, onDragStart }
}
<template> <template>
<teleport to="body">
<transition name="slide-fade"> <transition name="slide-fade">
<div <div
v-if="visible" v-if="visible"
class="find-replace-panel absolute top-4 right-4 z-50 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 transition-all duration-300 ease-in-out"
:style="{ width: isExpanded ? '560px' : '320px' }" :style="{
width: isExpanded ? '560px' : '320px',
transform: `translate(${dragOffset.x}px, ${dragOffset.y}px)`,
transition: dragging ? 'none' : 'width 0.3s ease-in-out, transform 0.1s ease-out'
}"
:class="{ 'is-expanded': isExpanded }" :class="{ 'is-expanded': isExpanded }"
> >
<!-- 头部标题与关闭按钮 --> <!-- 头部标题与关闭按钮 -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between cursor-move select-none" @mousedown="onDragStart">
<span class="text-xs font-bold text-color1 tracking-wide flex items-center space-x-1"> <span class="text-xs font-bold text-color1 tracking-wide flex items-center space-x-1">
<n-icon class="text-primary text-sm"> <n-icon class="text-primary text-sm">
<search-outline /> <search-outline />
</n-icon> </n-icon>
<span>查找与替换</span> <span>查找与替换</span>
</span> </span>
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1" @mousedown.stop>
<!-- 放大展开/收起按钮 (通过 CodeOutline 表示 <>) --> <!-- 放大展开/收起按钮 (通过 CodeOutline 表示 <>) -->
<CommonButton size="tiny" quaternary circle @click="toggleExpand" :title="isExpanded ? '折叠面板' : '放大面板'"> <CommonButton size="tiny" quaternary circle @click="toggleExpand" :title="isExpanded ? '折叠面板' : '放大面板'">
<template #icon> <template #icon>
...@@ -46,18 +51,18 @@ ...@@ -46,18 +51,18 @@
<!-- 选项开关 --> <!-- 选项开关 -->
<div class="flex items-center space-x-1 text-color3"> <div class="flex items-center space-x-1 text-color3">
<span <span
class="option-btn text-[10px] px-1 py-0.5 rounded cursor-pointer transition-colors" class="option-btn text-[10px] px-1.5 py-0.5 rounded cursor-pointer transition-all duration-150"
:class="{ 'active bg-primary/20 text-primary font-bold': matchCase }" :class="{ 'active': matchCase }"
title="区分大小写" title="区分大小写"
@click="toggleMatchCase" @click.stop="toggleMatchCase"
> >
Aa Aa
</span> </span>
<span <span
class="option-btn text-[10px] px-1 py-0.5 rounded cursor-pointer transition-colors" class="option-btn text-[10px] px-1.5 py-0.5 rounded cursor-pointer transition-all duration-150"
:class="{ 'active bg-primary/20 text-primary font-bold': regExp }" :class="{ 'active': regExp }"
title="正则表达式" title="正则表达式"
@click="toggleRegExp" @click.stop="toggleRegExp"
> >
.* .*
</span> </span>
...@@ -142,11 +147,12 @@ ...@@ -142,11 +147,12 @@
</div> </div>
</div> </div>
</transition> </transition>
</teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SearchOutline, CloseOutline, ChevronUpOutline, ChevronDownOutline, CodeOutline } from '@vicons/ionicons5' import { SearchOutline, CloseOutline, ChevronUpOutline, ChevronDownOutline, CodeOutline } from '@vicons/ionicons5'
import { useFindReplace } from './functionals' import { useFindReplace, useDraggable } from './functionals'
const props = defineProps<{ const props = defineProps<{
visible: boolean visible: boolean
...@@ -155,6 +161,9 @@ const props = defineProps<{ ...@@ -155,6 +161,9 @@ const props = defineProps<{
const emit = defineEmits(['update:visible']) const emit = defineEmits(['update:visible'])
const visibleRef = toRef(props, 'visible')
const { dragOffset, dragging, onDragStart } = useDraggable(visibleRef)
const { const {
isExpanded, isExpanded,
findQuery, findQuery,
...@@ -181,12 +190,25 @@ const { ...@@ -181,12 +190,25 @@ const {
</script> </script>
<style scoped> <style scoped>
.find-replace-panel { .option-btn {
border-color: var(--n-border-color); display: inline-flex;
align-items: center;
justify-content: center;
height: 18px;
padding: 0 4px;
font-size: 10px;
border-radius: 4px;
color: var(--n-text-color-3, #8c8c8c) !important;
transition: all 0.15s ease;
} }
.option-btn:hover { .option-btn:hover {
background-color: rgba(24, 160, 88, 0.1); background-color: rgba(24, 160, 88, 0.1);
} }
.option-btn.active {
background-color: color-mix(in srgb, var(--primary-color) 15%, transparent) !important;
color: var(--primary-color) !important;
font-weight: bold;
}
.match-item { .match-item {
transition: all 0.15s ease; transition: all 0.15s ease;
color: var(--n-text-color); color: var(--n-text-color);
......
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import { TRANSPARENT_TAGS, ALL_LIST_TAGS, CALS_TABLE_TAGS } from '@/configs/xmlTags'
// 列表节点标签定义
export const ALL_LIST_TAGS = ['LIST1', 'LIST2', 'LIST3', 'UNLIST']
// 表格节点标签定义
export const CALS_TABLE_TAGS = ['TABLE', 'TGROUP']
// 容器节点:直接穿透,不独立成块,而是遍历其子节点,以实现细粒度原子级虚拟滚动分块 // 容器节点:直接穿透,不独立成块,而是遍历其子节点,以实现细粒度原子级虚拟滚动分块
export const TRANSPARENT_TAGS = new Set(['JOBCARD', 'CEP', 'TASK', 'TFMATR']) export { TRANSPARENT_TAGS, ALL_LIST_TAGS, CALS_TABLE_TAGS }
export const TRANSPARENT_TAGS_SET = new Set(TRANSPARENT_TAGS)
/** /**
* 虚拟滚动:各类节点的默认估计高度(px) * 虚拟滚动:各类节点的默认估计高度(px)
......
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import { TRANSPARENT_TAGS, getEstimatedHeight, type EditorBlock, type BlockPosition } from '../constants' import { TRANSPARENT_TAGS_SET, getEstimatedHeight, type EditorBlock, type BlockPosition } from '../constants'
const getRealNodeId = (id: string): string => {
if (id && id.includes('-txt-')) {
return id.split('-txt-')[0]
}
return id
}
/** /**
* EditorPanel 组件核心逻辑 Hook * EditorPanel 组件核心逻辑 Hook
...@@ -11,16 +18,32 @@ export function useEditorPanel() { ...@@ -11,16 +18,32 @@ export function useEditorPanel() {
// ── 基础状态 ────────────────────────────────────────────────────────────── // ── 基础状态 ──────────────────────────────────────────────────────────────
const selectedNode = computed(() => editorStore.selectedNode) const selectedNode = computed(() => {
if (!editorStore.selectedNodeId) return null
const realId = getRealNodeId(editorStore.selectedNodeId)
return editorStore.nodeMap.get(realId)?.node || null
})
const nodePath = computed(() => { const nodePath = computed(() => {
if (!editorStore.selectedNodeId) return [] if (!editorStore.selectedNodeId) return []
const realId = getRealNodeId(editorStore.selectedNodeId)
const path: XmlNode[] = [] const path: XmlNode[] = []
let curr = editorStore.nodeMap.get(editorStore.selectedNodeId) let curr = editorStore.nodeMap.get(realId)
while (curr) { while (curr) {
path.unshift(curr.node) path.unshift(curr.node)
curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined
} }
if (editorStore.selectedNodeId.includes('-txt-')) {
path.push({
id: editorStore.selectedNodeId,
tagName: '#text',
attributes: {},
children: [],
textContent: '',
mixedContent: [],
parentId: realId
})
}
return path return path
}) })
...@@ -38,7 +61,19 @@ export function useEditorPanel() { ...@@ -38,7 +61,19 @@ export function useEditorPanel() {
const getEditorBlocks = (node: XmlNode): EditorBlock[] => { const getEditorBlocks = (node: XmlNode): EditorBlock[] => {
const blocks: EditorBlock[] = [] const blocks: EditorBlock[] = []
const walk = (n: XmlNode) => { const walk = (n: XmlNode) => {
if (TRANSPARENT_TAGS.has(n.tagName)) { if (n.tagName === 'CEP') {
const virtualHeaderNode: XmlNode = {
id: n.id,
tagName: 'CEP-HEADER',
attributes: n.attributes || {},
textContent: '',
children: [],
mixedContent: [],
parentId: n.parentId
}
blocks.push({ id: virtualHeaderNode.id, tagName: 'CEP-HEADER', rawNode: virtualHeaderNode })
n.children.forEach(walk)
} else if (TRANSPARENT_TAGS_SET.has(n.tagName)) {
n.children.forEach(walk) n.children.forEach(walk)
} else if (n.tagName === 'TOPIC') { } else if (n.tagName === 'TOPIC') {
// 特殊拆分:TOPIC 节点自身仅作为标题 Block,其内容子节点(SUBTASK 等)则作为平级 Block 扁平化,以实现细粒度虚拟化 // 特殊拆分:TOPIC 节点自身仅作为标题 Block,其内容子节点(SUBTASK 等)则作为平级 Block 扁平化,以实现细粒度虚拟化
...@@ -214,9 +249,10 @@ export function useEditorPanel() { ...@@ -214,9 +249,10 @@ export function useEditorPanel() {
* 最近的渲染块(如 SUBTASK)。后续路径搜索会自动回退到 CON/PARAC 等可见父元素。 * 最近的渲染块(如 SUBTASK)。后续路径搜索会自动回退到 CON/PARAC 等可见父元素。
*/ */
const findNearestBlockIdx = (targetId: string): number => { const findNearestBlockIdx = (targetId: string): number => {
const realId = getRealNodeId(targetId)
const blockIdxMap = new Map(blocksList.value.map((b, idx) => [b.id, idx])) const blockIdxMap = new Map(blocksList.value.map((b, idx) => [b.id, idx]))
if (blockIdxMap.has(targetId)) return blockIdxMap.get(targetId)! if (blockIdxMap.has(realId)) return blockIdxMap.get(realId)!
let curr = editorStore.nodeMap.get(targetId) let curr = editorStore.nodeMap.get(realId)
while (curr) { while (curr) {
if (blockIdxMap.has(curr.node.id)) return blockIdxMap.get(curr.node.id)! if (blockIdxMap.has(curr.node.id)) return blockIdxMap.get(curr.node.id)!
curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined
...@@ -242,12 +278,16 @@ export function useEditorPanel() { ...@@ -242,12 +278,16 @@ export function useEditorPanel() {
let el: HTMLElement | null = null let el: HTMLElement | null = null
let foundNodeId: string | null = null let foundNodeId: string | null = null
const path: XmlNode[] = [] const path: XmlNode[] = []
let curr = editorStore.nodeMap.get(newId) const realId = getRealNodeId(newId)
let curr = editorStore.nodeMap.get(realId)
while (curr) { while (curr) {
path.unshift(curr.node) path.unshift(curr.node)
curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined
} }
if (blockEl && path.length > 0) { if (blockEl && path.length > 0) {
// 优先在所属块内寻找虚拟文本节点本身的 DOM 元素
el = blockEl.querySelector(`[data-node-id="${newId}"]`) as HTMLElement | null
if (!el) {
for (let i = path.length - 1; i >= 0; i--) { for (let i = path.length - 1; i >= 0; i--) {
if (blockEl.dataset.nodeId === path[i].id) { if (blockEl.dataset.nodeId === path[i].id) {
el = blockEl el = blockEl
...@@ -261,6 +301,7 @@ export function useEditorPanel() { ...@@ -261,6 +301,7 @@ export function useEditorPanel() {
} }
} }
} }
}
if (el) { if (el) {
const rect = el.getBoundingClientRect() const rect = el.getBoundingClientRect()
...@@ -282,13 +323,17 @@ export function useEditorPanel() { ...@@ -282,13 +323,17 @@ export function useEditorPanel() {
top: Math.max(0, pos.top - vHeight / 2 + pos.height / 2), top: Math.max(0, pos.top - vHeight / 2 + pos.height / 2),
behavior: 'instant' as ScrollBehavior behavior: 'instant' as ScrollBehavior
}) })
// 立即同步更新 scrollTop.value,避免等待 scroll 事件 + rAF 延迟,从而在当前 Tick / nextTick 立即渲染出目标块 DOM
scrollTop.value = viewportRef.value.scrollTop
// 等挂载后再精确定位 // 等挂载后再精确定位
setTimeout(() => { setTimeout(() => {
if (!viewportRef.value) return if (!viewportRef.value) return
let targetEl: HTMLElement | null = null let targetEl: HTMLElement | null = null
let subFoundId: string | null = null let subFoundId: string | null = null
if (path && path.length > 0) { // 优先寻找虚拟文本节点本身
targetEl = viewportRef.value.querySelector(`[data-node-id="${newId}"]`) as HTMLElement | null
if (!targetEl && path && path.length > 0) {
for (let i = path.length - 1; i >= 0; i--) { for (let i = path.length - 1; i >= 0; i--) {
targetEl = viewportRef.value.querySelector(`[data-node-id="${path[i].id}"]`) as HTMLElement | null targetEl = viewportRef.value.querySelector(`[data-node-id="${path[i].id}"]`) as HTMLElement | null
if (targetEl) { if (targetEl) {
......
...@@ -88,20 +88,11 @@ const toggleFindReplace = () => { ...@@ -88,20 +88,11 @@ const toggleFindReplace = () => {
findReplaceVisible.value = !findReplaceVisible.value findReplaceVisible.value = !findReplaceVisible.value
} }
const handleKeydown = (e: KeyboardEvent) => { // 查找快捷键监听 (Ctrl+F)
const ctrl = e.ctrlKey || e.metaKey useKeyboardShortcuts({
if (ctrl && e.key.toLowerCase() === 'f') { onFind: () => {
e.preventDefault()
findReplaceVisible.value = true findReplaceVisible.value = true
} }
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
}) })
const handleEditSelectedNode = () => { const handleEditSelectedNode = () => {
......
...@@ -33,6 +33,7 @@ const { ...@@ -33,6 +33,7 @@ const {
} = useInsertFragmentModal() } = useInsertFragmentModal()
defineExpose({ defineExpose({
open open,
visible
}) })
</script> </script>
...@@ -6,5 +6,4 @@ export interface ListItemModel { ...@@ -6,5 +6,4 @@ export interface ListItemModel {
} }
// 静态有序列表标签定义 // 静态有序列表标签定义
export const ORDERED_LIST_TAGS = ['LIST1', 'LIST2', 'LIST3'] export { ORDERED_LIST_TAGS, ALL_LIST_TAGS } from '@/configs/xmlTags'
export const ALL_LIST_TAGS = ['LIST1', 'LIST2', 'LIST3', 'UNLIST']
...@@ -2,6 +2,7 @@ import { useEditorStore } from '@/store/editor' ...@@ -2,6 +2,7 @@ import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import type { ListItemModel } from '../constants' import type { ListItemModel } from '../constants'
import { ALL_LIST_TAGS } from '../constants' import { ALL_LIST_TAGS } from '../constants'
import { PARA_TAGS } from '@/configs/xmlTags'
/** /**
* 列表结构编辑器 (ListEditor) 组件专用 Hook 逻辑 * 列表结构编辑器 (ListEditor) 组件专用 Hook 逻辑
...@@ -34,7 +35,7 @@ export function useListEditor() { ...@@ -34,7 +35,7 @@ export function useListEditor() {
.filter(c => c.tagName === itemTagName) .filter(c => c.tagName === itemTagName)
.map(itemNode => { .map(itemNode => {
let text = itemNode.textContent || '' let text = itemNode.textContent || ''
const firstPara = itemNode.children.find(c => ['PARA', 'PARAC'].includes(c.tagName)) const firstPara = itemNode.children.find(c => PARA_TAGS.includes(c.tagName))
if (firstPara) { if (firstPara) {
text = firstPara.textContent || '' text = firstPara.textContent || ''
} }
...@@ -51,7 +52,7 @@ export function useListEditor() { ...@@ -51,7 +52,7 @@ export function useListEditor() {
const itemNode = node.children.find(c => c.id === itemId) const itemNode = node.children.find(c => c.id === itemId)
if (!itemNode) return if (!itemNode) return
const para = itemNode.children.find(c => ['PARA', 'PARAC'].includes(c.tagName)) const para = itemNode.children.find(c => PARA_TAGS.includes(c.tagName))
if (para) { if (para) {
para.textContent = text para.textContent = text
if (para.mixedContent.length > 0) { if (para.mixedContent.length > 0) {
......
...@@ -25,7 +25,31 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -25,7 +25,31 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
const showTextContentField = computed(() => { const showTextContentField = computed(() => {
if (!form.tagName) return false if (!form.tagName) return false
return isTextOnlyElement(form.tagName) || isMixedContentElement(form.tagName) if (form.tagName === '#text') return true
const tagName = form.tagName
if (!tagName) return false
// 如果是纯文本节点,直接允许
if (isTextOnlyElement(tagName)) return true
// 如果是混合内容节点
if (isMixedContentElement(tagName)) {
if (addNodeMode.value === 'edit') {
const nodeId = addNodeTargetId.value
const node = store.nodeMap.get(nodeId)?.node ?? null
if (node) {
const textItems = (node.mixedContent || []).filter((item) => item.type === 'text' && (item.text || '').trim())
// 如果已经包含多个文本节点,内容需要在各自虚拟节点下单独编辑,故这里隐藏
if (textItems.length > 1) {
return false
}
}
}
return true
}
return false
}) })
const rules = { const rules = {
...@@ -41,19 +65,27 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -41,19 +65,27 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
edit: '编辑节点' edit: '编辑节点'
} }
const nodeId = addNodeTargetId.value const nodeId = addNodeTargetId.value
const node = store.nodeMap.get(nodeId)?.node ?? null
if (addNodeMode.value === 'edit') { if (addNodeMode.value === 'edit') {
if (nodeId && nodeId.includes('-txt-')) {
return '编辑文本内容'
}
const node = store.nodeMap.get(nodeId)?.node ?? null
return node ? `编辑节点 <${node.tagName}>` : '编辑节点' return node ? `编辑节点 <${node.tagName}>` : '编辑节点'
} }
const node = store.nodeMap.get(nodeId)?.node ?? null
return node ? `${node.tagName} ${map[addNodeMode.value]}` : map[addNodeMode.value] return node ? `${node.tagName} ${map[addNodeMode.value]}` : map[addNodeMode.value]
}) })
// 标签下拉选项 // 标签下拉选项
const tagOptions = computed(() => { const tagOptions = computed(() => {
if (addNodeMode.value === 'edit') { if (addNodeMode.value === 'edit') {
return form.tagName ? [{ label: form.tagName, value: form.tagName }] : [] const label = form.tagName === '#text' ? '文本' : form.tagName
return form.tagName ? [{ label: label || '', value: form.tagName }] : []
} }
return addNodeAllowedTags.value.map((tag) => ({ label: tag, value: tag })) return addNodeAllowedTags.value.map((tag) => ({
label: tag === '#text' ? '文本' : tag,
value: tag
}))
}) })
// 属性定义列表 // 属性定义列表
...@@ -80,7 +112,28 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -80,7 +112,28 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
if (visible) { if (visible) {
if (addNodeMode.value === 'edit') { if (addNodeMode.value === 'edit') {
const nodeId = addNodeTargetId.value const nodeId = addNodeTargetId.value
const node = store.nodeMap.get(nodeId)?.node ?? null let node: XmlNode | null = null
if (nodeId && nodeId.includes('-txt-')) {
const realParentId = nodeId.split('-txt-')[0]
const textIdx = parseInt(nodeId.split('-txt-')[1], 10)
const parentItem = store.nodeMap.get(realParentId)
if (parentItem) {
const parentNode = parentItem.node
const currentText = parentNode.mixedContent[textIdx]?.text || ''
node = {
id: nodeId,
tagName: '#text',
attributes: {},
children: [],
textContent: currentText,
mixedContent: [],
parentId: parentNode.id
}
}
} else {
node = store.nodeMap.get(nodeId)?.node ?? null
}
if (node) { if (node) {
form.tagName = node.tagName form.tagName = node.tagName
form.textContent = node.textContent || '' form.textContent = node.textContent || ''
...@@ -112,7 +165,15 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -112,7 +165,15 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
const tree = store.xmlTree const tree = store.xmlTree
if (!tree) return if (!tree) return
const targetNode = store.nodeMap.get(addNodeTargetId.value)?.node const isVirtualText = addNodeTargetId.value && addNodeTargetId.value.includes('-txt-')
let targetNode: XmlNode | null = null
if (isVirtualText) {
const realParentId = addNodeTargetId.value.split('-txt-')[0]
targetNode = store.nodeMap.get(realParentId)?.node ?? null
} else {
targetNode = store.nodeMap.get(addNodeTargetId.value)?.node ?? null
}
if (!targetNode) return if (!targetNode) return
store.saveSnapshot() store.saveSnapshot()
...@@ -125,8 +186,24 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -125,8 +186,24 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
} }
if (addNodeMode.value === 'edit') { if (addNodeMode.value === 'edit') {
if (isVirtualText) {
const textIdx = parseInt(addNodeTargetId.value.split('-txt-')[1], 10)
if (targetNode.mixedContent && targetNode.mixedContent[textIdx]) {
targetNode.mixedContent[textIdx].text = form.textContent
}
// 同步更新父节点的 textContent
targetNode.textContent = targetNode.mixedContent
.map((item) => (item.type === 'text' ? item.text || '' : ''))
.join('')
store.rebuildNodeMap()
if (store.selectedNodeId === addNodeTargetId.value) {
store.setSelectedNodeId(addNodeTargetId.value)
}
window.$message?.success(`成功更新文本内容`)
addNodeVisible.value = false
} else {
targetNode.attributes = cleanAttrs targetNode.attributes = cleanAttrs
targetNode.textContent = form.textContent
// 同步更新混合内容子节点的文本 // 同步更新混合内容子节点的文本
if (isMixedContentElement(targetNode.tagName)) { if (isMixedContentElement(targetNode.tagName)) {
...@@ -141,6 +218,12 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -141,6 +218,12 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
} else if (form.textContent) { } else if (form.textContent) {
targetNode.mixedContent.unshift({ type: 'text', text: form.textContent }) targetNode.mixedContent.unshift({ type: 'text', text: form.textContent })
} }
// 重新拼合 textContent,确保与 mixedContent 数据一致
targetNode.textContent = targetNode.mixedContent
.map((item) => (item.type === 'text' ? item.text || '' : ''))
.join('')
} else {
targetNode.textContent = form.textContent
} }
// 如果当前编辑的正是选中的节点,强制更新 store 的选中节点引用以重新渲染视图 // 如果当前编辑的正是选中的节点,强制更新 store 的选中节点引用以重新渲染视图
...@@ -149,6 +232,56 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -149,6 +232,56 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
} }
window.$message?.success(`成功更新节点 <${targetNode.tagName}> 的属性与内容`) window.$message?.success(`成功更新节点 <${targetNode.tagName}> 的属性与内容`)
addNodeVisible.value = false addNodeVisible.value = false
}
} else {
if (form.tagName === '#text') {
if (addNodeMode.value === 'child') {
targetNode.mixedContent = targetNode.mixedContent || []
if (targetNode.mixedContent.length === 0 && targetNode.textContent) {
targetNode.mixedContent.push({ type: 'text', text: targetNode.textContent })
}
targetNode.mixedContent.push({ type: 'text', text: form.textContent })
targetNode.textContent = targetNode.mixedContent
.map((item) => (item.type === 'text' ? item.text || '' : ''))
.join('')
store.rebuildNodeMap()
const newTextIdx = targetNode.mixedContent.length - 1
store.setSelectedNodeId(`${targetNode.id}-txt-${newTextIdx}`)
} else {
let destParent: XmlNode | null = null
if (isVirtualText) {
destParent = targetNode
} else {
destParent = store.nodeMap.get(addNodeTargetId.value)?.parent ?? null
}
if (!destParent) return
destParent.mixedContent = destParent.mixedContent || []
if (isVirtualText) {
const textIdx = parseInt(addNodeTargetId.value.split('-txt-')[1], 10)
const insertIdx = addNodeMode.value === 'before' ? textIdx : textIdx + 1
destParent.mixedContent.splice(insertIdx, 0, { type: 'text', text: form.textContent })
} else {
const mixedIdx = destParent.mixedContent.findIndex((item) => item.nodeId === addNodeTargetId.value)
if (mixedIdx !== -1) {
const insertIdx = addNodeMode.value === 'before' ? mixedIdx : mixedIdx + 1
destParent.mixedContent.splice(insertIdx, 0, { type: 'text', text: form.textContent })
}
}
destParent.textContent = destParent.mixedContent
.map((item) => (item.type === 'text' ? item.text || '' : ''))
.join('')
store.rebuildNodeMap()
const newTextIdx = destParent.mixedContent.findIndex((item) => item.type === 'text' && item.text === form.textContent)
if (newTextIdx !== -1) {
store.setSelectedNodeId(`${destParent.id}-txt-${newTextIdx}`)
} else {
store.setSelectedNodeId(destParent.id)
}
}
window.$message?.success(`成功插入文本`)
addNodeVisible.value = false
} else { } else {
const newId = crypto.randomUUID() const newId = crypto.randomUUID()
const newNode: XmlNode = { const newNode: XmlNode = {
...@@ -164,13 +297,43 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -164,13 +297,43 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
if (addNodeMode.value === 'child') { if (addNodeMode.value === 'child') {
newNode.parentId = targetNode.id newNode.parentId = targetNode.id
targetNode.children.push(newNode) targetNode.children.push(newNode)
// 若父级支持混合内容,需要同步更新 mixedContent
if (isMixedContentElement(targetNode.tagName)) {
targetNode.mixedContent = targetNode.mixedContent || []
if (targetNode.mixedContent.length === 0 && targetNode.textContent) {
targetNode.mixedContent.push({ type: 'text', text: targetNode.textContent })
}
targetNode.mixedContent.push({ type: 'element', nodeId: newNode.id })
// 重新拼接父节点的 textContent,以防混合渲染时直接被覆盖
targetNode.textContent = targetNode.mixedContent
.map((item) => (item.type === 'text' ? item.text || '' : ''))
.join('')
}
} else {
let destParent: XmlNode | null = null
if (isVirtualText) {
destParent = targetNode
} else {
destParent = store.nodeMap.get(addNodeTargetId.value)?.parent ?? null
}
if (!destParent) return
newNode.parentId = destParent.id
if (isVirtualText) {
const textIdx = parseInt(addNodeTargetId.value.split('-txt-')[1], 10)
const insertIdx = addNodeMode.value === 'before' ? textIdx : textIdx + 1
destParent.mixedContent.splice(insertIdx, 0, { type: 'element', nodeId: newNode.id })
destParent.children.push(newNode)
} else { } else {
const parent = store.nodeMap.get(addNodeTargetId.value)?.parent const idx = destParent.children.findIndex((c) => c.id === addNodeTargetId.value)
if (!parent) return
newNode.parentId = parent.id
const idx = parent.children.findIndex((c) => c.id === addNodeTargetId.value)
const insertIdx = addNodeMode.value === 'before' ? idx : idx + 1 const insertIdx = addNodeMode.value === 'before' ? idx : idx + 1
parent.children.splice(insertIdx, 0, newNode) destParent.children.splice(insertIdx, 0, newNode)
const mixedIdx = destParent.mixedContent.findIndex((item) => item.nodeId === addNodeTargetId.value)
if (mixedIdx !== -1) {
destParent.mixedContent.splice(addNodeMode.value === 'before' ? mixedIdx : mixedIdx + 1, 0, { type: 'element', nodeId: newNode.id })
}
}
} }
store.rebuildNodeMap() store.rebuildNodeMap()
...@@ -178,6 +341,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) { ...@@ -178,6 +341,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
window.$message?.success(`成功插入节点 <${form.tagName}>`) window.$message?.success(`成功插入节点 <${form.tagName}>`)
addNodeVisible.value = false addNodeVisible.value = false
} }
}
} finally { } finally {
saving.value = false saving.value = false
} }
......
import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode'
import type { BlockedNodeDetail, SafeNodeItem } from '../../../constants'
import { getMinChildCount } from '@/utils/dtdManager'
export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) {
const editorStore = useEditorStore()
const show = ref(false)
const blockedNodes = ref<BlockedNodeDetail[]>([])
const safeNodes = ref<SafeNodeItem[]>([])
// 打开弹窗,进行 DTD 划分和状态初始化
const open = (selectedNodeIds: string[]) => {
const nodeMap = editorStore.nodeMap
const { safeIds, blocked } = partitionBatchDelete(selectedNodeIds, nodeMap)
blockedNodes.value = blocked
safeNodes.value = safeIds.map(id => {
const item = nodeMap.get(id)
const node = item?.node
const tagName = node?.tagName || '未知'
const displayName = node ? getNodeDisplayName(node) : '未知节点'
const parentPath = item?.parent ? getNodeParentPath(item.parent.id, nodeMap) : ''
return {
id,
tagName,
displayName,
parentPath,
checked: true // 默认全部勾选
}
})
show.value = true
}
// 计算全选状态
const isAllChecked = computed(() => {
if (safeNodes.value.length === 0) return false
return safeNodes.value.every(item => item.checked)
})
const isIndeterminate = computed(() => {
if (safeNodes.value.length === 0) return false
const checkedCount = safeNodes.value.filter(item => item.checked).length
return checkedCount > 0 && checkedCount < safeNodes.value.length
})
const checkedCount = computed(() => {
return safeNodes.value.filter(item => item.checked).length
})
const toggleCheckAll = (checked: boolean) => {
safeNodes.value.forEach(item => {
item.checked = checked
})
}
// 确认删除选中节点
const handleConfirm = () => {
const idsToDelete = safeNodes.value.filter(item => item.checked).map(item => item.id)
if (idsToDelete.length > 0) {
editorStore.batchDeleteMultipleNodes(idsToDelete)
window.$message?.success('批量选择性删除成功')
emit('confirm')
}
show.value = false
}
return {
show,
blockedNodes,
safeNodes,
isAllChecked,
isIndeterminate,
checkedCount,
open,
toggleCheckAll,
handleConfirm
}
}
// ── 内部辅助函数 ──
const getNodeDisplayName = (node: XmlNode): string => {
let 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) {
const preview = text.length > 80 ? text.substring(0, 80) + '...' : text
suffix = ` "${preview}"`
}
}
return `<${node.tagName}>${suffix}`
}
const getNodeParentPath = (
nodeId: string,
nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>
): 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 isAncestorSelected = (
nodeId: string,
selectedSet: Set<string>,
nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>
): boolean => {
let currentId: string | null = nodeId
while (currentId) {
if (selectedSet.has(currentId)) {
return true
}
const item = nodeMap.get(currentId)
currentId = item?.parent?.id || null
}
return false
}
const partitionBatchDelete = (
selectedNodeIds: string[],
nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>
) => {
const safeIds: string[] = []
const blocked: BlockedNodeDetail[] = []
const selectedSet = new Set(selectedNodeIds)
// 1. 按 parentId -> tagName 分组
const parentTagGroups = new Map<string, Map<string, string[]>>()
for (const nodeId of selectedNodeIds) {
const item = nodeMap.get(nodeId)
if (!item) continue
if (!item.parent) {
blocked.push({
id: nodeId,
tagName: item.node.tagName,
parentTagName: '',
reason: '根节点不允许删除'
})
continue
}
const parentId = item.parent.id
const tagName = item.node.tagName
if (!parentTagGroups.has(parentId)) {
parentTagGroups.set(parentId, new Map())
}
const tagMap = parentTagGroups.get(parentId)!
if (!tagMap.has(tagName)) {
tagMap.set(tagName, [])
}
tagMap.get(tagName)!.push(nodeId)
}
// 2. 校验每个分组
for (const [parentId, tagMap] of parentTagGroups.entries()) {
const parentItem = nodeMap.get(parentId)
if (!parentItem) continue
const parentNode = parentItem.node
if (isAncestorSelected(parentId, selectedSet, nodeMap)) {
for (const [_, nodeIds] of tagMap.entries()) {
safeIds.push(...nodeIds)
}
continue
}
for (const [tagName, nodeIds] of tagMap.entries()) {
const currentChildren = parentNode.children.filter((c) => c.tagName === tagName)
const totalCount = currentChildren.length
const minRequired = getMinChildCount(parentNode.tagName, tagName)
const maxDeletable = Math.max(0, totalCount - minRequired)
if (nodeIds.length <= maxDeletable) {
safeIds.push(...nodeIds)
} else {
const deletableCount = maxDeletable
const blockedCount = nodeIds.length - deletableCount
for (let i = 0; i < blockedCount; i++) {
blocked.push({
id: nodeIds[i],
tagName,
parentTagName: parentNode.tagName,
reason: `父节点 <${parentNode.tagName}> 下的子节点 <${tagName}> 最少需要保留 ${minRequired} 个(当前共 ${totalCount} 个,最多可删 ${maxDeletable} 个)`
})
}
for (let i = blockedCount; i < nodeIds.length; i++) {
safeIds.push(nodeIds[i])
}
}
}
}
return {
safeIds,
blocked
}
}
<template>
<CommonModal
v-model="show"
title="批量删除确认与 DTD 规则检查"
:width="600"
:show-confirm="safeNodes.length > 0"
confirm-text="确认删除选中节点"
@confirm="handleConfirm"
>
<div class="flex flex-col gap-4 py-1 text-xs max-h-[500px] overflow-y-auto pr-1">
<span class="text-color2 text-sm">
系统检测到您选中的节点中,部分节点由于 DTD 规则要求无法删除。您可以选择勾选并删除其余合法节点:
</span>
<!-- 1. 无法删除的节点列表 (Blocked Nodes) -->
<div v-if="blockedNodes.length > 0" class="flex flex-col gap-2">
<span class="font-bold text-danger flex items-center gap-1">
<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>
</div>
<!-- 2. 可删除的节点列表 (Safe-to-delete Nodes) -->
<div v-if="safeNodes.length > 0" class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="font-bold text-success flex items-center gap-1">
<n-icon><checkmark-circle-outline /></n-icon>
可安全删除的节点 ({{ safeNodes.length }} 个):
</span>
<n-checkbox
:checked="isAllChecked"
:indeterminate="isIndeterminate"
@update:checked="toggleCheckAll"
>
全选
</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
/>
<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. 空提示(例如没有可选删除节点) -->
<div v-if="safeNodes.length === 0" class="p-3 style-error-alert rounded-lg text-danger flex items-center justify-center font-bold text-sm">
当前所选节点由于规则约束均不可删除!请取消并重新选择。
</div>
<!-- 4. 底部小结 -->
<div v-if="safeNodes.length > 0" class="text-color3 text-right mt-1 border-t border-divider pt-2">
已选中 <strong class="text-success text-sm">{{ checkedCount }}</strong> / {{ safeNodes.length }} 个可删除节点进行删除
</div>
</div>
</CommonModal>
</template>
<script setup lang="ts">
import { CloseCircleOutline, CheckmarkCircleOutline } from '@vicons/ionicons5'
import { useBatchDeleteConfirmModal } from './functionals'
import { useEditorStore } from '@/store/editor'
const emit = defineEmits<{
confirm: []
}>()
const editorStore = useEditorStore()
const {
show,
blockedNodes,
safeNodes,
isAllChecked,
isIndeterminate,
checkedCount,
open,
toggleCheckAll,
handleConfirm
} = useBatchDeleteConfirmModal(emit)
const handleNodeClick = (item: any) => {
editorStore.setSelectedNodeId(item.id)
}
defineExpose({ open })
</script>
<style scoped>
.text-danger {
color: var(--error-color, var(--danger6, #f53f3f));
}
.text-success {
color: var(--success-color, var(--success6, #00b42a));
}
.style-error-alert {
background-color: var(--danger1, #ffece8);
border: 1px solid var(--danger2, #fdcdc5);
}
</style>
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
// 列表/文档型叶子节点标签判定 // 列表/文档型叶子节点标签判定
export const DOCUMENT_LIKE_TAGS = ['PARA', 'PARAC', 'TITLE', 'TITLEC', 'WARNING', 'CAUTION', 'NOTE'] export { DOCUMENT_LIKE_TAGS } from '@/configs/xmlTags'
// 查看规则弹窗数据定义 // 查看规则弹窗数据定义
export interface CheckRuleData { export interface CheckRuleData {
...@@ -33,3 +33,20 @@ export interface FlatNode { ...@@ -33,3 +33,20 @@ export interface FlatNode {
isExpanded: boolean isExpanded: boolean
rawNode: XmlNode rawNode: XmlNode
} }
// DTD 批量删除被拦截节点的明细定义
export interface BlockedNodeDetail {
id: string
tagName: string
parentTagName: string
reason: string
}
// DTD 批量删除可安全删除节点的明细定义
export interface SafeNodeItem {
id: string
tagName: string
displayName: string
parentPath: string
checked: boolean
}
...@@ -18,8 +18,10 @@ import { ...@@ -18,8 +18,10 @@ import {
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import { serializeTreeToXml } from '@/utils/xmlParser' import { serializeTreeToXml } from '@/utils/xmlParser'
import { isMixedContentElement, isTextOnlyElement, sortChildrenByDtd } from '@/utils/dtdManager'
import type { CheckRuleData, InsertMode, FlatNode } from '../constants' import type { CheckRuleData, InsertMode, FlatNode } from '../constants'
import { DOCUMENT_LIKE_TAGS } from '../constants' import { DOCUMENT_LIKE_TAGS } from '../constants'
import { STRUCTURAL_TAGS, WARNING_LIKE_TAGS, NOTE_AND_REF_TAGS, COLORED_TAGS } from '@/configs/xmlTags'
// ══════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════
// 全局共享状态:查看规则弹窗 // 全局共享状态:查看规则弹窗
...@@ -60,7 +62,8 @@ const icon = (component: any) => () => h(NIcon, null, { default: () => h(compone ...@@ -60,7 +62,8 @@ const icon = (component: any) => () => h(NIcon, null, { default: () => h(compone
export function useNodeTree( export function useNodeTree(
props: { expandedKeys: string[] }, props: { expandedKeys: string[] },
emit: (event: 'update:expandedKeys', keys: string[]) => void, emit: (event: 'update:expandedKeys', keys: string[]) => void,
onOpenInsertFragment?: (mode: 'above' | 'below' | 'inside', nodeId: string) => void onOpenInsertFragment?: (mode: 'above' | 'below' | 'inside', nodeId: string) => void,
onOpenBatchDeleteConfirm?: (selectedNodeIds: string[]) => void
) { ) {
const editorStore = useEditorStore() const editorStore = useEditorStore()
...@@ -153,48 +156,18 @@ export function useNodeTree( ...@@ -153,48 +156,18 @@ export function useNodeTree(
// 递归获取节点的文本预览,支持混合内容与行内子节点拼接 // 递归获取节点的文本预览,支持混合内容与行内子节点拼接
const getNodeTextPreview = (node: XmlNode): string => { const getNodeTextPreview = (node: XmlNode): string => {
const STRUCTURAL_TAGS = new Set([ const structuralTagsSet = new Set(STRUCTURAL_TAGS)
'JOBCARD',
'CEP', if (structuralTagsSet.has(node.tagName)) {
'TFMATR',
'PRETOPIC',
'TOPIC',
'SUBTASK',
'LIST1',
'L1ITEM',
'LIST2',
'L2ITEM',
'LIST3',
'L3ITEM',
'LIST4',
'L4ITEM',
'LIST5',
'L5ITEM',
'UNLIST',
'UNLITEM',
'NUMLIST',
'NUMLITEM',
'TABLE',
'TGROUP',
'TBODY',
'THEAD',
'ROW',
'ENTRY',
'WARNING',
'CAUTION',
'NOTE',
'CBSUBLST'
])
if (STRUCTURAL_TAGS.has(node.tagName)) {
return '' return ''
} }
const getFullText = (n: XmlNode): string => { const getFullText = (n: XmlNode): string => {
if (n.textContent && n.textContent.trim()) {
return n.textContent.trim()
}
if (n.mixedContent && n.mixedContent.length > 0) { if (n.mixedContent && n.mixedContent.length > 0) {
const textItems = n.mixedContent.filter((item) => item.type === 'text' && (item.text || '').trim())
if (textItems.length > 1) {
return '' // 多个文本节点时,不展示内容区
}
return n.mixedContent return n.mixedContent
.map((item) => { .map((item) => {
if (item.type === 'text') { if (item.type === 'text') {
...@@ -207,7 +180,10 @@ export function useNodeTree( ...@@ -207,7 +180,10 @@ export function useNodeTree(
}) })
.join('') .join('')
} }
return '' if (n.children && n.children.length > 0) {
return n.children.map(getFullText).join('')
}
return (n.textContent || '').trim()
} }
const text = getFullText(node).trim() const text = getFullText(node).trim()
...@@ -221,7 +197,17 @@ export function useNodeTree( ...@@ -221,7 +197,17 @@ export function useNodeTree(
} }
const list: FlatNode[] = [] const list: FlatNode[] = []
const hasChildren = node.children && node.children.length > 0 // 动态判定 hasChildren
let hasChildren = false
if (node.children && node.children.length > 0) {
hasChildren = true
} else if (node.mixedContent && node.mixedContent.length > 0) {
const textItems = node.mixedContent.filter((item) => item.type === 'text' && (item.text || '').trim())
const elementItems = node.mixedContent.filter((item) => item.type === 'element')
if (elementItems.length > 0 || textItems.length > 1) {
hasChildren = true
}
}
const isExpanded = expandedKeys.value.has(node.id) const isExpanded = expandedKeys.value.has(node.id)
let subtitle = '' let subtitle = ''
...@@ -244,6 +230,10 @@ export function useNodeTree( ...@@ -244,6 +230,10 @@ export function useNodeTree(
} else if (node.tagName === 'CBLST') { } else if (node.tagName === 'CBLST') {
const action = node.attributes.ACTION === 'verif-close' ? '确认关闭' : node.attributes.ACTION === 'open' ? '断开' : '操作' const action = node.attributes.ACTION === 'verif-close' ? '确认关闭' : node.attributes.ACTION === 'open' ? '断开' : '操作'
subtitle = `行动: ${action}` subtitle = `行动: ${action}`
} else if (node.tagName === 'CEP') {
const { CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR } = node.attributes || {}
const parts = [CHAPNBR, SECTNBR, SUBJNBR, FUNC, SEQ, CONFLTR].filter(Boolean)
subtitle = parts.join('-')
} else if (node.tagName === 'UNIT-RECORD') { } else if (node.tagName === 'UNIT-RECORD') {
const text = node.textContent ? node.textContent.trim() : '' const text = node.textContent ? node.textContent.trim() : ''
subtitle = `${text} (单位: ${node.attributes.UNIT || 'mm'})` subtitle = `${text} (单位: ${node.attributes.UNIT || 'mm'})`
...@@ -271,10 +261,49 @@ export function useNodeTree( ...@@ -271,10 +261,49 @@ export function useNodeTree(
// 如果处于搜索状态,强制展开展示搜索结果;否则根据 isExpanded 展开 // 如果处于搜索状态,强制展开展示搜索结果;否则根据 isExpanded 展开
if (hasChildren && (isExpanded || search)) { if (hasChildren && (isExpanded || search)) {
if (node.mixedContent && node.mixedContent.length > 0) {
const textItems = node.mixedContent.filter((item) => item.type === 'text' && (item.text || '').trim())
node.mixedContent.forEach((item, index) => {
if (item.type === 'element' && item.nodeId) {
const child = node.children.find((c) => c.id === item.nodeId)
if (child) {
list.push(...buildFlatList(child, depth + 1, search))
}
} else if (item.type === 'text') {
// 仅当文本项目数量大于 1 时,才生成虚拟的 #text 节点展示
if (textItems.length > 1) {
const textVal = item.text || ''
const trimmed = textVal.trim()
if (trimmed) {
const virtualId = `${node.id}-txt-${index}`
const virtualNode: XmlNode = {
id: virtualId,
tagName: '#text',
attributes: {},
children: [],
textContent: trimmed,
mixedContent: [],
parentId: node.id
}
list.push({
id: virtualId,
tagName: '#text',
subtitle: trimmed,
depth: depth + 1,
hasChildren: false,
isExpanded: false,
rawNode: virtualNode
})
}
}
}
})
} else {
for (const child of node.children) { for (const child of node.children) {
list.push(...buildFlatList(child, depth + 1, search)) list.push(...buildFlatList(child, depth + 1, search))
} }
} }
}
return list return list
} }
...@@ -332,7 +361,7 @@ export function useNodeTree( ...@@ -332,7 +361,7 @@ export function useNodeTree(
} }
if (firstChildIndex !== -1 && lastChildIndex !== -1) { if (firstChildIndex !== -1 && lastChildIndex !== -1) {
const lineLeft = childLevel * 20 + 8 const lineLeft = childLevel * 20 + 8 + (isBatchMode.value ? 24 : 0)
const lineTop = (index + 0.5) * ITEM_HEIGHT const lineTop = (index + 0.5) * ITEM_HEIGHT
const lineHeight = (lastChildIndex - index) * ITEM_HEIGHT const lineHeight = (lastChildIndex - index) * ITEM_HEIGHT
...@@ -401,16 +430,26 @@ export function useNodeTree( ...@@ -401,16 +430,26 @@ export function useNodeTree(
// 树展开与定位同步辅助函数 // 树展开与定位同步辅助函数
const syncTreeSelection = (newId: string | null) => { const syncTreeSelection = (newId: string | null) => {
if (!newId) return if (!newId) return
let isVirtual = false
let searchId = newId
if (newId.includes('-txt-')) {
isVirtual = true
searchId = newId.split('-txt-')[0]
}
// 自动展开其所有祖先节点 // 自动展开其所有祖先节点
const path: XmlNode[] = [] const path: XmlNode[] = []
let curr = editorStore.nodeMap.get(newId) let curr = editorStore.nodeMap.get(searchId)
while (curr) { while (curr) {
path.unshift(curr.node) path.unshift(curr.node)
curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined
} }
if (path.length > 0) { if (path.length > 0) {
let changed = false let changed = false
for (let i = 0; i < path.length - 1; i++) { // 若为虚拟文本子节点,则父节点本身(即 path 最后一项)也需要被自动加入展开集合中
const limit = isVirtual ? path.length : path.length - 1
for (let i = 0; i < limit; i++) {
const ancestorId = path[i].id const ancestorId = path[i].id
if (!expandedKeys.value.has(ancestorId)) { if (!expandedKeys.value.has(ancestorId)) {
expandedKeys.value.add(ancestorId) expandedKeys.value.add(ancestorId)
...@@ -466,9 +505,34 @@ export function useNodeTree( ...@@ -466,9 +505,34 @@ export function useNodeTree(
const getDropdownOptions = (nodeId: string): DropdownOption[] => { const getDropdownOptions = (nodeId: string): DropdownOption[] => {
const tree = editorStore.xmlTree const tree = editorStore.xmlTree
if (!tree) return [] if (!tree) return []
const item = editorStore.nodeMap.get(nodeId)
let realNodeId = nodeId
let isVirtual = false
if (nodeId && nodeId.includes('-txt-')) {
realNodeId = nodeId.split('-txt-')[0]
isVirtual = true
}
const item = editorStore.nodeMap.get(realNodeId)
if (!item) return [] if (!item) return []
const { node, parent } = item
let node = item.node
let parent = item.parent
if (isVirtual) {
const textIdx = parseInt(nodeId.split('-txt-')[1], 10)
const textVal = node.mixedContent[textIdx]?.text || ''
node = {
id: nodeId,
tagName: '#text',
attributes: {},
children: [],
textContent: textVal,
mixedContent: [],
parentId: node.id
}
parent = item.node
}
const options: DropdownOption[] = [] const options: DropdownOption[] = []
...@@ -479,16 +543,18 @@ export function useNodeTree( ...@@ -479,16 +543,18 @@ export function useNodeTree(
icon: icon(CodeWorkingOutline) icon: icon(CodeWorkingOutline)
}) })
// 查看规则 // 查看规则(虚拟文本节点不需要)
if (!isVirtual) {
options.push({ options.push({
label: '查看规则', label: '查看规则',
key: 'checkRule', key: 'checkRule',
icon: icon(EyeOutline) icon: icon(EyeOutline)
}) })
}
// 编辑节点(属性) // 编辑节点(属性/内容
options.push({ options.push({
label: '编辑节点', label: isVirtual ? '编辑文本' : '编辑节点',
key: 'editNode', key: 'editNode',
icon: icon(CreateOutline) icon: icon(CreateOutline)
}) })
...@@ -502,7 +568,12 @@ export function useNodeTree( ...@@ -502,7 +568,12 @@ export function useNodeTree(
// 粘贴节点(有缓存时显示) // 粘贴节点(有缓存时显示)
if (copyNodeCache.value) { if (copyNodeCache.value) {
const pasteChildren: DropdownOption[] = [ const pasteChildren: DropdownOption[] = isVirtual
? [
{ label: '粘贴到上方', key: 'pasteAbove', icon: icon(ClipboardOutline) },
{ label: '粘贴到下方', key: 'pasteBelow', icon: icon(ClipboardOutline) }
]
: [
{ label: '粘贴到上方', key: 'pasteAbove', icon: icon(ClipboardOutline) }, { label: '粘贴到上方', key: 'pasteAbove', icon: icon(ClipboardOutline) },
{ label: '粘贴到下方', key: 'pasteBelow', icon: icon(ClipboardOutline) }, { label: '粘贴到下方', key: 'pasteBelow', icon: icon(ClipboardOutline) },
{ label: '粘贴到内部', key: 'pasteInside', icon: icon(ClipboardOutline) } { label: '粘贴到内部', key: 'pasteInside', icon: icon(ClipboardOutline) }
...@@ -516,7 +587,12 @@ export function useNodeTree( ...@@ -516,7 +587,12 @@ export function useNodeTree(
} }
// 粘贴XML片段 // 粘贴XML片段
const pasteFragmentChildren: DropdownOption[] = [ const pasteFragmentChildren: DropdownOption[] = isVirtual
? [
{ label: '粘贴到上方', key: 'pasteFragmentAbove', icon: icon(ClipboardOutline), disabled: !parent },
{ label: '粘贴到下方', key: 'pasteFragmentBelow', icon: icon(ClipboardOutline), disabled: !parent }
]
: [
{ label: '粘贴到上方', key: 'pasteFragmentAbove', icon: icon(ClipboardOutline), disabled: !parent }, { label: '粘贴到上方', key: 'pasteFragmentAbove', icon: icon(ClipboardOutline), disabled: !parent },
{ label: '粘贴到下方', key: 'pasteFragmentBelow', icon: icon(ClipboardOutline), disabled: !parent }, { label: '粘贴到下方', key: 'pasteFragmentBelow', icon: icon(ClipboardOutline), disabled: !parent },
{ label: '粘贴到内部', key: 'pasteFragmentInside', icon: icon(ClipboardOutline) } { label: '粘贴到内部', key: 'pasteFragmentInside', icon: icon(ClipboardOutline) }
...@@ -528,12 +604,16 @@ export function useNodeTree( ...@@ -528,12 +604,16 @@ export function useNodeTree(
children: pasteFragmentChildren children: pasteFragmentChildren
}) })
// 删除节点(受 DTD 约束) // 删除节点(受 DTD 约束,文本节点永远允许删除
if (parent) { if (parent) {
const childCount = parent.children.filter((c) => c.tagName === node.tagName).length const deletable = isVirtual
const deletable = canDeleteChild(parent.tagName, node.tagName, childCount) ? true
: (() => {
const existingTags = parent.children.map((c) => c.tagName)
return canDeleteChild(parent.tagName, node.tagName, existingTags)
})()
options.push({ options.push({
label: deletable ? '删除节点' : '删除节点', label: '删除节点',
key: 'deleteNode', key: 'deleteNode',
icon: icon(TrashOutline), icon: icon(TrashOutline),
disabled: !deletable disabled: !deletable
...@@ -543,7 +623,8 @@ export function useNodeTree( ...@@ -543,7 +623,8 @@ export function useNodeTree(
// 编辑结构子菜单 // 编辑结构子菜单
const structureChildren: DropdownOption[] = [] const structureChildren: DropdownOption[] = []
// 添加子节点 // 添加子节点(文本节点不能拥有子节点)
if (!isVirtual) {
const allowedChild = getAllowedChildren(node.tagName) const allowedChild = getAllowedChildren(node.tagName)
if (allowedChild.length > 0) { if (allowedChild.length > 0) {
structureChildren.push({ structureChildren.push({
...@@ -552,8 +633,9 @@ export function useNodeTree( ...@@ -552,8 +633,9 @@ export function useNodeTree(
icon: icon(AddCircleOutline) icon: icon(AddCircleOutline)
}) })
} }
}
// 插入到上方(兄弟节点前) // 插入到上方/下方
if (parent) { if (parent) {
const insertable = getInsertableChildren( const insertable = getInsertableChildren(
parent.tagName, parent.tagName,
...@@ -596,16 +678,46 @@ export function useNodeTree( ...@@ -596,16 +678,46 @@ export function useNodeTree(
const handleDropdownAction = async (key: string, nodeId: string): Promise<void> => { const handleDropdownAction = async (key: string, nodeId: string): Promise<void> => {
const tree = editorStore.xmlTree const tree = editorStore.xmlTree
if (!tree) return if (!tree) return
const item = editorStore.nodeMap.get(nodeId)
let realNodeId = nodeId
let isVirtual = false
if (nodeId && nodeId.includes('-txt-')) {
realNodeId = nodeId.split('-txt-')[0]
isVirtual = true
}
const item = editorStore.nodeMap.get(realNodeId)
if (!item) return if (!item) return
const { node, parent } = item
let node = item.node
let parent = item.parent
if (isVirtual) {
const textIdx = parseInt(nodeId.split('-txt-')[1], 10)
const textVal = node.mixedContent[textIdx]?.text || ''
node = {
id: nodeId,
tagName: '#text',
attributes: {},
children: [],
textContent: textVal,
mixedContent: [],
parentId: node.id
}
parent = item.node
}
switch (key) { switch (key) {
// ── 查看XML ── // ── 查看XML ──
case 'viewXml': { case 'viewXml': {
if (isVirtual) {
viewXmlTitle.value = `查看文本节点的 XML`
viewXmlContent.value = node.textContent
} else {
const xmlFragment = serializeTreeToXml(node) const xmlFragment = serializeTreeToXml(node)
viewXmlTitle.value = `查看节点 [${node.tagName}] 的 XML 片段` viewXmlTitle.value = `查看节点 [${node.tagName}] 的 XML 片段`
viewXmlContent.value = xmlFragment viewXmlContent.value = xmlFragment
}
viewXmlVisible.value = true viewXmlVisible.value = true
break break
} }
...@@ -635,7 +747,7 @@ export function useNodeTree( ...@@ -635,7 +747,7 @@ export function useNodeTree(
// ── 复制节点 ── // ── 复制节点 ──
case 'copyNode': { case 'copyNode': {
copyNodeCache.value = JSON.parse(JSON.stringify(node)) // 深拷贝 copyNodeCache.value = JSON.parse(JSON.stringify(node)) // 深拷贝
window.$message?.success('节点已复制') window.$message?.success(isVirtual ? '文本已复制' : '节点已复制')
break break
} }
...@@ -643,11 +755,45 @@ export function useNodeTree( ...@@ -643,11 +755,45 @@ export function useNodeTree(
case 'pasteAbove': { case 'pasteAbove': {
if (!copyNodeCache.value || !parent) break if (!copyNodeCache.value || !parent) break
const cloned = deepCloneWithNewIds(copyNodeCache.value, parent.id) const cloned = deepCloneWithNewIds(copyNodeCache.value, parent.id)
const idx = parent.children.findIndex((c) => c.id === nodeId)
editorStore.saveSnapshot() editorStore.saveSnapshot()
if (cloned.tagName === '#text') {
if (isVirtual) {
const textIdx = parseInt(nodeId.split('-txt-')[1], 10)
parent.mixedContent.splice(textIdx, 0, { type: 'text', text: cloned.textContent })
} else {
const mixedIdx = parent.mixedContent.findIndex((item) => item.nodeId === nodeId)
if (mixedIdx !== -1) {
parent.mixedContent.splice(mixedIdx, 0, { type: 'text', text: cloned.textContent })
}
}
parent.textContent = parent.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
} else {
if (isVirtual) {
const textIdx = parseInt(nodeId.split('-txt-')[1], 10)
parent.mixedContent.splice(textIdx, 0, { type: 'element', nodeId: cloned.id })
parent.children.push(cloned)
} else {
const idx = parent.children.findIndex((c) => c.id === nodeId)
parent.children.splice(idx, 0, cloned) parent.children.splice(idx, 0, cloned)
const mixedIdx = parent.mixedContent.findIndex((item) => item.nodeId === nodeId)
if (mixedIdx !== -1) {
parent.mixedContent.splice(mixedIdx, 0, { type: 'element', nodeId: cloned.id })
}
}
}
editorStore.rebuildNodeMap() editorStore.rebuildNodeMap()
if (cloned.tagName === '#text') {
const newTextIdx = parent.mixedContent.findIndex((item) => item.type === 'text' && item.text === cloned.textContent)
if (newTextIdx !== -1) {
editorStore.setSelectedNodeId(`${parent.id}-txt-${newTextIdx}`)
} else {
editorStore.setSelectedNodeId(parent.id)
}
} else {
editorStore.setSelectedNodeId(cloned.id) editorStore.setSelectedNodeId(cloned.id)
}
window.$message?.success('粘贴成功') window.$message?.success('粘贴成功')
break break
} }
...@@ -656,11 +802,45 @@ export function useNodeTree( ...@@ -656,11 +802,45 @@ export function useNodeTree(
case 'pasteBelow': { case 'pasteBelow': {
if (!copyNodeCache.value || !parent) break if (!copyNodeCache.value || !parent) break
const cloned = deepCloneWithNewIds(copyNodeCache.value, parent.id) const cloned = deepCloneWithNewIds(copyNodeCache.value, parent.id)
const idx = parent.children.findIndex((c) => c.id === nodeId)
editorStore.saveSnapshot() editorStore.saveSnapshot()
if (cloned.tagName === '#text') {
if (isVirtual) {
const textIdx = parseInt(nodeId.split('-txt-')[1], 10)
parent.mixedContent.splice(textIdx + 1, 0, { type: 'text', text: cloned.textContent })
} else {
const mixedIdx = parent.mixedContent.findIndex((item) => item.nodeId === nodeId)
if (mixedIdx !== -1) {
parent.mixedContent.splice(mixedIdx + 1, 0, { type: 'text', text: cloned.textContent })
}
}
parent.textContent = parent.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
} else {
if (isVirtual) {
const textIdx = parseInt(nodeId.split('-txt-')[1], 10)
parent.mixedContent.splice(textIdx + 1, 0, { type: 'element', nodeId: cloned.id })
parent.children.push(cloned)
} else {
const idx = parent.children.findIndex((c) => c.id === nodeId)
parent.children.splice(idx + 1, 0, cloned) parent.children.splice(idx + 1, 0, cloned)
const mixedIdx = parent.mixedContent.findIndex((item) => item.nodeId === nodeId)
if (mixedIdx !== -1) {
parent.mixedContent.splice(mixedIdx + 1, 0, { type: 'element', nodeId: cloned.id })
}
}
}
editorStore.rebuildNodeMap() editorStore.rebuildNodeMap()
if (cloned.tagName === '#text') {
const newTextIdx = parent.mixedContent.findIndex((item) => item.type === 'text' && item.text === cloned.textContent)
if (newTextIdx !== -1) {
editorStore.setSelectedNodeId(`${parent.id}-txt-${newTextIdx}`)
} else {
editorStore.setSelectedNodeId(parent.id)
}
} else {
editorStore.setSelectedNodeId(cloned.id) editorStore.setSelectedNodeId(cloned.id)
}
window.$message?.success('粘贴成功') window.$message?.success('粘贴成功')
break break
} }
...@@ -708,11 +888,30 @@ export function useNodeTree( ...@@ -708,11 +888,30 @@ export function useNodeTree(
break break
} }
try { try {
const desc = isVirtual ? '此文本节点' : `节点 <${node.tagName}>`
await window.$dialog.warning({ await window.$dialog.warning({
title: '确认删除', title: '确认删除',
content: `确定要删除节点 <${node.tagName}> 吗?该操作将连带删除其所有子节点,且不可撤销!` content: `确定要删除${desc}吗?该操作将连带删除其所有子节点,且不可撤销!`
}) })
editorStore.saveSnapshot()
if (isVirtual && parent) {
const textIdx = parseInt(nodeId.split('-txt-')[1], 10)
parent.mixedContent.splice(textIdx, 1)
// 退化校验:如果子元素为空,直接收归为纯文本并清空混合数组
if (parent.children.length === 0) {
const mergedText = parent.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
parent.textContent = mergedText
parent.mixedContent = []
} else {
parent.textContent = parent.mixedContent.map((item) => (item.type === 'text' ? item.text || '' : '')).join('')
}
editorStore.setSelectedNodeId(parent.id)
editorStore.rebuildNodeMap()
} else {
editorStore.deleteSelectedNode() editorStore.deleteSelectedNode()
}
window.$message?.success('删除成功') window.$message?.success('删除成功')
} catch { } catch {
// 取消 // 取消
...@@ -722,10 +921,16 @@ export function useNodeTree( ...@@ -722,10 +921,16 @@ export function useNodeTree(
// ── 添加子节点 ── // ── 添加子节点 ──
case 'addChildNode': { case 'addChildNode': {
const allowed = getInsertableChildren( let allowed = getInsertableChildren(
node.tagName, node.tagName,
node.children.map((c) => c.tagName) node.children.map((c) => c.tagName)
) )
if (isMixedContentElement(node.tagName) || isTextOnlyElement(node.tagName)) {
if (!allowed.includes('#text')) {
allowed.push('#text')
}
}
allowed = sortChildrenByDtd(node.tagName, allowed)
addNodeTargetId.value = nodeId addNodeTargetId.value = nodeId
addNodeMode.value = 'child' addNodeMode.value = 'child'
addNodeAllowedTags.value = allowed addNodeAllowedTags.value = allowed
...@@ -736,10 +941,16 @@ export function useNodeTree( ...@@ -736,10 +941,16 @@ export function useNodeTree(
// ── 插入到上方(兄弟,前)── // ── 插入到上方(兄弟,前)──
case 'insertBefore': { case 'insertBefore': {
if (!parent) break if (!parent) break
const insertable = getInsertableChildren( let insertable = getInsertableChildren(
parent.tagName, parent.tagName,
parent.children.map((c) => c.tagName) parent.children.map((c) => c.tagName)
) )
if (isMixedContentElement(parent.tagName) || isTextOnlyElement(parent.tagName)) {
if (!insertable.includes('#text')) {
insertable.push('#text')
}
}
insertable = sortChildrenByDtd(parent.tagName, insertable)
addNodeTargetId.value = nodeId addNodeTargetId.value = nodeId
addNodeMode.value = 'before' addNodeMode.value = 'before'
addNodeAllowedTags.value = insertable addNodeAllowedTags.value = insertable
...@@ -750,10 +961,16 @@ export function useNodeTree( ...@@ -750,10 +961,16 @@ export function useNodeTree(
// ── 插入到下方(兄弟,后)── // ── 插入到下方(兄弟,后)──
case 'insertAfter': { case 'insertAfter': {
if (!parent) break if (!parent) break
const insertable = getInsertableChildren( let insertable = getInsertableChildren(
parent.tagName, parent.tagName,
parent.children.map((c) => c.tagName) parent.children.map((c) => c.tagName)
) )
if (isMixedContentElement(parent.tagName) || isTextOnlyElement(parent.tagName)) {
if (!insertable.includes('#text')) {
insertable.push('#text')
}
}
insertable = sortChildrenByDtd(parent.tagName, insertable)
addNodeTargetId.value = nodeId addNodeTargetId.value = nodeId
addNodeMode.value = 'after' addNodeMode.value = 'after'
addNodeAllowedTags.value = insertable addNodeAllowedTags.value = insertable
...@@ -816,9 +1033,7 @@ export function useNodeTree( ...@@ -816,9 +1033,7 @@ export function useNodeTree(
const hasCustomColor = (item: FlatNode): boolean => { const hasCustomColor = (item: FlatNode): boolean => {
const tag = item.tagName.toUpperCase() const tag = item.tagName.toUpperCase()
return ['WARNING', 'EFFECT', 'CONEFFECT', 'SBEFF', 'SBEFFC', 'CAUTION', 'NOTE', 'REFBLOCK', 'REFINT', 'REFEXT', 'GRPHCREF', 'EIN'].includes( return COLORED_TAGS.map((t) => t.toUpperCase()).includes(tag)
tag
)
} }
const getNodeStyle = (item: FlatNode) => { const getNodeStyle = (item: FlatNode) => {
...@@ -826,18 +1041,50 @@ export function useNodeTree( ...@@ -826,18 +1041,50 @@ export function useNodeTree(
return {} return {}
} }
const tag = item.tagName.toUpperCase() const tag = item.tagName.toUpperCase()
if (['WARNING', 'EFFECT', 'CONEFFECT', 'SBEFF', 'SBEFFC'].includes(tag)) { if (WARNING_LIKE_TAGS.map((t) => t.toUpperCase()).includes(tag)) {
return { color: 'red' } return { color: 'red' }
} }
if (['CAUTION'].includes(tag)) { if (tag === 'CAUTION') {
return { color: '#ff6a00' } return { color: '#ff6a00' }
} }
if (['NOTE', 'REFBLOCK', 'REFINT', 'REFEXT', 'GRPHCREF', 'EIN'].includes(tag)) { if (NOTE_AND_REF_TAGS.map((t) => t.toUpperCase()).includes(tag)) {
return { color: 'blue' } return { color: 'blue' }
} }
return {} return {}
} }
// === 批量管理模式 ===
const isBatchMode = ref(false)
const selectedKeys = ref<Set<string>>(new Set())
const toggleBatchMode = () => {
isBatchMode.value = !isBatchMode.value
if (!isBatchMode.value) {
selectedKeys.value.clear()
}
}
const handleToggleCheck = (id: string, checked: boolean) => {
if (checked) {
selectedKeys.value.add(id)
} else {
selectedKeys.value.delete(id)
}
selectedKeys.value = new Set(selectedKeys.value)
}
const clearBatchSelection = () => {
selectedKeys.value.clear()
selectedKeys.value = new Set()
isBatchMode.value = false
}
const handleBatchDelete = async () => {
if (selectedKeys.value.size === 0) return
const ids = Array.from(selectedKeys.value)
onOpenBatchDeleteConfirm?.(ids)
}
return { return {
pattern, pattern,
isTreeSelecting, isTreeSelecting,
...@@ -866,6 +1113,13 @@ export function useNodeTree( ...@@ -866,6 +1113,13 @@ export function useNodeTree(
handleDropdownSelect, handleDropdownSelect,
handleScroll, handleScroll,
hasCustomColor, hasCustomColor,
getNodeStyle getNodeStyle,
// 批量管理模式
isBatchMode,
selectedKeys,
toggleBatchMode,
handleToggleCheck,
clearBatchSelection,
handleBatchDelete
} }
} }
<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">
<!-- 搜索过滤 --> <!-- 搜索过滤与批量管理 -->
<div class="p-3 border-b border-divider"> <div class="p-3 border-b border-divider flex items-center space-x-2">
<n-input v-model:value="pattern" placeholder="搜索节点名称..." size="small"> <n-input v-model:value="pattern" placeholder="搜索节点名称..." 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">
<template #icon>
<n-icon><list-outline /></n-icon>
</template>
批量管理
</CommonButton>
</div>
<!-- 批量操作管理栏 -->
<div v-if="isBatchMode" class="px-3 py-2 bg-fill-2 border-b border-divider flex items-center justify-between text-xs">
<span class="text-color2">
已选择
<strong class="text-primary">{{ selectedKeys.size }}</strong>
个节点
</span>
<div class="flex items-center space-x-1.5">
<CommonButton size="tiny" type="error" :disabled="selectedKeys.size === 0" @click="handleBatchDelete">
<template #icon>
<n-icon><trash-outline /></n-icon>
</template>
删除选中
</CommonButton>
<CommonButton size="tiny" @click="clearBatchSelection">
<template #icon>
<n-icon><close-outline /></n-icon>
</template>
取消
</CommonButton>
</div>
</div> </div>
<!-- 虚拟滚动树组件容器 --> <!-- 虚拟滚动树组件容器 -->
...@@ -35,20 +64,32 @@ ...@@ -35,20 +64,32 @@
:class="[ :class="[
editorStore.selectedNodeId === item.id editorStore.selectedNodeId === item.id
? 'bg-primary text-white tree-node-selected' ? 'bg-primary text-white tree-node-selected'
: (showDropdown || isAnyModalVisible) && contextNodeId === item.id
? 'tree-node-context-active'
: hasCustomColor(item) : hasCustomColor(item)
? 'hover:bg-fill-3' ? 'hover:bg-fill-3'
: 'hover:bg-fill-3 text-color2' : 'hover:bg-fill-3 text-color2'
]" ]"
:style="[ :style="[
{ {
paddingLeft: item.depth * 20 + 8 + 'px', paddingLeft: item.depth * 20 + 8 + (isBatchMode ? 24 : 0) + 'px',
'--tree-level': item.depth '--tree-level': item.depth,
'--batch-shift': isBatchMode ? '24px' : '0px'
}, },
getNodeStyle(item) getNodeStyle(item)
]" ]"
@click="handleSelect(item.id)" @click="handleSelect(item.id)"
@contextmenu.prevent="(e) => handleContextMenu(e, item)" @contextmenu.prevent="(e) => handleContextMenu(e, item)"
> >
<!-- Checkbox (批量管理模式) -->
<n-checkbox
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 (展开/折叠 减号/加号) --> <!-- Switcher (展开/折叠 减号/加号) -->
<div <div
v-if="item.hasChildren" v-if="item.hasChildren"
...@@ -88,7 +129,13 @@ ...@@ -88,7 +129,13 @@
v-if="item.subtitle" v-if="item.subtitle"
class="text-xs truncate" class="text-xs truncate"
:class="[ :class="[
editorStore.selectedNodeId === item.id ? 'text-white/70' : hasCustomColor(item) ? 'opacity-80' : 'text-color3' editorStore.selectedNodeId === 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)" v-html="highlightText(item.subtitle, pattern)"
></span> ></span>
...@@ -109,8 +156,8 @@ ...@@ -109,8 +156,8 @@
:options="dropdownOptions" :options="dropdownOptions"
:x="dropdownX" :x="dropdownX"
:y="dropdownY" :y="dropdownY"
@clickoutside="showDropdown = false"
@select="handleDropdownSelect" @select="handleDropdownSelect"
@clickoutside="showDropdown = false"
/> />
<!-- 查看规则弹窗 --> <!-- 查看规则弹窗 -->
...@@ -124,17 +171,21 @@ ...@@ -124,17 +171,21 @@
<!-- 插入 XML 片段弹窗 --> <!-- 插入 XML 片段弹窗 -->
<InsertFragmentModal ref="insertFragmentModalRef" /> <InsertFragmentModal ref="insertFragmentModalRef" />
<!-- 批量删除确认弹窗 -->
<BatchDeleteConfirmModal ref="batchDeleteConfirmModalRef" @confirm="clearBatchSelection" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SearchOutline } from '@vicons/ionicons5' import { SearchOutline, ListOutline, TrashOutline, CloseOutline } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import { useNodeTree } from './functionals' import { useNodeTree, checkRuleVisible, viewXmlVisible, addNodeVisible } from './functionals'
import CheckRuleModal from './components/CheckRuleModal/index.vue' import CheckRuleModal from './components/CheckRuleModal/index.vue'
import AddNodeModal from './components/AddNodeModal/index.vue' import AddNodeModal from './components/AddNodeModal/index.vue'
import ViewXmlModal from './components/ViewXmlModal/index.vue' import ViewXmlModal from './components/ViewXmlModal/index.vue'
import InsertFragmentModal from '@/views/editor/components/EditorToolbar/components/InsertFragmentModal/index.vue' import InsertFragmentModal from '../EditorToolbar/components/InsertFragmentModal/index.vue'
import BatchDeleteConfirmModal from './components/BatchDeleteConfirmModal/index.vue'
const props = defineProps<{ const props = defineProps<{
expandedKeys: string[] expandedKeys: string[]
...@@ -145,6 +196,10 @@ const emit = defineEmits(['update:expandedKeys']) ...@@ -145,6 +196,10 @@ const emit = defineEmits(['update:expandedKeys'])
const editorStore = useEditorStore() const editorStore = useEditorStore()
const insertFragmentModalRef = ref<any>(null) const insertFragmentModalRef = ref<any>(null)
const isAnyModalVisible = computed(() => {
return checkRuleVisible.value || viewXmlVisible.value || addNodeVisible.value || !!insertFragmentModalRef.value?.visible
})
const { const {
pattern, pattern,
viewportRef, viewportRef,
...@@ -157,6 +212,7 @@ const { ...@@ -157,6 +212,7 @@ const {
dropdownOptions, dropdownOptions,
dropdownX, dropdownX,
dropdownY, dropdownY,
contextNodeId,
handleScroll, handleScroll,
handleSelect, handleSelect,
handleContextMenu, handleContextMenu,
...@@ -165,10 +221,26 @@ const { ...@@ -165,10 +221,26 @@ const {
highlightText, highlightText,
handleDropdownSelect, handleDropdownSelect,
hasCustomColor, hasCustomColor,
getNodeStyle getNodeStyle,
} = useNodeTree(props, emit, (mode, nodeId) => { // 批量管理模式
isBatchMode,
selectedKeys,
toggleBatchMode,
handleToggleCheck,
clearBatchSelection,
handleBatchDelete
} = useNodeTree(
props,
emit,
(mode, nodeId) => {
insertFragmentModalRef.value?.open(mode, nodeId) insertFragmentModalRef.value?.open(mode, nodeId)
}) },
(ids) => {
batchDeleteConfirmModalRef.value?.open(ids)
}
)
const batchDeleteConfirmModalRef = ref<any>(null)
</script> </script>
<style scoped> <style scoped>
...@@ -214,7 +286,7 @@ const { ...@@ -214,7 +286,7 @@ const {
width: 12px; width: 12px;
border-top: 1px dashed var(--divider-color, rgba(0, 0, 0, 0.15)); border-top: 1px dashed var(--divider-color, rgba(0, 0, 0, 0.15));
pointer-events: none; pointer-events: none;
left: calc(var(--tree-level, 0) * 20px + 8px); left: calc(var(--tree-level, 0) * 20px + 8px + var(--batch-shift, 0px));
} }
/* 根级节点不显示水平虚线 */ /* 根级节点不显示水平虚线 */
...@@ -236,4 +308,21 @@ const { ...@@ -236,4 +308,21 @@ const {
background-color: rgba(255, 255, 255, 0.3) !important; background-color: rgba(255, 255, 255, 0.3) !important;
color: #fff !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;
}
</style> </style>
/** 批量操作弹框的属性类型 */
export interface TableBatchModalProps {
/** 弹框标题 */
title: string
/** 说明文字 */
description: string
/** 操作数量 */
count: number
/** 是否为删除类操作(控制 max 上限) */
isDelete: boolean
}
/** 批量操作弹框确认事件类型 */
export type ConfirmEmit = (event: 'confirm', count: number, action: string) => void
import type { FormInst } from 'naive-ui'
import type { TableBatchModalProps, ConfirmEmit } from '../constants'
/**
* TableBatchModal 组件内部状态与逻辑 Hook
* 管理弹框的显示、数量输入以及确认事件的派发
*/
export function useTableBatchModal(emit: ConfirmEmit, formRef: Ref<FormInst | null>) {
const show = ref(false)
const title = ref('')
const description = ref('')
const isDelete = ref(false)
const pendingAction = ref('')
const form = reactive({
count: 1 as number | null
})
const rules = {
count: [
{
required: true,
validator: (rule: any, value: any) => {
if (value === null || value === undefined || value === '') {
return new Error('请输入数量')
}
if (!Number.isInteger(value) || value < 1) {
return new Error('请输入大于等于 1 的整数')
}
return true
},
trigger: ['input', 'blur']
}
]
}
const open = (meta: TableBatchModalProps & { action: string }) => {
title.value = meta.title
description.value = meta.description
isDelete.value = meta.isDelete
form.count = meta.count
pendingAction.value = meta.action
show.value = true
}
const handleConfirm = async () => {
try {
await formRef.value?.validate()
} catch {
return
}
if (form.count !== null) {
emit('confirm', form.count, pendingAction.value)
show.value = false
}
}
return { show, title, description, isDelete, form, rules, open, handleConfirm }
}
<template>
<CommonModal v-model="show" :title="title" :width="360" @confirm="handleConfirm">
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<div class="flex flex-col gap-3">
<span class="text-sm text-color2">{{ description }}</span>
<n-form-item path="count">
<CommonInputNumber
v-model:value="form.count"
:min="1"
:max="isDelete ? 999 : 50"
:step="1"
:strict="true"
:show-button="true"
class="w-full"
placeholder="请输入数量"
/>
</n-form-item>
</div>
</n-form>
</CommonModal>
</template>
<script setup lang="ts">
import type { FormInst } from 'naive-ui'
import { useTableBatchModal } from './functionals'
const emit = defineEmits<{
confirm: [count: number, action: string]
}>()
const formRef = ref<FormInst | null>(null)
const { show, title, description, isDelete, form, rules, open, handleConfirm } = useTableBatchModal(emit, formRef)
defineExpose({ open })
</script>
<style scoped></style>
...@@ -4,6 +4,7 @@ export interface CellParagraphModel { ...@@ -4,6 +4,7 @@ export interface CellParagraphModel {
id: string id: string
tagName: string tagName: string
text: string text: string
attributes?: Record<string, string>
} }
export interface TableCellModel { export interface TableCellModel {
...@@ -38,5 +39,30 @@ export interface TableStructureModel { ...@@ -38,5 +39,30 @@ export interface TableStructureModel {
} }
// 表格组件相关的提示文本定义 // 表格组件相关的提示文本定义
export const CELL_EDIT_TIP = '提示:表格支持直接点击单元格进行双击/聚焦修改,光标离开时自动保存文本。' export const CELL_EDIT_TIP = '提示:直接点击单元格可聚焦修改,光标离开时自动保存。Ctrl + 点击可多选单元格。'
export const DEFAULT_CELL_TEXT = '新单元格' export const DEFAULT_CELL_TEXT = '新单元格'
/** 批量操作弹框的单条元数据类型 */
export interface BatchActionMeta {
title: string
description: string
isDelete: boolean
count: number
}
/** 批量操作弹框组件对外暴露的接口(避免循环依赖) */
export interface BatchModalRef {
open: (meta: BatchActionMeta & { action: string }) => void
}
/** 上下文菜单所有批量操作的元数据映射 */
export const ACTION_META: Record<string, BatchActionMeta> = {
'row-above': { title: '在上方插入行', description: '在当前行上方插入多少行?', isDelete: false, count: 1 },
'row-below': { title: '在下方插入行', description: '在当前行下方插入多少行?', isDelete: false, count: 1 },
'row-append': { title: '在末尾追加行', description: '在表格末尾追加多少行?', isDelete: false, count: 1 },
'col-left': { title: '在左侧插入列', description: '在当前列左侧插入多少列?', isDelete: false, count: 1 },
'col-right': { title: '在右侧插入列', description: '在当前列右侧插入多少列?', isDelete: false, count: 1 },
'col-append': { title: '在末尾追加列', description: '在表格末尾追加多少列?', isDelete: false, count: 1 },
'row-delete': { title: '删除行', description: '从当前行开始向下删除多少行?', isDelete: true, count: 1 },
'col-delete': { title: '删除列', description: '从当前列开始向右删除多少列?', isDelete: true, count: 1 },
}
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import type { TableCellModel, TableRowModel, TableStructureModel } from '../constants' import type { TableCellModel, TableRowModel, TableStructureModel, BatchModalRef, BatchActionMeta, CellParagraphModel } from '../constants'
import { ACTION_META } from '../constants'
import { COMPLEX_ENTRY_TAGS, PARA_TAGS } from '@/configs/xmlTags'
const getDeepText = (node: XmlNode): string => { const getDeepText = (node: XmlNode): string => {
if (node.textContent) return node.textContent
if (node.mixedContent && node.mixedContent.length > 0) { if (node.mixedContent && node.mixedContent.length > 0) {
return node.mixedContent return node.mixedContent
.map((item) => { .map((item) => {
...@@ -19,7 +20,7 @@ const getDeepText = (node: XmlNode): string => { ...@@ -19,7 +20,7 @@ const getDeepText = (node: XmlNode): string => {
if (node.children && node.children.length > 0) { if (node.children && node.children.length > 0) {
return node.children.map(getDeepText).join('') return node.children.map(getDeepText).join('')
} }
return '' return node.textContent || ''
} }
const setDeepText = (node: XmlNode, text: string): void => { const setDeepText = (node: XmlNode, text: string): void => {
...@@ -55,6 +56,9 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -55,6 +56,9 @@ export function useTableEditor(props: { node: XmlNode }) {
const selectedCellIds = ref<string[]>([]) const selectedCellIds = ref<string[]>([])
const insertedRowIds = ref<Set<string>>(new Set())
const insertedCellIds = ref<Set<string>>(new Set())
/** /**
* 辅助查找当前节点树中的 TGROUP 节点 * 辅助查找当前节点树中的 TGROUP 节点
*/ */
...@@ -193,17 +197,22 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -193,17 +197,22 @@ export function useTableEditor(props: { node: XmlNode }) {
const morerowsAttr = entryNode.attributes.MOREROWS || entryNode.attributes.morerows || '0' const morerowsAttr = entryNode.attributes.MOREROWS || entryNode.attributes.morerows || '0'
const cellRowspan = (parseInt(morerowsAttr, 10) || 0) + 1 const cellRowspan = (parseInt(morerowsAttr, 10) || 0) + 1
// 检测是否含有 UNLIST / NUMLIST 等复杂列表结构(非纯 PARA/PARAC) // 检测是否含有复杂列表等容器结构,或者其下的 PARA/PARAC 内嵌套了子节点(例如 EIN 标签)
const COMPLEX_ENTRY_TAGS = new Set(['UNLIST', 'NUMLIST', 'LIST1', 'L1', 'WARNING', 'CAUTION', 'NOTE', 'GRAPHIC', 'TABLE']) const complexTagsSet = new Set(COMPLEX_ENTRY_TAGS)
const hasComplexChildren = entryNode.children.some((c) => COMPLEX_ENTRY_TAGS.has(c.tagName)) const hasComplexChildren = entryNode.children.some((c) => {
if (complexTagsSet.has(c.tagName)) return true
if (PARA_TAGS.includes(c.tagName) && c.children && c.children.length > 0) return true
return false
})
// 解析文字 // 解析文字
let paragraphs = entryNode.children let paragraphs: CellParagraphModel[] = entryNode.children
.filter((c) => c.tagName === 'PARAC' || c.tagName === 'PARA') .filter((c) => PARA_TAGS.includes(c.tagName))
.map((pNode) => ({ .map((pNode) => ({
id: pNode.id, id: pNode.id,
tagName: pNode.tagName, tagName: pNode.tagName,
text: getDeepText(pNode) text: getDeepText(pNode),
attributes: pNode.attributes
})) }))
if (paragraphs.length === 0 && !hasComplexChildren) { if (paragraphs.length === 0 && !hasComplexChildren) {
paragraphs.push({ paragraphs.push({
...@@ -361,12 +370,24 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -361,12 +370,24 @@ export function useTableEditor(props: { node: XmlNode }) {
if (idx !== -1) { if (idx !== -1) {
const insertIdx = insertBelow ? idx + 1 : idx const insertIdx = insertBelow ? idx + 1 : idx
sectionNode.children.splice(insertIdx, 0, newRow) sectionNode.children.splice(insertIdx, 0, newRow)
insertedRowIds.value.add(rowId)
setTimeout(() => {
insertedRowIds.value.delete(rowId)
}, 5000)
store.rebuildNodeMap() store.rebuildNodeMap()
return return
} }
} }
sectionNode.children.push(newRow) sectionNode.children.push(newRow)
insertedRowIds.value.add(rowId)
setTimeout(() => {
insertedRowIds.value.delete(rowId)
}, 5000)
store.rebuildNodeMap() store.rebuildNodeMap()
} }
...@@ -479,13 +500,20 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -479,13 +500,20 @@ export function useTableEditor(props: { node: XmlNode }) {
const tableStructure = parseTable(node) const tableStructure = parseTable(node)
const insertedColCellIds: string[] = []
const createDefaultColEntry = (parentRowId: string): XmlNode => {
const cellNode = createDefaultEntry(parentRowId)
insertedColCellIds.push(cellNode.id)
return cellNode
}
const insertCellToRows = (sectionNode?: XmlNode, structRows: any[] = []) => { const insertCellToRows = (sectionNode?: XmlNode, structRows: any[] = []) => {
if (!sectionNode) return if (!sectionNode) return
const rows = sectionNode.children.filter((c) => c.tagName === 'ROW') const rows = sectionNode.children.filter((c) => c.tagName === 'ROW')
rows.forEach((row) => { rows.forEach((row) => {
const structRow = structRows.find((r) => r.id === row.id) const structRow = structRows.find((r) => r.id === row.id)
if (!structRow) { if (!structRow) {
row.children.push(createDefaultEntry(row.id)) row.children.push(createDefaultColEntry(row.id))
return return
} }
...@@ -493,7 +521,7 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -493,7 +521,7 @@ export function useTableEditor(props: { node: XmlNode }) {
if (rightCell) { if (rightCell) {
const idx = row.children.findIndex((c) => c.id === rightCell.id) const idx = row.children.findIndex((c) => c.id === rightCell.id)
if (idx !== -1) { if (idx !== -1) {
row.children.splice(idx, 0, createDefaultEntry(row.id)) row.children.splice(idx, 0, createDefaultColEntry(row.id))
return return
} }
} }
...@@ -502,18 +530,23 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -502,18 +530,23 @@ export function useTableEditor(props: { node: XmlNode }) {
if (leftCell) { if (leftCell) {
const idx = row.children.findIndex((c) => c.id === leftCell.id) const idx = row.children.findIndex((c) => c.id === leftCell.id)
if (idx !== -1) { if (idx !== -1) {
row.children.splice(idx + 1, 0, createDefaultEntry(row.id)) row.children.splice(idx + 1, 0, createDefaultColEntry(row.id))
return return
} }
} }
row.children.push(createDefaultEntry(row.id)) row.children.push(createDefaultColEntry(row.id))
}) })
} }
insertCellToRows(thead, tableStructure.theadRows) insertCellToRows(thead, tableStructure.theadRows)
insertCellToRows(tbody, tableStructure.tbodyRows) insertCellToRows(tbody, tableStructure.tbodyRows)
insertedColCellIds.forEach((id) => insertedCellIds.value.add(id))
setTimeout(() => {
insertedColCellIds.forEach((id) => insertedCellIds.value.delete(id))
}, 5000)
store.rebuildNodeMap() store.rebuildNodeMap()
} }
...@@ -561,6 +594,99 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -561,6 +594,99 @@ export function useTableEditor(props: { node: XmlNode }) {
store.rebuildNodeMap() store.rebuildNodeMap()
} }
/**
* 从 startRowId 所在行开始,向下(或同 section 内)删除 count 行
*/
const batchDeleteRows = (startRowId: string, count: number): void => {
const tgroup = findTgroup(props.node)
if (!tgroup) return
const thead = tgroup.children.find((c) => c.tagName === 'THEAD')
const tbody = tgroup.children.find((c) => c.tagName === 'TBODY')
let sectionNode: XmlNode | undefined
let startIdx = -1
for (const sec of [thead, tbody].filter(Boolean) as XmlNode[]) {
const rows = sec.children.filter((c) => c.tagName === 'ROW')
const idx = rows.findIndex((r) => r.id === startRowId)
if (idx !== -1) {
sectionNode = sec
startIdx = idx
break
}
}
if (!sectionNode || startIdx === -1) return
store.saveSnapshot()
const rows = sectionNode.children.filter((c) => c.tagName === 'ROW')
const toDelete = rows.slice(startIdx, startIdx + count).map((r) => r.id)
toDelete.forEach((id) => {
const idx = sectionNode!.children.findIndex((c) => c.id === id)
if (idx !== -1) sectionNode!.children.splice(idx, 1)
})
store.rebuildNodeMap()
window.$message.success(`已删除 ${toDelete.length} 行`)
}
/**
* 从 startColIdx 开始,向右删除 count 列(从右向左删,保证索引正确)
*/
const batchDeleteColumns = (startColIdx: number, count: number): void => {
const tgroup = findTgroup(props.node)
if (!tgroup) return
const currentCols = parseInt(tgroup.attributes.COLS || '0', 10)
const actualCount = Math.min(count, currentCols - startColIdx, currentCols - 1)
if (actualCount <= 0) {
window.$message.warning('表格必须保留至少一列')
return
}
store.saveSnapshot()
// 从右向左删除,保证前面的列索引不受影响
for (let i = actualCount - 1; i >= 0; i--) {
const colIdx = startColIdx + i
const currentColsNow = parseInt(tgroup.attributes.COLS || '0', 10)
if (currentColsNow <= 1) break
tgroup.attributes.COLS = (currentColsNow - 1).toString()
const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
if (colSpecs[colIdx]) {
const specId = colSpecs[colIdx].id
const specIdx = tgroup.children.findIndex((c) => c.id === specId)
if (specIdx !== -1) tgroup.children.splice(specIdx, 1)
}
const thead = tgroup.children.find((c) => c.tagName === 'THEAD')
const tbody = tgroup.children.find((c) => c.tagName === 'TBODY')
const deleteCellFromSection = (sec?: XmlNode) => {
if (!sec) return
sec.children.filter((c) => c.tagName === 'ROW').forEach((row) => {
const entries = row.children.filter((c) => c.tagName === 'ENTRY')
if (entries[colIdx]) {
const entryIdx = row.children.findIndex((c) => c.id === entries[colIdx].id)
if (entryIdx !== -1) row.children.splice(entryIdx, 1)
}
})
}
deleteCellFromSection(thead)
deleteCellFromSection(tbody)
}
store.rebuildNodeMap()
window.$message.success(`已删除 ${actualCount} 列`)
}
const mergeMultipleCells = ( const mergeMultipleCells = (
tableNode: XmlNode, tableNode: XmlNode,
cellIds: string[], cellIds: string[],
...@@ -1112,6 +1238,188 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1112,6 +1238,188 @@ export function useTableEditor(props: { node: XmlNode }) {
return false return false
} }
// ─── 右键上下文菜单 ───────────────────────────────────────────────────────
const contextMenu = ref({
show: false,
x: 0,
y: 0,
/** 当前右键所在的单元格 id */
cellId: '' as string,
/** 当前右键所在的行 id */
rowId: '' as string,
/** 当前右键所在列索引(用于列操作) */
colIdx: 0 as number
})
/**
* 构造右键菜单选项,根据当前选中状态动态组合
*/
const contextMenuOptions = computed(() => {
const hasCell = !!contextMenu.value.cellId
const multiSelected = selectedCellIds.value.length >= 2
const canMerge = canMergeSelected.value
const canSplit = canSplitSelected.value
return [
{
label: '在上方插入行',
key: 'row-above',
disabled: !hasCell
},
{
label: '在下方插入行',
key: 'row-below',
disabled: !hasCell
},
{
label: '在末尾追加行',
key: 'row-append'
},
{ type: 'divider', key: 'd1' },
{
label: '在左侧插入列',
key: 'col-left',
disabled: !hasCell
},
{
label: '在右侧插入列',
key: 'col-right',
disabled: !hasCell
},
{
label: '在末尾追加列',
key: 'col-append'
},
{ type: 'divider', key: 'd2' },
{
label: '删除此行',
key: 'row-delete',
disabled: !hasCell
},
{
label: '删除此列',
key: 'col-delete',
disabled: !hasCell || structure.value.cols <= 1
},
...(multiSelected || canSplit
? [
{ type: 'divider', key: 'd3' },
...(multiSelected
? [
{
label: '合并单元格',
key: 'merge',
disabled: !canMerge
}
]
: []),
...(canSplit
? [
{
label: '拆分单元格',
key: 'split'
}
]
: [])
]
: [])
]
})
/**
* 处理右键菜单项选择
*/
const handleContextMenuSelect = (key: string) => {
contextMenu.value.show = false
const { cellId, rowId, colIdx } = contextMenu.value
// 确保选中当前右键的单元格(若尚未选中)
if (cellId && !selectedCellIds.value.includes(cellId)) {
selectedCellIds.value = [cellId]
store.setSelectedNodeId(cellId)
}
switch (key) {
case 'row-above':
handleRowAddSelect('above')
break
case 'row-below':
handleRowAddSelect('below')
break
case 'row-append':
handleRowAddSelect('append')
break
case 'col-left':
handleColAddSelect('left')
break
case 'col-right':
handleColAddSelect('right')
break
case 'col-append':
handleColAddSelect('append')
break
case 'row-delete':
if (rowId) handleRowDelete(rowId)
break
case 'col-delete':
handleColumnDelete(colIdx)
break
case 'merge':
handleMergeMultiple()
break
case 'split':
handleSplitSelected()
break
}
}
/**
* 单元格右键事件处理
*/
const handleCellContextMenu = (cell: TableCellModel, rowId: string, e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
// 若单元格未在多选中,则单选它
if (!selectedCellIds.value.includes(cell.id)) {
selectedCellIds.value = [cell.id]
store.setSelectedNodeId(cell.id)
}
contextMenu.value = {
show: true,
x: e.clientX,
y: e.clientY,
cellId: cell.id,
rowId,
colIdx: cell.colIdx ?? 0
}
}
/**
* 行号单元格右键事件处理(只知道行,不知道具体列)
*/
const handleRowContextMenu = (rowId: string, e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
store.setSelectedNodeId(rowId)
contextMenu.value = {
show: true,
x: e.clientX,
y: e.clientY,
cellId: '',
rowId,
colIdx: 0
}
}
const closeContextMenu = () => {
contextMenu.value.show = false
}
return { return {
structure, structure,
selectedCellIds, selectedCellIds,
...@@ -1131,6 +1439,16 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1131,6 +1439,16 @@ export function useTableEditor(props: { node: XmlNode }) {
handleColumnDelete, handleColumnDelete,
isNodeSelected, isNodeSelected,
isCellSelected, isCellSelected,
// 右键上下文菜单
contextMenu,
contextMenuOptions,
handleCellContextMenu,
handleRowContextMenu,
handleContextMenuSelect,
closeContextMenu,
// 批量操作
batchDeleteRows,
batchDeleteColumns,
// 底层逻辑 // 底层逻辑
parseTable, parseTable,
updateCellText, updateCellText,
...@@ -1139,6 +1457,81 @@ export function useTableEditor(props: { node: XmlNode }) { ...@@ -1139,6 +1457,81 @@ export function useTableEditor(props: { node: XmlNode }) {
addColumn, addColumn,
deleteColumn, deleteColumn,
mergeMultipleCells, mergeMultipleCells,
splitCell splitCell,
insertedRowIds,
insertedCellIds
}
}
/**
* 批量操作弹框相关逻辑 Hook
* 管理右键菜单与 TableBatchModal 组件的联动,以及批量插入/删除的执行
*/
export function useTableBatchActions(deps: {
batchModalRef: Ref<BatchModalRef | undefined>
handleContextMenuSelect: (key: string) => void
handleRowAddSelect: (pos: string) => void
handleColAddSelect: (pos: string) => void
batchDeleteRows: (rowId: string, count: number) => void
batchDeleteColumns: (colIdx: number, count: number) => void
contextMenu: Ref<{ cellId: string; rowId: string; colIdx: number; show: boolean; x: number; y: number }>
selectedCellIds: Ref<string[]>
closeContextMenu: () => void
}) {
const store = useEditorStore()
/** 弹出批量操作弹框(merge/split 则直接执行) */
const openBatchModal = (key: string) => {
const meta: BatchActionMeta | undefined = ACTION_META[key]
if (!meta) {
deps.handleContextMenuSelect(key)
return
}
deps.batchModalRef.value?.open({ ...meta, action: key })
}
/** 右键菜单选中时拦截,弹出数量弹框 */
const onContextMenuSelect = (key: string) => {
deps.closeContextMenu()
openBatchModal(key)
}
/** 确认执行批量操作(由 TableBatchModal 的 @confirm 事件触发) */
const executeBatchAction = (count: number, action: string) => {
const { cellId, rowId, colIdx } = deps.contextMenu.value
if (cellId && !deps.selectedCellIds.value.includes(cellId)) {
deps.selectedCellIds.value = [cellId]
store.setSelectedNodeId(cellId)
}
switch (action) {
case 'row-above':
for (let i = 0; i < count; i++) deps.handleRowAddSelect('above')
break
case 'row-below':
for (let i = 0; i < count; i++) deps.handleRowAddSelect('below')
break
case 'row-append':
for (let i = 0; i < count; i++) deps.handleRowAddSelect('append')
break
case 'col-left':
for (let i = 0; i < count; i++) deps.handleColAddSelect('left')
break
case 'col-right':
for (let i = 0; i < count; i++) deps.handleColAddSelect('right')
break
case 'col-append':
for (let i = 0; i < count; i++) deps.handleColAddSelect('append')
break
case 'row-delete':
if (rowId) deps.batchDeleteRows(rowId, count)
break
case 'col-delete':
deps.batchDeleteColumns(colIdx, count)
break
} }
}
return { openBatchModal, onContextMenuSelect, executeBatchAction }
} }
<template> <template>
<div class="flex-1 flex flex-col p-4 space-y-4 overflow-auto bg-transparent min-h-0" @click.stop> <div class="flex-1 flex flex-col p-4 space-y-4 overflow-auto bg-transparent min-h-0" @click.stop @click="closeContextMenu">
<!-- 表格工具栏 --> <!-- 表格工具栏 -->
<div class="flex items-center justify-between pb-2 border-b border-divider"> <div class="flex items-center pb-2 border-b border-divider space-x-2">
<div class="flex items-center space-x-2">
<n-tag type="info" size="small">{{ node.tagName }}</n-tag> <n-tag type="info" size="small">{{ node.tagName }}</n-tag>
<span class="text-xs text-color3">{{ CELL_EDIT_TIP }}</span> <span class="text-xs text-color3">{{ CELL_EDIT_TIP }}</span>
</div> <span class="text-xs text-color3 select-none">· 右键单元格可快速操作</span>
<div class="flex items-center space-x-2">
<!-- 单元格合并与拆分快捷工具 -->
<template v-if="selectedCellIds.length > 0">
<div class="flex items-center space-x-1.5 mr-1" v-if="selectedCellIds.length >= 2 || canSplitSelected">
<CommonButton
v-if="selectedCellIds.length >= 2"
size="tiny"
secondary
:type="canMergeSelected ? 'primary' : 'default'"
:disabled="!canMergeSelected"
@click="handleMergeMultiple"
>
合并单元格
</CommonButton>
<span v-if="selectedCellIds.length >= 2 && !canMergeSelected" class="text-[11px] text-error select-none">
(选中的单元格不符合合并矩形规则)
</span>
<CommonButton v-if="canSplitSelected" size="tiny" secondary @click="handleSplitSelected">拆分单元格</CommonButton>
</div>
<n-divider vertical class="mx-1" v-if="selectedCellIds.length >= 2 || canSplitSelected" />
</template>
<n-dropdown
trigger="click"
:options="colAddOptions"
@select="handleColAddSelect"
>
<CommonButton size="tiny" secondary type="primary">
<template #icon>
<n-icon><add-outline /></n-icon>
</template>
添加列
</CommonButton>
</n-dropdown>
<n-dropdown
trigger="click"
:options="rowAddOptions"
@select="handleRowAddSelect"
>
<CommonButton size="tiny" secondary type="primary">
<template #icon>
<n-icon><add-outline /></n-icon>
</template>
添加行
</CommonButton>
</n-dropdown>
</div>
</div> </div>
<!-- 可视化二维表格视图 --> <!-- 可视化二维表格视图 -->
...@@ -82,7 +33,10 @@ ...@@ -82,7 +33,10 @@
:key="row.id" :key="row.id"
:data-node-id="row.id" :data-node-id="row.id"
class="border-b border-divider hover:bg-fill-3 group transition-colors cursor-pointer transition-all" class="border-b border-divider hover:bg-fill-3 group transition-colors cursor-pointer transition-all"
:class="[isNodeSelected(row.id) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/10' : '']" :class="[
isNodeSelected(row.id) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/10' : '',
insertedRowIds.has(row.id) ? 'inserted-row-highlight' : ''
]"
@click.stop="editorStore.setSelectedNodeId(row.id)" @click.stop="editorStore.setSelectedNodeId(row.id)"
> >
<!-- 表头行选择号 --> <!-- 表头行选择号 -->
...@@ -90,6 +44,7 @@ ...@@ -90,6 +44,7 @@
class="p-2 border border-divider text-center select-none cursor-pointer transition-colors bg-fill-4 text-color3 font-bold" class="p-2 border border-divider text-center select-none cursor-pointer transition-colors bg-fill-4 text-color3 font-bold"
:class="[isNodeSelected(row.id) ? 'bg-primary text-white' : 'hover:bg-fill-3']" :class="[isNodeSelected(row.id) ? 'bg-primary text-white' : 'hover:bg-fill-3']"
@click.stop="editorStore.setSelectedNodeId(row.id)" @click.stop="editorStore.setSelectedNodeId(row.id)"
@contextmenu.prevent="handleRowContextMenu(row.id, $event)"
> >
H{{ structure.theadRows.length > 1 ? rIdx + 1 : '' }} H{{ structure.theadRows.length > 1 ? rIdx + 1 : '' }}
</th> </th>
...@@ -101,21 +56,16 @@ ...@@ -101,21 +56,16 @@
:data-node-id="cell.id" :data-node-id="cell.id"
class="p-2 text-left font-bold bg-fill-4 border border-divider text-color1 transition-colors cursor-pointer relative" class="p-2 text-left font-bold bg-fill-4 border border-divider text-color1 transition-colors cursor-pointer relative"
:class="[ :class="[
isCellSelected(cell, cell.colIdx ?? 0) ? 'bg-primary/10 outline outline-2 outline-primary outline-offset-[-2px]' : '', isCellSelected(cell, cell.colIdx ?? 0) ? 'bg-link-1 outline outline-2 outline-link-6 outline-offset-[-2px]' : '',
isNodeSelected(structure.theadId) ? 'bg-primary/10 border-primary/40' : '' isNodeSelected(structure.theadId) ? 'bg-primary/10 border-primary/40' : '',
insertedCellIds.has(cell.id) ? 'inserted-cell-highlight' : ''
]" ]"
@click.stop="handleCellClick(cell, $event)" @click.stop="handleCellClick(cell, $event)"
@contextmenu.prevent="handleCellContextMenu(cell, row.id, $event)"
> >
<!-- 如果 ENTRY 含有复杂子节点(UNLIST 等),用 DocNodeRenderer 渲染全部子节点 -->
<template v-if="cell.hasComplexChildren && cell.rawNode"> <template v-if="cell.hasComplexChildren && cell.rawNode">
<DocNodeRenderer <DocNodeRenderer v-for="child in cell.rawNode!.children" :key="child.id" :node="child" :parent="cell.rawNode" />
v-for="child in cell.rawNode!.children"
:key="child.id"
:node="child"
:parent="cell.rawNode"
/>
</template> </template>
<!-- 否则渲染普通 PARA 文本段落 -->
<template v-else> <template v-else>
<div <div
v-for="para in cell.paragraphs" v-for="para in cell.paragraphs"
...@@ -123,6 +73,7 @@ ...@@ -123,6 +73,7 @@
:data-node-id="para.id" :data-node-id="para.id"
contenteditable="true" contenteditable="true"
:class="[isNodeSelected(para.id) ? 'ring-2 ring-primary bg-primary/5 font-bold' : 'hover:bg-fill-3']" :class="[isNodeSelected(para.id) ? 'ring-2 ring-primary bg-primary/5 font-bold' : 'hover:bg-fill-3']"
:style="para.attributes?.MERGED === 'TRUE' ? { backgroundColor: 'yellow' } : {}"
@click.stop="handleParaClick(para, cell, $event)" @click.stop="handleParaClick(para, cell, $event)"
@blur="(e) => handleCellBlur(para.id, e)" @blur="(e) => handleCellBlur(para.id, e)"
v-text="para.text" v-text="para.text"
...@@ -152,7 +103,10 @@ ...@@ -152,7 +103,10 @@
:key="row.id" :key="row.id"
:data-node-id="row.id" :data-node-id="row.id"
class="border-b border-divider hover:bg-fill-3 group transition-colors cursor-pointer transition-all" class="border-b border-divider hover:bg-fill-3 group transition-colors cursor-pointer transition-all"
:class="[isNodeSelected(row.id) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/10' : '']" :class="[
isNodeSelected(row.id) ? 'outline outline-2 outline-primary outline-offset-[-2px] bg-primary/10' : '',
insertedRowIds.has(row.id) ? 'inserted-row-highlight' : ''
]"
@click.stop="editorStore.setSelectedNodeId(row.id)" @click.stop="editorStore.setSelectedNodeId(row.id)"
> >
<!-- 行号选择单元格 --> <!-- 行号选择单元格 -->
...@@ -160,6 +114,7 @@ ...@@ -160,6 +114,7 @@
class="p-2 border border-divider text-center select-none cursor-pointer transition-colors font-bold" class="p-2 border border-divider text-center select-none cursor-pointer transition-colors font-bold"
:class="[isNodeSelected(row.id) ? 'bg-primary text-white' : 'bg-fill-4 text-color3 hover:bg-fill-3']" :class="[isNodeSelected(row.id) ? 'bg-primary text-white' : 'bg-fill-4 text-color3 hover:bg-fill-3']"
@click.stop="editorStore.setSelectedNodeId(row.id)" @click.stop="editorStore.setSelectedNodeId(row.id)"
@contextmenu.prevent="handleRowContextMenu(row.id, $event)"
> >
{{ rIdx + 1 }} {{ rIdx + 1 }}
</td> </td>
...@@ -171,21 +126,16 @@ ...@@ -171,21 +126,16 @@
:data-node-id="cell.id" :data-node-id="cell.id"
class="p-2 border border-divider text-color2 transition-colors cursor-pointer relative" class="p-2 border border-divider text-color2 transition-colors cursor-pointer relative"
:class="[ :class="[
isCellSelected(cell, cell.colIdx ?? 0) ? 'bg-primary/10 outline outline-2 outline-primary outline-offset-[-2px]' : '', isCellSelected(cell, cell.colIdx ?? 0) ? 'bg-link-1 outline outline-2 outline-link-6 outline-offset-[-2px]' : '',
isNodeSelected(structure.tbodyId) ? 'bg-primary/5 border-primary/30' : '' isNodeSelected(structure.tbodyId) ? 'bg-primary/5 border-primary/30' : '',
insertedCellIds.has(cell.id) ? 'inserted-cell-highlight' : ''
]" ]"
@click.stop="handleCellClick(cell, $event)" @click.stop="handleCellClick(cell, $event)"
@contextmenu.prevent="handleCellContextMenu(cell, row.id, $event)"
> >
<!-- 如果 ENTRY 含有复杂子节点(UNLIST 等),用 DocNodeRenderer 渲染全部子节点 -->
<template v-if="cell.hasComplexChildren && cell.rawNode"> <template v-if="cell.hasComplexChildren && cell.rawNode">
<DocNodeRenderer <DocNodeRenderer v-for="child in cell.rawNode!.children" :key="child.id" :node="child" :parent="cell.rawNode" />
v-for="child in cell.rawNode!.children"
:key="child.id"
:node="child"
:parent="cell.rawNode"
/>
</template> </template>
<!-- 否则渲染普通 PARA 文本段落 -->
<template v-else> <template v-else>
<div <div
v-for="para in cell.paragraphs" v-for="para in cell.paragraphs"
...@@ -193,6 +143,7 @@ ...@@ -193,6 +143,7 @@
:data-node-id="para.id" :data-node-id="para.id"
contenteditable="true" contenteditable="true"
:class="[isNodeSelected(para.id) ? 'ring-2 ring-primary bg-primary/5' : 'hover:bg-fill-3']" :class="[isNodeSelected(para.id) ? 'ring-2 ring-primary bg-primary/5' : 'hover:bg-fill-3']"
:style="para.attributes?.MERGED === 'TRUE' ? { backgroundColor: 'yellow' } : {}"
@click.stop="handleParaClick(para, cell, $event)" @click.stop="handleParaClick(para, cell, $event)"
@blur="(e) => handleCellBlur(para.id, e)" @blur="(e) => handleCellBlur(para.id, e)"
v-text="para.text" v-text="para.text"
...@@ -211,7 +162,6 @@ ...@@ -211,7 +162,6 @@
<!-- 列删除快捷按钮行 --> <!-- 列删除快捷按钮行 -->
<tr class="hover:bg-transparent"> <tr class="hover:bg-transparent">
<!-- 行号列占位 -->
<td class="p-1 bg-transparent border-none"></td> <td class="p-1 bg-transparent border-none"></td>
<td v-for="(_, cIdx) in structure.cols" :key="cIdx" class="p-1 text-center bg-transparent border-none"> <td v-for="(_, cIdx) in structure.cols" :key="cIdx" class="p-1 text-center bg-transparent border-none">
<CommonButton <CommonButton
...@@ -232,17 +182,34 @@ ...@@ -232,17 +182,34 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- 右键上下文菜单 -->
<teleport to="body">
<n-dropdown
placement="bottom-start"
trigger="manual"
:x="contextMenu.x"
:y="contextMenu.y"
:show="contextMenu.show"
:options="contextMenuOptions"
@clickoutside="closeContextMenu"
@select="onContextMenuSelect"
/>
</teleport>
<!-- 批量操作弹框 -->
<TableBatchModal ref="batchModalRef" @confirm="executeBatchAction" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { AddOutline, TrashOutline } from '@vicons/ionicons5' import { TrashOutline } from '@vicons/ionicons5'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import { useTableEditor } from './functionals' import { useTableEditor, useTableBatchActions } from './functionals'
import { CELL_EDIT_TIP } from './constants' import { CELL_EDIT_TIP } from './constants'
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import type { TableCellModel } from './constants' import DocNodeRenderer from '../DocNodeRenderer/index.vue'
import DocNodeRenderer from '@/views/editor/components/DocNodeRenderer/index.vue' import TableBatchModal from './components/TableBatchModal/index.vue'
const props = defineProps<{ const props = defineProps<{
node: XmlNode node: XmlNode
...@@ -252,22 +219,67 @@ const editorStore = useEditorStore() ...@@ -252,22 +219,67 @@ const editorStore = useEditorStore()
const { const {
structure, structure,
selectedCellIds, selectedCellIds,
rowAddOptions,
colAddOptions,
canMergeSelected,
canSplitSelected,
handleCellClick, handleCellClick,
handleParaClick, handleParaClick,
handleCellBlur, handleCellBlur,
handleRowAddSelect, handleRowAddSelect,
handleColAddSelect, handleColAddSelect,
handleMergeMultiple,
handleSplitSelected,
handleRowDelete, handleRowDelete,
handleColumnDelete, handleColumnDelete,
isNodeSelected, isNodeSelected,
isCellSelected isCellSelected,
contextMenu,
contextMenuOptions,
handleCellContextMenu,
handleRowContextMenu,
handleContextMenuSelect,
closeContextMenu,
batchDeleteRows,
batchDeleteColumns,
insertedRowIds,
insertedCellIds
} = useTableEditor(props) } = useTableEditor(props)
/** TableBatchModal 组件实例引用 */
const batchModalRef = ref<InstanceType<typeof TableBatchModal>>()
const { onContextMenuSelect, executeBatchAction } = useTableBatchActions({
batchModalRef,
handleContextMenuSelect,
handleRowAddSelect,
handleColAddSelect,
batchDeleteRows,
batchDeleteColumns,
contextMenu,
selectedCellIds,
closeContextMenu
})
</script> </script>
<style scoped></style> <style scoped>
/* 新插入行的高亮 */
.inserted-row-highlight td,
.inserted-row-highlight th {
animation: inserted-fade-glow 4s ease-out forwards;
}
/* 新插入列(单元格)的高亮 */
.inserted-cell-highlight {
animation: inserted-fade-glow 4s ease-out forwards;
}
@keyframes inserted-fade-glow {
0% {
background-color: color-mix(in srgb, var(--success-color, #23c343) 25%, transparent);
outline: 1px solid color-mix(in srgb, var(--success-color, #23c343) 40%, transparent);
}
10% {
background-color: color-mix(in srgb, var(--success-color, #23c343) 25%, transparent);
outline: 1px solid color-mix(in srgb, var(--success-color, #23c343) 40%, transparent);
}
100% {
background-color: transparent;
outline: 1px solid transparent;
}
}
</style>
...@@ -3,14 +3,3 @@ ...@@ -3,14 +3,3 @@
*/ */
export const DEFAULT_FILE_NAME = 'AMEA-A282400-02-1_0_0_updated.xml' export const DEFAULT_FILE_NAME = 'AMEA-A282400-02-1_0_0_updated.xml'
// 文本与混合节点标签分类
export const TEXT_ONLY_TAGS = ['TITLE', 'TITLEC', 'ZONE']
export const MIXED_CONTENT_TAGS = ['PARA', 'PARAC', 'WARNING', 'CAUTION', 'NOTE']
// 列表节点标签定义
export const ORDERED_LIST_TAGS = ['LIST1', 'LIST2', 'LIST3']
export const ALL_LIST_TAGS = ['LIST1', 'LIST2', 'LIST3', 'UNLIST']
// 表格节点标签定义
export const CALS_TABLE_TAGS = ['TABLE', 'TGROUP']
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