Commit a43e8977 by pangchong

feat(editor): 支持本地暂存多种插入方式及效果预览优化

- 完善本地暂存功能,支持暂存整卡或选中片段的多样插入模式(上方、下方、内部、替换、追加根节点)
- 增加对XML节点插入时父节点mixedContent的同步维护,保证编辑状态一致
- 引入插入操作的可用性动态计算与理由提示,禁止不合规插入并提供明确反馈
- 实现暂存片段的效果预览功能,支持60FPS流式渲染大文档,提升加载体验与响应速度
- 优化签字表格及渲染组件样式,确保暗色模式下边框清晰可见
- 更新本地暂存帮助文档,增加操作步骤及预览功能说明,加强用户引导
- 修复XML节点操作中replace模式的DTD计数逻辑错误,提升数据一致性
- 调整编辑器内部相关类型和工具函数,支持文档块扁平化及虚拟化渲染需求
parent c19e8786
<template>
<div class="my-3 flex overflow-x-auto select-none" :class="containerClass">
<table class="border-collapse border border-black text-xs bg-white text-black">
<table
class="border-collapse border border-black text-xs text-black"
:class="node.attributes?.MERGED === 'TRUE' ? '' : 'bg-white'"
:style="node.attributes?.MERGED === 'TRUE' ? 'background-color: yellow; color: #000000;' : ''"
>
<tbody>
<!-- 第一行:表头与签字人 -->
<tr class="h-10">
......
......@@ -1009,16 +1009,37 @@ export const FAQ_DATABASE: FaqItem[] = [
id: 'faq_stash_manager',
category: 'file',
categoryName: '文件与导出',
title: '什么是“本地暂存(Stash)”?如何防丢失?',
keywords: ['暂存', '本地暂存', 'stash', '草稿', '恢复', '防丢失', '版本', '历史暂存', '备份', 'zc', 'zancun', 'cg', 'caogao'],
synonyms: ['怎么存草稿', '电脑关了数据还在吗', '怎么备份当前工卡', '恢复暂存', '暂存版本'],
answer: '本地暂存功能类似于 Git Stash,能够将您当前编辑的工卡快照保存在本地浏览器中,防止意外刷新或关闭导致数据丢失。',
steps: [
'在顶部工具栏点击【本地暂存】按钮。',
'点击“新建暂存”,输入备注名称(如“发动机大修第一版”),系统立即保存当前完整状态。',
'随时可在暂存列表中查看历史快照、对比变动或一键【恢复】到对应版本。'
title: '什么是“本地暂存(Stash)”?如何防丢失与效果预览?',
keywords: [
'暂存',
'本地暂存',
'stash',
'草稿',
'恢复',
'防丢失',
'版本',
'历史暂存',
'备份',
'预览效果',
'zc',
'zancun',
'cg',
'caogao',
'ylxg'
],
synonyms: ['怎么存草稿', '电脑关了数据还在吗', '怎么备份当前工卡', '恢复暂存', '暂存版本', '暂存预览'],
answer: '本地暂存类似于 Git Stash 与历史快照库,可将正在编辑的完整 XML 或局部片段保存在浏览器本地持久化缓存中,防止误操作或页面刷新丢失数据。',
steps: [
'在顶部工具栏点击【本地暂存】按钮,进入管理面板。',
'【暂存整卡/片段】:支持一键【暂存整个 XML】或针对当前光标所选节点【暂存选中片段】。',
'【详情与代码预览】:点击左侧列表项可查看格式化 XML 代码与文件体积,并支持一键复制 XML 文本或导出为文件。',
'【排版效果预览】:点击列表项右侧的眼睛图标【预览效果】,可通过 60FPS 流式平滑加载直接查看渲染后的真实文档排版。',
'【灵活应用恢复】:完整文档支持一键覆盖恢复当前编辑区;片段支持在当前目标节点的“上方插入”、“下方插入”、“插入子节点”、“替换当前节点”或“追加到末尾”。'
],
highlights: [
'暂存数据保存在本地持久化存储中,重启电脑或关闭页面均不丢失。',
'对于超大 XML 文档,效果预览内置时间切片流式渲染,加载动效丝滑无卡顿。'
],
highlights: ['暂存数据存储在本地持久化存储中,即使关闭浏览器或重启电脑也能随时找回。'],
actions: [{ label: '打开本地暂存', type: 'event', payload: 'trigger_stash' }],
relatedQuestions: ['faq_export_xml', 'faq_compare_card'],
isHot: true
......
......@@ -516,8 +516,8 @@ export const useEditorStore = defineStore('editor', {
},
insertXmlFragment(xmlString: string, mode: 'above' | 'below' | 'inside', targetNodeId?: string) {
const targetId = targetNodeId || this.selectedNodeId
if (!this.xmlTree || !targetId) {
const rawTargetId = targetNodeId || this.selectedNodeId
if (!this.xmlTree || !rawTargetId) {
throw new Error('请先选择一个目标节点!')
}
......@@ -526,6 +526,7 @@ export const useEditorStore = defineStore('editor', {
this.skipExpandOnSelect = true
}
const targetId = rawTargetId.includes('-txt-') ? rawTargetId.split('-txt-')[0] : rawTargetId
const item = this.nodeMap.get(targetId)
if (!item) {
throw new Error('当前的目标节点无效!')
......@@ -598,7 +599,28 @@ export const useEditorStore = defineStore('editor', {
destParentNode.children.push(...newNodes)
}
this.selectedNodeId = newNodes[newNodes.length - 1].id
// 同步维护父节点的 mixedContent (若存在)
if (destParentNode.mixedContent && destParentNode.mixedContent.length > 0) {
const mixedIdx = destParentNode.mixedContent.findIndex((m) => m.type === 'element' && m.nodeId === targetId)
const newMixedItems = newNodes.map((n) => ({ type: 'element' as const, nodeId: n.id }))
if (mode === 'above') {
if (mixedIdx !== -1) {
destParentNode.mixedContent.splice(mixedIdx, 0, ...newMixedItems)
} else {
destParentNode.mixedContent.unshift(...newMixedItems)
}
} else if (mode === 'below') {
if (mixedIdx !== -1) {
destParentNode.mixedContent.splice(mixedIdx + 1, 0, ...newMixedItems)
} else {
destParentNode.mixedContent.push(...newMixedItems)
}
} else {
destParentNode.mixedContent.push(...newMixedItems)
}
}
this.setSelectedNodeId(newNodes[newNodes.length - 1].id, true)
newNodes.forEach((node) => this.handleNodeInsertion(node))
this.rebuildNodeMap()
......@@ -610,11 +632,25 @@ export const useEditorStore = defineStore('editor', {
return newNodes.length
},
insertXmlNodeFragment(fragment: XmlNode, mode: 'above' | 'below' | 'inside' | 'replace', targetNodeId?: string) {
const targetId = targetNodeId || this.selectedNodeId
if (!this.xmlTree || !targetId) {
insertXmlNodeFragment(
fragment: XmlNode,
mode: 'above' | 'below' | 'inside' | 'replace' | 'append-root',
targetNodeId?: string
) {
if (!this.xmlTree) {
throw new Error('当前编辑器未加载任何 XML 文档!')
}
let targetId: string
if (mode === 'append-root') {
targetId = this.xmlTree.id
} else {
const rawTargetId = targetNodeId || this.selectedNodeId
if (!rawTargetId) {
throw new Error('请先选择一个目标节点!')
}
targetId = rawTargetId.includes('-txt-') ? rawTargetId.split('-txt-')[0] : rawTargetId
}
const appStore = useAppStore()
if (!appStore.autoExpandOnInsert) {
......@@ -654,9 +690,11 @@ export const useEditorStore = defineStore('editor', {
// 确定被插入的目标父节点
let destParentNode: XmlNode
if (mode === 'above' || mode === 'below' || mode === 'replace') {
if (mode === 'append-root') {
destParentNode = this.xmlTree
} else if (mode === 'above' || mode === 'below' || mode === 'replace') {
if (!parentNode) {
throw new Error('无法操作根节点')
throw new Error('根节点无法执行同级插入或替换操作!')
}
destParentNode = parentNode
} else {
......@@ -665,7 +703,11 @@ export const useEditorStore = defineStore('editor', {
// 严格校验 DTD 规则约束
const tagName = fragment.tagName
const existingCount = destParentNode.children.filter((c) => c.tagName === tagName).length
let existingCount = destParentNode.children.filter((c) => c.tagName === tagName).length
// 修复 replace 模式下同名替换时被替换节点应被扣除计数的 Bug
if (mode === 'replace' && targetNode.tagName === tagName) {
existingCount = Math.max(0, existingCount - 1)
}
if (!canAddChild(destParentNode.tagName, tagName, existingCount)) {
throw new Error(`DTD 校验失败: 节点 <${destParentNode.tagName}> 无法接受子元素 <${tagName}>`)
}
......@@ -696,11 +738,38 @@ export const useEditorStore = defineStore('editor', {
destParentNode.children.splice(index, 1, clonedNode)
}
} else {
// inside
// inside or append-root
destParentNode.children.push(clonedNode)
}
this.selectedNodeId = clonedNode.id
// 同步维护父节点的 mixedContent (若存在)
if (destParentNode.mixedContent && destParentNode.mixedContent.length > 0) {
const mixedIdx = destParentNode.mixedContent.findIndex((m) => m.type === 'element' && m.nodeId === targetId)
if (mode === 'replace') {
if (mixedIdx !== -1) {
destParentNode.mixedContent.splice(mixedIdx, 1, { type: 'element', nodeId: clonedNode.id })
} else {
destParentNode.mixedContent.push({ type: 'element', nodeId: clonedNode.id })
}
} else if (mode === 'above') {
if (mixedIdx !== -1) {
destParentNode.mixedContent.splice(mixedIdx, 0, { type: 'element', nodeId: clonedNode.id })
} else {
destParentNode.mixedContent.unshift({ type: 'element', nodeId: clonedNode.id })
}
} else if (mode === 'below') {
if (mixedIdx !== -1) {
destParentNode.mixedContent.splice(mixedIdx + 1, 0, { type: 'element', nodeId: clonedNode.id })
} else {
destParentNode.mixedContent.push({ type: 'element', nodeId: clonedNode.id })
}
} else {
// inside or append-root
destParentNode.mixedContent.push({ type: 'element', nodeId: clonedNode.id })
}
}
this.setSelectedNodeId(clonedNode.id, true)
this.handleNodeInsertion(clonedNode)
this.rebuildNodeMap()
......@@ -709,6 +778,7 @@ export const useEditorStore = defineStore('editor', {
this.skipExpandOnSelect = false
})
}
return clonedNode
},
setXmlTree(tree: XmlNode) {
......
......@@ -11,7 +11,7 @@
isSelected ? 'ring-2 ring-primary ring-offset-1 rounded-sm bg-primary/5' : '',
!renderInline && isContainer ? 'py-1 px-1' : ''
]"
:style="node.attributes?.MERGED === 'TRUE' ? { backgroundColor: 'yellow' } : {}"
:style="node.attributes?.MERGED === 'TRUE' && node.tagName !== 'SIGNOFF' ? { backgroundColor: 'yellow' } : {}"
@click.stop="handleNodeClick($event)"
>
<!-- 智能翻译加载遮罩 -->
......@@ -1171,23 +1171,36 @@
<template v-else-if="node.tagName === 'SIGNOFF'">
<component v-if="CustomSignoff" :is="CustomSignoff" :node="node" />
<div v-else class="my-3 overflow-x-auto select-none">
<table class="min-w-[400px] border-collapse border border-black dark:border-white/40 text-xs bg-card text-color1">
<table
class="min-w-[400px] border-collapse border border-black dark:border-white/40 text-xs text-color1"
:class="node.attributes?.MERGED === 'TRUE' ? 'merged-signoff' : 'bg-card'"
:style="node.attributes?.MERGED === 'TRUE' ? 'background-color: yellow; color: #000000;' : ''"
>
<tbody>
<tr class="h-8">
<!-- 标签 -->
<td class="px-2 border border-black dark:border-white/40 font-bold text-center bg-fill-2 min-w-[60px]">
<td
class="px-2 border border-black dark:border-white/40 font-bold text-center min-w-[60px]"
:class="node.attributes?.MERGED === 'TRUE' ? '' : 'bg-fill-2'"
>
{{ node.attributes.TAG || '签字' }}
</td>
<!-- Mech 栏 -->
<td class="px-2 border border-black dark:border-white/40 font-bold text-center bg-fill-2/50 w-20">操作人 MECH</td>
<td
class="px-2 border border-black dark:border-white/40 font-bold text-center w-20"
:class="node.attributes?.MERGED === 'TRUE' ? '' : 'bg-fill-2/50'"
>操作人 MECH</td>
<td class="px-2 border border-black dark:border-white/40 text-center w-28 font-mono">
{{ node.attributes.mech ? `${node.attributes.mech} ${node.attributes.mechName || ''}` : '——' }}
</td>
<!-- Insp (若有) -->
<template v-if="['B', 'D', 'E', 'C'].includes(node.attributes['CK-LEVEL'] || 'B')">
<td class="px-2 border border-black dark:border-white/40 font-bold text-center bg-fill-2/50 w-20">
<td
class="px-2 border border-black dark:border-white/40 font-bold text-center w-20"
:class="node.attributes?.MERGED === 'TRUE' ? '' : 'bg-fill-2/50'"
>
{{ node.attributes['CK-LEVEL'] === 'C' ? '确认人 VERF' : '检验员 INSP' }}
</td>
<td class="px-2 border border-black dark:border-white/40 text-center w-28 font-mono">
......@@ -1726,4 +1739,10 @@ const handleNodeClick = (e: MouseEvent) => {
display: none !important;
}
}
/* 当处于 MERGED (黄色背景) 时,强制表格外边框及所有内部单元格边框为黑色实线,防止暗色模式下 dark:border-white/40 导致边框在黄色背景上隐形消失 */
.merged-signoff,
.merged-signoff td {
border-color: #000000 !important;
}
</style>
......@@ -82,3 +82,52 @@ export interface BlockPosition {
bottom: number
height: number
}
/**
* 递归构建扁平化文档块列表
* JOBCARD / CEP / TASK 为透明容器,穿透递归其子节点
*/
export const getEditorBlocks = (node: XmlNode): EditorBlock[] => {
const blocks: EditorBlock[] = []
const walk = (n: XmlNode) => {
if (!n) return
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 (n.tagName === 'TASK') {
// TASK 节点:创建虚拟 TASK-HEADER 展示任务编号,然后穿透子节点
const virtualTaskHeaderNode: XmlNode = {
id: n.id,
tagName: 'TASK-HEADER',
attributes: n.attributes || {},
textContent: '',
children: [],
mixedContent: [],
parentId: n.parentId
}
blocks.push({ id: virtualTaskHeaderNode.id, tagName: 'TASK-HEADER', rawNode: virtualTaskHeaderNode })
;(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 扁平化,以实现细粒度虚拟化
blocks.push({ id: n.id, tagName: 'TOPIC', rawNode: n })
const contentNodes = (n.children || []).filter((c) => c.tagName !== 'TITLE' && c.tagName !== 'TITLEC')
contentNodes.forEach(walk)
} else {
blocks.push({ id: n.id, tagName: n.tagName, rawNode: n })
}
}
walk(node)
return blocks
}
......@@ -18,7 +18,12 @@ export function useStashModal() {
const viewEffectModalRef = ref<any>(null)
const selectedNode = computed(() => editorStore.selectedNode)
const selectedNodeParent = computed(() => editorStore.selectedNodeParent)
const hasXmlTree = computed(() => !!editorStore.xmlTree)
const isRootSelected = computed(() => {
if (!editorStore.xmlTree || !editorStore.selectedNode) return false
return editorStore.selectedNode.id === editorStore.xmlTree.id
})
// 过滤后的列表
const filteredItems = computed(() => {
......@@ -43,6 +48,126 @@ export function useStashModal() {
const isCopied = ref(false)
// 针对当前选中的暂存项,动态计算各种应用操作的可用性与原因说明
const applyStatus = computed(() => {
const item = activeItem.value
if (!item) {
return {
above: { enabled: false, reason: '未选择暂存项' },
below: { enabled: false, reason: '未选择暂存项' },
inside: { enabled: false, reason: '未选择暂存项' },
replace: { enabled: false, reason: '未选择暂存项' },
appendRoot: { enabled: false, reason: '未选择暂存项' }
}
}
const tag = item.xmlNode.tagName
const cur = selectedNode.value
const parent = selectedNodeParent.value
const isRoot = isRootSelected.value
const tree = editorStore.xmlTree
// 1. 在上方插入
let aboveEnabled = false
let aboveReason = ''
if (!tree) {
aboveReason = '编辑器中无打开的 XML 文档'
} else if (!cur) {
aboveReason = '未在编辑区选中目标节点'
} else if (isRoot || !parent) {
aboveReason = '根节点无法插入同级兄弟节点'
} else {
const count = parent.children.filter((c) => c.tagName === tag).length
if (!canAddChild(parent.tagName, tag, count)) {
aboveReason = `父节点 <${parent.tagName}> 约束无法容纳同级子元素 <${tag}>`
} else {
aboveEnabled = true
aboveReason = `在当前节点 <${cur.tagName}> 前方插入同级 <${tag}>`
}
}
// 2. 在下方插入
let belowEnabled = false
let belowReason = ''
if (!tree) {
belowReason = '编辑器中无打开的 XML 文档'
} else if (!cur) {
belowReason = '未在编辑区选中目标节点'
} else if (isRoot || !parent) {
belowReason = '根节点无法插入同级兄弟节点'
} else {
const count = parent.children.filter((c) => c.tagName === tag).length
if (!canAddChild(parent.tagName, tag, count)) {
belowReason = `父节点 <${parent.tagName}> 约束无法容纳同级子元素 <${tag}>`
} else {
belowEnabled = true
belowReason = `在当前节点 <${cur.tagName}> 后方插入同级 <${tag}>`
}
}
// 3. 作为子元素插入内部
let insideEnabled = false
let insideReason = ''
if (!tree) {
insideReason = '编辑器中无打开的 XML 文档'
} else if (!cur) {
insideReason = '未在编辑区选中目标节点'
} else {
const count = cur.children.filter((c) => c.tagName === tag).length
if (!canAddChild(cur.tagName, tag, count)) {
insideReason = `目标节点 <${cur.tagName}> 约束无法容纳子元素 <${tag}>`
} else {
insideEnabled = true
insideReason = `作为子元素插入到当前 <${cur.tagName}> 内部末尾`
}
}
// 4. 替换当前选中节点
let replaceEnabled = false
let replaceReason = ''
if (!tree) {
replaceReason = '编辑器中无打开的 XML 文档'
} else if (!cur) {
replaceReason = '未在编辑区选中目标节点'
} else if (isRoot || !parent) {
replaceReason = '根节点无法被直接替换'
} else {
let count = parent.children.filter((c) => c.tagName === tag).length
if (cur.tagName === tag) {
count = Math.max(0, count - 1)
}
if (!canAddChild(parent.tagName, tag, count)) {
replaceReason = `父节点 <${parent.tagName}> 约束不允许替换为 <${tag}>`
} else {
replaceEnabled = true
replaceReason = `将当前节点 <${cur.tagName}> 替换为暂存片段 <${tag}>(需确认)`
}
}
// 5. 追加到根节点末尾
let appendRootEnabled = false
let appendRootReason = ''
if (!tree) {
appendRootReason = '编辑器中无打开的 XML 文档'
} else {
const count = tree.children.filter((c) => c.tagName === tag).length
if (!canAddChild(tree.tagName, tag, count)) {
appendRootReason = `根节点 <${tree.tagName}> 约束无法容纳子元素 <${tag}>`
} else {
appendRootEnabled = true
appendRootReason = `直接作为子节点追加到文档根节点 <${tree.tagName}> 末尾`
}
}
return {
above: { enabled: aboveEnabled, reason: aboveReason },
below: { enabled: belowEnabled, reason: belowReason },
inside: { enabled: insideEnabled, reason: insideReason },
replace: { enabled: replaceEnabled, reason: replaceReason },
appendRoot: { enabled: appendRootEnabled, reason: appendRootReason }
}
})
// 动态提示默认生成的暂存别名
const defaultNamePlaceholder = computed(() => {
const timeStr = new Date().toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
......@@ -165,12 +290,11 @@ export function useStashModal() {
try {
await window.$dialog?.warning({
title: '确认恢复完整 XML',
content: `您确定要应用暂存的完整 XML "${item.name}" 吗?这将会覆盖当前编辑区的所有内容!`
content: `您确定要应用暂存的完整 XML "${item.name}" 吗?这将会完全覆盖当前编辑区的所有内容并重置撤销历史!`
})
visible.value = false
window.$loading?.show('正在恢复 XML…')
// 先序列化为字符串(同步但快),再通过 parseXmlToTreeAsync 的内置 setTimeout 异步重建树
// 这样主线程不会被阻塞,loading 动画有足够时间上屏
const xmlString = serializeTreeToXml(item.xmlNode, 0, true)
try {
const tree = await parseXmlToTreeAsync(xmlString)
......@@ -186,11 +310,32 @@ export function useStashModal() {
}
}
const handleApplyFragment = (item: StashItem, mode: 'above' | 'below' | 'inside' | 'replace') => {
const handleApplyFragment = async (
item: StashItem,
mode: 'above' | 'below' | 'inside' | 'replace' | 'append-root'
) => {
if (mode === 'replace') {
try {
await window.$dialog?.warning({
title: '确认替换当前节点',
content: `您确定要将当前选中的节点 <${selectedNode.value?.tagName || '未知'}> 替换为暂存片段 <${item.xmlNode.tagName}> 吗?原节点内容将被覆盖。`
})
} catch (e) {
return
}
}
try {
editorStore.insertXmlNodeFragment(item.xmlNode, mode)
visible.value = false
window.$message?.success('已成功应用并插入 XML 片段!')
const modeTextMap: Record<string, string> = {
above: '在上方插入',
below: '在下方插入',
inside: '插入子节点',
replace: '替换节点',
'append-root': '追加到文档末尾'
}
window.$message?.success(`已成功完成【${modeTextMap[mode] || '插入'}XML 片段!`)
} catch (err: any) {
window.$message?.error(err.message || '应用片段失败,请检查 DTD 约束!')
}
......@@ -223,7 +368,7 @@ export function useStashModal() {
const handleExport = (item: StashItem) => {
try {
const singleLineXml = serializeTreeToXml(item.xmlNode, 0, false).replace(/\r?\n\s*/g, '')
const xml = `<?xml version="1.0" encoding="utf-8"?>${singleLineXml}`
const xml = item.type === 'full' ? `<?xml version="1.0" encoding="utf-8"?>${singleLineXml}` : singleLineXml
const blob = new Blob([xml], { type: 'application/xml;charset=utf-8;' })
const safeName = item.name.replace(/[\/\\:*?"<>|]/g, '_')
openDownloadModal({
......@@ -253,8 +398,8 @@ export function useStashModal() {
const handlePreviewEffect = (item: StashItem) => {
try {
const xmlText = serializeTreeToXml(item.xmlNode, 0, false)
viewEffectModalRef.value?.open(`预览暂存效果:${item.name}`, xmlText)
// 直接传递 item.xmlNode,避免耗时的大型 XML 字符串序列化与反序列化双重卡顿,由 ViewEffectModal 异步平滑渲染
viewEffectModalRef.value?.open(`预览暂存效果:${item.name}`, item.xmlNode)
} catch (err: any) {
window.$message?.error(`准备预览失败: ${err.message || err}`)
}
......@@ -276,7 +421,10 @@ export function useStashModal() {
showRenameModal,
editingItemName,
selectedNode,
selectedNodeParent,
hasXmlTree,
isRootSelected,
applyStatus,
open,
handleStashFull,
handleStashFragment,
......
......@@ -111,21 +111,27 @@
<div class="flex-1 flex flex-col p-4 min-w-0 bg-fill-1">
<div v-if="activeItem" class="flex-1 flex flex-col min-h-0">
<!-- 顶栏:元信息 -->
<div class="mb-3 flex items-center justify-between">
<div class="flex-1 min-w-0 mr-4">
<div class="mb-3 flex items-center justify-between shrink-0">
<div class="flex-1 min-w-0 mr-3">
<div class="text-xs font-bold text-color1 truncate" :title="activeItem.name">{{ activeItem.name }}</div>
<div class="text-[10px] text-color3 mt-0.5 flex items-center gap-2">
<span>记录时间:{{ activeItem.time }}</span>
<div class="text-[10px] text-color3 mt-0.5 flex items-center gap-1.5 whitespace-nowrap overflow-hidden text-ellipsis">
<span class="whitespace-nowrap">记录时间: {{ activeItem.time }}</span>
<span v-if="activeItemSizeStr" class="opacity-50">|</span>
<span v-if="activeItemSizeStr" class="text-color2">大小:{{ activeItemSizeStr }}</span>
<span v-if="activeItemSizeStr" class="text-color2 whitespace-nowrap">大小: {{ activeItemSizeStr }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<CommonButton v-if="activeItem.type === 'full'" size="small" type="warning" secondary @click="handleExport(activeItem)">
<div class="flex items-center gap-1.5 shrink-0">
<CommonButton v-if="activeItem.type !== 'full'" size="small" secondary @click="handlePreviewEffect(activeItem)">
<template #icon>
<n-icon><EyeOutline /></n-icon>
</template>
效果预览
</CommonButton>
<CommonButton size="small" secondary @click="handleExport(activeItem)">
<template #icon>
<n-icon><DownloadOutline /></n-icon>
</template>
下载 XML
导出 XML
</CommonButton>
<CommonButton size="small" :type="isCopied ? 'success' : 'primary'" secondary @click="handleCopyXml(activeItem)">
<template #icon>
......@@ -152,28 +158,127 @@
</div>
<!-- 操作面板 -->
<div class="mt-4 p-4 border border-divider rounded-xl bg-card space-y-3">
<div class="text-xs font-bold text-color2">应用操作</div>
<div class="mt-3 p-3 border border-divider rounded-xl bg-card flex flex-col gap-2.5 shadow-sm">
<!-- 顶行:操作标题与当前选中目标看板 -->
<div class="flex items-center justify-between gap-3 pb-2 border-b border-divider/60">
<div class="flex items-center gap-2 min-w-0">
<span class="text-xs font-bold text-color1 flex items-center gap-1.5 shrink-0">
<n-icon class="text-primary"><AppsOutline /></n-icon>
应用操作
</span>
<span class="text-[11px] text-color3 font-mono bg-fill-2 px-1.5 py-0.5 rounded shrink-0">
{{ activeItem.type === 'full' ? '完整文档' : '片段' }}: &lt;{{ activeItem.xmlNode.tagName }}&gt;
</span>
</div>
<!-- 目标节点状态指示条 -->
<div class="flex items-center gap-1.5 text-xs min-w-0 shrink-0">
<span class="text-[11px] text-color3">当前目标:</span>
<template v-if="selectedNode">
<span class="inline-flex items-center gap-1 font-mono text-[11px] bg-primary/10 text-primary border border-primary/20 px-2 py-0.5 rounded font-medium">
&lt;{{ selectedNode.tagName }}&gt;
<span v-if="isRootSelected" class="text-[10px] text-color3 font-sans">(根节点)</span>
</span>
<span v-if="selectedNodeParent" class="text-[10px] text-color3 max-w-[160px] truncate" :title="selectedNodeParent.tagName">
父级: &lt;{{ selectedNodeParent.tagName }}&gt;
</span>
</template>
<template v-else>
<span class="text-[11px] text-warning flex items-center gap-1 bg-warning/10 px-2 py-0.5 rounded border border-warning/20">
<n-icon><AlertCircleOutline /></n-icon>
未选中节点
</span>
</template>
</div>
</div>
<!-- 针对完整 XML 的恢复 -->
<div v-if="activeItem.type === 'full'" class="flex flex-col space-y-2">
<div v-if="activeItem.type === 'full'" class="flex flex-col gap-2">
<CommonButton type="primary" class="w-full" @click="handleApplyFull(activeItem)">
<template #icon>
<n-icon><SyncOutline /></n-icon>
</template>
恢复此 XML 文档并覆盖当前编辑器
</CommonButton>
<div class="text-[10px] text-color3 text-center">注意:这会覆盖编辑器中已打开的所有节点内容。</div>
<div class="text-[10px] text-color3 text-center">
注意:全量覆盖将清空当前编辑区所有节点并重置撤销历史,系统将提示确认。
</div>
</div>
<!-- 针对片段的导入 -->
<div v-else class="grid grid-cols-2 gap-3">
<CommonButton type="warning" secondary @click="handleApplyFragment(activeItem, 'inside')">
<!-- 针对片段的导入操作按钮组 (纯粹的 2x2 四大相对操作) -->
<div v-else class="grid grid-cols-2 gap-2.5">
<!-- 1. 作为子元素插入内部 (左上) -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
secondary
class="w-full"
:disabled="!applyStatus.inside.enabled"
@click="handleApplyFragment(activeItem, 'inside')"
>
<template #icon>
<n-icon><LogInOutline /></n-icon>
</template>
作为子元素插入内部
</CommonButton>
<CommonButton type="warning" secondary @click="handleApplyFragment(activeItem, 'replace')">替换当前选中节点</CommonButton>
<CommonButton type="default" secondary @click="handleApplyFragment(activeItem, 'above')">在当前节点上方插入</CommonButton>
<CommonButton type="default" secondary @click="handleApplyFragment(activeItem, 'below')">在当前节点下方插入</CommonButton>
</template>
{{ applyStatus.inside.reason }}
</n-tooltip>
<!-- 2. 替换当前选中节点 (右上) -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
type="warning"
secondary
class="w-full"
:disabled="!applyStatus.replace.enabled"
@click="handleApplyFragment(activeItem, 'replace')"
>
<template #icon>
<n-icon><SwapHorizontalOutline /></n-icon>
</template>
替换当前选中节点
</CommonButton>
</template>
{{ applyStatus.replace.reason }}
</n-tooltip>
<!-- 3. 在当前节点上方插入 (左下) -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
secondary
class="w-full"
:disabled="!applyStatus.above.enabled"
@click="handleApplyFragment(activeItem, 'above')"
>
<template #icon>
<n-icon><ArrowUpOutline /></n-icon>
</template>
在当前节点上方插入
</CommonButton>
</template>
{{ applyStatus.above.reason }}
</n-tooltip>
<!-- 4. 在当前节点下方插入 (右下) -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
secondary
class="w-full"
:disabled="!applyStatus.below.enabled"
@click="handleApplyFragment(activeItem, 'below')"
>
<template #icon>
<n-icon><ArrowDownOutline /></n-icon>
</template>
在当前节点下方插入
</CommonButton>
</template>
{{ applyStatus.below.reason }}
</n-tooltip>
</div>
</div>
</div>
......@@ -210,7 +315,14 @@ import {
EyeOutline,
DownloadOutline,
CopyOutline,
CheckmarkOutline
CheckmarkOutline,
ArrowUpOutline,
ArrowDownOutline,
LogInOutline,
AddCircleOutline,
SwapHorizontalOutline,
AlertCircleOutline,
AppsOutline
} from '@vicons/ionicons5'
const {
......@@ -229,7 +341,10 @@ const {
showRenameModal,
editingItemName,
selectedNode,
selectedNodeParent,
hasXmlTree,
isRootSelected,
applyStatus,
open,
handleStashFull,
handleStashFragment,
......
import type { EditorBlock } from '@/views/editor/components/EditorPanel/constants'
export interface ViewEffectState {
visible: boolean
title: string
xmlTree: any
loading: boolean
blocks: EditorBlock[]
}
import type { XmlNode } from '@/types/xmlNode'
import { getEditorBlocks, type EditorBlock } from '@/views/editor/components/EditorPanel/constants'
export function useViewEffectModal() {
const visible = ref(false)
const title = ref('')
const xmlTree = ref<XmlNode | null>(null)
const loading = ref(false)
const blocks = ref<EditorBlock[]>([])
let rafId: number | null = null
const open = (modalTitle: string, xmlContent: string) => {
const cancelRender = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
}
const open = async (modalTitle: string, source: string | XmlNode) => {
cancelRender()
title.value = modalTitle
try {
// 解析 XML 片段
xmlTree.value = parseXmlToTree(xmlContent)
blocks.value = []
// 遵循规则 12:先开窗后加载
visible.value = true
loading.value = true
const startTime = Date.now()
try {
// 给浏览器 1 帧绘制时间,确保弹窗以原生 60FPS 顺畅展开,骨架屏和 loading 动画平滑启动
await nextTick()
await new Promise((resolve) => requestAnimationFrame(resolve))
let rootNode: XmlNode
if (typeof source === 'string') {
rootNode = await parseXmlToTreeAsync(source)
} else {
rootNode = JSON.parse(JSON.stringify(source))
}
const allBlocks = getEditorBlocks(rootNode)
if (allBlocks.length === 0) {
loading.value = false
return
}
// 预留合理的最小感知时长(400ms),确保大文件加载动效清晰可见、平滑过渡,避免一闪而过无感知
const MIN_LOADING_TIME = 400
const elapsed = Date.now() - startTime
if (elapsed < MIN_LOADING_TIME) {
await new Promise((resolve) => setTimeout(resolve, MIN_LOADING_TIME - elapsed))
}
if (!visible.value) return
// 首屏仅渲染第一批(6 个块,足以填满弹窗可视高度),挂载耗时 < 10ms,绝不卡顿主线程动画
const BATCH_SIZE = 6
blocks.value = allBlocks.slice(0, BATCH_SIZE)
// 等待首屏 DOM 挂载完成
await nextTick()
// 首屏立即可见,立刻关闭 loading,让用户平滑过渡到文档效果
loading.value = false
// 后续块采用时间切片(Time Slicing),在后续空闲帧每帧追加一批,零掉帧无感流式加载
if (allBlocks.length > BATCH_SIZE) {
let currentIndex = BATCH_SIZE
const renderNextBatch = () => {
if (!visible.value || currentIndex >= allBlocks.length) {
rafId = null
return
}
const nextIndex = Math.min(currentIndex + 8, allBlocks.length)
blocks.value.push(...allBlocks.slice(currentIndex, nextIndex))
currentIndex = nextIndex
if (currentIndex < allBlocks.length) {
rafId = requestAnimationFrame(renderNextBatch)
} else {
rafId = null
}
}
rafId = requestAnimationFrame(renderNextBatch)
}
} catch (err: any) {
window.$message?.error(`解析 XML 效果失败: ${err.message || err}`)
loading.value = false
}
}
watch(visible, (val) => {
if (!val) {
cancelRender()
blocks.value = []
}
})
onBeforeUnmount(() => {
cancelRender()
})
return {
visible,
title,
xmlTree,
loading,
blocks,
open
}
}
<template>
<CommonModal v-model="visible" :title="title" :width="900" :show-confirm="false" cancel-text="关闭">
<DocNodeRenderer v-if="xmlTree" :node="xmlTree" :parent="null" />
<CommonModal
v-model="visible"
:title="title"
:width="900"
:loading="loading"
:show-confirm="false"
cancel-text="关闭"
>
<div class="min-h-[400px] p-2">
<!-- 渲染文档树效果(时间切片分批流式渲染,保持60FPS高刷与瞬时首屏) -->
<div v-if="blocks.length > 0" class="space-y-1">
<div v-for="block in blocks" :key="block.id" class="w-full py-0.5">
<DocNodeRenderer :node="block.rawNode" :parent="null" />
</div>
</div>
<!-- 加载中的文档骨架占位效果(首屏加载前呈现,60FPS 平滑脉冲) -->
<div v-else-if="loading" class="flex flex-col gap-4 py-8 px-6 animate-pulse select-none">
<div class="flex items-center justify-between">
<div class="h-6 bg-fill-3 rounded-md w-1/3"></div>
<div class="h-5 bg-fill-2 rounded-md w-24"></div>
</div>
<div class="h-4 bg-fill-3 rounded w-full"></div>
<div class="h-4 bg-fill-3 rounded w-5/6"></div>
<div class="h-4 bg-fill-3 rounded w-4/5"></div>
<div class="my-2 p-4 bg-fill-2 border border-divider/60 rounded-lg flex flex-col gap-3">
<div class="h-4 bg-fill-3 rounded w-1/4"></div>
<div class="h-3 bg-fill-3 rounded w-3/4"></div>
<div class="h-3 bg-fill-3 rounded w-2/3"></div>
</div>
<div class="h-4 bg-fill-3 rounded w-11/12"></div>
<div class="h-4 bg-fill-3 rounded w-3/4"></div>
<div class="h-4 bg-fill-3 rounded w-4/6"></div>
</div>
<!-- 空状态或解析无内容 -->
<div v-else class="flex flex-col items-center justify-center py-16 text-color3 select-none">
<n-icon size="40" class="opacity-30 mb-2">
<DocumentTextOutline />
</n-icon>
<div class="text-xs">暂无内容或预览数据为空</div>
</div>
</div>
</CommonModal>
</template>
<script setup lang="ts">
import DocNodeRenderer from '../../../../../DocNodeRenderer/index.vue'
import { DocumentTextOutline } from '@vicons/ionicons5'
import { useViewEffectModal } from './functionals'
const { visible, title, xmlTree, open } = useViewEffectModal()
const { visible, title, loading, blocks, open } = useViewEffectModal()
// 注入只读模式的对比状态,禁用一切编辑行为及组件内部编辑事件
provide('diffMode', true)
......@@ -19,3 +61,5 @@ defineExpose({
</script>
<style scoped></style>
......@@ -55,7 +55,10 @@ export function useTemplateSelectModal() {
}
const handleInsert = () => {
if (!selectedTemplate.value) return
if (!selectedTemplate.value) {
window.$message?.warning('请从列表中选择要插入的模板')
return
}
try {
const mode = insertBelowSetting.value ? 'below' : 'inside'
......
......@@ -5,7 +5,6 @@
:width="750"
:loading="loading"
confirm-text="确认插入"
:confirm-disabled="!selectedTemplate"
@confirm="handleInsert"
>
<div class="flex flex-col space-y-4">
......
<template>
<div class="my-3 flex overflow-x-auto select-none" :class="containerClass">
<table class="border-collapse border border-black dark:border-white/40 text-xs bg-card text-color1">
<table
class="border-collapse border border-black dark:border-white/40 text-xs text-color1"
:class="node.attributes?.MERGED === 'TRUE' ? 'merged-signoff' : 'bg-card'"
:style="node.attributes?.MERGED === 'TRUE' ? 'background-color: yellow; color: #000000;' : ''"
>
<tbody>
<!-- 第一行:表头与签字人 -->
<tr class="h-10">
<!-- 工作者 Per.By -->
<td class="px-2 border border-black dark:border-white/40 font-bold text-center w-24 bg-fill-2/50">
<td
class="px-2 border border-black dark:border-white/40 font-bold text-center w-24"
:class="node.attributes?.MERGED === 'TRUE' ? '' : 'bg-fill-2/50'"
>
工作者
<br />
Per.By
......@@ -17,7 +24,10 @@
<!-- 检查者 Insp.By (CK-LEVEL B, 且支持 D/E 兼容) -->
<template v-if="['B', 'D', 'E'].includes(node.attributes['CK-LEVEL'])">
<td class="px-2 border border-black dark:border-white/40 font-bold text-center w-24 bg-fill-2/50">
<td
class="px-2 border border-black dark:border-white/40 font-bold text-center w-24"
:class="node.attributes?.MERGED === 'TRUE' ? '' : 'bg-fill-2/50'"
>
检查者
<br />
Insp.By
......@@ -30,7 +40,10 @@
<!-- 必检 RII.By (CK-LEVEL C) -->
<template v-else-if="node.attributes['CK-LEVEL'] === 'C'">
<td class="px-2 border border-black dark:border-white/40 font-bold text-center w-24 bg-fill-2/50">
<td
class="px-2 border border-black dark:border-white/40 font-bold text-center w-24"
:class="node.attributes?.MERGED === 'TRUE' ? '' : 'bg-fill-2/50'"
>
必检
<br />
RII.By
......@@ -90,3 +103,11 @@ const containerClass = computed(() => {
return location === 'LEFT' ? 'justify-start' : 'justify-end'
})
</script>
<style scoped>
/* 当处于 MERGED (黄色背景) 时,强制表格外边框及所有内部单元格边框为黑色实线,防止暗色模式下 dark:border-white/40 导致边框在黄色背景上隐形消失 */
.merged-signoff,
.merged-signoff td {
border-color: #000000 !important;
}
</style>
......@@ -68,7 +68,7 @@ export default defineConfig(({ mode }) => {
changeOrigin: true,
secure: false,
headers: {
Cookie: '_udid=7927a0ca-972a-4e44-948a-c2edfd510fdb; JSESSIONID=C4A682632B2A7670516350D782474347; _amro_sk=31047732-4a3e-4681-b99a-16e99ee22810'
Cookie: '_udid=2189d6e1-8312-4c16-a133-837951dfe125; JSESSIONID=3545B5297E4782E7963868A0CB522864; _amro_sk=8547dbec-a248-46ec-870a-f5b99c15d716'
}
},
'/mnt': {
......
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