Commit 733dcbc0 by pangchong

feat(dtd): 添加海南航空作业卡完整DTD文件定义

- 新增JOBCARD.dtd文件,包含详细的DTD结构定义
- 定义多种元素和属性以支持复杂文档结构
- 包含AXML实体和注释,规范作业卡文本格式
- 实现多层列表、表格、图形及注释的DTD规则
- 增加对任务、步骤、设备、警告等业务元素的支持
- 明确签名、修订和生效日期等元数据规范
parent 45e708d4
......@@ -1433,6 +1433,48 @@ export const useEditorStore = defineStore('editor', {
*/
resetZoom() {
this.editorZoom = 100
},
/**
* 根据 REFID / KEY / ID / GRAPHICKEY / GNBR 查找目标节点(支持优先匹配特定标签如 GRAPHIC / SHEET)
*/
findNodeByRef(refId: string, preferredTagName?: string): XmlNode | null {
if (!this.nodeMap || !refId) return null
const cleanRefId = refId.trim()
const isMatch = (n: XmlNode) => {
const attrs = n.attributes || {}
return (
attrs.KEY === cleanRefId ||
attrs.ID === cleanRefId ||
attrs.GRAPHICKEY === cleanRefId ||
attrs.GNBR === cleanRefId
)
}
if (preferredTagName) {
for (const item of this.nodeMap.values()) {
const n = item.node
if (n.tagName === preferredTagName || (preferredTagName === 'GRAPHIC' && n.tagName === 'SHEET')) {
if (isMatch(n)) {
if (n.tagName === 'SHEET' && preferredTagName === 'GRAPHIC' && item.parent) {
return item.parent
}
return n
}
}
}
}
for (const item of this.nodeMap.values()) {
const n = item.node
if (isMatch(n)) {
return n
}
}
return null
}
}
})
......@@ -14,6 +14,15 @@
:options="def.enumValues.map((v: any) => ({ label: v, value: v }))"
@change="handleAttrChange"
/>
<CommonSelect
v-else-if="name === 'REFID' && node.tagName === 'GRPHCREF' && graphicKeyOptions.length > 0"
v-model:value="model[name]"
:options="graphicKeyOptions"
tag
filterable
placeholder="可选择或输入引用的 GRAPHIC KEY"
@change="handleAttrChange"
/>
<n-input v-else v-model:value="model[name]" @input="handleAttrChange" />
</n-form-item>
</div>
......@@ -25,12 +34,14 @@
import { SettingsOutline } from '@vicons/ionicons5'
import type { XmlNode } from '@/types/xmlNode'
import { useAttributeEditor } from './functionals'
import { useEditorStore } from '@/store/editor'
const props = defineProps<{
node: XmlNode
}>()
const themeVars = useThemeVars()
const store = useEditorStore()
const { updateAttributes } = useAttributeEditor()
const model = ref<Record<string, string>>({})
......@@ -43,6 +54,24 @@ const hasAttributes = computed(() => {
return Object.keys(attributesDef.value).length > 0
})
const graphicKeyOptions = computed(() => {
const options: { label: string; value: string }[] = []
if (!store.nodeMap) return options
for (const item of store.nodeMap.values()) {
const n = item.node
if (n.tagName === 'GRAPHIC' && n.attributes?.KEY) {
const keyVal = n.attributes.KEY
const titleChild = n.children.find((c) => c.tagName === 'TITLE' || c.tagName === 'TITLEC')
const titleText = titleChild ? titleChild.textContent : ''
options.push({
label: titleText ? `${keyVal} (${titleText})` : keyVal,
value: keyVal
})
}
}
return options
})
watch(
() => props.node.id,
() => {
......
......@@ -433,7 +433,8 @@
<span
class="inline-ref font-mono font-medium hover:underline cursor-pointer select-all"
style="color: blue"
@click.stop="editorStore.setSelectedNodeId(node.id)"
title="按住 Ctrl + 点击直接跳转到关联插图节点"
@click.stop="handleNodeClick($event)"
>
<template v-if="!insideRefBlock">
{{ shouldShowPrefix(node.textContent) ? (isChineseContext ? '(参考: ' : '(Ref: ') : '(' }}
......@@ -1441,6 +1442,34 @@ const {
hasNonCbDataChildren,
getNonCbDataChildren
} = useDocNodeRenderer(props)
const handleNodeClick = (e: MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
let refId =
props.node.attributes?.REFID ||
props.node.attributes?.STRUCTID ||
props.node.attributes?.GRAPHICKEY ||
props.node.attributes?.GNBR
if (!refId && props.node.textContent) {
refId = props.node.textContent.trim()
refId = refId
.replace(/^\(?(?:Ref:\s*|参考:\s*)?|\)?$/gi, '')
.replace(/\[Sh\.\d+\]/gi, '')
.trim()
}
if (refId) {
const targetNode = editorStore.findNodeByRef(refId, 'GRAPHIC')
if (targetNode) {
editorStore.setSelectedNodeId(targetNode.id)
window.$message?.success?.(`已按 Ctrl 跳转到关联插图 <${targetNode.tagName}> (KEY/ID: ${refId})`)
return
} else {
window.$message?.warning?.(`未找到 KEY/ID 为 "${refId}" 的目标插图节点`)
}
}
}
editorStore.setSelectedNodeId(props.node.id)
}
</script>
<style scoped>
......
......@@ -11,37 +11,61 @@ export const TRANSPARENT_TAGS_SET = new Set(TRANSPARENT_TAGS)
*/
export const ESTIMATED_HEIGHT = 200
/** 递归计算节点及其子节点的估算总高度 */
export const getNodeEstimatedHeight = (node: XmlNode): number => {
if (!node) return ESTIMATED_HEIGHT
const baseHeight = (tagName: string): number => {
switch (tagName) {
case 'TABLE':
return 400
case 'GRAPHIC':
case 'SHEET':
return 350
case 'WARNING':
case 'CAUTION':
return 180
case 'NOTE':
return 150
case 'PARA':
case 'PARAC':
case 'TITLE':
case 'TITLEC':
return 60
case 'UNLIST':
case 'LIST1':
case 'LIST2':
case 'LIST3':
return 100
case 'L1ITEM':
case 'L2ITEM':
case 'L3ITEM':
case 'UNLITEM':
return 60
case 'SMJC-HEADER':
case 'SMUC-HEADER':
return 120
case 'SIGNOFF':
return 40
default:
return 80
}
}
if (!node.children || node.children.length === 0) {
return baseHeight(node.tagName)
}
let sum = 30
for (const child of node.children) {
sum += getNodeEstimatedHeight(child)
}
return Math.max(sum, baseHeight(node.tagName))
}
/** 按节点类型获取预估高度(用于虚拟列表初始高度计算) */
export const getEstimatedHeight = (tagName: string): number => {
switch (tagName) {
case 'TABLE':
return 400 // 表格通常较高
case 'GRAPHIC':
case 'SHEET':
return 350 // 图片/图纸/图纸页通常较高
case 'WARNING':
return 180 // 警告块
case 'CAUTION':
return 180
case 'NOTE':
return 150
case 'PRETOPIC':
return 160 // 模板段落
case 'UNLIST':
case 'LIST1':
case 'LIST2':
case 'LIST3':
return 200 // 列表
case 'PARA':
case 'PARAC':
return 80 // 普通段落
case 'SMUC-HEADER':
return 120
case 'FINLIST':
return 100
default:
return ESTIMATED_HEIGHT
}
return getNodeEstimatedHeight({ tagName, children: [] } as any)
}
// 编辑器文档块接口
......
import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode'
import { TRANSPARENT_TAGS_SET, getEstimatedHeight, type EditorBlock, type BlockPosition } from '../constants'
import { TRANSPARENT_TAGS_SET, getNodeEstimatedHeight, type EditorBlock, type BlockPosition } from '../constants'
const getRealNodeId = (id: string): string => {
if (id && id.includes('-txt-')) {
......@@ -110,8 +110,8 @@ export function useEditorPanel() {
const list: BlockPosition[] = []
let currentTop = 0
for (const block of blocksList.value) {
// 优先使用 ResizeObserver 测量到的真实高度,否则按节点类型估算
const h = heightsMap.value[block.id] ?? getEstimatedHeight(block.tagName)
// 优先使用 ResizeObserver 测量到的真实高度,否则按节点结构递归计算估算高度
const h = heightsMap.value[block.id] ?? getNodeEstimatedHeight(block.rawNode)
list.push({ id: block.id, top: currentTop, bottom: currentTop + h, height: h })
currentTop += h
}
......@@ -270,6 +270,28 @@ export function useEditorPanel() {
return -1
}
/**
* 辅助函数:严格限制在目标块 (blockElement) 容器内部精准匹配节点 DOM 元素
* 绝跨块全局检索,防止定位错跳至无关联的全局元素
*/
const findElInBlock = (blockElement: HTMLElement, targetNodeId: string, nodePath: XmlNode[]): HTMLElement => {
let targetEl = blockElement.querySelector(`[data-node-id="${targetNodeId}"]`) as HTMLElement | null
if (targetEl) return targetEl
if (nodePath && nodePath.length > 0) {
for (let i = nodePath.length - 1; i >= 0; i--) {
const pid = nodePath[i].id
if (blockElement.dataset.nodeId === pid || blockElement.dataset.blockId === pid) {
return blockElement
}
targetEl = blockElement.querySelector(`[data-node-id="${pid}"]`) as HTMLElement | null
if (targetEl) return targetEl
}
}
return blockElement
}
const syncEditorScroll = (newId: string | null, force = false) => {
if (!newId || !editorStore.xmlTree) return
......@@ -280,13 +302,13 @@ export function useEditorPanel() {
nextTick(() => {
if (!viewportRef.value) return
// 1. 先从路径中由下到上找最近的已挂载 DOM 元素,需限制在所属文档块已挂载的前提下
const blockId = blocksList.value[blockIdx]?.id
const blockEl =
blockId && viewportRef.value ? (viewportRef.value.querySelector(`[data-node-id="${blockId}"]`) as HTMLElement | null) : null
(blockId ? (blockElMap.get(blockId) as HTMLElement | null) : null) ||
(blockId && viewportRef.value
? (viewportRef.value.querySelector(`[data-block-id="${blockId}"], [data-node-id="${blockId}"]`) as HTMLElement | null)
: null)
let el: HTMLElement | null = null
let foundNodeId: string | null = null
const path: XmlNode[] = []
const realId = getRealNodeId(newId)
let curr = editorStore.nodeMap.get(realId)
......@@ -294,72 +316,82 @@ export function useEditorPanel() {
path.unshift(curr.node)
curr = curr.parent ? editorStore.nodeMap.get(curr.parent.id) : undefined
}
if (blockEl && path.length > 0) {
// 优先在所属块内寻找虚拟文本节点本身的 DOM 元素
el = blockEl.querySelector(`[data-node-id="${newId}"]`) as HTMLElement | null
if (!el) {
for (let i = path.length - 1; i >= 0; i--) {
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
}
}
}
}
if (el) {
const rect = el.getBoundingClientRect()
// 1. 若 DOM 块已挂载在视口中
if (blockEl) {
const targetEl = findElInBlock(blockEl, newId, path)
const rect = targetEl.getBoundingClientRect()
const containerRect = viewportRef.value.getBoundingClientRect()
// 已在可视范围内且非强制滚动,不做处理
// 计算 targetEl 中心点与容器中心的偏离距离
const targetCenter = rect.top + rect.height / 2
const containerCenter = containerRect.top + containerRect.height / 2
const diff = Math.abs(targetCenter - containerCenter)
// 已在可视范围内且非强制,不做处理
if (!force && rect.top >= containerRect.top + 20 && rect.bottom <= containerRect.bottom - 20) {
return
}
el.scrollIntoView({ behavior: 'auto', block: 'center' })
// 如果当前节点已基本居中 (偏差 <= 35px),不再重复触发 scrollIntoView,防止画面二次抖动与闪烁
if (diff <= 35 && rect.top >= containerRect.top && rect.bottom <= containerRect.bottom) {
return
}
targetEl.scrollIntoView({ behavior: 'auto', block: 'center' })
return
}
// 2. DOM 未挂载:先瞬间跳转到估算位置触发虚拟列表挂载
// 2. 若 DOM 未挂载:精准累加子节点内部 offset 估算位置,实现一步到位精准跳转,彻底消除闪烁
const pos = positions.value[blockIdx]
if (!pos) return
const vHeight = viewportRef.value.clientHeight
// 计算目标节点在 Raw Block 内部的垂直高度偏移
let internalOffset = 0
const rawBlockNode = blocksList.value[blockIdx].rawNode
if (rawBlockNode && rawBlockNode.children && realId !== rawBlockNode.id) {
for (const child of rawBlockNode.children) {
if (child.id === realId || path.some((p) => p.id === child.id)) break
internalOffset += getNodeEstimatedHeight(child)
}
}
const estimatedTargetTop = pos.top + internalOffset
const targetScrollTop = Math.max(0, estimatedTargetTop - vHeight / 2)
viewportRef.value.scrollTo({
top: Math.max(0, pos.top - vHeight / 2 + pos.height / 2),
top: targetScrollTop,
behavior: 'instant' as ScrollBehavior
})
// 立即同步更新 scrollTop.value,避免等待 scroll 事件 + rAF 延迟,从而在当前 Tick / nextTick 立即渲染出目标块 DOM
scrollTop.value = viewportRef.value.scrollTop
// 等挂载后再精确定位
setTimeout(() => {
// DOM 挂载后微调:仅在实际偏移大于 45px 时才进行二次修正,消除画面微抖与闪烁
nextTick(() => {
if (!viewportRef.value) return
let targetEl: HTMLElement | null = null
// 优先寻找虚拟文本节点本身
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) {
break
}
}
}
if (targetEl) {
const curBlockEl =
(blockId ? (blockElMap.get(blockId) as HTMLElement | null) : null) ||
(blockId && viewportRef.value
? (viewportRef.value.querySelector(`[data-block-id="${blockId}"], [data-node-id="${blockId}"]`) as HTMLElement | null)
: null)
if (!curBlockEl) return
const targetEl = findElInBlock(curBlockEl, newId, path)
const rect = targetEl.getBoundingClientRect()
const containerRect = viewportRef.value.getBoundingClientRect()
const targetCenter = rect.top + rect.height / 2
const containerCenter = containerRect.top + containerRect.height / 2
if (Math.abs(targetCenter - containerCenter) > 45) {
targetEl.scrollIntoView({ behavior: 'auto', block: 'center' })
}
}, 100)
})
})
}
watch(
() => editorStore.selectedNodeId,
(newId) => syncEditorScroll(newId, false)
(newId) => syncEditorScroll(newId, true)
)
watch(
() => editorStore.lastUndoRedoTime,
......
......@@ -371,6 +371,24 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
}
}
const graphicKeyOptions = computed(() => {
const options: { label: string; value: string }[] = []
if (!store.nodeMap) return options
for (const item of store.nodeMap.values()) {
const n = item.node
if (n.tagName === 'GRAPHIC' && n.attributes?.KEY) {
const keyVal = n.attributes.KEY
const titleChild = n.children.find((c) => c.tagName === 'TITLE' || c.tagName === 'TITLEC')
const titleText = titleChild ? titleChild.textContent : ''
options.push({
label: titleText ? `${keyVal} (${titleText})` : keyVal,
value: keyVal
})
}
}
return options
})
return {
form,
rules,
......@@ -378,6 +396,7 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
modalTitle,
tagOptions,
attributeDefs,
graphicKeyOptions,
showTextContentField,
onTagChange,
handleConfirm
......
......@@ -24,6 +24,15 @@
v-model:value="form.attrs[attr.name]"
:options="attr.enumValues.map((v) => ({ label: v, value: v }))"
/>
<!-- 引用插图 KEY:带快捷下拉的 select/tag -->
<CommonSelect
v-else-if="attr.name === 'REFID' && form.tagName === 'GRPHCREF' && graphicKeyOptions.length > 0"
v-model:value="form.attrs[attr.name]"
:options="graphicKeyOptions"
tag
filterable
placeholder="可直接选择或输入目标 GRAPHIC 的 KEY"
/>
<!-- 普通文本 -->
<n-input v-else v-model:value="form.attrs[attr.name]" />
<!-- 属性说明 -->
......@@ -52,7 +61,7 @@ import { useAddNodeModal } from './functionals'
const formRef = ref<FormInst | null>(null)
const { form, rules, saving, modalTitle, tagOptions, attributeDefs, showTextContentField, onTagChange, handleConfirm } = useAddNodeModal(formRef)
const { form, rules, saving, modalTitle, tagOptions, attributeDefs, graphicKeyOptions, showTextContentField, onTagChange, handleConfirm } = useAddNodeModal(formRef)
const open = (mode: 'child' | 'before' | 'after' | 'edit', targetId: string, allowedTags: string[]) => {
addNodeMode.value = mode
......
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