Commit dccdbde4 by pangchong

feat(editor): 集成DTD规范校验及专属错误弹窗

- 在编辑器视图中新增DTD校验结果弹窗组件DtdValidateModal
- 实现基于DTD规则对当前XML文档树的严格合规校验
- 校验过程中支持元素定义、必填属性、枚举值、子元素顺序及出现次数等多维度检测
- 校验首个错误将返回详细定位信息,包括节点ID、路径和具体违规描述
- 编辑器工具栏新增“验证DTD”按钮,点击触发整体DTD校验并展示结果
- 新增校验弹窗,显示错误详情,可手动定位并高亮首个错误节点
- 优化节点树和文档大纲组件,实现自动滚动至并高亮当前选择节点
- 更新CommonModal组件插槽逻辑,支持自定义footer内容以适配新弹窗需求
- 增加多个类型定义及辅助函数,完善DTD规则加载和验证逻辑,支持动态环境扩展读取
- 新增FAQ条目,指导用户如何使用一键验证XML内容合规性功能
parent dcde4ffd
...@@ -37,16 +37,17 @@ ...@@ -37,16 +37,17 @@
</template> </template>
<template #footer v-if="showFooter"> <template #footer v-if="showFooter">
<n-space justify="end" align="center" class="p-[10px]"> <div v-if="$slots.footer" class="p-[10px] w-full">
<slot name="footer"> <slot name="footer"></slot>
<slot name="footer-extra"></slot> </div>
<CommonButton v-if="showCancel" @click="handleCancel" :disabled="loading"> <n-space v-else justify="end" align="center" class="p-[10px]">
{{ cancelText }} <slot name="footer-extra"></slot>
</CommonButton> <CommonButton v-if="showCancel" @click="handleCancel" :disabled="loading">
<CommonButton v-if="showConfirm" type="primary" :loading="loading" @click="handleConfirm"> {{ cancelText }}
{{ confirmText }} </CommonButton>
</CommonButton> <CommonButton v-if="showConfirm" type="primary" :loading="loading" @click="handleConfirm">
</slot> {{ confirmText }}
</CommonButton>
</n-space> </n-space>
</template> </template>
</n-modal> </n-modal>
......
...@@ -1031,6 +1031,28 @@ export const FAQ_DATABASE: FaqItem[] = [ ...@@ -1031,6 +1031,28 @@ export const FAQ_DATABASE: FaqItem[] = [
isHot: false isHot: false
}, },
{ {
id: 'faq_validate_xml',
category: 'file',
categoryName: '文件与导出',
title: '如何验证 XML 内容是否符合当前 DTD 规范?',
keywords: ['验证', '校验', 'dtd验证', 'xml验证', '合规性', '检查错误', '定位错误', '验证dtd', '校验xml', 'yz', 'yanzheng', 'jy', 'jiaoyan'],
synonyms: ['怎么验证xml', 'xml是否合规', '检查dtd错误', '定位xml错误节点', '校验dtd规则'],
answer: '系统提供一键验证整篇 XML 文档能力。验证会按文档由上至下的顺序依次严格校验元素定义、必填属性、枚举取值、EMPTY/PCDATA 约束、子元素包含关系、出现次数及排列顺序。',
steps: [
'点击顶部工具栏的【验证 DTD】按钮。',
'若完全合规,系统将即时提示验证通过,确认文档结构与属性完全符合 DTD 规范约束。',
'若存在不合规项,系统会弹出专属的【DTD 规范校验未通过】弹框,醒目呈现具体违规原因、规则类别、违规节点标签及完整的层级路径。',
'弹窗不会自动强制跳转,避免打断当前编辑视口;用户核对错误后可手动点击弹窗右下角的【定位到错误节点】按钮,系统将自动展开节点树并在编辑器中高亮聚焦该节点。'
],
highlights: [
'扫描严格遵循自顶向下的文档先序遍历,优先发现并捕获整篇文档中最靠前的一处结构或属性违规。',
'采用独立业务弹窗醒目呈现错误详情,支持用户在弹窗中自主决定手动定位或直接关闭,交互平滑不干扰编辑。'
],
actions: [{ label: '立即验证 XML', type: 'event', payload: 'trigger_validate_xml' }],
relatedQuestions: ['faq_upload_dtd', 'faq_export_xml'],
isHot: true
},
{
id: 'faq_export_xml', id: 'faq_export_xml',
category: 'file', category: 'file',
categoryName: '文件与导出', categoryName: '文件与导出',
......
...@@ -109,3 +109,30 @@ export interface TreeNodeOption { ...@@ -109,3 +109,30 @@ export interface TreeNodeOption {
children?: TreeNodeOption[] children?: TreeNodeOption[]
prefix?: string prefix?: string
} }
/**
* DTD 校验错误详细信息
*/
export interface DtdValidationError {
/** 错误节点的唯一 ID,用于直接在编辑器与节点树中定位并高亮 */
nodeId: string
/** 错误节点的标签名称 */
tagName: string
/** 从根节点到错误节点的完整层级路径 (例如: JOBCARD > CEP > TOPIC > STEP) */
path: string
/** 错误分类类型 */
type:
| 'undefined_element'
| 'empty_violation'
| 'pcdata_violation'
| 'missing_attribute'
| 'invalid_attribute'
| 'fixed_attribute_mismatch'
| 'disallowed_child'
| 'occurrence_violation'
| 'missing_child'
| 'order_violation'
| 'schema_not_loaded'
/** 具体错误原因描述 */
message: string
}
import type { DtdValidationError } from '@/types/xmlNode'
/**
* DtdValidateModal 组件名称
*/
export const COMPONENT_NAME = 'DtdValidateModal'
/**
* 校验错误类型分类映射字典
*/
export const ERROR_CATEGORY_MAP: Record<DtdValidationError['type'], string> = {
undefined_element: '未定义的元素',
empty_violation: 'EMPTY 元素违规包含内容',
pcdata_violation: '纯元素模型违规包含文本',
missing_attribute: '缺少必填属性 (#REQUIRED)',
invalid_attribute: '属性取值不符合规则约束',
fixed_attribute_mismatch: '固定属性值不匹配 (#FIXED)',
disallowed_child: '不允许的子元素',
occurrence_violation: '子元素出现次数超出上限',
missing_child: '缺少必填子元素',
order_violation: '子元素先后顺序违规',
schema_not_loaded: 'DTD 规则未就绪'
}
/**
* 弹窗组件暴露的方法接口
*/
export interface DtdValidateModalExpose {
open: (error: DtdValidationError) => void
close: () => void
}
import type { DtdValidationError } from '@/types/xmlNode'
import { ERROR_CATEGORY_MAP } from '../constants'
import { useEditorStore } from '@/store/editor'
/**
* DTD 校验结果弹框专用业务逻辑 Hook
*/
export function useDtdValidateModal() {
const editorStore = useEditorStore()
const show = ref(false)
const errorInfo = ref<DtdValidationError | null>(null)
// 将路径按照 ' > ' 分割为路径数组
const pathSegments = computed(() => {
if (!errorInfo.value?.path) return []
return errorInfo.value.path
.split(' > ')
.map((seg) => seg.trim())
.filter(Boolean)
})
// 错误分类标签文案
const errorCategoryName = computed(() => {
if (!errorInfo.value?.type) return 'DTD 规范违规'
return ERROR_CATEGORY_MAP[errorInfo.value.type] || 'DTD 规范违规'
})
// 打开弹窗,载入错误信息
const open = (error: DtdValidationError) => {
errorInfo.value = error
show.value = true
}
// 关闭弹窗(保持当前节点不变)
const close = () => {
show.value = false
}
// 手动定位到错误节点并高亮聚焦
const locateErrorNode = () => {
if (errorInfo.value?.nodeId) {
editorStore.setSelectedNodeId(errorInfo.value.nodeId, true)
window.$message.success(`已定位并聚焦到错误节点 <${errorInfo.value.tagName}>`)
}
show.value = false
}
return {
show,
errorInfo,
pathSegments,
errorCategoryName,
open,
close,
locateErrorNode
}
}
<template>
<CommonModal
v-model="show"
title="DTD 规范校验未通过"
:auto-focus="false"
:width="640"
:show-confirm="false"
:show-cancel="false"
class="dtd-validate-modal"
>
<div class="space-y-4">
<!-- 醒目错误警示卡片 -->
<div class="p-4 rounded-lg bg-danger-1 border border-danger-3 flex items-start gap-3">
<n-icon size="26" class="text-danger flex-shrink-0 mt-0.5">
<AlertCircleOutline />
</n-icon>
<div class="flex-1 min-w-0">
<div class="text-base font-bold text-danger">检测到不符合 DTD 规范的结构错误</div>
<div class="text-xs text-color3 mt-1 leading-relaxed">
系统按自顶向下先序遍历规则找到了首个违规节点。您可以查看下方的具体错误原因与节点路径,并点击底部按钮手动定位到问题节点。
</div>
</div>
</div>
<!-- 错误详情区 -->
<div class="p-4 rounded-lg bg-fill-2 border border-color2 space-y-3.5">
<!-- 错误原因 -->
<div>
<div class="text-xs font-semibold text-color3 mb-1.5">具体违规原因:</div>
<div class="p-3 rounded bg-fill-1 border border-color1 text-sm font-medium text-color1 leading-relaxed select-text">
{{ errorInfo?.message }}
</div>
</div>
<!-- 节点标签 & 错误规则类别 -->
<div class="grid grid-cols-2 gap-3 pt-1">
<div class="flex items-center gap-2">
<span class="text-xs text-color3">违规节点:</span>
<CommonTag type="error" :value="`<${errorInfo?.tagName || ''}>`" class="font-mono font-semibold" />
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-color3">规则类别:</span>
<CommonTag type="warning" :value="errorCategoryName" />
</div>
</div>
<!-- 节点在 XML 文档树中的层级路径 -->
<div>
<div class="text-xs font-semibold text-color3 mb-1.5">节点所在层级路径:</div>
<div class="flex flex-wrap items-center gap-1.5 p-2.5 rounded bg-fill-1 border border-color1 select-text">
<template v-for="(seg, idx) in pathSegments" :key="idx">
<span
:class="[
'px-2 py-0.5 rounded text-xs font-mono transition-colors',
idx === pathSegments.length - 1
? 'bg-danger-1 text-danger font-bold border border-danger-3'
: 'bg-fill-3 text-color2'
]"
>
&lt;{{ seg }}&gt;
</span>
<span v-if="idx < pathSegments.length - 1" class="text-color4 text-xs font-bold">/</span>
</template>
</div>
</div>
</div>
</div>
<template #footer>
<div class="dtd-modal-footer flex items-center justify-between w-full px-1">
<span class="text-xs text-color3 flex items-center gap-1 select-none">
<n-icon class="text-sm"><InformationCircleOutline /></n-icon>
<span>提示:关闭弹框不会更改当前所选节点位置</span>
</span>
<div class="flex items-center gap-2">
<CommonButton @click="close">关闭</CommonButton>
<CommonButton type="primary" @click="locateErrorNode">
<template #icon>
<n-icon><LocateOutline /></n-icon>
</template>
定位到错误节点
</CommonButton>
</div>
</div>
</template>
</CommonModal>
</template>
<script setup lang="ts">
import { AlertCircleOutline, LocateOutline, InformationCircleOutline } from '@vicons/ionicons5'
import { useDtdValidateModal } from './functionals'
const { show, errorInfo, pathSegments, errorCategoryName, open, close, locateErrorNode } = useDtdValidateModal()
defineExpose({
open,
close
})
</script>
<style scoped></style>
...@@ -406,6 +406,21 @@ ...@@ -406,6 +406,21 @@
</button> </button>
</n-dropdown> </n-dropdown>
<!-- 验证 DTD 规则 -->
<n-tooltip trigger="hover" :show-delay="300">
<template #trigger>
<button
type="button"
class="toolbar-btn flex items-center gap-1.5 px-2.5 h-7 rounded text-color2 hover:bg-fill-2 active:bg-fill-3 transition-colors select-none focus:outline-none flex-shrink-0"
@click="emit('validate')"
>
<n-icon class="text-base"><checkmark-circle-outline /></n-icon>
<span class="text-xs font-medium">验证 DTD</span>
</button>
</template>
校验当前整篇 XML 内容是否符合 DTD 规范约束,并定位首个错误
</n-tooltip>
<button <button
type="button" type="button"
class="toolbar-btn flex items-center gap-1.5 px-2.5 h-7 rounded text-color2 hover:bg-fill-2 active:bg-fill-3 transition-colors select-none focus:outline-none flex-shrink-0 disabled:opacity-50" class="toolbar-btn flex items-center gap-1.5 px-2.5 h-7 rounded text-color2 hover:bg-fill-2 active:bg-fill-3 transition-colors select-none focus:outline-none flex-shrink-0 disabled:opacity-50"
...@@ -642,6 +657,7 @@ onMounted(() => { ...@@ -642,6 +657,7 @@ onMounted(() => {
eventBus.on('trigger_insert_template', () => handleInsert('TEMPLATE')) eventBus.on('trigger_insert_template', () => handleInsert('TEMPLATE'))
eventBus.on('trigger_insert_fragment', () => insertFragmentModalRef.value?.open(insertBelow.value)) eventBus.on('trigger_insert_fragment', () => insertFragmentModalRef.value?.open(insertBelow.value))
eventBus.on('trigger_upload_dtd', () => triggerUploadDtd()) eventBus.on('trigger_upload_dtd', () => triggerUploadDtd())
eventBus.on('trigger_validate_xml', () => emit('validate'))
eventBus.on('trigger_import_xml', () => triggerUpload()) eventBus.on('trigger_import_xml', () => triggerUpload())
eventBus.on('trigger_export_xml', () => emit('export')) eventBus.on('trigger_export_xml', () => emit('export'))
eventBus.on('trigger_preview', () => emit('preview')) eventBus.on('trigger_preview', () => emit('preview'))
......
...@@ -366,6 +366,13 @@ export function useDocOutlineView(props: DocOutlineProps) { ...@@ -366,6 +366,13 @@ export function useDocOutlineView(props: DocOutlineProps) {
} }
) )
watch(
() => editorStore.forceScrollSignal,
() => {
scrollToActiveOutline()
}
)
const scrollLeft = ref(0) const scrollLeft = ref(0)
const viewportWidth = ref(0) const viewportWidth = ref(0)
const scrollWidth = ref(0) const scrollWidth = ref(0)
......
...@@ -691,19 +691,23 @@ export function useNodeTree( ...@@ -691,19 +691,23 @@ export function useNodeTree(
if (isTreeSelecting.value) { if (isTreeSelecting.value) {
return return
} }
const targetScrollId = effectiveSelectedId.value || newId const doScroll = () => {
const idx = flatList.value.findIndex((item) => item.id === targetScrollId) const targetScrollId = effectiveSelectedId.value || newId
if (idx !== -1 && viewportRef.value) { const idx = flatList.value.findIndex((item) => item.id === targetScrollId)
const itemTop = idx * ITEM_HEIGHT if (idx !== -1 && viewportRef.value) {
const vHeight = viewportRef.value.clientHeight const itemTop = idx * ITEM_HEIGHT
const vHeight = viewportRef.value.clientHeight
const targetTop = Math.max(0, itemTop - vHeight / 2)
viewportRef.value.scrollTo({ const targetTop = Math.max(0, itemTop - vHeight / 2)
top: targetTop, viewportRef.value.scrollTo({
behavior: 'auto' top: targetTop,
}) behavior: 'auto'
scrollTop.value = targetTop })
scrollTop.value = targetTop
}
} }
doScroll()
setTimeout(doScroll, 60)
}) })
} }
...@@ -715,6 +719,16 @@ export function useNodeTree( ...@@ -715,6 +719,16 @@ export function useNodeTree(
} }
) )
// 监听强制滚动定位信号,即使 selectedNodeId 未改变也必须强制重新展开并滚动定位
watch(
() => editorStore.forceScrollSignal,
() => {
if (editorStore.selectedNodeId) {
syncTreeSelection(editorStore.selectedNodeId, true)
}
}
)
// 监听回退/重做以同步树的展开与定位 // 监听回退/重做以同步树的展开与定位
watch( watch(
() => editorStore.lastUndoRedoTime, () => editorStore.lastUndoRedoTime,
......
import { useEditorStore } from '@/store/editor' import { useEditorStore } from '@/store/editor'
import { useDtdStore } from '@/store/dtd' import { useDtdStore } from '@/store/dtd'
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode, DtdValidationError } from '@/types/xmlNode'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
import { DEFAULT_XML_FILE } from '@/configs' import { DEFAULT_XML_FILE } from '@/configs'
import { currentJobCardMainData } from '../constants' import { currentJobCardMainData } from '../constants'
// 导入通用及定制环境目录下的本地默认模板 XML 文本 // 导入通用及定制环境目录下的本地默认模板 XML 文本
...@@ -103,55 +102,20 @@ export function useEditor() { ...@@ -103,55 +102,20 @@ export function useEditor() {
return keys return keys
} }
const validate = (): void => { const validate = (): DtdValidationError | null => {
if (!store.xmlTree) return if (!store.xmlTree) {
window.$message.warning('当前暂无可验证的 XML 文档内容!')
const warnings: string[] = [] return null
const validateNode = (node: XmlNode) => {
// 虚拟辅助排版节点本身不需要 DTD 验证
if (VIRTUAL_LAYOUT_TAGS.includes(node.tagName)) {
return
}
const rule = getElementRule(node.tagName)
if (rule) {
for (const [attrName, attrDef] of Object.entries(rule.attributes)) {
if (attrDef.requirement === '#REQUIRED' && !node.attributes[attrName]) {
warnings.push(`节点 <${node.tagName}> 缺失必填属性: ${attrName}`)
}
}
for (const child of node.children) {
// 分页符等虚拟节点不参与 DTD 子节点合法性校验
if (VIRTUAL_LAYOUT_TAGS.includes(child.tagName)) {
continue
}
if (!rule.allowedChildren.includes(child.tagName)) {
warnings.push(`节点 <${node.tagName}> 包含不允许的子节点 <${child.tagName}>`)
}
}
} else {
warnings.push(`元素 <${node.tagName}> 在 DTD 中未定义`)
}
node.children.forEach(validateNode)
} }
validateNode(store.xmlTree) const error = validateXmlAgainstDtd(store.xmlTree)
if (warnings.length === 0) { if (!error) {
window.$notification.success('未发现任何结构与属性异常,XML 结构完全合规!', { window.$message.success('DTD 验证通过!当前文档结构与属性完全符合 DTD 规范约束')
title: 'DTD 验证成功' return null
})
} else {
window.$notification.warning(`发现 ${warnings.length} 处潜在的合规性问题。`, {
title: 'DTD 语法检测警告',
meta: () =>
h(
'div',
{ class: 'mt-2 max-h-48 overflow-auto space-y-1' },
warnings.slice(0, 10).map((w) => h('div', { class: 'text-xs text-warning' }, w))
)
})
} }
return error
} }
const prepareCleanedHtml = () => { const prepareCleanedHtml = () => {
......
...@@ -41,6 +41,9 @@ ...@@ -41,6 +41,9 @@
<div id="print-preview-container" style="display: none"> <div id="print-preview-container" style="display: none">
<DocNodeRenderer v-if="editorStore.xmlTree" :node="editorStore.xmlTree" :parent="null" /> <DocNodeRenderer v-if="editorStore.xmlTree" :node="editorStore.xmlTree" :parent="null" />
</div> </div>
<!-- DTD 校验结果弹窗 (业务专有组件) -->
<DtdValidateModal ref="dtdValidateModalRef" />
</div> </div>
</template> </template>
...@@ -53,10 +56,12 @@ import NodeTree from './components/NodeTree/index.vue' ...@@ -53,10 +56,12 @@ import NodeTree from './components/NodeTree/index.vue'
import EditorPanel from './components/EditorPanel/index.vue' import EditorPanel from './components/EditorPanel/index.vue'
import Splitter from './components/Splitter/index.vue' import Splitter from './components/Splitter/index.vue'
import DocNodeRenderer from './components/DocNodeRenderer/index.vue' import DocNodeRenderer from './components/DocNodeRenderer/index.vue'
import DtdValidateModal from './components/DtdValidateModal/index.vue'
const themeVars = useThemeVars() const themeVars = useThemeVars()
const editorStore = useEditorStore() const editorStore = useEditorStore()
const appStore = useAppStore() const appStore = useAppStore()
const dtdValidateModalRef = ref<InstanceType<typeof DtdValidateModal>>()
const expandedKeys = computed({ const expandedKeys = computed({
get: () => editorStore.expandedKeys, get: () => editorStore.expandedKeys,
set: (val) => editorStore.setExpandedKeys(val) set: (val) => editorStore.setExpandedKeys(val)
...@@ -85,7 +90,12 @@ const handleCollapseAll = () => { ...@@ -85,7 +90,12 @@ const handleCollapseAll = () => {
expandedKeys.value = [editorStore.xmlTree.id] expandedKeys.value = [editorStore.xmlTree.id]
} }
} }
const handleValidate = () => validate() const handleValidate = () => {
const error = validate()
if (error) {
dtdValidateModalRef.value?.open(error)
}
}
</script> </script>
<style scoped></style> <style scoped></style>
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