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 @@
</template>
<template #footer v-if="showFooter">
<n-space justify="end" align="center" class="p-[10px]">
<slot name="footer">
<slot name="footer-extra"></slot>
<CommonButton v-if="showCancel" @click="handleCancel" :disabled="loading">
{{ cancelText }}
</CommonButton>
<CommonButton v-if="showConfirm" type="primary" :loading="loading" @click="handleConfirm">
{{ confirmText }}
</CommonButton>
</slot>
<div v-if="$slots.footer" class="p-[10px] w-full">
<slot name="footer"></slot>
</div>
<n-space v-else justify="end" align="center" class="p-[10px]">
<slot name="footer-extra"></slot>
<CommonButton v-if="showCancel" @click="handleCancel" :disabled="loading">
{{ cancelText }}
</CommonButton>
<CommonButton v-if="showConfirm" type="primary" :loading="loading" @click="handleConfirm">
{{ confirmText }}
</CommonButton>
</n-space>
</template>
</n-modal>
......
......@@ -1031,6 +1031,28 @@ export const FAQ_DATABASE: FaqItem[] = [
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',
category: 'file',
categoryName: '文件与导出',
......
......@@ -109,3 +109,30 @@ export interface TreeNodeOption {
children?: TreeNodeOption[]
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 { DtdSchema, DtdElement, DtdAttribute, DtdContentModel, XmlNode } from '@/types/xmlNode'
import type { DtdSchema, DtdElement, DtdAttribute, DtdContentModel, XmlNode, DtdValidationError } from '@/types/xmlNode'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
export type { DtdValidationError }
/**
* DTD 规则管理器
* 提供基于 DTD 规范的元素规则查询能力
*/
// 顶层预加载各定制环境(如 bx、xm 等)下的 dtdExtension.json 扩展配置文件
const extensionModules = import.meta.glob<Record<string, any>>(
['../*/assets/json/dtdExtension.json', '../**/assets/json/dtdExtension.json', '../*/assets/json/*.json'],
{
eager: true,
import: 'default'
}
)
let _schema: DtdSchema | null = null
/**
* 加载 DTD Schema(支持传入 DtdSchema 对象或 DTD 原文文本)
* 并自动整合当前环境变量 (VITE_CUSTOM_CODE) 下对应定制目录的 dtdExtension.json 扩展规则
*/
export function loadDtdSchema(input: DtdSchema | string): void {
const rawSchema = typeof input === 'string' ? parseDtd(input) : input
......@@ -17,21 +29,19 @@ export function loadDtdSchema(input: DtdSchema | string): void {
const schema = JSON.parse(JSON.stringify(rawSchema)) as DtdSchema
// 根据定制环境代号 VITE_CUSTOM_CODE 自动加载对应的 DTD 扩展配置
const customCode = import.meta.env.VITE_CUSTOM_CODE
const customCode = typeof import.meta !== 'undefined' && import.meta.env ? import.meta.env.VITE_CUSTOM_CODE : undefined
let dtdExtension: any = null
if (customCode) {
// 使用 import.meta.glob 匹配并载入所有子目录下的 assets/json 目录中的 json 文件
const modules = import.meta.glob('../**/assets/json/*.json', { eager: true }) as Record<string, any>
const matchKey = Object.keys(modules).find(
const matchKey = Object.keys(extensionModules).find(
(key) => key.toLowerCase().includes(`/${customCode.toLowerCase()}/`) && key.toLowerCase().endsWith('/dtdextension.json')
)
if (matchKey) {
dtdExtension = modules[matchKey].default || modules[matchKey]
dtdExtension = extensionModules[matchKey]
}
}
// 合并自定义 DTD 扩展配置
// 合并自定义 DTD 扩展配置 (如 SIGNOFF 扩展的 CK-LEVEL 枚举值列表等)
if (dtdExtension && dtdExtension.elements) {
for (const [tagName, elementExt] of Object.entries(dtdExtension.elements as Record<string, any>)) {
if (schema.elements[tagName]) {
......@@ -39,14 +49,16 @@ export function loadDtdSchema(input: DtdSchema | string): void {
if (elementExt && elementExt.attributes) {
for (const [attrName, attrExt] of Object.entries(elementExt.attributes as Record<string, any>)) {
if (targetElement.attributes[attrName]) {
// 覆盖属性定义
// 覆盖/合并属性定义(包括 enumValues 枚举集合与 typeDefinition)
Object.assign(targetElement.attributes[attrName], attrExt)
} else {
// 新增属性定义
// 新增扩展属性定义
targetElement.attributes[attrName] = attrExt as any
}
}
}
} else {
schema.elements[tagName] = elementExt as any
}
}
}
......@@ -351,19 +363,27 @@ function buildSequenceConstraints(model: DtdContentModel): Array<[string, string
const process = (m: DtdContentModel) => {
if (m.type === 'sequence' && m.children && m.children.length > 1) {
// 同一 sequence 中:位置 i 的元素集合必须早于位置 j (j>i) 的元素集合
for (let i = 0; i < m.children.length; i++) {
const before = getLeafNames(m.children[i])
for (let j = i + 1; j < m.children.length; j++) {
const after = getLeafNames(m.children[j])
for (const a of before) {
for (const b of after) {
// 跳过自相矛盾的约束:若 a 同时出现在 after 段(即 a 可在后段再次出现),
// 说明 a 与 b 之间无严格先后顺序,不应生成约束,否则会导致
// (a→b) 和 (b→a) 同时存在,使任何排列都无法通过验证。
if (a !== b && !after.includes(a) && !constraints.some(([x, y]) => x === a && y === b)) {
constraints.push([a, b])
}
// 收集同一 sequence 每一项中出现的所有叶子元素名
const segmentsLeafNames: string[][] = m.children.map(getLeafNames)
const minSeg: Record<string, number> = {}
const maxSeg: Record<string, number> = {}
segmentsLeafNames.forEach((names, idx) => {
names.forEach((name) => {
if (minSeg[name] === undefined) minSeg[name] = idx
maxSeg[name] = idx
})
})
// 仅当 a 的所有可能出现段(maxSeg[a])均严格早于 b 的任何出现段(minSeg[b])时,
// 才能确定 a 必须严格整体先于 b 出现。
// 若 a 可在 b 之后出现(或反之),说明两者在该序列中存在交叉或重复,不应生成单向约束。
const allNames = Object.keys(minSeg)
for (const a of allNames) {
for (const b of allNames) {
if (a !== b && maxSeg[a] < minSeg[b]) {
if (!constraints.some(([x, y]) => x === a && y === b)) {
constraints.push([a, b])
}
}
}
......@@ -587,3 +607,265 @@ export function moveNodeInParent(parent: XmlNode | null | undefined, nodeId: str
return false
}
/**
* 递归解析并返回内容模型中所缺失的第一个必填/必选子元素描述
*/
function findMissingRequiredChildDesc(model: DtdContentModel, tags: string[]): string | null {
const occurrence = model.occurrence || 'once'
const isRequired = occurrence === 'once' || occurrence === 'oneOrMore'
// 1. 如果是叶子节点元素引用
if (model.type === 'elementRef' && model.name) {
const count = tags.filter((t) => t === model.name).length
if (isRequired && count === 0) {
return `缺少必填子元素 <${model.name}>`
}
return null
}
// 2. 如果是纯文本
if (model.type === 'pcdata') {
return null
}
// 3. 如果是选择组 (choice)
if (model.type === 'choice') {
if (!isRequired) return null
if (model.children && model.children.length > 0) {
const anySatisfied = model.children.some((child) => validateTagsAgainstModel(child, tags))
if (!anySatisfied) {
const childNames = model.children
.map((c) => (c.name ? `<${c.name}>` : c.type))
.join(' 或 ')
return `缺少必选子元素之一(必须包含 [${childNames}] 之一)`
}
}
return null
}
// 4. 如果是顺序组 (sequence)
if (model.type === 'sequence') {
if (!isRequired) return null
if (model.children) {
for (const child of model.children) {
if (!validateTagsAgainstModel(child, tags)) {
const missing = findMissingRequiredChildDesc(child, tags)
if (missing) return missing
}
}
}
return null
}
return null
}
/**
* 验证整个 XML 文档树是否符合当前 DTD 规范
* 采用从上往下的文档先序深度优先遍历,若不符合立即返回首个错误节点的精确定位及错误原因
*
* @param rootNode XML 根节点
* @returns 首个错误信息对象,若完全合规返回 null
*/
export function validateXmlAgainstDtd(rootNode: XmlNode): DtdValidationError | null {
const schema = getDtdSchema()
if (!schema) {
return {
nodeId: rootNode.id,
tagName: rootNode.tagName,
path: rootNode.tagName,
type: 'schema_not_loaded',
message: '当前 DTD 规范尚未载入,无法进行验证'
}
}
function traverse(node: XmlNode, pathSegments: string[]): DtdValidationError | null {
// 虚拟辅助排版节点(如 PAGEBREAK)忽略校验
if (VIRTUAL_LAYOUT_TAGS.includes(node.tagName)) {
return null
}
const currentPathSegments = [...pathSegments, node.tagName]
const currentPath = currentPathSegments.join(' > ')
// 1. 元素定义校验:是否存在于 DTD 中
const rule = getElementRule(node.tagName)
if (!rule) {
return {
nodeId: node.id,
tagName: node.tagName,
path: currentPath,
type: 'undefined_element',
message: `元素 <${node.tagName}> 未在当前生效的 DTD 规范中定义`
}
}
// 2. 属性合法性校验
if (rule.attributes) {
for (const [attrName, attrDef] of Object.entries(rule.attributes)) {
const attrVal = node.attributes ? node.attributes[attrName] : undefined
// a) 必填属性检查 (#REQUIRED)
if (attrDef.requirement === '#REQUIRED') {
if (attrVal === undefined || attrVal === null || attrVal.trim() === '') {
return {
nodeId: node.id,
tagName: node.tagName,
path: currentPath,
type: 'missing_attribute',
message: `元素 <${node.tagName}> 缺失必填属性 "${attrName}"`
}
}
}
// b) 固定值属性检查 (#FIXED)
if (attrDef.requirement === '#FIXED' && attrDef.defaultValue) {
if (attrVal !== undefined && attrVal !== null && attrVal !== attrDef.defaultValue) {
return {
nodeId: node.id,
tagName: node.tagName,
path: currentPath,
type: 'fixed_attribute_mismatch',
message: `元素 <${node.tagName}> 的属性 "${attrName}" 为固定值 (#FIXED),必须为 "${attrDef.defaultValue}",当前为 "${attrVal}"`
}
}
}
// c) 枚举值有效性检查
if (attrDef.enumValues && attrDef.enumValues.length > 0) {
if (attrVal !== undefined && attrVal !== null && attrVal.trim() !== '') {
if (!attrDef.enumValues.includes(attrVal)) {
return {
nodeId: node.id,
tagName: node.tagName,
path: currentPath,
type: 'invalid_attribute',
message: `元素 <${node.tagName}> 的属性 "${attrName}" 取值 "${attrVal}" 无效,有效取值范围为: [${attrDef.enumValues.join(', ')}]`
}
}
}
}
}
}
const model = rule.contentModel.parsed
const realChildren = (node.children || []).filter((c) => !VIRTUAL_LAYOUT_TAGS.includes(c.tagName))
const childTags = realChildren.map((c) => c.tagName)
// 3. EMPTY 空元素约束校验
if (model.type === 'empty') {
if (realChildren.length > 0) {
const firstChild = realChildren[0]
return {
nodeId: firstChild.id,
tagName: firstChild.tagName,
path: `${currentPath} > ${firstChild.tagName}`,
type: 'empty_violation',
message: `父元素 <${node.tagName}> 在 DTD 中定义为 EMPTY 空元素,不允许包含子元素 <${firstChild.tagName}>`
}
}
if (
(node.textContent && node.textContent.trim() !== '') ||
(node.mixedContent && node.mixedContent.some((m) => m.type === 'text' && m.text && m.text.trim() !== ''))
) {
return {
nodeId: node.id,
tagName: node.tagName,
path: currentPath,
type: 'empty_violation',
message: `元素 <${node.tagName}> 在 DTD 中定义为 EMPTY 空元素,不允许包含文本内容`
}
}
}
// 4. PCDATA (纯文本) 约束校验
if (model.type === 'pcdata') {
if (realChildren.length > 0) {
const firstChild = realChildren[0]
return {
nodeId: firstChild.id,
tagName: firstChild.tagName,
path: `${currentPath} > ${firstChild.tagName}`,
type: 'pcdata_violation',
message: `元素 <${node.tagName}> 为纯文本元素 (#PCDATA),不允许包含子元素 <${firstChild.tagName}>`
}
}
}
// 5. 校验当前节点在内容模型中是否缺失必填子元素
if (model.type !== 'empty' && model.type !== 'pcdata' && model.type !== 'mixed') {
if (!validateTagsAgainstModel(model, childTags)) {
const missingDesc = findMissingRequiredChildDesc(model, childTags)
return {
nodeId: node.id,
tagName: node.tagName,
path: currentPath,
type: 'missing_child',
message: `元素 <${node.tagName}> 未满足内容模型要求: ${missingDesc || `缺少必要的子元素组合(DTD 规范: ${rule.contentModel.raw})`}`
}
}
}
// 6. 子元素出现次数上限校验 (occurrence: once / optional)
const tagCounts = new Map<string, number>()
for (const child of realChildren) {
const count = (tagCounts.get(child.tagName) || 0) + 1
tagCounts.set(child.tagName, count)
const occ = findChildOccurrence(model, child.tagName)
if ((occ === 'once' || occ === 'optional') && count > 1) {
return {
nodeId: child.id,
tagName: child.tagName,
path: `${currentPath} > ${child.tagName}`,
type: 'occurrence_violation',
message: `元素 <${node.tagName}> 中的子元素 <${child.tagName}> 出现次数超限(DTD 约束为 ${occ === 'once' ? '只能出现 1 次' : '最多允许 1 次'},当前已出现第 ${count} 次)`
}
}
}
// 7. 子元素先后顺序校验 (sequence order constraints)
if (model.type !== 'mixed' && model.type !== 'pcdata') {
const constraints = buildSequenceConstraints(model)
if (constraints.length > 0) {
for (const [a, b] of constraints) {
const lastA = childTags.lastIndexOf(a)
const firstB = childTags.indexOf(b)
if (lastA !== -1 && firstB !== -1 && lastA > firstB) {
const offendingChild = realChildren[lastA]
return {
nodeId: offendingChild.id,
tagName: offendingChild.tagName,
path: `${currentPath} > ${offendingChild.tagName}`,
type: 'order_violation',
message: `元素 <${node.tagName}> 的子元素先后顺序违反 DTD 约束:子元素 <${a}> 必须全部在子元素 <${b}> 之前出现`
}
}
}
}
}
// 8. 逐个检查子元素本身(先序遍历:先检查当前子节点是否被父节点允许,再递归检查子节点自身)
for (const child of realChildren) {
if (!rule.allowedChildren.includes(child.tagName)) {
return {
nodeId: child.id,
tagName: child.tagName,
path: `${currentPath} > ${child.tagName}`,
type: 'disallowed_child',
message: `父元素 <${node.tagName}> 不允许包含子元素 <${child.tagName}>`
}
}
const childErr = traverse(child, currentPathSegments)
if (childErr) {
return childErr
}
}
return null
}
return traverse(rootNode, [])
}
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 @@
</button>
</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
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"
......@@ -642,6 +657,7 @@ onMounted(() => {
eventBus.on('trigger_insert_template', () => handleInsert('TEMPLATE'))
eventBus.on('trigger_insert_fragment', () => insertFragmentModalRef.value?.open(insertBelow.value))
eventBus.on('trigger_upload_dtd', () => triggerUploadDtd())
eventBus.on('trigger_validate_xml', () => emit('validate'))
eventBus.on('trigger_import_xml', () => triggerUpload())
eventBus.on('trigger_export_xml', () => emit('export'))
eventBus.on('trigger_preview', () => emit('preview'))
......
......@@ -366,6 +366,13 @@ export function useDocOutlineView(props: DocOutlineProps) {
}
)
watch(
() => editorStore.forceScrollSignal,
() => {
scrollToActiveOutline()
}
)
const scrollLeft = ref(0)
const viewportWidth = ref(0)
const scrollWidth = ref(0)
......
......@@ -691,19 +691,23 @@ export function useNodeTree(
if (isTreeSelecting.value) {
return
}
const targetScrollId = effectiveSelectedId.value || newId
const idx = flatList.value.findIndex((item) => item.id === targetScrollId)
if (idx !== -1 && viewportRef.value) {
const itemTop = idx * ITEM_HEIGHT
const vHeight = viewportRef.value.clientHeight
const targetTop = Math.max(0, itemTop - vHeight / 2)
viewportRef.value.scrollTo({
top: targetTop,
behavior: 'auto'
})
scrollTop.value = targetTop
const doScroll = () => {
const targetScrollId = effectiveSelectedId.value || newId
const idx = flatList.value.findIndex((item) => item.id === targetScrollId)
if (idx !== -1 && viewportRef.value) {
const itemTop = idx * ITEM_HEIGHT
const vHeight = viewportRef.value.clientHeight
const targetTop = Math.max(0, itemTop - vHeight / 2)
viewportRef.value.scrollTo({
top: targetTop,
behavior: 'auto'
})
scrollTop.value = targetTop
}
}
doScroll()
setTimeout(doScroll, 60)
})
}
......@@ -715,6 +719,16 @@ export function useNodeTree(
}
)
// 监听强制滚动定位信号,即使 selectedNodeId 未改变也必须强制重新展开并滚动定位
watch(
() => editorStore.forceScrollSignal,
() => {
if (editorStore.selectedNodeId) {
syncTreeSelection(editorStore.selectedNodeId, true)
}
}
)
// 监听回退/重做以同步树的展开与定位
watch(
() => editorStore.lastUndoRedoTime,
......
import { useEditorStore } from '@/store/editor'
import { useDtdStore } from '@/store/dtd'
import type { XmlNode } from '@/types/xmlNode'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
import type { XmlNode, DtdValidationError } from '@/types/xmlNode'
import { DEFAULT_XML_FILE } from '@/configs'
import { currentJobCardMainData } from '../constants'
// 导入通用及定制环境目录下的本地默认模板 XML 文本
......@@ -103,55 +102,20 @@ export function useEditor() {
return keys
}
const validate = (): void => {
if (!store.xmlTree) return
const warnings: string[] = []
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)
const validate = (): DtdValidationError | null => {
if (!store.xmlTree) {
window.$message.warning('当前暂无可验证的 XML 文档内容!')
return null
}
validateNode(store.xmlTree)
const error = validateXmlAgainstDtd(store.xmlTree)
if (warnings.length === 0) {
window.$notification.success('未发现任何结构与属性异常,XML 结构完全合规!', {
title: 'DTD 验证成功'
})
} 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))
)
})
if (!error) {
window.$message.success('DTD 验证通过!当前文档结构与属性完全符合 DTD 规范约束')
return null
}
return error
}
const prepareCleanedHtml = () => {
......
......@@ -41,6 +41,9 @@
<div id="print-preview-container" style="display: none">
<DocNodeRenderer v-if="editorStore.xmlTree" :node="editorStore.xmlTree" :parent="null" />
</div>
<!-- DTD 校验结果弹窗 (业务专有组件) -->
<DtdValidateModal ref="dtdValidateModalRef" />
</div>
</template>
......@@ -53,10 +56,12 @@ import NodeTree from './components/NodeTree/index.vue'
import EditorPanel from './components/EditorPanel/index.vue'
import Splitter from './components/Splitter/index.vue'
import DocNodeRenderer from './components/DocNodeRenderer/index.vue'
import DtdValidateModal from './components/DtdValidateModal/index.vue'
const themeVars = useThemeVars()
const editorStore = useEditorStore()
const appStore = useAppStore()
const dtdValidateModalRef = ref<InstanceType<typeof DtdValidateModal>>()
const expandedKeys = computed({
get: () => editorStore.expandedKeys,
set: (val) => editorStore.setExpandedKeys(val)
......@@ -85,7 +90,12 @@ const handleCollapseAll = () => {
expandedKeys.value = [editorStore.xmlTree.id]
}
}
const handleValidate = () => validate()
const handleValidate = () => {
const error = validate()
if (error) {
dtdValidateModalRef.value?.open(error)
}
}
</script>
<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