Commit a43e8977 by pangchong

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

- 完善本地暂存功能,支持暂存整卡或选中片段的多样插入模式(上方、下方、内部、替换、追加根节点)
- 增加对XML节点插入时父节点mixedContent的同步维护,保证编辑状态一致
- 引入插入操作的可用性动态计算与理由提示,禁止不合规插入并提供明确反馈
- 实现暂存片段的效果预览功能,支持60FPS流式渲染大文档,提升加载体验与响应速度
- 优化签字表格及渲染组件样式,确保暗色模式下边框清晰可见
- 更新本地暂存帮助文档,增加操作步骤及预览功能说明,加强用户引导
- 修复XML节点操作中replace模式的DTD计数逻辑错误,提升数据一致性
- 调整编辑器内部相关类型和工具函数,支持文档块扁平化及虚拟化渲染需求
parent c19e8786
<template> <template>
<div class="my-3 flex overflow-x-auto select-none" :class="containerClass"> <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> <tbody>
<!-- 第一行:表头与签字人 --> <!-- 第一行:表头与签字人 -->
<tr class="h-10"> <tr class="h-10">
......
...@@ -1009,16 +1009,37 @@ export const FAQ_DATABASE: FaqItem[] = [ ...@@ -1009,16 +1009,37 @@ export const FAQ_DATABASE: FaqItem[] = [
id: 'faq_stash_manager', id: 'faq_stash_manager',
category: 'file', category: 'file',
categoryName: '文件与导出', categoryName: '文件与导出',
title: '什么是“本地暂存(Stash)”?如何防丢失?', title: '什么是“本地暂存(Stash)”?如何防丢失与效果预览?',
keywords: ['暂存', '本地暂存', 'stash', '草稿', '恢复', '防丢失', '版本', '历史暂存', '备份', 'zc', 'zancun', 'cg', 'caogao'], keywords: [
synonyms: ['怎么存草稿', '电脑关了数据还在吗', '怎么备份当前工卡', '恢复暂存', '暂存版本'], '暂存',
answer: '本地暂存功能类似于 Git Stash,能够将您当前编辑的工卡快照保存在本地浏览器中,防止意外刷新或关闭导致数据丢失。', '本地暂存',
steps: [ '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' }], actions: [{ label: '打开本地暂存', type: 'event', payload: 'trigger_stash' }],
relatedQuestions: ['faq_export_xml', 'faq_compare_card'], relatedQuestions: ['faq_export_xml', 'faq_compare_card'],
isHot: true isHot: true
......
...@@ -516,8 +516,8 @@ export const useEditorStore = defineStore('editor', { ...@@ -516,8 +516,8 @@ export const useEditorStore = defineStore('editor', {
}, },
insertXmlFragment(xmlString: string, mode: 'above' | 'below' | 'inside', targetNodeId?: string) { insertXmlFragment(xmlString: string, mode: 'above' | 'below' | 'inside', targetNodeId?: string) {
const targetId = targetNodeId || this.selectedNodeId const rawTargetId = targetNodeId || this.selectedNodeId
if (!this.xmlTree || !targetId) { if (!this.xmlTree || !rawTargetId) {
throw new Error('请先选择一个目标节点!') throw new Error('请先选择一个目标节点!')
} }
...@@ -526,6 +526,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -526,6 +526,7 @@ export const useEditorStore = defineStore('editor', {
this.skipExpandOnSelect = true this.skipExpandOnSelect = true
} }
const targetId = rawTargetId.includes('-txt-') ? rawTargetId.split('-txt-')[0] : rawTargetId
const item = this.nodeMap.get(targetId) const item = this.nodeMap.get(targetId)
if (!item) { if (!item) {
throw new Error('当前的目标节点无效!') throw new Error('当前的目标节点无效!')
...@@ -598,7 +599,28 @@ export const useEditorStore = defineStore('editor', { ...@@ -598,7 +599,28 @@ export const useEditorStore = defineStore('editor', {
destParentNode.children.push(...newNodes) 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)) newNodes.forEach((node) => this.handleNodeInsertion(node))
this.rebuildNodeMap() this.rebuildNodeMap()
...@@ -610,11 +632,25 @@ export const useEditorStore = defineStore('editor', { ...@@ -610,11 +632,25 @@ export const useEditorStore = defineStore('editor', {
return newNodes.length return newNodes.length
}, },
insertXmlNodeFragment(fragment: XmlNode, mode: 'above' | 'below' | 'inside' | 'replace', targetNodeId?: string) { insertXmlNodeFragment(
const targetId = targetNodeId || this.selectedNodeId fragment: XmlNode,
if (!this.xmlTree || !targetId) { 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('请先选择一个目标节点!') throw new Error('请先选择一个目标节点!')
} }
targetId = rawTargetId.includes('-txt-') ? rawTargetId.split('-txt-')[0] : rawTargetId
}
const appStore = useAppStore() const appStore = useAppStore()
if (!appStore.autoExpandOnInsert) { if (!appStore.autoExpandOnInsert) {
...@@ -654,9 +690,11 @@ export const useEditorStore = defineStore('editor', { ...@@ -654,9 +690,11 @@ export const useEditorStore = defineStore('editor', {
// 确定被插入的目标父节点 // 确定被插入的目标父节点
let destParentNode: XmlNode 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) { if (!parentNode) {
throw new Error('无法操作根节点') throw new Error('根节点无法执行同级插入或替换操作!')
} }
destParentNode = parentNode destParentNode = parentNode
} else { } else {
...@@ -665,7 +703,11 @@ export const useEditorStore = defineStore('editor', { ...@@ -665,7 +703,11 @@ export const useEditorStore = defineStore('editor', {
// 严格校验 DTD 规则约束 // 严格校验 DTD 规则约束
const tagName = fragment.tagName 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)) { if (!canAddChild(destParentNode.tagName, tagName, existingCount)) {
throw new Error(`DTD 校验失败: 节点 <${destParentNode.tagName}> 无法接受子元素 <${tagName}>`) throw new Error(`DTD 校验失败: 节点 <${destParentNode.tagName}> 无法接受子元素 <${tagName}>`)
} }
...@@ -696,11 +738,38 @@ export const useEditorStore = defineStore('editor', { ...@@ -696,11 +738,38 @@ export const useEditorStore = defineStore('editor', {
destParentNode.children.splice(index, 1, clonedNode) destParentNode.children.splice(index, 1, clonedNode)
} }
} else { } else {
// inside // inside or append-root
destParentNode.children.push(clonedNode) 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.handleNodeInsertion(clonedNode)
this.rebuildNodeMap() this.rebuildNodeMap()
...@@ -709,6 +778,7 @@ export const useEditorStore = defineStore('editor', { ...@@ -709,6 +778,7 @@ export const useEditorStore = defineStore('editor', {
this.skipExpandOnSelect = false this.skipExpandOnSelect = false
}) })
} }
return clonedNode
}, },
setXmlTree(tree: XmlNode) { setXmlTree(tree: XmlNode) {
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
isSelected ? 'ring-2 ring-primary ring-offset-1 rounded-sm bg-primary/5' : '', isSelected ? 'ring-2 ring-primary ring-offset-1 rounded-sm bg-primary/5' : '',
!renderInline && isContainer ? 'py-1 px-1' : '' !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)" @click.stop="handleNodeClick($event)"
> >
<!-- 智能翻译加载遮罩 --> <!-- 智能翻译加载遮罩 -->
...@@ -1171,23 +1171,36 @@ ...@@ -1171,23 +1171,36 @@
<template v-else-if="node.tagName === 'SIGNOFF'"> <template v-else-if="node.tagName === 'SIGNOFF'">
<component v-if="CustomSignoff" :is="CustomSignoff" :node="node" /> <component v-if="CustomSignoff" :is="CustomSignoff" :node="node" />
<div v-else class="my-3 overflow-x-auto select-none"> <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> <tbody>
<tr class="h-8"> <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 || '签字' }} {{ node.attributes.TAG || '签字' }}
</td> </td>
<!-- Mech 栏 --> <!-- 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"> <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 || ''}` : '——' }} {{ node.attributes.mech ? `${node.attributes.mech} ${node.attributes.mechName || ''}` : '——' }}
</td> </td>
<!-- Insp (若有) --> <!-- Insp (若有) -->
<template v-if="['B', 'D', 'E', 'C'].includes(node.attributes['CK-LEVEL'] || 'B')"> <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' }} {{ node.attributes['CK-LEVEL'] === 'C' ? '确认人 VERF' : '检验员 INSP' }}
</td> </td>
<td class="px-2 border border-black dark:border-white/40 text-center w-28 font-mono"> <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) => { ...@@ -1726,4 +1739,10 @@ const handleNodeClick = (e: MouseEvent) => {
display: none !important; display: none !important;
} }
} }
/* 当处于 MERGED (黄色背景) 时,强制表格外边框及所有内部单元格边框为黑色实线,防止暗色模式下 dark:border-white/40 导致边框在黄色背景上隐形消失 */
.merged-signoff,
.merged-signoff td {
border-color: #000000 !important;
}
</style> </style>
...@@ -82,3 +82,52 @@ export interface BlockPosition { ...@@ -82,3 +82,52 @@ export interface BlockPosition {
bottom: number bottom: number
height: 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() { ...@@ -18,7 +18,12 @@ export function useStashModal() {
const viewEffectModalRef = ref<any>(null) const viewEffectModalRef = ref<any>(null)
const selectedNode = computed(() => editorStore.selectedNode) const selectedNode = computed(() => editorStore.selectedNode)
const selectedNodeParent = computed(() => editorStore.selectedNodeParent)
const hasXmlTree = computed(() => !!editorStore.xmlTree) 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(() => { const filteredItems = computed(() => {
...@@ -43,6 +48,126 @@ export function useStashModal() { ...@@ -43,6 +48,126 @@ export function useStashModal() {
const isCopied = ref(false) 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 defaultNamePlaceholder = computed(() => {
const timeStr = new Date().toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' }) const timeStr = new Date().toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
...@@ -165,12 +290,11 @@ export function useStashModal() { ...@@ -165,12 +290,11 @@ export function useStashModal() {
try { try {
await window.$dialog?.warning({ await window.$dialog?.warning({
title: '确认恢复完整 XML', title: '确认恢复完整 XML',
content: `您确定要应用暂存的完整 XML "${item.name}" 吗?这将会覆盖当前编辑区的所有内容!` content: `您确定要应用暂存的完整 XML "${item.name}" 吗?这将会完全覆盖当前编辑区的所有内容并重置撤销历史!`
}) })
visible.value = false visible.value = false
window.$loading?.show('正在恢复 XML…') window.$loading?.show('正在恢复 XML…')
// 先序列化为字符串(同步但快),再通过 parseXmlToTreeAsync 的内置 setTimeout 异步重建树 // 先序列化为字符串(同步但快),再通过 parseXmlToTreeAsync 的内置 setTimeout 异步重建树
// 这样主线程不会被阻塞,loading 动画有足够时间上屏
const xmlString = serializeTreeToXml(item.xmlNode, 0, true) const xmlString = serializeTreeToXml(item.xmlNode, 0, true)
try { try {
const tree = await parseXmlToTreeAsync(xmlString) const tree = await parseXmlToTreeAsync(xmlString)
...@@ -186,11 +310,32 @@ export function useStashModal() { ...@@ -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 { try {
editorStore.insertXmlNodeFragment(item.xmlNode, mode) editorStore.insertXmlNodeFragment(item.xmlNode, mode)
visible.value = false 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) { } catch (err: any) {
window.$message?.error(err.message || '应用片段失败,请检查 DTD 约束!') window.$message?.error(err.message || '应用片段失败,请检查 DTD 约束!')
} }
...@@ -223,7 +368,7 @@ export function useStashModal() { ...@@ -223,7 +368,7 @@ export function useStashModal() {
const handleExport = (item: StashItem) => { const handleExport = (item: StashItem) => {
try { try {
const singleLineXml = serializeTreeToXml(item.xmlNode, 0, false).replace(/\r?\n\s*/g, '') 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 blob = new Blob([xml], { type: 'application/xml;charset=utf-8;' })
const safeName = item.name.replace(/[\/\\:*?"<>|]/g, '_') const safeName = item.name.replace(/[\/\\:*?"<>|]/g, '_')
openDownloadModal({ openDownloadModal({
...@@ -253,8 +398,8 @@ export function useStashModal() { ...@@ -253,8 +398,8 @@ export function useStashModal() {
const handlePreviewEffect = (item: StashItem) => { const handlePreviewEffect = (item: StashItem) => {
try { try {
const xmlText = serializeTreeToXml(item.xmlNode, 0, false) // 直接传递 item.xmlNode,避免耗时的大型 XML 字符串序列化与反序列化双重卡顿,由 ViewEffectModal 异步平滑渲染
viewEffectModalRef.value?.open(`预览暂存效果:${item.name}`, xmlText) viewEffectModalRef.value?.open(`预览暂存效果:${item.name}`, item.xmlNode)
} catch (err: any) { } catch (err: any) {
window.$message?.error(`准备预览失败: ${err.message || err}`) window.$message?.error(`准备预览失败: ${err.message || err}`)
} }
...@@ -276,7 +421,10 @@ export function useStashModal() { ...@@ -276,7 +421,10 @@ export function useStashModal() {
showRenameModal, showRenameModal,
editingItemName, editingItemName,
selectedNode, selectedNode,
selectedNodeParent,
hasXmlTree, hasXmlTree,
isRootSelected,
applyStatus,
open, open,
handleStashFull, handleStashFull,
handleStashFragment, handleStashFragment,
......
...@@ -111,21 +111,27 @@ ...@@ -111,21 +111,27 @@
<div class="flex-1 flex flex-col p-4 min-w-0 bg-fill-1"> <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 v-if="activeItem" class="flex-1 flex flex-col min-h-0">
<!-- 顶栏:元信息 --> <!-- 顶栏:元信息 -->
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between shrink-0">
<div class="flex-1 min-w-0 mr-4"> <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-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"> <div class="text-[10px] text-color3 mt-0.5 flex items-center gap-1.5 whitespace-nowrap overflow-hidden text-ellipsis">
<span>记录时间:{{ activeItem.time }}</span> <span class="whitespace-nowrap">记录时间: {{ activeItem.time }}</span>
<span v-if="activeItemSizeStr" class="opacity-50">|</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> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-1.5 shrink-0">
<CommonButton v-if="activeItem.type === 'full'" size="small" type="warning" secondary @click="handleExport(activeItem)"> <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> <template #icon>
<n-icon><DownloadOutline /></n-icon> <n-icon><DownloadOutline /></n-icon>
</template> </template>
下载 XML 导出 XML
</CommonButton> </CommonButton>
<CommonButton size="small" :type="isCopied ? 'success' : 'primary'" secondary @click="handleCopyXml(activeItem)"> <CommonButton size="small" :type="isCopied ? 'success' : 'primary'" secondary @click="handleCopyXml(activeItem)">
<template #icon> <template #icon>
...@@ -152,28 +158,127 @@ ...@@ -152,28 +158,127 @@
</div> </div>
<!-- 操作面板 --> <!-- 操作面板 -->
<div class="mt-4 p-4 border border-divider rounded-xl bg-card space-y-3"> <div class="mt-3 p-3 border border-divider rounded-xl bg-card flex flex-col gap-2.5 shadow-sm">
<div class="text-xs font-bold text-color2">应用操作</div> <!-- 顶行:操作标题与当前选中目标看板 -->
<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 的恢复 --> <!-- 针对完整 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)"> <CommonButton type="primary" class="w-full" @click="handleApplyFull(activeItem)">
<template #icon> <template #icon>
<n-icon><SyncOutline /></n-icon> <n-icon><SyncOutline /></n-icon>
</template> </template>
恢复此 XML 文档并覆盖当前编辑器 恢复此 XML 文档并覆盖当前编辑器
</CommonButton> </CommonButton>
<div class="text-[10px] text-color3 text-center">注意:这会覆盖编辑器中已打开的所有节点内容。</div> <div class="text-[10px] text-color3 text-center">
注意:全量覆盖将清空当前编辑区所有节点并重置撤销历史,系统将提示确认。
</div>
</div> </div>
<!-- 针对片段的导入 --> <!-- 针对片段的导入操作按钮组 (纯粹的 2x2 四大相对操作) -->
<div v-else class="grid grid-cols-2 gap-3"> <div v-else class="grid grid-cols-2 gap-2.5">
<CommonButton type="warning" secondary @click="handleApplyFragment(activeItem, 'inside')"> <!-- 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>
<CommonButton type="warning" secondary @click="handleApplyFragment(activeItem, 'replace')">替换当前选中节点</CommonButton> </template>
<CommonButton type="default" secondary @click="handleApplyFragment(activeItem, 'above')">在当前节点上方插入</CommonButton> {{ applyStatus.inside.reason }}
<CommonButton type="default" secondary @click="handleApplyFragment(activeItem, 'below')">在当前节点下方插入</CommonButton> </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> </div>
</div> </div>
...@@ -210,7 +315,14 @@ import { ...@@ -210,7 +315,14 @@ import {
EyeOutline, EyeOutline,
DownloadOutline, DownloadOutline,
CopyOutline, CopyOutline,
CheckmarkOutline CheckmarkOutline,
ArrowUpOutline,
ArrowDownOutline,
LogInOutline,
AddCircleOutline,
SwapHorizontalOutline,
AlertCircleOutline,
AppsOutline
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
const { const {
...@@ -229,7 +341,10 @@ const { ...@@ -229,7 +341,10 @@ const {
showRenameModal, showRenameModal,
editingItemName, editingItemName,
selectedNode, selectedNode,
selectedNodeParent,
hasXmlTree, hasXmlTree,
isRootSelected,
applyStatus,
open, open,
handleStashFull, handleStashFull,
handleStashFragment, handleStashFragment,
......
import type { EditorBlock } from '@/views/editor/components/EditorPanel/constants'
export interface ViewEffectState { export interface ViewEffectState {
visible: boolean visible: boolean
title: string title: string
xmlTree: any loading: boolean
blocks: EditorBlock[]
} }
import type { XmlNode } from '@/types/xmlNode' import type { XmlNode } from '@/types/xmlNode'
import { getEditorBlocks, type EditorBlock } from '@/views/editor/components/EditorPanel/constants'
export function useViewEffectModal() { export function useViewEffectModal() {
const visible = ref(false) const visible = ref(false)
const title = ref('') 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 title.value = modalTitle
try { blocks.value = []
// 解析 XML 片段 // 遵循规则 12:先开窗后加载
xmlTree.value = parseXmlToTree(xmlContent)
visible.value = true 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) { } catch (err: any) {
window.$message?.error(`解析 XML 效果失败: ${err.message || err}`) window.$message?.error(`解析 XML 效果失败: ${err.message || err}`)
loading.value = false
}
} }
watch(visible, (val) => {
if (!val) {
cancelRender()
blocks.value = []
} }
})
onBeforeUnmount(() => {
cancelRender()
})
return { return {
visible, visible,
title, title,
xmlTree, loading,
blocks,
open open
} }
} }
<template> <template>
<CommonModal v-model="visible" :title="title" :width="900" :show-confirm="false" cancel-text="关闭"> <CommonModal
<DocNodeRenderer v-if="xmlTree" :node="xmlTree" :parent="null" /> 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> </CommonModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import DocNodeRenderer from '../../../../../DocNodeRenderer/index.vue' import DocNodeRenderer from '../../../../../DocNodeRenderer/index.vue'
import { DocumentTextOutline } from '@vicons/ionicons5'
import { useViewEffectModal } from './functionals' import { useViewEffectModal } from './functionals'
const { visible, title, xmlTree, open } = useViewEffectModal() const { visible, title, loading, blocks, open } = useViewEffectModal()
// 注入只读模式的对比状态,禁用一切编辑行为及组件内部编辑事件 // 注入只读模式的对比状态,禁用一切编辑行为及组件内部编辑事件
provide('diffMode', true) provide('diffMode', true)
...@@ -19,3 +61,5 @@ defineExpose({ ...@@ -19,3 +61,5 @@ defineExpose({
</script> </script>
<style scoped></style> <style scoped></style>
...@@ -55,7 +55,10 @@ export function useTemplateSelectModal() { ...@@ -55,7 +55,10 @@ export function useTemplateSelectModal() {
} }
const handleInsert = () => { const handleInsert = () => {
if (!selectedTemplate.value) return if (!selectedTemplate.value) {
window.$message?.warning('请从列表中选择要插入的模板')
return
}
try { try {
const mode = insertBelowSetting.value ? 'below' : 'inside' const mode = insertBelowSetting.value ? 'below' : 'inside'
......
...@@ -5,7 +5,6 @@ ...@@ -5,7 +5,6 @@
:width="750" :width="750"
:loading="loading" :loading="loading"
confirm-text="确认插入" confirm-text="确认插入"
:confirm-disabled="!selectedTemplate"
@confirm="handleInsert" @confirm="handleInsert"
> >
<div class="flex flex-col space-y-4"> <div class="flex flex-col space-y-4">
......
<template> <template>
<div class="my-3 flex overflow-x-auto select-none" :class="containerClass"> <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> <tbody>
<!-- 第一行:表头与签字人 --> <!-- 第一行:表头与签字人 -->
<tr class="h-10"> <tr class="h-10">
<!-- 工作者 Per.By --> <!-- 工作者 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 /> <br />
Per.By Per.By
...@@ -17,7 +24,10 @@ ...@@ -17,7 +24,10 @@
<!-- 检查者 Insp.By (CK-LEVEL B, 且支持 D/E 兼容) --> <!-- 检查者 Insp.By (CK-LEVEL B, 且支持 D/E 兼容) -->
<template v-if="['B', 'D', 'E'].includes(node.attributes['CK-LEVEL'])"> <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 /> <br />
Insp.By Insp.By
...@@ -30,7 +40,10 @@ ...@@ -30,7 +40,10 @@
<!-- 必检 RII.By (CK-LEVEL C) --> <!-- 必检 RII.By (CK-LEVEL C) -->
<template v-else-if="node.attributes['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 /> <br />
RII.By RII.By
...@@ -90,3 +103,11 @@ const containerClass = computed(() => { ...@@ -90,3 +103,11 @@ const containerClass = computed(() => {
return location === 'LEFT' ? 'justify-start' : 'justify-end' return location === 'LEFT' ? 'justify-start' : 'justify-end'
}) })
</script> </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 }) => { ...@@ -68,7 +68,7 @@ export default defineConfig(({ mode }) => {
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
headers: { 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': { '/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