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 @@
---
## 4. 自动导入 (Auto Import) 规范
- **禁止手动导入 Vue 常用 API**:如 `ref`, `computed`, `reactive`, `h`, `nextTick`, `onMounted` 等,以及 `useTableSearch`, `useDictStore`, `service`, `toCamelCase`, `openUploadModal` 等常用 Hooks 与 Utils,严禁手动编写 `import` 语句
- **禁止手动导入 formatDate**:全局工具函数 `formatDate` 已通过自动导入机制全局生效,在所有 `.vue``.ts` 文件中直接调用即可。
- **禁止手动导入常用 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` 语句**,直接全局使用即可
---
......@@ -195,3 +195,10 @@
- 在编写 `CommonSelect``n-input` 组件时,对于普通属性、枚举选择或内容输入,**禁止**手动添加 `placeholder="请选择"``placeholder="选择"``placeholder="请输入"` 等冗余的占位字符。
- 只在具有明确指示性特指文案的业务场景下(如“请选择切换动画”等特有场景),方可添加具体的占位文本。
- **事件监听规范**:对 `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 @@
"@alova/scene-vue": "^1.6.2",
"@bpmn-io/properties-panel": "^3.40.4",
"@vicons/ionicons5": "^0.13.0",
"@vueuse/core": "^14.3.0",
"alova": "^3.5.0",
"bpmn-js": "^17.11.1",
"bpmn-js-properties-panel": "^5.53.0",
......@@ -1704,6 +1705,12 @@
"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": {
"version": "0.13.0",
"resolved": "https://registry.npmmirror.com/@vicons/ionicons5/-/ionicons5-0.13.0.tgz",
......@@ -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": {
"version": "0.8.11",
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
......
......@@ -18,11 +18,13 @@
"@alova/scene-vue": "^1.6.2",
"@bpmn-io/properties-panel": "^3.40.4",
"@vicons/ionicons5": "^0.13.0",
"@vueuse/core": "^14.3.0",
"alova": "^3.5.0",
"bpmn-js": "^17.11.1",
"bpmn-js-properties-panel": "^5.53.0",
"camunda-bpmn-moddle": "^7.0.1",
"dayjs": "^1.11.19",
"diff": "^7.0.0",
"docx-preview": "^0.3.7",
"less": "^4.5.1",
"lodash-es": "^4.18.1",
......@@ -36,8 +38,7 @@
"vue-i18n": "^11.2.8",
"vue-router": "^5.0.3",
"vuedraggable": "^4.1.0",
"xlsx": "^0.18.5",
"diff": "^7.0.0"
"xlsx": "^0.18.5"
},
"devDependencies": {
"@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'
const themeVars = useThemeVars()
const appStore = useAppStore()
const editorStore = useEditorStore()
const globalImportModalRef = ref()
const globalExportModalRef = ref()
const globalAttachmentModalRef = ref()
......@@ -32,51 +31,10 @@ const globalUploadModalRef = ref()
const globalPreviewModalRef = ref()
const globalDownloadModalRef = ref()
// 全局快捷键处理
const handleGlobalKeydown = (e: KeyboardEvent) => {
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
}
}
}
// 启用全局快捷键
useKeyboardShortcuts({}, true)
onMounted(() => {
window.addEventListener('keydown', handleGlobalKeydown, true)
window.$importModal = globalImportModalRef.value
window.$exportModal = globalExportModalRef.value
window.$attachmentModal = globalAttachmentModalRef.value
......@@ -87,10 +45,6 @@ onMounted(() => {
// 初始化应用主题
appStore.applyTheme()
})
onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalKeydown, true)
})
</script>
<style scoped>
......
......@@ -295,6 +295,7 @@ export const useEditorStore = defineStore('editor', {
state: (): EditorState => ({
xmlTree: null,
selectedNodeId: null,
renderedSelectedNodeId: null,
nodeMap: markRaw(new Map()),
undoStack: [],
redoStack: [],
......@@ -411,10 +412,10 @@ export const useEditorStore = defineStore('editor', {
}
// 3. 严格校验 DTD 规则约束
const existingTags = destParentNode.children.map(c => c.tagName)
const existingTags = destParentNode.children.map((c) => c.tagName)
for (const node of newNodes) {
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)) {
throw new Error(`DTD 校验失败: 节点 <${destParentNode.tagName}> 无法接受子元素 <${tagName}>`)
}
......@@ -424,23 +425,23 @@ export const useEditorStore = defineStore('editor', {
// 4. 递归修正插入子节点的 parentId 关系
const setParentRecursive = (n: XmlNode, pid: string) => {
n.parentId = pid
n.children.forEach(c => setParentRecursive(c, n.id))
n.children.forEach((c) => setParentRecursive(c, n.id))
}
this.saveSnapshot()
newNodes.forEach(node => setParentRecursive(node, destParentNode.id))
newNodes.forEach((node) => setParentRecursive(node, destParentNode.id))
// 5. 将解析出来的新节点追加到指定位置,并重置选中焦点
if (mode === 'above') {
const index = destParentNode.children.findIndex(c => c.id === targetId)
const index = destParentNode.children.findIndex((c) => c.id === targetId)
if (index !== -1) {
destParentNode.children.splice(index, 0, ...newNodes)
} else {
destParentNode.children.unshift(...newNodes)
}
} 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) {
destParentNode.children.splice(index + 1, 0, ...newNodes)
} else {
......@@ -466,14 +467,38 @@ export const useEditorStore = defineStore('editor', {
},
setSelectedNodeId(id: string | null) {
const oldId = this.selectedNodeId
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 (id && nodeSelectedRefs.has(id)) {
const r = nodeSelectedRefs.get(id)
if (renderedId && nodeSelectedRefs.has(renderedId)) {
const r = nodeSelectedRefs.get(renderedId)
if (r) r.value = true
}
},
......@@ -508,15 +533,15 @@ export const useEditorStore = defineStore('editor', {
undo() {
if (this.undoStack.length === 0 || !this.xmlTree) return
const previousSnapshot = this.undoStack.pop()!
const currentClone = JSON.parse(JSON.stringify(this.xmlTree))
const currentSnapshot = {
tree: currentClone,
selectedNodeId: this.selectedNodeId
selectedNodeId: previousSnapshot.selectedNodeId || this.selectedNodeId
}
this.redoStack.push(currentSnapshot)
const previousSnapshot = this.undoStack.pop()!
this.xmlTree = previousSnapshot.tree
this.rebuildNodeMap()
if (previousSnapshot.selectedNodeId && this.nodeMap.has(previousSnapshot.selectedNodeId)) {
......@@ -628,12 +653,51 @@ export const useEditorStore = defineStore('editor', {
// 处理混合内容 (mixedContent) 中的对应项
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.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()
},
/**
* 移动子节点位置
*/
......@@ -788,7 +852,7 @@ export const useEditorStore = defineStore('editor', {
*/
replaceAllMatches(query: string, replacement: string, options: { matchCase: boolean; regExp: boolean }) {
if (!this.xmlTree || !query) return 0
// 先查找出所有符合条件的匹配项
const matches = this.findText(query, options)
if (matches.length === 0) return 0
......@@ -798,7 +862,7 @@ export const useEditorStore = defineStore('editor', {
// 按照从后往前的顺序对同一个节点内的文本进行替换,防止位置偏移
const nodeGroups: Record<string, typeof matches> = {}
matches.forEach(m => {
matches.forEach((m) => {
const key = `${m.nodeId}-${m.type}-${m.mixedIndex ?? 'none'}`
if (!nodeGroups[key]) {
nodeGroups[key] = []
......@@ -809,7 +873,7 @@ export const useEditorStore = defineStore('editor', {
let replaceCount = 0
// 针对每个文本单元,自后向前替换
Object.keys(nodeGroups).forEach(key => {
Object.keys(nodeGroups).forEach((key) => {
const group = nodeGroups[key]
// 按 start 降序排列
group.sort((a, b) => b.start - a.start)
......@@ -820,7 +884,7 @@ export const useEditorStore = defineStore('editor', {
if (firstMatch.type === 'textContent') {
let text = node.textContent || ''
group.forEach(m => {
group.forEach((m) => {
const prefix = text.substring(0, m.start)
const suffix = text.substring(m.start + m.length)
text = prefix + replacement + suffix
......@@ -831,7 +895,7 @@ export const useEditorStore = defineStore('editor', {
const item = node.mixedContent[firstMatch.mixedIndex]
if (item && item.type === 'text') {
let text = item.text || ''
group.forEach(m => {
group.forEach((m) => {
const prefix = text.substring(0, m.start)
const suffix = text.substring(m.start + m.length)
text = prefix + replacement + suffix
......
......@@ -8,6 +8,7 @@ export interface Snapshot {
export interface EditorState {
xmlTree: XmlNode | null
selectedNodeId: string | null
renderedSelectedNodeId: string | null // 实际渲染的被选中节点 ID
nodeMap: Map<string, { node: XmlNode; parent: XmlNode | null }>
undoStack: Snapshot[] // 保存 Snapshot 历史快照
redoStack: Snapshot[] // 保存 Snapshot 重做快照
......
......@@ -107,19 +107,82 @@ export function canAddChild(parentTagName: string, childTagName: string, current
/**
* 判断某个子节点是否可以被删除(考虑 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)
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 childOccurrence = findChildOccurrence(model, childTagName)
// 'once' 或 'oneOrMore' 的子元素至少需要一个
if ((childOccurrence === 'once' || childOccurrence === 'oneOrMore') && currentChildCount <= 1) {
return false
if (childOccurrence === 'once' || childOccurrence === 'oneOrMore') {
return 1
}
return true
return 0
}
/**
......@@ -151,8 +214,8 @@ export function getInsertableChildren(parentTagName: string, existingChildren: s
const model = rule.contentModel.parsed
return allowed.filter(childTag => {
const count = existingChildren.filter(t => t === childTag).length
return allowed.filter((childTag) => {
const count = existingChildren.filter((t) => t === childTag).length
const occurrence = findChildOccurrence(model, childTag)
if (occurrence === 'once' && count >= 1) return false
......@@ -186,3 +249,46 @@ export function getAllElementNames(): string[] {
if (!_schema) return []
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 { INLINE_ELEMENTS } from '@/configs/xmlTags'
/**
* 生成唯一 ID
......@@ -7,18 +8,10 @@ function generateId(): string {
return crypto.randomUUID ? crypto.randomUUID() : `node_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
}
/**
* 判断节点是否为行内元素(嵌在文本中的元素)
*/
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'
])
const INLINE_ELEMENTS_SET = new Set(INLINE_ELEMENTS)
export function isInlineElement(tagName: string): boolean {
return INLINE_ELEMENTS.has(tagName)
return INLINE_ELEMENTS_SET.has(tagName)
}
/**
......
export const LIST_ITEM_TAGS = [
'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]
]
export { LIST_ITEM_TAGS, HEADER_TAGS, ROMAN_LOOKUP } from '@/configs/xmlTags'
import type { XmlNode } from '@/types/xmlNode'
import { useEditorStore, nodeSelectedRefs } from '@/store/editor'
import { LIST_ITEM_TAGS, HEADER_TAGS, ROMAN_LOOKUP } from '../constants'
import { ALERT_AND_EFF_TAGS, ALERT_BLOCK_TAGS } from '@/configs/xmlTags'
import { 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 }) {
const editorStore = useEditorStore()
const { locale } = useI18n()
const isSelected = ref(editorStore.selectedNodeId === props.node.id)
const isSelected = ref(editorStore.renderedSelectedNodeId === props.node.id)
onMounted(() => {
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(() => {
......@@ -20,7 +39,7 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
const parentAlert = inject('insideAlert', false)
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)
}
......@@ -290,3 +309,39 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
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)
}
import { useEditorStore } from '@/store/editor'
import { useMessage } from 'naive-ui'
export function useFindReplace(
props: { visible: boolean; syncEditorScroll: (nodeId: string, force: boolean) => void },
emit: (event: 'update:visible', value: boolean) => void
) {
const editorStore = useEditorStore()
const message = useMessage()
const isExpanded = ref(false)
const findQuery = ref('')
......@@ -123,7 +121,7 @@ export function useFindReplace(
const success = editorStore.replaceMatch(match, replaceQuery.value)
if (success) {
message.success('替换成功')
window.$message?.success('替换成功')
// 重新查找以保持精准的位置和剩余结果统计
const prevIndex = currentMatchIndex.value
doSearch()
......@@ -134,7 +132,7 @@ export function useFindReplace(
focusMatch(nextIdx)
}
} else {
message.error('替换失败')
window.$message?.error('替换失败')
}
}
......@@ -147,12 +145,12 @@ export function useFindReplace(
})
if (count > 0) {
message.success(`成功替换了 ${count} 处匹配`)
window.$message?.success(`成功替换了 ${count} 处匹配`)
matches.value = []
currentMatchIndex.value = -1
findQuery.value = ''
} else {
message.info('未找到可替换的匹配项')
window.$message?.info('未找到可替换的匹配项')
}
}
......@@ -175,6 +173,15 @@ export function useFindReplace(
emit('update:visible', false)
}
// ESC 键关闭面板
useKeyboardShortcuts({
onEscape: () => {
if (props.visible) {
closePanel()
}
}
})
// 自动对焦查找框
watch(
() => props.visible,
......@@ -215,3 +222,50 @@ export function useFindReplace(
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>
<transition name="slide-fade">
<div
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"
:style="{ width: isExpanded ? '560px' : '320px' }"
:class="{ 'is-expanded': isExpanded }"
>
<teleport to="body">
<transition name="slide-fade">
<div
v-if="visible"
class="find-replace-panel absolute top-20 right-4 z-[999] bg-card/90 backdrop-blur-md border border-divider shadow-2xl rounded-xl p-4 flex flex-col space-y-3 select-none transition-all duration-300 ease-in-out"
: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 }"
>
<!-- 头部标题与关闭按钮 -->
<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">
<n-icon class="text-primary text-sm">
<search-outline />
</n-icon>
<span>查找与替换</span>
</span>
<div class="flex items-center space-x-1">
<div class="flex items-center space-x-1" @mousedown.stop>
<!-- 放大展开/收起按钮 (通过 CodeOutline 表示 <>) -->
<CommonButton size="tiny" quaternary circle @click="toggleExpand" :title="isExpanded ? '折叠面板' : '放大面板'">
<template #icon>
......@@ -46,18 +51,18 @@
<!-- 选项开关 -->
<div class="flex items-center space-x-1 text-color3">
<span
class="option-btn text-[10px] px-1 py-0.5 rounded cursor-pointer transition-colors"
:class="{ 'active bg-primary/20 text-primary font-bold': matchCase }"
class="option-btn text-[10px] px-1.5 py-0.5 rounded cursor-pointer transition-all duration-150"
:class="{ 'active': matchCase }"
title="区分大小写"
@click="toggleMatchCase"
@click.stop="toggleMatchCase"
>
Aa
</span>
<span
class="option-btn text-[10px] px-1 py-0.5 rounded cursor-pointer transition-colors"
:class="{ 'active bg-primary/20 text-primary font-bold': regExp }"
class="option-btn text-[10px] px-1.5 py-0.5 rounded cursor-pointer transition-all duration-150"
:class="{ 'active': regExp }"
title="正则表达式"
@click="toggleRegExp"
@click.stop="toggleRegExp"
>
.*
</span>
......@@ -142,11 +147,12 @@
</div>
</div>
</transition>
</teleport>
</template>
<script setup lang="ts">
import { SearchOutline, CloseOutline, ChevronUpOutline, ChevronDownOutline, CodeOutline } from '@vicons/ionicons5'
import { useFindReplace } from './functionals'
import { useFindReplace, useDraggable } from './functionals'
const props = defineProps<{
visible: boolean
......@@ -155,6 +161,9 @@ const props = defineProps<{
const emit = defineEmits(['update:visible'])
const visibleRef = toRef(props, 'visible')
const { dragOffset, dragging, onDragStart } = useDraggable(visibleRef)
const {
isExpanded,
findQuery,
......@@ -181,12 +190,25 @@ const {
</script>
<style scoped>
.find-replace-panel {
border-color: var(--n-border-color);
.option-btn {
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 {
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 {
transition: all 0.15s ease;
color: var(--n-text-color);
......
import type { XmlNode } from '@/types/xmlNode'
// 列表节点标签定义
export const ALL_LIST_TAGS = ['LIST1', 'LIST2', 'LIST3', 'UNLIST']
// 表格节点标签定义
export const CALS_TABLE_TAGS = ['TABLE', 'TGROUP']
import { TRANSPARENT_TAGS, ALL_LIST_TAGS, CALS_TABLE_TAGS } from '@/configs/xmlTags'
// 容器节点:直接穿透,不独立成块,而是遍历其子节点,以实现细粒度原子级虚拟滚动分块
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)
......
import { useEditorStore } from '@/store/editor'
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
......@@ -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(() => {
if (!editorStore.selectedNodeId) return []
const realId = getRealNodeId(editorStore.selectedNodeId)
const path: XmlNode[] = []
let curr = editorStore.nodeMap.get(editorStore.selectedNodeId)
let curr = editorStore.nodeMap.get(realId)
while (curr) {
path.unshift(curr.node)
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
})
......@@ -38,7 +61,19 @@ export function useEditorPanel() {
const getEditorBlocks = (node: XmlNode): EditorBlock[] => {
const blocks: EditorBlock[] = []
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)
} else if (n.tagName === 'TOPIC') {
// 特殊拆分:TOPIC 节点自身仅作为标题 Block,其内容子节点(SUBTASK 等)则作为平级 Block 扁平化,以实现细粒度虚拟化
......@@ -214,9 +249,10 @@ export function useEditorPanel() {
* 最近的渲染块(如 SUBTASK)。后续路径搜索会自动回退到 CON/PARAC 等可见父元素。
*/
const findNearestBlockIdx = (targetId: string): number => {
const realId = getRealNodeId(targetId)
const blockIdxMap = new Map(blocksList.value.map((b, idx) => [b.id, idx]))
if (blockIdxMap.has(targetId)) return blockIdxMap.get(targetId)!
let curr = editorStore.nodeMap.get(targetId)
if (blockIdxMap.has(realId)) return blockIdxMap.get(realId)!
let curr = editorStore.nodeMap.get(realId)
while (curr) {
if (blockIdxMap.has(curr.node.id)) return blockIdxMap.get(curr.node.id)!
curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined
......@@ -242,22 +278,27 @@ export function useEditorPanel() {
let el: HTMLElement | null = null
let foundNodeId: string | null = null
const path: XmlNode[] = []
let curr = editorStore.nodeMap.get(newId)
const realId = getRealNodeId(newId)
let curr = editorStore.nodeMap.get(realId)
while (curr) {
path.unshift(curr.node)
curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined
}
if (blockEl && path.length > 0) {
for (let i = path.length - 1; i >= 0; i--) {
if (blockEl.dataset.nodeId === path[i].id) {
el = blockEl
foundNodeId = path[i].id
break
}
el = blockEl.querySelector(`[data-node-id="${path[i].id}"]`) as HTMLElement | null
if (el) {
foundNodeId = path[i].id
break
// 优先在所属块内寻找虚拟文本节点本身的 DOM 元素
el = blockEl.querySelector(`[data-node-id="${newId}"]`) as HTMLElement | null
if (!el) {
for (let i = path.length - 1; i >= 0; i--) {
if (blockEl.dataset.nodeId === path[i].id) {
el = blockEl
foundNodeId = path[i].id
break
}
el = blockEl.querySelector(`[data-node-id="${path[i].id}"]`) as HTMLElement | null
if (el) {
foundNodeId = path[i].id
break
}
}
}
}
......@@ -282,13 +323,17 @@ export function useEditorPanel() {
top: Math.max(0, pos.top - vHeight / 2 + pos.height / 2),
behavior: 'instant' as ScrollBehavior
})
// 立即同步更新 scrollTop.value,避免等待 scroll 事件 + rAF 延迟,从而在当前 Tick / nextTick 立即渲染出目标块 DOM
scrollTop.value = viewportRef.value.scrollTop
// 等挂载后再精确定位
setTimeout(() => {
if (!viewportRef.value) return
let targetEl: HTMLElement | 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--) {
targetEl = viewportRef.value.querySelector(`[data-node-id="${path[i].id}"]`) as HTMLElement | null
if (targetEl) {
......
......@@ -88,20 +88,11 @@ const toggleFindReplace = () => {
findReplaceVisible.value = !findReplaceVisible.value
}
const handleKeydown = (e: KeyboardEvent) => {
const ctrl = e.ctrlKey || e.metaKey
if (ctrl && e.key.toLowerCase() === 'f') {
e.preventDefault()
// 查找快捷键监听 (Ctrl+F)
useKeyboardShortcuts({
onFind: () => {
findReplaceVisible.value = true
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
const handleEditSelectedNode = () => {
......
......@@ -33,6 +33,7 @@ const {
} = useInsertFragmentModal()
defineExpose({
open
open,
visible
})
</script>
......@@ -6,5 +6,4 @@ export interface ListItemModel {
}
// 静态有序列表标签定义
export const ORDERED_LIST_TAGS = ['LIST1', 'LIST2', 'LIST3']
export const ALL_LIST_TAGS = ['LIST1', 'LIST2', 'LIST3', 'UNLIST']
export { ORDERED_LIST_TAGS, ALL_LIST_TAGS } from '@/configs/xmlTags'
......@@ -2,6 +2,7 @@ import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode'
import type { ListItemModel } from '../constants'
import { ALL_LIST_TAGS } from '../constants'
import { PARA_TAGS } from '@/configs/xmlTags'
/**
* 列表结构编辑器 (ListEditor) 组件专用 Hook 逻辑
......@@ -34,7 +35,7 @@ export function useListEditor() {
.filter(c => c.tagName === itemTagName)
.map(itemNode => {
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) {
text = firstPara.textContent || ''
}
......@@ -51,7 +52,7 @@ export function useListEditor() {
const itemNode = node.children.find(c => c.id === itemId)
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) {
para.textContent = text
if (para.mixedContent.length > 0) {
......
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'
// 列表/文档型叶子节点标签判定
export const DOCUMENT_LIKE_TAGS = ['PARA', 'PARAC', 'TITLE', 'TITLEC', 'WARNING', 'CAUTION', 'NOTE']
export { DOCUMENT_LIKE_TAGS } from '@/configs/xmlTags'
// 查看规则弹窗数据定义
export interface CheckRuleData {
......@@ -33,3 +33,20 @@ export interface FlatNode {
isExpanded: boolean
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
}
<template>
<div class="flex flex-col h-full border-r border-divider relative bg-card">
<!-- 搜索过滤 -->
<div class="p-3 border-b border-divider">
<n-input v-model:value="pattern" placeholder="搜索节点名称..." size="small">
<!-- 搜索过滤与批量管理 -->
<div class="p-3 border-b border-divider flex items-center space-x-2">
<n-input v-model:value="pattern" placeholder="搜索节点名称..." size="small" class="flex-1">
<template #prefix>
<n-icon><search-outline /></n-icon>
</template>
</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>
<!-- 虚拟滚动树组件容器 -->
......@@ -35,20 +64,32 @@
:class="[
editorStore.selectedNodeId === item.id
? 'bg-primary text-white tree-node-selected'
: hasCustomColor(item)
? 'hover:bg-fill-3'
: 'hover:bg-fill-3 text-color2'
: (showDropdown || isAnyModalVisible) && contextNodeId === item.id
? 'tree-node-context-active'
: hasCustomColor(item)
? 'hover:bg-fill-3'
: 'hover:bg-fill-3 text-color2'
]"
:style="[
{
paddingLeft: item.depth * 20 + 8 + 'px',
'--tree-level': item.depth
paddingLeft: item.depth * 20 + 8 + (isBatchMode ? 24 : 0) + 'px',
'--tree-level': item.depth,
'--batch-shift': isBatchMode ? '24px' : '0px'
},
getNodeStyle(item)
]"
@click="handleSelect(item.id)"
@contextmenu.prevent="(e) => handleContextMenu(e, item)"
>
<!-- 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 (展开/折叠 减号/加号) -->
<div
v-if="item.hasChildren"
......@@ -88,7 +129,13 @@
v-if="item.subtitle"
class="text-xs truncate"
: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)"
></span>
......@@ -109,8 +156,8 @@
:options="dropdownOptions"
:x="dropdownX"
:y="dropdownY"
@clickoutside="showDropdown = false"
@select="handleDropdownSelect"
@clickoutside="showDropdown = false"
/>
<!-- 查看规则弹窗 -->
......@@ -124,17 +171,21 @@
<!-- 插入 XML 片段弹窗 -->
<InsertFragmentModal ref="insertFragmentModalRef" />
<!-- 批量删除确认弹窗 -->
<BatchDeleteConfirmModal ref="batchDeleteConfirmModalRef" @confirm="clearBatchSelection" />
</div>
</template>
<script setup lang="ts">
import { SearchOutline } from '@vicons/ionicons5'
import { SearchOutline, ListOutline, TrashOutline, CloseOutline } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor'
import { useNodeTree } from './functionals'
import { useNodeTree, checkRuleVisible, viewXmlVisible, addNodeVisible } from './functionals'
import CheckRuleModal from './components/CheckRuleModal/index.vue'
import AddNodeModal from './components/AddNodeModal/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<{
expandedKeys: string[]
......@@ -145,6 +196,10 @@ const emit = defineEmits(['update:expandedKeys'])
const editorStore = useEditorStore()
const insertFragmentModalRef = ref<any>(null)
const isAnyModalVisible = computed(() => {
return checkRuleVisible.value || viewXmlVisible.value || addNodeVisible.value || !!insertFragmentModalRef.value?.visible
})
const {
pattern,
viewportRef,
......@@ -157,6 +212,7 @@ const {
dropdownOptions,
dropdownX,
dropdownY,
contextNodeId,
handleScroll,
handleSelect,
handleContextMenu,
......@@ -165,10 +221,26 @@ const {
highlightText,
handleDropdownSelect,
hasCustomColor,
getNodeStyle
} = useNodeTree(props, emit, (mode, nodeId) => {
insertFragmentModalRef.value?.open(mode, nodeId)
})
getNodeStyle,
// 批量管理模式
isBatchMode,
selectedKeys,
toggleBatchMode,
handleToggleCheck,
clearBatchSelection,
handleBatchDelete
} = useNodeTree(
props,
emit,
(mode, nodeId) => {
insertFragmentModalRef.value?.open(mode, nodeId)
},
(ids) => {
batchDeleteConfirmModalRef.value?.open(ids)
}
)
const batchDeleteConfirmModalRef = ref<any>(null)
</script>
<style scoped>
......@@ -214,7 +286,7 @@ const {
width: 12px;
border-top: 1px dashed var(--divider-color, rgba(0, 0, 0, 0.15));
pointer-events: none;
left: calc(var(--tree-level, 0) * 20px + 8px);
left: calc(var(--tree-level, 0) * 20px + 8px + var(--batch-shift, 0px));
}
/* 根级节点不显示水平虚线 */
......@@ -236,4 +308,21 @@ const {
background-color: rgba(255, 255, 255, 0.3) !important;
color: #fff !important;
}
/* 右键或弹窗高亮时的节点样式 */
.tree-node-context-active {
background-color: var(--primary1) !important;
outline: 1px solid var(--primary3) !important;
}
.tree-node-context-active :deep(span),
.tree-node-context-active :deep(.n-icon),
.tree-node-context-active :deep(svg) {
color: var(--primary-color) !important;
}
/* 右键或弹窗高亮时的子标题样式 */
.tree-node-context-active-subtitle {
color: var(--primary-color) !important;
opacity: 0.8;
}
</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 {
id: string
tagName: string
text: string
attributes?: Record<string, string>
}
export interface TableCellModel {
......@@ -38,5 +39,30 @@ export interface TableStructureModel {
}
// 表格组件相关的提示文本定义
export const CELL_EDIT_TIP = '提示:表格支持直接点击单元格进行双击/聚焦修改,光标离开时自动保存文本。'
export const CELL_EDIT_TIP = '提示:直接点击单元格可聚焦修改,光标离开时自动保存。Ctrl + 点击可多选单元格。'
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 },
}
......@@ -3,14 +3,3 @@
*/
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