<template>
|
<div class="report-word-editor" @keydown.ctrl.s.prevent="requestSave" @keydown.meta.s.prevent="requestSave">
|
<div class="page-settings">
|
<span>页边距(毫米,应用到全文)</span>
|
<label v-for="(label, side) in marginLabels" :key="side">
|
{{ label }} <input v-model.number="margins[side]" type="number" min="0" max="80" step="0.1"
|
:aria-label="`${label}页边距(毫米)`" :disabled="readonly || !ready || applyingMargins" />
|
</label>
|
<el-button size="small" :loading="applyingMargins" :disabled="readonly || !ready" @click="applyMargins">应用页边距</el-button>
|
</div>
|
<div ref="toolbar" class="word-toolbar" :inert="readonly || undefined"></div>
|
<div class="word-canvas" :inert="readonly || undefined">
|
<div ref="canvas"></div>
|
</div>
|
<div v-if="!ready || error" class="editor-status">
|
<span>{{ error || '正在加载 Word 文档…' }}</span>
|
<el-button v-if="error" @click="openDocument">重试</el-button>
|
</div>
|
<div v-if="readonly" class="editor-busy">正在保存或确认审核,请稍候</div>
|
</div>
|
</template>
|
|
<script setup>
|
import { onMounted, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
import { ElMessage } from 'element-plus'
|
import { normalizeReportDocxForEditor } from '@/utils/reportDocxLayout'
|
import { loadSuperDoc } from '@/utils/loadSuperDoc'
|
import { superdocToolbarZh } from '@/utils/superdocToolbarZh'
|
import 'superdoc/style.css'
|
|
const props = defineProps({ document: { type: Object, required: true }, readonly: Boolean })
|
const emit = defineEmits(['ready', 'error', 'change', 'save'])
|
const canvas = ref(null)
|
const toolbar = ref(null)
|
const editor = shallowRef(null)
|
const ready = ref(false)
|
const applyingMargins = ref(false)
|
const error = ref('')
|
const margins = ref({ top: 25.4, bottom: 25.4, left: 25.4, right: 25.4 })
|
const marginLabels = { top: '上', bottom: '下', left: '左', right: '右' }
|
let generation = 0
|
let readyTimer
|
let toolbarObserver
|
let fontSelectionTarget = false
|
let fontSelectionKnown = false
|
|
function rememberFontSelection(event) {
|
if (!event.target?.closest?.('[data-item="btn-fontFamily"]')) return
|
const selection = editor.value?.ui?.selection?.getSnapshot?.()
|
fontSelectionKnown = selection?.status === 'ready'
|
fontSelectionTarget = Boolean(selection?.target || selection?.selectionTarget)
|
}
|
|
function onFontOptionMousedown(event) {
|
if (!event.target?.closest?.('.sd-font-combobox__option')) return
|
if (!ready.value || !fontSelectionKnown || fontSelectionTarget) return
|
const owner = toolbar.value?.querySelector('[data-superdoc-ui-owner]')?.getAttribute('data-superdoc-ui-owner')
|
const menuOwner = event.target.closest('[data-superdoc-ui-owner]')?.getAttribute('data-superdoc-ui-owner')
|
if (owner && menuOwner && owner !== menuOwner) return
|
ElMessage.info('请先在报告中选中文字,或将光标放入正文,再选择字体')
|
}
|
|
function localizeMeasurementUnit(includeMenu = false) {
|
// SuperDoc 2.25 把单位的当前值及两个菜单项写死为英文,strings 配置无法覆盖。
|
const label = toolbar.value?.querySelector('[data-item="btn-measurementUnit"] > .sd-button-label')
|
if (label?.textContent === 'in') label.textContent = '英寸'
|
if (label?.textContent === 'cm') label.textContent = '厘米'
|
if (!includeMenu) return
|
for (const option of document.querySelectorAll('[data-item="btn-measurement-unit-option"]')) {
|
const text = option.querySelector('.toolbar-dropdown-option__label')
|
if (text?.textContent === 'Inches') {
|
text.textContent = '英寸'
|
option.setAttribute('aria-label', '度量单位 - 英寸')
|
} else if (text?.textContent === 'Centimeters') {
|
text.textContent = '厘米'
|
option.setAttribute('aria-label', '度量单位 - 厘米')
|
}
|
}
|
}
|
|
function onMeasurementUnitClick(event) {
|
if (event.target?.closest?.('[data-item="btn-measurementUnit"], [data-item="btn-measurement-unit-option"]')) {
|
requestAnimationFrame(() => localizeMeasurementUnit(true))
|
}
|
}
|
|
function fail(cause, current) {
|
if (current !== generation) return
|
clearTimeout(readyTimer)
|
error.value = cause?.message || String(cause || 'Word 编辑器加载失败')
|
emit('error', error.value)
|
}
|
|
async function openDocument() {
|
const current = ++generation
|
clearTimeout(readyTimer)
|
editor.value?.destroy()
|
editor.value = null
|
fontSelectionTarget = false
|
fontSelectionKnown = false
|
ready.value = false
|
error.value = ''
|
try {
|
const [documentBlob, { SuperDoc }] = await Promise.all([
|
normalizeReportDocxForEditor(props.document.blob, props.document.code),
|
loadSuperDoc()
|
])
|
if (current !== generation) return
|
readyTimer = setTimeout(() => fail(new Error('Word 加载超时,请重试'), current), 60000)
|
editor.value = new SuperDoc({
|
selector: canvas.value,
|
document: new File([documentBlob], props.document.title, {
|
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
}),
|
documentMode: 'editing',
|
role: 'editor',
|
telemetry: { enabled: false },
|
ui: { toolbar: { container: toolbar.value, strings: superdocToolbarZh }, ruler: true, comments: false },
|
onReady: async ({ superdoc }) => {
|
if (current !== generation) return
|
clearTimeout(readyTimer)
|
try {
|
const result = await superdoc.activeEditor.doc.sections.list({ limit: 1 })
|
const initial = result.items[0]?.margins
|
if (initial) {
|
for (const side of Object.keys(marginLabels)) {
|
if (initial[side] != null) margins.value[side] = Math.round(initial[side] * 25.4 * 10) / 10
|
}
|
}
|
ready.value = true
|
localizeMeasurementUnit()
|
emit('ready')
|
} catch (cause) { fail(cause, current) }
|
},
|
onEditorUpdate: () => { if (current === generation && ready.value) emit('change') },
|
onContentError: ({ error: cause }) => fail(cause, current),
|
onException: (event) => {
|
if (current !== generation) return
|
// 无效选区等可恢复的编辑命令错误不应强制重载并丢弃用户尚未保存的内容。
|
if (ready.value && !event.workerFailure && event.stage !== 'document-init') {
|
ElMessage.warning(event.error?.message || String(event.error || '操作未完成,请调整选区后重试'))
|
} else {
|
fail(event.error, current)
|
}
|
}
|
})
|
} catch (cause) { fail(cause, current) }
|
}
|
|
async function applyMargins() {
|
if (!ready.value || props.readonly || applyingMargins.value) return
|
applyingMargins.value = true
|
try {
|
const sections = editor.value.activeEditor.doc.sections
|
if (Object.values(margins.value).some(mm => typeof mm !== 'number' || !Number.isFinite(mm) || mm < 0 || mm > 80)) {
|
throw new Error('页边距请输入 0 到 80 毫米的数值')
|
}
|
// SuperDoc 2.25 的公开页属性 API 使用英寸;导出器负责转换为 DOCX twips。
|
const input = Object.fromEntries(Object.entries(margins.value).map(([side, mm]) => [side, mm / 25.4]))
|
// 分页读取全部节,修改真正的 OOXML 节页边距,而不是 CSS。
|
let offset = 0
|
while (true) {
|
const result = await sections.list({ limit: 100, offset })
|
for (const section of result.items) {
|
const receipt = await sections.setPageMargins({ target: section.address, ...input })
|
if (!receipt.success && receipt.failure?.code !== 'NO_OP') throw new Error(receipt.failure?.message || '修改页边距失败')
|
}
|
offset += result.items.length
|
if (offset >= result.total || result.items.length === 0) break
|
}
|
emit('change')
|
ElMessage.success('页边距已应用,点击保存后生效')
|
} catch (cause) { ElMessage.error(cause.message || '修改页边距失败') }
|
finally { applyingMargins.value = false }
|
}
|
|
async function exportDocx() {
|
if (!ready.value || error.value || !editor.value) throw new Error('Word 尚未加载完成,不能保存')
|
if (applyingMargins.value) throw new Error('页边距正在应用,请稍候再保存')
|
const blob = await editor.value.export({ exportType: ['docx'], triggerDownload: false })
|
if (!(blob instanceof Blob) || !blob.size) throw new Error('Word 导出失败')
|
return blob
|
}
|
function requestSave() { if (ready.value && !props.readonly) emit('save') }
|
watch(() => props.document, openDocument)
|
onMounted(() => {
|
toolbarObserver = new MutationObserver(() => localizeMeasurementUnit())
|
toolbarObserver.observe(toolbar.value, { childList: true, characterData: true, subtree: true })
|
document.addEventListener('click', onMeasurementUnitClick)
|
toolbar.value.addEventListener('mousedown', rememberFontSelection, true)
|
document.addEventListener('mousedown', onFontOptionMousedown, true)
|
openDocument()
|
})
|
onBeforeUnmount(() => {
|
generation++
|
clearTimeout(readyTimer)
|
editor.value?.destroy()
|
toolbarObserver?.disconnect()
|
document.removeEventListener('click', onMeasurementUnitClick)
|
toolbar.value?.removeEventListener('mousedown', rememberFontSelection, true)
|
document.removeEventListener('mousedown', onFontOptionMousedown, true)
|
})
|
defineExpose({ exportDocx })
|
</script>
|
|
<style scoped>
|
/* 编辑器使用报告 DOCX 的字体名;显式绑定本机 Word 字体供浏览器排版。 */
|
@font-face { font-family: '宋体'; src: local('SimSun'), local('宋体'); }
|
@font-face { font-family: '黑体'; src: local('SimHei'), local('黑体'); }
|
.report-word-editor { height: 100%; position: relative; display: flex; flex-direction: column; border: 1px solid #dcdfe6; }
|
.page-settings { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; padding: 8px; background: #fff; }
|
.page-settings label { display: flex; align-items: center; gap: 4px; }
|
.page-settings input { width: 70px; padding: 5px; border: 1px solid #dcdfe6; border-radius: 4px; }
|
.word-toolbar { flex-shrink: 0; }
|
.word-canvas { flex: 1; min-height: 0; overflow: auto; background: #f3f4f6; }
|
/* SuperDoc 滚动时会把文档容器缩到纸张宽度,导致整页重新贴到左边。 */
|
.word-canvas :deep(.superdoc),
|
.word-canvas :deep(.superdoc__layers),
|
.word-canvas :deep(.superdoc__document),
|
.word-canvas :deep(.superdoc__sub-document),
|
.word-canvas :deep(.v2-super-editor),
|
.word-canvas :deep(.v2-super-editor__visuals),
|
.word-canvas :deep(.v2-super-editor__stage) { width: 100% !important; }
|
.word-canvas :deep(.v2-super-editor__stage) { display: flex !important; flex-direction: column !important; align-items: center !important; }
|
.word-canvas :deep(.v2-super-editor__stage > *) { margin-inline: auto !important; }
|
.word-canvas :deep(.superdoc-layout) { margin-inline: auto !important; }
|
.word-canvas :deep(.superdoc-page) { margin-inline: auto !important; }
|
/* 普通菜单和字体选项都挂在 body 上;默认层级 2000 低于审核弹窗。 */
|
:global(.sd-toolbar-dropdown-menu[data-superdoc-ui-owner]),
|
:global(.sd-font-combobox__listbox[data-superdoc-ui-owner]) { z-index: 4000 !important; }
|
.editor-status, .editor-busy { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; background: rgba(255,255,255,.95); z-index: 10; }
|
.editor-busy { background: rgba(255,255,255,.6); }
|
</style>
|