Commit c65b23cc by pangchong

feat(dtd): 支持上传和切换自定义DTD规范文件

- 新增DTD状态管理仓库,支持保存和切换用户上传的自定义DTD
- 实现DTD文件的上传流程,支持语法解析和校验
- 提供恢复默认DTD(系统内置JOBCARD.dtd)功能
- 编辑器工具栏新增上传DTD按钮及相关交互菜单
- 优化DTD解析器,支持解析完整DTD规范内容及元素属性定义
- 实现DTD规则动态加载与应用,提高元素约束校验灵活性
- 自定义DTD内容存储在本地,刷新页面依然生效
- 更新类型声明,支持.dtd文件模块导入
- 文档FAQ新增上传自定义DTD文件的说明与操作步骤
parent afe8d920
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -1012,6 +1012,28 @@ export const FAQ_DATABASE: FaqItem[] = [
isHot: true
},
{
id: 'faq_upload_dtd',
category: 'file',
categoryName: '文件与导出',
title: '如何上传自定义 DTD 规范文件?',
keywords: ['dtd', '上传dtd', '导入dtd', '切换dtd', 'dtd规范', '自定义dtd', '恢复默认dtd', 'scdtd', 'drdtd', 'hfmrdtd'],
synonyms: ['怎么上传dtd', '自定义dtd规范', '怎么切换dtd', '换一个dtd文件', '恢复默认dtd'],
answer: '系统支持上传自定义的 DTD 规范文件。上传后将优先采用新 DTD 规则进行元素约束校验;若未上传或恢复默认,则自动使用系统内置的 JOBCARD.dtd 规范。',
steps: [
'点击顶部工具栏的【上传 DTD】按钮(或鼠标悬浮展开管理菜单)。',
'在弹出的文件选择器中选择您的 `.dtd` 文件。',
'系统自动校验 DTD 语法并实时生效,后续节点添加、位置移动与 DTD 校验将全面遵循新规则。',
'如需恢复系统默认规则,可在【上传 DTD】悬浮菜单中点击【恢复默认 DTD】。'
],
highlights: [
'上传的自定义 DTD 会自动保存在浏览器本地,刷新页面依然有效。',
'若未上传或执行了恢复默认,系统将无缝回退至内置的 JOBCARD.dtd。'
],
actions: [{ label: '上传 DTD 文件', type: 'event', payload: 'trigger_upload_dtd' }],
relatedQuestions: ['faq_import_xml', 'faq_validate_xml'],
isHot: false
},
{
id: 'faq_export_xml',
category: 'file',
categoryName: '文件与导出',
......
import type { DtdState } from './types'
import defaultDtdRaw from '@/assets/json/JOBCARD.dtd?raw'
/**
* DTD 规则状态仓库 (Pinia)
* 管理用户上传的自定义 DTD 与系统默认 JOBCARD.dtd 之间的状态切换与持久化
*/
export const useDtdStore = defineStore('dtd', {
state: (): DtdState => ({
customDtdContent: null,
customDtdName: ''
}),
getters: {
/**
* 是否正在使用用户上传的自定义 DTD
*/
isCustomDtdActive: (state): boolean => Boolean(state.customDtdContent),
/**
* 当前生效的 DTD 文件名
*/
activeDtdName: (state): string => (state.customDtdContent ? state.customDtdName || '自定义.dtd' : 'JOBCARD.dtd')
},
actions: {
/**
* 上传并应用新的自定义 DTD
*/
setCustomDtd(content: string, fileName = 'custom.dtd') {
// 严格预校验 DTD 语法与 AST 内容
const parsed = parseDtd(content)
if (!parsed || Object.keys(parsed.elements || {}).length === 0) {
throw new Error('DTD 内容不合法或未能解析出任何 ELEMENT 声明')
}
this.customDtdContent = content
this.customDtdName = fileName
loadDtdSchema(content)
},
/**
* 恢复为系统内置默认 JOBCARD.dtd 规则
*/
resetDefaultDtd() {
this.customDtdContent = null
this.customDtdName = ''
loadDtdSchema(defaultDtdRaw)
},
/**
* 初始化加载 DTD(若 Pinia 状态中存在自定义 DTD 则优先应用,否则应用系统默认 DTD)
*/
initDtd() {
if (this.customDtdContent) {
try {
loadDtdSchema(this.customDtdContent)
return
} catch (err) {
console.warn('加载自定义 DTD 失败,回退到默认 DTD 规则:', err)
this.customDtdContent = null
this.customDtdName = ''
}
}
loadDtdSchema(defaultDtdRaw)
}
},
persist: true
})
export * from './types'
/**
* DTD 规则仓库状态类型定义
*/
export interface DtdState {
/** 用户上传的自定义 DTD 原始内容 (null 表示使用系统默认) */
customDtdContent: string | null
/** 用户上传的 DTD 文件名 */
customDtdName: string
}
......@@ -143,3 +143,13 @@ declare module 'naive-ui' {
colorBg5?: string
}
}
declare module '*.dtd' {
const content: string
export default content
}
declare module '*.dtd?raw' {
const content: string
export default content
}
......@@ -3,17 +3,19 @@ import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
/**
* DTD 规则管理器
* 提供基于 dtd.json 的元素规则查询能力
* 提供基于 DTD 规范的元素规则查询能力
*/
let _schema: DtdSchema | null = null
/**
* 加载 DTD Schema
* 加载 DTD Schema(支持传入 DtdSchema 对象或 DTD 原文文本)
*/
export function loadDtdSchema(json: DtdSchema): void {
export function loadDtdSchema(input: DtdSchema | string): void {
const rawSchema = typeof input === 'string' ? parseDtd(input) : input
// 深度复制一份原始 schema 避免直接改变导入的数据
const schema = JSON.parse(JSON.stringify(json)) as DtdSchema
const schema = JSON.parse(JSON.stringify(rawSchema)) as DtdSchema
// 根据定制环境代号 VITE_CUSTOM_CODE 自动加载对应的 DTD 扩展配置
const customCode = import.meta.env.VITE_CUSTOM_CODE
......
import type { DtdSchema, DtdElement, DtdAttribute, DtdContentModel } from '@/types/xmlNode'
/**
* 辅助函数:将修饰符映射为 occurrence 枚举
*/
function parseOccurrence(suffix: string): 'once' | 'optional' | 'zeroOrMore' | 'oneOrMore' {
if (suffix === '?') return 'optional'
if (suffix === '*') return 'zeroOrMore'
if (suffix === '+') return 'oneOrMore'
return 'once'
}
/**
* 分词器:将内容模型表达式分词
*/
function tokenizeModel(str: string): string[] {
const tokens: string[] = []
let i = 0
while (i < str.length) {
const ch = str[i]
if (/\s/.test(ch)) {
i++
continue
}
if (ch === '(' || ch === ')' || ch === ',' || ch === '|' || ch === '?' || ch === '*' || ch === '+') {
tokens.push(ch)
i++
} else if (ch === '#') {
let end = i
while (end < str.length && /[#A-Za-z0-9_-]/.test(str[end])) end++
tokens.push(str.slice(i, end))
i = end
} else {
let end = i
while (end < str.length && /[A-Za-z0-9_.-]/.test(str[end])) end++
tokens.push(str.slice(i, end))
i = end
}
}
return tokens
}
/**
* 解析 DTD 内容模型表达式字符串为树形 DtdContentModel
*/
export function parseModelString(raw: string): DtdContentModel {
const str = raw.trim()
if (str === 'EMPTY') {
return { type: 'empty', occurrence: 'once' }
}
if (/^\(\s*#PCDATA\s*\)$/.test(str)) {
return { type: 'pcdata', occurrence: 'once' }
}
// 混合内容 (#PCDATA | ...)*
if (/^\(\s*#PCDATA\s*\|/.test(str)) {
const occMatch = str.match(/\)\s*([*?+]?)$/)
const occ = parseOccurrence(occMatch ? occMatch[1] : '')
const inner = str.replace(/^\(\s*#PCDATA\s*\|?/, '').replace(/\)\s*[*?+]?$/, '')
const parts = inner.split('|').map((s) => s.trim()).filter(Boolean)
const children: DtdContentModel[] = [
{ type: 'pcdata', occurrence: 'once' },
...parts.map((name) => ({
type: 'elementRef' as const,
occurrence: 'once' as const,
name
}))
]
return {
type: 'mixed',
occurrence: occ,
children
}
}
const tokens = tokenizeModel(str)
let pos = 0
function peek(): string | undefined {
return tokens[pos]
}
function consume(expected?: string): string {
const token = tokens[pos]
if (expected && token !== expected) {
throw new Error(`Expected ${expected} but got ${token} at index ${pos}`)
}
pos++
return token
}
function parseItem(): DtdContentModel {
let node: DtdContentModel
if (peek() === '(') {
consume('(')
const items: DtdContentModel[] = []
let sep: string | null = null
while (peek() !== ')') {
items.push(parseItem())
if (peek() === ',' || peek() === '|') {
const currentSep = consume()
if (!sep) sep = currentSep
}
}
consume(')')
let occurrence: 'once' | 'optional' | 'zeroOrMore' | 'oneOrMore' = 'once'
if (peek() === '?' || peek() === '*' || peek() === '+') {
occurrence = parseOccurrence(consume())
}
if (items.length === 1) {
const inner = items[0]
const occ = occurrence !== 'once' ? occurrence : inner.occurrence
node = { ...inner, occurrence: occ }
} else {
node = {
type: sep === '|' ? 'choice' : 'sequence',
occurrence,
children: items
}
}
} else {
const name = consume()
let occurrence: 'once' | 'optional' | 'zeroOrMore' | 'oneOrMore' = 'once'
if (peek() === '?' || peek() === '*' || peek() === '+') {
occurrence = parseOccurrence(consume())
}
if (name === '#PCDATA') {
node = { type: 'pcdata', occurrence }
} else {
node = { type: 'elementRef', occurrence, name }
}
}
return node
}
return parseItem()
}
/**
* 遍历内容模型树,收集所有允许出现的子元素名称
*/
export function collectAllowedChildren(model: DtdContentModel): string[] {
const list: string[] = []
function walk(node?: DtdContentModel) {
if (!node) return
if (node.type === 'elementRef' && node.name) {
if (!list.includes(node.name)) {
list.push(node.name)
}
}
if (node.children) {
node.children.forEach(walk)
}
}
walk(model)
return list
}
/**
* 解析 ATTLIST 属性定义声明块
*/
export function parseAttributes(blockText: string): Record<string, DtdAttribute> {
const attrs: Record<string, DtdAttribute> = {}
const text = blockText.trim()
let i = 0
function skipWhitespace() {
while (i < text.length && /\s/.test(text[i])) i++
}
while (i < text.length) {
skipWhitespace()
if (i >= text.length) break
// 1. 属性名
const nameStart = i
while (i < text.length && /[A-Za-z0-9_-]/.test(text[i])) i++
const attrName = text.slice(nameStart, i)
if (!attrName) {
i++
continue
}
skipWhitespace()
if (i >= text.length) break
// 2. 类型定义
let typeDef = ''
if (text[i] === '(') {
let openParen = 1
const typeStart = i
i++
while (i < text.length && openParen > 0) {
if (text[i] === '(') openParen++
else if (text[i] === ')') openParen--;
i++
}
typeDef = text.slice(typeStart, i).trim().replace(/\s+/g, ' ')
} else {
const typeStart = i
while (i < text.length && /[A-Za-z0-9_-]/.test(text[i])) i++
typeDef = text.slice(typeStart, i).trim()
}
skipWhitespace()
if (i >= text.length) break
// 3. 约束要求和默认值
let requirement = '#IMPLIED'
let defaultValue: string | null = null
if (text[i] === '#') {
const reqStart = i
while (i < text.length && /[#A-Za-z0-9_-]/.test(text[i])) i++
requirement = text.slice(reqStart, i).trim()
if (requirement === '#FIXED') {
skipWhitespace()
if (text[i] === '"' || text[i] === "'") {
const quote = text[i]
i++
const valStart = i
while (i < text.length && text[i] !== quote) i++
defaultValue = text.slice(valStart, i)
if (text[i] === quote) i++
}
}
} else if (text[i] === '"' || text[i] === "'") {
requirement = '#DEFAULT'
const quote = text[i]
i++
const valStart = i
while (i < text.length && text[i] !== quote) i++
defaultValue = text.slice(valStart, i)
if (text[i] === quote) i++
}
// 解析 parsedType 和 enumValues
let parsedType = 'String'
let enumValues: string[] | null = null
if (typeDef.startsWith('(') && typeDef.endsWith(')')) {
parsedType = 'Enumeration'
enumValues = typeDef
.slice(1, -1)
.split('|')
.map((s) => s.trim())
.filter(Boolean)
} else if (typeDef === 'ID') {
parsedType = 'ID'
} else if (typeDef === 'NMTOKEN') {
parsedType = 'NameToken'
} else if (typeDef === 'ENTITY') {
parsedType = 'Entity'
} else if (typeDef === 'CDATA') {
parsedType = 'String'
} else {
parsedType = typeDef
}
attrs[attrName] = {
typeDefinition: typeDef,
parsedType,
requirement,
enumValues,
defaultValue
}
}
return attrs
}
let cachedDtdRaw: string | null = null
let cachedSchema: DtdSchema | null = null
/**
* 将 DTD 原始内容解析为完整的 DtdSchema 对象
* 内置缓存,相同字符串直接返回缓存结果
*/
export function parseDtd(rawContent: string): DtdSchema {
if (cachedDtdRaw === rawContent && cachedSchema) {
return cachedSchema
}
// 1. 去掉注释
let content = rawContent.replace(/<!--[\s\S]*?-->/g, '')
// 2. 提取参数实体 (内部实体定义)
const entities: Record<string, string> = {}
const entityRegex = /<!ENTITY\s+%\s+([A-Za-z0-9_.-]+)\s+("[^"]*"|'[^']*')\s*>/g
let match: RegExpExecArray | null
while ((match = entityRegex.exec(content)) !== null) {
const name = match[1]
const val = match[2].slice(1, -1)
entities[name] = val
}
// 3. 展开参数实体 (支持嵌套实体循环展开)
for (let iter = 0; iter < 10; iter++) {
let changed = false
content = content.replace(/%([A-Za-z0-9_.-]+);?/g, (full, name) => {
if (entities[name] !== undefined) {
changed = true
return entities[name]
}
return full
})
if (!changed) break
}
// 4. 提取 ELEMENT 声明
const elements: Record<string, DtdElement> = {}
const elementRegex = /<!ELEMENT\s+([A-Za-z0-9_-]+)\s+([^>]+)>/g
while ((match = elementRegex.exec(content)) !== null) {
const name = match[1]
const rawModel = match[2].trim()
if (elements[name]) continue
const parsedModel = parseModelString(rawModel)
const allowedChildren = collectAllowedChildren(parsedModel)
elements[name] = {
description: `元素: ${name}`,
contentModel: {
raw: rawModel,
parsed: parsedModel,
humanReadable: `高级解析器解析的${parsedModel.type}结构: ${rawModel.replace(/\s+/g, ' ').slice(0, 40)}...`
},
attributes: {},
allowedChildren
}
}
// 5. 提取 ATTLIST 属性定义
const attlistRegex = /<!ATTLIST\s+([A-Za-z0-9_-]+)\s+([\s\S]*?)>/g
while ((match = attlistRegex.exec(content)) !== null) {
const targetElement = match[1]
const block = match[2]
const parsedAttrs = parseAttributes(block)
if (elements[targetElement]) {
Object.assign(elements[targetElement].attributes, parsedAttrs)
}
}
const result: DtdSchema = { elements }
cachedDtdRaw = rawContent
cachedSchema = result
return result
}
import { useEditorStore, createTableStructure } from '@/store/editor'
import { useAppStore } from '@/store/app/index'
import { useDtdStore } from '@/store/dtd'
import type { XmlNode } from '@/types/xmlNode'
/**
......@@ -8,6 +9,7 @@ import type { XmlNode } from '@/types/xmlNode'
export function useEditorToolbar(emit: any) {
const editorStore = useEditorStore()
const appStore = useAppStore()
const dtdStore = useDtdStore()
const insertBelow = ref(true)
const fileInputRef = ref<HTMLInputElement | null>(null)
......@@ -202,6 +204,88 @@ export function useEditorToolbar(emit: any) {
}
}
const isUploadingDtd = ref(false)
const triggerUploadDtd = () => {
openUploadModal({
accept: '.dtd',
title: '上传 DTD 规范文件',
onSuccess: async (res, file) => {
let text = ''
if (file) {
text = await file.text()
} else if (res && typeof res === 'string') {
text = res
}
if (!text) {
window.$message.error('未获取到 DTD 文件内容')
return
}
isUploadingDtd.value = true
window.$loading?.show('解析并载入 DTD 规范中…')
try {
dtdStore.setCustomDtd(text, file?.name || 'custom.dtd')
window.$message.success(`DTD 规则上传成功!已切换并生效新规则(${file?.name || 'custom.dtd'})`)
} catch (err: any) {
window.$message.error('DTD 解析失败: ' + (err.message || '格式错误'))
} finally {
isUploadingDtd.value = false
window.$loading?.hide()
}
}
})
}
const handleRestoreDefaultDtd = async () => {
if (!dtdStore.isCustomDtdActive) {
window.$message.info('当前已在使用系统默认 JOBCARD.dtd 规则')
return
}
try {
await window.$dialog.warning({
title: '恢复默认 DTD',
content: `确定要清除已上传的自定义 DTD(${dtdStore.activeDtdName}),恢复为系统默认的 JOBCARD.dtd 规则吗?`
})
dtdStore.resetDefaultDtd()
window.$message.success('已恢复为系统默认 JOBCARD.dtd 规则!')
} catch {
// 用户取消无需处理
}
}
const dtdDropdownOptions = computed(() => {
return [
{
label: '上传新 DTD 文件 (.dtd)',
key: 'upload'
},
{
label: `当前规则: ${dtdStore.activeDtdName}${dtdStore.isCustomDtdActive ? ' (自定义)' : ' (系统默认)'}`,
key: 'info',
disabled: true
},
{
type: 'divider',
key: 'd1'
},
{
label: '恢复默认 DTD (JOBCARD.dtd)',
key: 'restore',
disabled: !dtdStore.isCustomDtdActive
}
]
})
const handleDtdDropdownSelect = (key: string) => {
if (key === 'upload') {
triggerUploadDtd()
} else if (key === 'restore') {
handleRestoreDefaultDtd()
}
}
const triggerUpload = () => {
openUploadModal({
accept: '.xml',
......@@ -268,8 +352,12 @@ export function useEditorToolbar(emit: any) {
}
}
const isCustomDtdActive = computed(() => dtdStore.isCustomDtdActive)
const activeDtdName = computed(() => dtdStore.activeDtdName)
return {
editorStore,
dtdStore,
appStore,
insertBelow,
fileInputRef,
......@@ -293,6 +381,12 @@ export function useEditorToolbar(emit: any) {
xmlSizeStr,
templateSelectModalRef,
isCurrentMerged,
handleToggleMerged
handleToggleMerged,
isUploadingDtd,
triggerUploadDtd,
isCustomDtdActive,
activeDtdName,
dtdDropdownOptions,
handleDtdDropdownSelect
}
}
......@@ -389,6 +389,27 @@
<!-- 组 4:文件管理与导入导出 -->
<div class="flex items-center gap-0.5 flex-shrink-0">
<!-- 上传 / 管理 DTD 规则 -->
<n-dropdown trigger="hover" :options="dtdDropdownOptions" @select="handleDtdDropdownSelect">
<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"
:disabled="isUploadingDtd"
@click="triggerUploadDtd"
>
<n-icon class="text-base" :class="isCustomDtdActive ? 'text-primary' : ''">
<sync-outline v-if="isUploadingDtd" class="animate-spin" />
<document-text-outline v-else />
</n-icon>
<span class="text-xs font-medium">{{ isUploadingDtd ? '解析中…' : '上传 DTD' }}</span>
<span
v-if="isCustomDtdActive"
class="w-1.5 h-1.5 rounded-full bg-primary"
title="当前正在生效用户自定义 DTD 规则"
></span>
</button>
</n-dropdown>
<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"
......@@ -555,7 +576,8 @@ import {
SettingsOutline,
GitCompareOutline,
ArchiveOutline,
GitMergeOutline
GitMergeOutline,
DocumentTextOutline
} from '@vicons/ionicons5'
import { useEditorToolbar } from './functionals'
import { GREEN_BUTTONS } from './constants'
......@@ -601,7 +623,13 @@ const {
xmlSizeStr,
templateSelectModalRef,
isCurrentMerged,
handleToggleMerged
handleToggleMerged,
isUploadingDtd,
triggerUploadDtd,
isCustomDtdActive,
activeDtdName,
dtdDropdownOptions,
handleDtdDropdownSelect
} = useEditorToolbar(emit)
const insertFragmentModalRef = ref<any>(null)
......@@ -617,6 +645,7 @@ onMounted(() => {
eventBus.on('trigger_insert_selection', () => handleInsert('SELECTION'))
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_import_xml', () => triggerUpload())
eventBus.on('trigger_export_xml', () => emit('export'))
eventBus.on('trigger_preview', () => emit('preview'))
......
import { useEditorStore } from '@/store/editor'
import { useDtdStore } from '@/store/dtd'
import type { XmlNode } from '@/types/xmlNode'
// 静态导入 DTD JSON
import dtdJson from '@/assets/json/dtd.json'
import { VIRTUAL_LAYOUT_TAGS } from '@/configs/xmlTags'
import { DEFAULT_XML_FILE } from '@/configs'
import { currentJobCardMainData } from '../constants'
......@@ -17,6 +16,7 @@ const xmlModules = import.meta.glob(['../../../assets/file/*.xml', '../../../*/a
*/
export function useEditor() {
const store = useEditorStore()
const dtdStore = useDtdStore()
const cleanupFns: (() => void)[] = []
onUnmounted(() => {
......@@ -26,7 +26,7 @@ export function useEditor() {
const initialize = async (): Promise<void> => {
window.$loading?.show('加载数据中…')
try {
loadDtdSchema(dtdJson as any)
dtdStore.initDtd()
const customCode = import.meta.env.VITE_CUSTOM_CODE
const customXmlKey = customCode ? `../../../${customCode}/assets/file/${DEFAULT_XML_FILE}` : ''
......
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