Commit b862acf8 by pangchong

feat(editor): 实现表格列和跨列规格节点删除功能

- 支持删除 COLSPEC 节点,自动更新表格列数及列索引属性
- 支持删除对应列的所有 ENTRY 单元格,并调整跨列单元格的起止位置
- 支持删除 SPANSPEC 节点,并还原相关 ENTRY 节的跨列属性
- 优化删除逻辑,优先处理特殊节点 COLSPEC 和 SPANSPEC,剩余节点统一删除
- 实现表格列宽拖拽调整功能,支持动态更新 COLSPEC 宽度属性
- 添加预览打印功能,生成干净无编辑样式的打印版 HTML
- 支持将预览内容导出为独立 HTML 文件下载
- 在打印预览及导出 HTML 时排除编辑辅助元素及相关样式
- 修改节点渲染组件,隐藏重复中英文段落,减少重复内容展示
- 编辑界面增加隐藏的打印预览容器,用以生成纯净打印内容
- 编辑工具栏新增预览和下载 HTML 按钮,提升用户操作便利性
parent 4dafd6c7
......@@ -779,6 +779,19 @@ export const useEditorStore = defineStore('editor', {
} else {
const parent = item.parent
if (!parent) return
if (item.node.tagName === 'COLSPEC') {
this.deleteColspecNode(realId)
this.rebuildNodeMap()
return
}
if (item.node.tagName === 'SPANSPEC') {
this.deleteSpanspecNode(realId)
this.rebuildNodeMap()
return
}
const index = parent.children.findIndex((c: XmlNode) => c.id === realId)
if (index !== -1) {
parent.children.splice(index, 1)
......@@ -830,6 +843,160 @@ export const useEditorStore = defineStore('editor', {
},
/**
* 删除 COLSPEC 节点内部处理逻辑(不含 snapshot 和 rebuild)
*/
deleteColspecNode(realId: string) {
const item = this.nodeMap.get(realId)
if (!item) return
const parent = item.parent
if (!parent || parent.tagName !== 'TGROUP') return
const tgroup = parent
const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
const currentCols = parseInt(tgroup.attributes.COLS || '0', 10)
if (currentCols <= 1 || colSpecs.length <= 1) {
window.$message.warning('表格必须保留至少一列')
return
}
const colIndex = colSpecs.findIndex((c) => c.id === realId)
if (colIndex === -1) return
// 更新 COLS 属性
tgroup.attributes.COLS = (currentCols - 1).toString()
// 删除该 COLSPEC 节点
const idx = tgroup.children.findIndex((c) => c.id === realId)
if (idx !== -1) tgroup.children.splice(idx, 1)
// 重新排列剩余 COLSPEC 的 COLNAME 和 COLNUM
const remainingColSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
remainingColSpecs.forEach((spec, index) => {
spec.attributes.COLNAME = `col${index + 1}`
spec.attributes.COLNUM = (index + 1).toString()
})
// 在 rows 中删除对应的 ENTRY 单元格,并调整其余 entry 的 NAMEST 和 NAMEEND 属性
const thead = tgroup.children.find((c) => c.tagName === 'THEAD')
const tbody = tgroup.children.find((c) => c.tagName === 'TBODY')
const deleteCellFromRows = (sectionNode?: XmlNode) => {
if (!sectionNode) return
sectionNode.children
.filter((c) => c.tagName === 'ROW')
.forEach((row) => {
const entries = row.children.filter((c) => c.tagName === 'ENTRY')
if (entries[colIndex]) {
const cellId = entries[colIndex].id
const entryIdx = row.children.findIndex((c) => c.id === cellId)
if (entryIdx !== -1) row.children.splice(entryIdx, 1)
}
row.children
.filter((c) => c.tagName === 'ENTRY')
.forEach((entry) => {
const namest = entry.attributes.NAMEST
const nameend = entry.attributes.NAMEEND
if (namest && nameend) {
const sColIdx = colSpecs.findIndex((s) => s.attributes.COLNAME === namest)
const eColIdx = colSpecs.findIndex((s) => s.attributes.COLNAME === nameend)
if (sColIdx !== -1 && eColIdx !== -1) {
let newSColIdx = sColIdx
let newEColIdx = eColIdx
if (sColIdx > colIndex) {
newSColIdx = sColIdx - 1
}
if (eColIdx >= colIndex) {
newEColIdx = eColIdx - 1
}
if (newSColIdx < newEColIdx) {
entry.attributes.NAMEST = `col${newSColIdx + 1}`
entry.attributes.NAMEEND = `col${newEColIdx + 1}`
} else {
delete entry.attributes.NAMEST
delete entry.attributes.NAMEEND
}
}
}
})
})
}
deleteCellFromRows(thead)
deleteCellFromRows(tbody)
if (realId === this.selectedNodeId || `${realId}-txt-` === this.selectedNodeId) {
let nextSelectedId = tgroup.id
if (colSpecs.length > 1) {
if (colIndex > 0) {
nextSelectedId = colSpecs[colIndex - 1].id
} else if (colIndex < colSpecs.length - 1) {
nextSelectedId = colSpecs[colIndex + 1].id
}
}
this.setSelectedNodeId(nextSelectedId)
}
},
/**
* 删除 SPANSPEC 节点内部处理逻辑(不含 snapshot 和 rebuild)
*/
deleteSpanspecNode(realId: string) {
const item = this.nodeMap.get(realId)
if (!item) return
const parent = item.parent
if (!parent || parent.tagName !== 'TGROUP') return
const tgroup = parent
const spanName = item.node.attributes.SPANNAME || item.node.attributes.spanname || ''
const namest = item.node.attributes.NAMEST || item.node.attributes.namest || ''
const nameend = item.node.attributes.NAMEEND || item.node.attributes.nameend || ''
const idx = tgroup.children.findIndex((c) => c.id === realId)
if (idx !== -1) tgroup.children.splice(idx, 1)
if (spanName) {
const thead = tgroup.children.find((c) => c.tagName === 'THEAD')
const tbody = tgroup.children.find((c) => c.tagName === 'TBODY')
const convertSpansInRows = (sectionNode?: XmlNode) => {
if (!sectionNode) return
sectionNode.children
.filter((c) => c.tagName === 'ROW')
.forEach((row) => {
row.children
.filter((c) => c.tagName === 'ENTRY')
.forEach((entry) => {
const refSpanName = entry.attributes.SPANNAME || entry.attributes.spanname || ''
if (refSpanName === spanName) {
delete entry.attributes.SPANNAME
delete entry.attributes.spanname
if (namest && nameend) {
entry.attributes.NAMEST = namest
entry.attributes.NAMEEND = nameend
}
}
})
})
}
convertSpansInRows(thead)
convertSpansInRows(tbody)
}
if (realId === this.selectedNodeId) {
const spanSpecs = tgroup.children.filter((c) => c.tagName === 'SPANSPEC')
let nextSelectedId = tgroup.id
if (spanSpecs.length > 0) {
nextSelectedId = spanSpecs[0].id
}
this.setSelectedNodeId(nextSelectedId)
}
},
/**
* 删除当前选中的节点
*/
deleteSelectedNode() {
......@@ -846,17 +1013,36 @@ export const useEditorStore = defineStore('editor', {
this.saveSnapshot()
const deleteSet = new Set(nodeIds)
// 1. 优先处理特殊节点 COLSPEC 和 SPANSPEC
const specialColspecIds = nodeIds.filter((id) => this.nodeMap.get(id)?.node.tagName === 'COLSPEC')
const specialSpanspecIds = nodeIds.filter((id) => this.nodeMap.get(id)?.node.tagName === 'SPANSPEC')
const removeNodes = (node: XmlNode) => {
node.children = node.children.filter((c) => !deleteSet.has(c.id))
node.mixedContent = node.mixedContent.filter((item: any) => !deleteSet.has(item.nodeId || ''))
node.children.forEach(removeNodes)
}
specialColspecIds.forEach((id) => {
this.deleteColspecNode(id)
})
removeNodes(this.xmlTree)
specialSpanspecIds.forEach((id) => {
this.deleteSpanspecNode(id)
})
// 2. 剩余的普通节点通过标准过滤进行删除
const specialSet = new Set([...specialColspecIds, ...specialSpanspecIds])
const remainingDeleteIds = nodeIds.filter((id) => !specialSet.has(id))
if (remainingDeleteIds.length > 0) {
const deleteSet = new Set(remainingDeleteIds)
const removeNodes = (node: XmlNode) => {
node.children = node.children.filter((c) => !deleteSet.has(c.id))
node.mixedContent = node.mixedContent.filter((item: any) => !deleteSet.has(item.nodeId || ''))
node.children.forEach(removeNodes)
}
removeNodes(this.xmlTree)
}
if (deleteSet.has(this.selectedNodeId || '')) {
const allDeleteSet = new Set(nodeIds)
if (allDeleteSet.has(this.selectedNodeId || '')) {
this.selectedNodeId = this.xmlTree.id
}
......
......@@ -56,6 +56,67 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
provide('insideChinese', true)
}
const shouldHideDuplicate = computed(() => {
const node = props.node
const parent = props.parent
if (!parent) return false
const isParaPair = (node.tagName === 'PARA' || node.tagName === 'PARAC')
const isTitlePair = (node.tagName === 'TITLE' || node.tagName === 'TITLEC')
if (!isParaPair && !isTitlePair) return false
const siblingTagName = node.tagName === 'PARA' ? 'PARAC' :
node.tagName === 'PARAC' ? 'PARA' :
node.tagName === 'TITLE' ? 'TITLEC' : 'TITLE'
const siblings = parent.children.filter(c => c.tagName === siblingTagName)
const getEffectiveText = (n: XmlNode): string => {
if (n.textContent) return n.textContent.trim()
if (n.mixedContent && n.mixedContent.length > 0) {
return n.mixedContent
.filter(item => item.type === 'text')
.map(item => item.text || '')
.join('')
.trim()
}
if (n.children && n.children.length > 0) {
const getDeep = (nn: XmlNode): string => {
if (nn.textContent) return nn.textContent.trim()
if (nn.mixedContent && nn.mixedContent.length > 0) {
return nn.mixedContent
.filter(item => item.type === 'text')
.map(item => item.text || '')
.join('')
.trim()
}
if (nn.children && nn.children.length > 0) {
return nn.children.map(getDeep).join('').trim()
}
return ''
}
return getDeep(n)
}
return ''
}
const currentEffectiveText = getEffectiveText(node)
if (!currentEffectiveText) return false
const hasDuplicateSibling = siblings.some(sib => {
return getEffectiveText(sib) === currentEffectiveText
})
if (hasDuplicateSibling) {
// Hide the English tag (PARA or TITLE) and keep the Chinese one (PARAC or TITLEC)
if (node.tagName === 'PARA' || node.tagName === 'TITLE') {
return true
}
}
return false
})
const renderInline = computed(() => {
return props.isInline || INLINE_ELEMENTS_SET.has(props.node.tagName)
})
......@@ -375,6 +436,7 @@ export function useDocNodeRenderer(props: { node: XmlNode; parent?: XmlNode | nu
isHeaderTag,
isInsideAlert,
isChineseContext,
shouldHideDuplicate,
formatEff,
getListBullet,
getTopicSeqNum,
......
<template>
<component
v-if="!shouldHideDuplicate"
:is="renderInline ? 'span' : 'div'"
:data-node-id="node.id"
class="doc-node-wrapper relative transition-all"
......@@ -499,7 +500,7 @@
<!-- 16. CBLST (电路断路器列表) 特殊处理 (仿照 PDF 样式表格) -->
<template v-else-if="node.tagName === 'CBLST'">
<div class="my-4 overflow-x-auto select-none border border-divider rounded-lg p-3 bg-card shadow-sm">
<div class="text-xs font-bold text-color3 mb-2 flex items-center space-x-1">
<div class="text-xs font-bold text-color3 mb-2 flex items-center space-x-1 print-hide">
<n-icon color="var(--primary-color)"><grid-outline /></n-icon>
<span>
电路断路器清单 (CBLST) - 行动:
......@@ -618,7 +619,7 @@
<!-- 18. CALS TABLE (表格) 可视化编辑器集成 -->
<template v-else-if="node.tagName === 'TABLE'">
<div class="my-4 border border-divider rounded-lg overflow-hidden bg-card p-3 shadow-sm">
<div class="flex items-center space-x-2 mb-2 pb-2 border-b border-divider">
<div class="flex items-center space-x-2 mb-2 pb-2 border-b border-divider print-hide">
<n-icon color="var(--primary-color)"><grid-outline /></n-icon>
<span class="text-xs font-bold text-color2">表格编辑区域</span>
</div>
......@@ -629,7 +630,7 @@
<!-- 19. GRAPHIC (图片) 可视化处理 -->
<template v-else-if="node.tagName === 'GRAPHIC'">
<div class="my-4 border border-divider rounded-lg overflow-hidden bg-fill-2 p-4 flex flex-col items-center">
<div class="w-full flex items-center justify-between pb-2 border-b border-divider mb-3">
<div class="w-full flex items-center justify-between pb-2 border-b border-divider mb-3 print-hide">
<span class="text-xs font-bold text-color2 flex items-center space-x-1">
<n-icon><image-outline /></n-icon>
<span>工卡附图 [GNBR: {{ node.children.find((c) => c.tagName === 'SHEET')?.attributes.GNBR || '无' }}]</span>
......@@ -699,7 +700,7 @@
<!-- 22. SELECTION (选项组) 处理 -->
<template v-else-if="node.tagName === 'SELECTION'">
<div class="my-3 p-3 bg-fill-3 rounded-lg border border-divider space-y-2">
<div class="text-xs font-bold text-color3 mb-1 select-none">选项组配置:</div>
<div class="text-xs font-bold text-color3 mb-1 select-none print-hide">选项组配置:</div>
<div class="flex flex-wrap gap-4">
<div
v-for="item in node.children"
......@@ -787,7 +788,7 @@
<!-- 25. ZONELST 隐藏处理 (只在被选中时渲染灰色提示框) -->
<template v-else-if="node.tagName === 'ZONELST'">
<div v-if="isSelected" class="p-2 border border-dashed border-divider rounded bg-fill-2 text-center text-xs text-color3 select-none">
<div v-if="isSelected" class="p-2 border border-dashed border-divider rounded bg-fill-2 text-center text-xs text-color3 select-none print-hide">
[ 区域列表 &lt;ZONELST&gt; PDF 渲染中已设为隐藏,不予展示内容 ]
</div>
</template>
......@@ -1013,6 +1014,7 @@ const {
isHeaderTag,
isInsideAlert,
isChineseContext,
shouldHideDuplicate,
formatEff,
getListBullet,
getTopicSeqNum,
......
......@@ -93,12 +93,18 @@
导出 XML
</CommonButton>
<!-- <CommonButton size="small" type="primary" class="preview-btn flex-shrink-0" @click="emit('preview')">
<CommonButton size="small" type="primary" class="preview-btn flex-shrink-0" @click="emit('download-html')">
<template #icon>
<n-icon><eye-outline /></n-icon>
</template>
下载html
</CommonButton>
<CommonButton size="small" type="primary" class="preview-btn flex-shrink-0" @click="emit('preview')">
<template #icon>
<n-icon><eye-outline /></n-icon>
</template>
预览工卡
</CommonButton> -->
</CommonButton>
<n-divider vertical class="!mx-0 flex-shrink-0" />
......@@ -196,7 +202,7 @@ import BatchTranslateModal from './components/BatchTranslateModal/index.vue'
import ExtractTranslateModal from './components/ExtractTranslateModal/index.vue'
import SearchTranslateModal from './components/SearchTranslateModal/index.vue'
const emit = defineEmits(['save', 'validate', 'export', 'preview'])
const emit = defineEmits(['save', 'validate', 'export', 'preview', 'download-html'])
const {
editorStore,
......
......@@ -57,6 +57,8 @@ export function useTableEditor(props: { node: XmlNode }) {
})
const selectedCellIds = ref<string[]>([])
const resizedWidths = ref<string[] | null>(null)
const preventCellClick = ref(false)
const insertedRowIds = ref<Set<string>>(new Set())
const insertedCellIds = ref<Set<string>>(new Set())
......@@ -216,6 +218,24 @@ export function useTableEditor(props: { node: XmlNode }) {
text: getDeepText(pNode),
attributes: pNode.attributes
}))
const filteredParagraphs: CellParagraphModel[] = []
for (let i = 0; i < paragraphs.length; i++) {
const current = paragraphs[i]
const isParaPair = current.tagName === 'PARA' || current.tagName === 'PARAC'
if (isParaPair) {
const siblingTagName = current.tagName === 'PARA' ? 'PARAC' : 'PARA'
const hasDuplicate = paragraphs.some((p) => {
return p.tagName === siblingTagName && p.text.trim() === current.text.trim()
})
if (hasDuplicate && current.tagName === 'PARA') {
continue
}
}
filteredParagraphs.push(current)
}
paragraphs = filteredParagraphs
if (paragraphs.length === 0 && !hasComplexChildren) {
paragraphs.push({
id: entryNode.id,
......@@ -1127,10 +1147,12 @@ export function useTableEditor(props: { node: XmlNode }) {
}
const handleCellClick = (cell: TableCellModel, e: MouseEvent) => {
if (preventCellClick.value) return
selectCellLogic(cell, e)
}
const handleParaClick = (para: any, cell: TableCellModel, e: MouseEvent) => {
if (preventCellClick.value) return
store.setSelectedNodeId(para.id)
selectCellLogic(cell, e)
}
......@@ -1550,6 +1572,9 @@ export function useTableEditor(props: { node: XmlNode }) {
/** 计算各列的宽度样式 */
const colWidthStyles = computed(() => {
if (resizedWidths.value) {
return resizedWidths.value
}
const specs = structure.value.colSpecs || []
const colsCount = structure.value.cols
......@@ -1704,6 +1729,100 @@ export function useTableEditor(props: { node: XmlNode }) {
return rowNodeObj.node.children.filter((c) => c.tagName !== 'ENTRY')
}
const handleResizeStart = (cell: TableCellModel, e: MouseEvent) => {
const colIdx = (cell.colIdx ?? 0) + (cell.colspan ?? 1) - 1
if (colIdx < 0 || colIdx >= structure.value.cols) return
const tableEl = (e.target as HTMLElement).closest('table')
if (!tableEl) return
const colElements = tableEl.querySelectorAll('colgroup col')
if (colElements.length < structure.value.cols + 2) return
const startWidths: number[] = []
for (let i = 0; i < structure.value.cols; i++) {
const colEl = colElements[i + 1] as HTMLElement
const computedWidth = parseFloat(window.getComputedStyle(colEl).width)
startWidths.push(isNaN(computedWidth) ? 80 : computedWidth)
}
const startX = e.clientX
let justResized = false
const handleMouseMove = (moveEvent: MouseEvent) => {
const dx = moveEvent.clientX - startX
if (Math.abs(dx) > 2) {
justResized = true
}
const newWidths = [...startWidths]
const minWidth = 40
if (colIdx < structure.value.cols - 1) {
let newWidthI = startWidths[colIdx] + dx
let newWidthNext = startWidths[colIdx + 1] - dx
if (newWidthI < minWidth) {
const diff = minWidth - newWidthI
newWidthI = minWidth
newWidthNext = newWidthNext - diff
}
if (newWidthNext < minWidth) {
const diff = minWidth - newWidthNext
newWidthNext = minWidth
newWidthI = newWidthI - diff
}
newWidths[colIdx] = newWidthI
newWidths[colIdx + 1] = newWidthNext
} else {
let newWidthI = startWidths[colIdx] + dx
if (newWidthI < minWidth) {
newWidthI = minWidth
}
newWidths[colIdx] = newWidthI
}
resizedWidths.value = newWidths.map((w) => `${w}px`)
}
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
if (justResized) {
preventCellClick.value = true
setTimeout(() => {
preventCellClick.value = false
}, 50)
}
if (resizedWidths.value) {
store.saveSnapshot()
const tgroup = findTgroup(props.node)
if (tgroup) {
const colSpecs = tgroup.children.filter((c) => c.tagName === 'COLSPEC')
colSpecs.forEach((spec, idx) => {
if (resizedWidths.value) {
const finalWidth = parseFloat(resizedWidths.value[idx])
const originalWidth = spec.attributes.COLWIDTH || spec.attributes.colwidth || ''
if (originalWidth.includes('*') || !originalWidth) {
spec.attributes.COLWIDTH = `${Math.round(finalWidth)}*`
} else {
const unit = originalWidth.replace(/[0-9.]/g, '').trim() || 'px'
spec.attributes.COLWIDTH = `${Math.round(finalWidth)}${unit}`
}
}
})
}
resizedWidths.value = null
store.rebuildNodeMap()
}
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
return {
structure,
selectedCellIds,
......@@ -1748,7 +1867,8 @@ export function useTableEditor(props: { node: XmlNode }) {
// 样式计算
colWidthStyles,
tableFrameClass,
getCellStyle
getCellStyle,
handleResizeStart
}
}
......
......@@ -94,6 +94,11 @@
v-text="para.text"
></div>
</template>
<!-- 拖拽调整列宽手柄 -->
<div
class="absolute -right-1 top-0 bottom-0 w-2 cursor-col-resize hover:bg-primary/40 active:bg-primary/60 transition-colors z-20 select-none"
@mousedown.stop.prevent="handleResizeStart(cell, $event)"
></div>
</th>
<!-- 表头操作栏 -->
<th class="p-2 border border-divider bg-fill-4 text-center">
......@@ -180,6 +185,11 @@
v-text="para.text"
></div>
</template>
<!-- 拖拽调整列宽手柄 -->
<div
class="absolute -right-1 top-0 bottom-0 w-2 cursor-col-resize hover:bg-primary/40 active:bg-primary/60 transition-colors z-20 select-none"
@mousedown.stop.prevent="handleResizeStart(cell, $event)"
></div>
</td>
<!-- 行删除按钮 -->
<td class="p-2 border border-divider text-center">
......@@ -273,7 +283,8 @@ const {
getRowNonEntryChildren,
colWidthStyles,
tableFrameClass,
getCellStyle
getCellStyle,
handleResizeStart
} = useTableEditor(props)
/** TableBatchModal 组件实例引用 */
......
......@@ -106,11 +106,276 @@ export function useEditor() {
}
}
const prepareCleanedHtml = () => {
const container = document.getElementById('print-preview-container')
if (!container) return null
// 克隆 HTML 树以进行离线清理
const clone = container.cloneNode(true) as HTMLElement
clone.style.display = 'block'
// 0. 移除所有带有 print-hide 类名的编辑辅助/提示元素
clone.querySelectorAll('.print-hide').forEach((el) => {
el.remove()
})
// 1. 去除 contenteditable 属性,防止打印时显示光标或被编辑
clone.querySelectorAll('[contenteditable]').forEach((el) => {
el.removeAttribute('contenteditable')
})
// 2. 清理全部与编辑、高亮、聚焦相关的样式类名
clone.querySelectorAll('*').forEach((el) => {
const classes = Array.from(el.classList)
classes.forEach((cls) => {
if (
cls.startsWith('ring-') ||
cls.startsWith('outline-') ||
cls === 'outline' ||
cls.startsWith('bg-primary') ||
cls.includes('focus:') ||
cls.includes('hover:') ||
cls === 'cursor-pointer' ||
cls === 'transition-all' ||
cls === 'transition-colors'
) {
el.classList.remove(cls)
}
})
})
// 3. 移除表格的调整列宽拖拽手柄
clone.querySelectorAll('.cursor-col-resize').forEach((el) => {
el.remove()
})
// 4. 移除表格的辅助列(行号列、操作删除列)与删除快捷行
clone.querySelectorAll('table').forEach((table) => {
// 如果没有 colgroup,说明不是 CALS 数据表格,可能是签字点表格,跳过辅助列裁剪
if (!table.querySelector('colgroup')) {
return
}
// 标记为真实 CALS 数据表格,以便应用 100% 宽度等定制打印样式
table.classList.add('cals-table')
// 移除列删除快捷按钮行(即 tbody 中的最后一行 tr)
table.querySelectorAll('tbody').forEach((tbody) => {
const rows = Array.from(tbody.children).filter((el) => el.tagName === 'TR')
if (rows.length > 0) {
rows[rows.length - 1].remove()
}
})
// 移除 colgroup 的首尾 col
table.querySelectorAll('colgroup').forEach((cg) => {
const cols = cg.querySelectorAll('col')
if (cols.length >= 2) {
cols[0].remove()
cols[cols.length - 1].remove()
}
})
// 移除每一行的首尾单元格
table.querySelectorAll('tr').forEach((tr) => {
const cells = Array.from(tr.children)
if (cells.length >= 2) {
cells[0].remove()
cells[cells.length - 1].remove()
}
})
// 移除外层的灰色卡片背景及阴影边框样式,使表格呈现扁平文档打印风格
const wrapper = table.parentElement
if (wrapper) {
wrapper.className = ''
const grandparent = wrapper.parentElement
if (grandparent) {
grandparent.className = ''
}
}
})
// 移除图片 (GRAPHIC) 外层的边框与背景色类名
clone.querySelectorAll('.flex.flex-col.items-center').forEach((el) => {
if (el.classList.contains('border-divider')) {
el.className = 'flex flex-col items-center'
}
})
// 5. 移除表格顶部的快捷操作工具栏
clone.querySelectorAll('.flex.items-center.pb-2.border-b').forEach((el) => {
el.remove()
})
// 6. 获取当前页面的所有 CSS 样式(含 Tailwind 和 Naive UI 样式)
const styles = Array.from(document.querySelectorAll('link[rel="stylesheet"], style'))
.map((el) => el.outerHTML)
.join('\n')
return {
innerHTML: clone.innerHTML,
styles
}
}
const printStyles = `
body {
background-color: #ffffff !important;
color: #000000 !important;
padding: 40px !important;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* 移除非表格元素的虚线边框、段落分隔线以及容器装饰边框 */
.border-b.border-divider\\/30,
.border-b.border-divider,
.border-l.border-dashed {
border: none !important;
border-bottom: none !important;
border-left: none !important;
padding-left: 0 !important;
padding-bottom: 0 !important;
padding-top: 0 !important;
margin-left: 0 !important;
}
/* 移除外层卡片及编辑容器的圆角边框、背景及阴影 */
.border.border-divider.rounded-lg,
.rounded-lg.shadow-inner.bg-fill-2,
.bg-card.p-3 {
border: none !important;
box-shadow: none !important;
background: transparent !important;
background-color: transparent !important;
padding: 0 !important;
margin: 0 !important;
}
/* 隐藏编辑器辅助标记元素 */
.print-hide {
display: none !important;
}
/* 避免打印时因祖先容器的 overflow 限制造成表格右侧截断 */
.print-preview-root,
.print-preview-root *,
table.cals-table {
overflow: visible !important;
}
/* 强置数据表格黑白打印边界与 100% 宽度,并覆写编辑态的 min-width 限制以自适应收缩 */
table.cals-table {
border-collapse: collapse !important;
width: 100% !important;
min-width: 0 !important;
margin-top: 10px !important;
margin-bottom: 20px !important;
}
table.cals-table th, table.cals-table td {
border: 1px solid #000000 !important;
padding: 8px 10px !important;
}
@media print {
body {
padding: 0 !important;
}
/* 打印优化:避免表格行或图片在中间被截断分页 */
table, tr, img {
page-break-inside: avoid !important;
}
}
`
const handlePreview = () => {
const cleaned = prepareCleanedHtml()
if (!cleaned) return
// 7. 创建隐藏的 iframe 以启动原生打印,避免弹出新窗口/标签页
const iframe = document.createElement('iframe')
iframe.style.position = 'fixed'
iframe.style.right = '0'
iframe.style.bottom = '0'
iframe.style.width = '0'
iframe.style.height = '0'
iframe.style.border = '0'
document.body.appendChild(iframe)
const iframeDoc = iframe.contentWindow?.document || iframe.contentDocument
if (!iframeDoc) {
window.$message.error('无法创建打印通道')
document.body.removeChild(iframe)
return
}
iframeDoc.write(`
<!DOCTYPE html>
<html>
<head>
<title>工卡预览与打印</title>
${cleaned.styles}
<style>
${printStyles}
</style>
</head>
<body>
<div class="print-preview-root">
${cleaned.innerHTML}
</div>
<script>
window.onload = function() {
setTimeout(() => {
window.print();
}, 300);
};
<\/script>
</body>
</html>
`)
iframeDoc.close()
// 打印完成后(或取消后)销毁 iframe 释放内存
const handleAfterPrint = () => {
document.body.removeChild(iframe)
}
if (iframe.contentWindow) {
iframe.contentWindow.addEventListener('afterprint', handleAfterPrint)
} else {
// 安全回退,防备老旧浏览器不支持 afterprint 事件
setTimeout(() => {
if (iframe.parentNode) {
document.body.removeChild(iframe)
}
}, 5000)
}
}
const handleDownloadHtml = () => {
const cleaned = prepareCleanedHtml()
if (!cleaned) return
const htmlContent = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>工卡预览与打印</title>
${cleaned.styles}
<style>
${printStyles}
</style>
</head>
<body>
<div class="print-preview-root">
${cleaned.innerHTML}
</div>
</body>
</html>`
const blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8;' })
openDownloadModal({
fileName: 'jobcard_flow_preview.html',
title: '下载 HTML 预览包',
localBlob: blob
})
}
return {
initialize,
save,
exportXml,
getAllNodeKeys,
validate
validate,
handlePreview,
handleDownloadHtml
}
}
......@@ -7,6 +7,8 @@
@export="handleExport"
@expand-all="handleExpandAll"
@collapse-all="handleCollapseAll"
@preview="handlePreview"
@download-html="handleDownloadHtml"
/>
<!-- 主体布局:自定义可拖拽分割面板 -->
......@@ -28,6 +30,11 @@
<EditorPanel />
</div>
</div>
<!-- 用于打印/预览的隐藏容器,不使用虚拟滚动以加载完整文档 -->
<div id="print-preview-container" style="display: none;">
<DocNodeRenderer v-if="editorStore.xmlTree" :node="editorStore.xmlTree" :parent="null" />
</div>
</div>
</template>
......@@ -38,6 +45,7 @@ import EditorToolbar from './components/EditorToolbar/index.vue'
import NodeTree from './components/NodeTree/index.vue'
import EditorPanel from './components/EditorPanel/index.vue'
import Splitter from './components/Splitter/index.vue'
import DocNodeRenderer from './components/DocNodeRenderer/index.vue'
const themeVars = useThemeVars()
const editorStore = useEditorStore()
......@@ -51,7 +59,7 @@ const leftWidthPx = ref(560)
const isCollapsed = ref(false)
// 实例化业务逻辑 Hook
const { initialize, save, exportXml, getAllNodeKeys, validate } = useEditor()
const { initialize, save, exportXml, getAllNodeKeys, validate, handlePreview, handleDownloadHtml } = useEditor()
onMounted(() => {
initialize()
......
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