452 lines
12 KiB
Vue
452 lines
12 KiB
Vue
<script setup>
|
||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { NButton, NSelect, NSpace, NSpin, NTag, useMessage } from 'naive-ui'
|
||
import { saveAs } from 'file-saver'
|
||
import PizZip from 'pizzip'
|
||
import Docxtemplater from 'docxtemplater'
|
||
import { renderAsync } from 'docx-preview'
|
||
|
||
import CommonPage from '@/components/page/CommonPage.vue'
|
||
import api from '@/api'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const message = useMessage()
|
||
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const exporting = ref(false)
|
||
const editorRef = ref(null)
|
||
const htmlContent = ref('')
|
||
const detailData = ref(null)
|
||
|
||
const pageMeta = computed(() => ({
|
||
valuationId: route.query.valuationId || route.query.id,
|
||
templateId: route.query.templateId,
|
||
title: route.query.title || '评估报告',
|
||
}))
|
||
|
||
const defaultHtml = `
|
||
<div class="doc-header">
|
||
<h1>非遗资产评估报告</h1>
|
||
<p>本页为在线编辑预览,内容来自 Word 模板渲染结果。</p>
|
||
</div>
|
||
<div class="doc-block">
|
||
<h2>一、资产概览</h2>
|
||
<p>资产名称:<strong>${pageMeta.value.title}</strong></p>
|
||
<p>生成时间:${new Date().toLocaleDateString()}</p>
|
||
</div>
|
||
<div class="doc-block">
|
||
<h2>二、正文示例</h2>
|
||
<p>在这里可以直接修改文字样式、对齐方式,也可以粘贴从 Word 模板转换的 HTML,实现 1:1 的样式复刻。</p>
|
||
<p>正文段落采用 14px/1.8 行距,并限制版心宽度,便于接近 A4 排版。</p>
|
||
</div>
|
||
`
|
||
|
||
const syncEditorContent = async () => {
|
||
await nextTick()
|
||
if (editorRef.value) {
|
||
editorRef.value.innerHTML = htmlContent.value || defaultHtml
|
||
}
|
||
}
|
||
|
||
const ensureDetailData = async () => {
|
||
if (detailData.value || !pageMeta.value.valuationId) return
|
||
try {
|
||
const { data } = await api.getValuationById({ valuation_id: pageMeta.value.valuationId })
|
||
detailData.value = data
|
||
} catch (error) {
|
||
console.error('获取详情数据失败', error)
|
||
}
|
||
}
|
||
|
||
const renderDocxWithPreview = async () => {
|
||
await ensureDetailData()
|
||
try {
|
||
const resp = await fetch('/report_template.docx')
|
||
if (!resp.ok) throw new Error('模板文件读取失败')
|
||
const arrayBuffer = await resp.arrayBuffer()
|
||
const zip = new PizZip(arrayBuffer)
|
||
const doc = new Docxtemplater(zip, {
|
||
paragraphLoop: true,
|
||
linebreaks: true,
|
||
delimiters: { start: '${', end: '}' },
|
||
nullGetter: (part) => (!part.module ? '${' + part.value + '}' : ''),
|
||
})
|
||
if (detailData.value) {
|
||
doc.render(detailData.value)
|
||
}
|
||
|
||
const blob = doc.getZip().generate({
|
||
type: 'blob',
|
||
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||
})
|
||
|
||
if (editorRef.value) {
|
||
editorRef.value.innerHTML = ''
|
||
await renderAsync(blob, editorRef.value, null, {
|
||
inWrapper: true,
|
||
ignoreWidth: false,
|
||
ignoreHeight: false,
|
||
className: 'docx-preview',
|
||
})
|
||
// 允许直接编辑渲染结果
|
||
editorRef.value.setAttribute('contenteditable', 'true')
|
||
htmlContent.value = editorRef.value.innerHTML
|
||
}
|
||
} catch (error) {
|
||
console.error('Word 模板渲染失败', error)
|
||
message.error('Word 模板渲染失败,显示默认示例')
|
||
await syncEditorContent()
|
||
}
|
||
}
|
||
|
||
const renderFromBackendHtml = async () => {
|
||
let rendered = false
|
||
try {
|
||
const res = await api.renderReportTemplate({
|
||
valuation_id: pageMeta.value.valuationId,
|
||
template_id: pageMeta.value.templateId,
|
||
})
|
||
const payload = res?.data ?? res
|
||
const html = payload?.html ?? payload?.content ?? (typeof payload === 'string' ? payload : '')
|
||
const css = payload?.css
|
||
if (html && editorRef.value) {
|
||
const combined = css ? `<style>${css}</style>${html}` : html
|
||
editorRef.value.innerHTML = combined
|
||
editorRef.value.setAttribute('contenteditable', 'true')
|
||
htmlContent.value = combined
|
||
rendered = true
|
||
}
|
||
} catch (error) {
|
||
console.error('加载后端渲染模板失败', error)
|
||
}
|
||
|
||
if (!rendered) {
|
||
await renderDocxWithPreview()
|
||
}
|
||
}
|
||
|
||
const fetchTemplateHtml = async () => {
|
||
loading.value = true
|
||
try {
|
||
await renderFromBackendHtml()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const handleInput = () => {
|
||
if (!editorRef.value) return
|
||
htmlContent.value = editorRef.value.innerHTML
|
||
}
|
||
|
||
const focusEditor = () => {
|
||
if (editorRef.value) {
|
||
const target = editorRef.value.querySelector('[contenteditable]') || editorRef.value
|
||
target.focus()
|
||
}
|
||
}
|
||
|
||
const exec = (command, value) => {
|
||
focusEditor()
|
||
document.execCommand(command, false, value)
|
||
handleInput()
|
||
}
|
||
|
||
const wrapSelection = (styles = {}) => {
|
||
const sel = window.getSelection()
|
||
if (!sel || !sel.rangeCount) return
|
||
const range = sel.getRangeAt(0)
|
||
if (range.collapsed) return
|
||
const span = document.createElement('span')
|
||
Object.entries(styles).forEach(([k, v]) => {
|
||
span.style[k] = v
|
||
})
|
||
span.appendChild(range.extractContents())
|
||
range.insertNode(span)
|
||
sel.removeAllRanges()
|
||
const newRange = document.createRange()
|
||
newRange.selectNodeContents(span)
|
||
sel.addRange(newRange)
|
||
handleInput()
|
||
}
|
||
|
||
const fontSizeOptions = [
|
||
{ label: '12px', value: 12 },
|
||
{ label: '14px', value: 14 },
|
||
{ label: '16px', value: 16 },
|
||
{ label: '18px', value: 18 },
|
||
{ label: '20px', value: 20 },
|
||
{ label: '24px', value: 24 },
|
||
]
|
||
|
||
const lineHeightOptions = [
|
||
{ label: '1.2', value: '1.2' },
|
||
{ label: '1.5', value: '1.5' },
|
||
{ label: '1.8', value: '1.8' },
|
||
{ label: '2.0', value: '2' },
|
||
]
|
||
|
||
const applyFontSize = (size) => {
|
||
if (!size) return
|
||
focusEditor()
|
||
wrapSelection({ fontSize: `${size}px` })
|
||
}
|
||
|
||
const applyLineHeight = (height) => {
|
||
if (!height) return
|
||
focusEditor()
|
||
const sel = window.getSelection()
|
||
if (!sel || !sel.anchorNode) return
|
||
const block = sel.anchorNode.parentElement?.closest('p')
|
||
if (block) {
|
||
block.style.lineHeight = height
|
||
handleInput()
|
||
}
|
||
}
|
||
|
||
const applyTextColor = (event) => {
|
||
const color = event?.target?.value
|
||
if (!color) return
|
||
focusEditor()
|
||
exec('foreColor', color)
|
||
}
|
||
|
||
const applyBackColor = (event) => {
|
||
const color = event?.target?.value
|
||
if (!color) return
|
||
focusEditor()
|
||
exec('hiliteColor', color)
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
if (!htmlContent.value) {
|
||
message.warning('暂无内容可保存')
|
||
return
|
||
}
|
||
saving.value = true
|
||
try {
|
||
await api.saveReportDraft({
|
||
valuation_id: pageMeta.value.valuationId,
|
||
template_id: pageMeta.value.templateId,
|
||
html: htmlContent.value,
|
||
})
|
||
message.success('草稿已保存')
|
||
} catch (error) {
|
||
console.error('保存失败', error)
|
||
message.error(error?.message || '保存失败,请稍后重试')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
const handleExport = async () => {
|
||
if (!pageMeta.value.valuationId) {
|
||
message.error('缺少参数,无法导出')
|
||
return
|
||
}
|
||
exporting.value = true
|
||
try {
|
||
const res = await api.exportReportTemplate({
|
||
valuation_id: pageMeta.value.valuationId,
|
||
template_id: pageMeta.value.templateId,
|
||
})
|
||
const blob = res instanceof Blob ? res : new Blob([res])
|
||
const fileName = `${pageMeta.value.title || '报告'}.pdf`
|
||
saveAs(blob, fileName)
|
||
message.success('导出成功')
|
||
} catch (error) {
|
||
console.error('导出失败', error)
|
||
message.error('导出失败,请检查后端接口')
|
||
} finally {
|
||
exporting.value = false
|
||
}
|
||
}
|
||
|
||
const handleBack = () => {
|
||
router.back()
|
||
}
|
||
|
||
onMounted(fetchTemplateHtml)
|
||
</script>
|
||
|
||
<template>
|
||
<CommonPage title="报告在线编辑" :show-footer="false">
|
||
<template #header>
|
||
<div class="editor-page__header">
|
||
<div class="title">
|
||
<h2>{{ pageMeta.title || '报告在线编辑' }}</h2>
|
||
<div class="meta">
|
||
<NTag size="small" type="info">估值ID:{{ pageMeta.valuationId || '未知' }}</NTag>
|
||
<NTag v-if="pageMeta.templateId" size="small" type="success">
|
||
模板ID:{{ pageMeta.templateId }}
|
||
</NTag>
|
||
</div>
|
||
</div>
|
||
<NSpace>
|
||
<NButton @click="handleBack">返回</NButton>
|
||
<NButton secondary @click="handleSave" :loading="saving">保存草稿</NButton>
|
||
<NButton type="primary" @click="handleExport" :loading="exporting">
|
||
导出 PDF
|
||
</NButton>
|
||
</NSpace>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="editor-page">
|
||
<div class="editor-toolbar">
|
||
<NSpace wrap>
|
||
<NButton size="small" @click="exec('bold')">加粗</NButton>
|
||
<NButton size="small" @click="exec('italic')">斜体</NButton>
|
||
<NButton size="small" @click="exec('underline')">下划线</NButton>
|
||
<NButton size="small" @click="exec('justifyLeft')">左对齐</NButton>
|
||
<NButton size="small" @click="exec('justifyCenter')">居中</NButton>
|
||
<NButton size="small" @click="exec('justifyRight')">右对齐</NButton>
|
||
<NButton size="small" @click="exec('insertUnorderedList')">无序列表</NButton>
|
||
<NButton size="small" @click="exec('insertOrderedList')">有序列表</NButton>
|
||
<NButton size="small" @click="exec('indent')">增加缩进</NButton>
|
||
<NButton size="small" @click="exec('outdent')">减少缩进</NButton>
|
||
<NButton size="small" @click="exec('removeFormat')">清除格式</NButton>
|
||
<NButton size="small" @click="exec('undo')">撤销</NButton>
|
||
<NButton size="small" @click="exec('redo')">重做</NButton>
|
||
</NSpace>
|
||
<NSpace wrap>
|
||
<NSelect
|
||
size="small"
|
||
style="width: 120px"
|
||
placeholder="字号"
|
||
:options="fontSizeOptions"
|
||
@update:value="applyFontSize"
|
||
/>
|
||
<NSelect
|
||
size="small"
|
||
style="width: 120px"
|
||
placeholder="行距"
|
||
:options="lineHeightOptions"
|
||
@update:value="applyLineHeight"
|
||
/>
|
||
<label class="color-picker">
|
||
文字色
|
||
<input type="color" @input="applyTextColor" />
|
||
</label>
|
||
<label class="color-picker">
|
||
背景色
|
||
<input type="color" @input="applyBackColor" />
|
||
</label>
|
||
</NSpace>
|
||
</div>
|
||
|
||
<NSpin :show="loading">
|
||
<div class="editor-wrapper">
|
||
<div
|
||
ref="editorRef"
|
||
class="editor-canvas"
|
||
contenteditable="true"
|
||
spellcheck="false"
|
||
@input="handleInput"
|
||
></div>
|
||
</div>
|
||
</NSpin>
|
||
</div>
|
||
</CommonPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.editor-page__header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.editor-page__header .title {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.editor-page__header h2 {
|
||
margin: 0;
|
||
}
|
||
|
||
.meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.editor-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.editor-toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background: #f7f7f9;
|
||
border: 1px solid #e0e4eb;
|
||
border-radius: 10px;
|
||
padding: 10px 12px;
|
||
}
|
||
|
||
.color-picker {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: #555;
|
||
}
|
||
|
||
.color-picker input {
|
||
width: 28px;
|
||
height: 28px;
|
||
padding: 0;
|
||
border: none;
|
||
background: transparent;
|
||
}
|
||
|
||
.editor-wrapper {
|
||
width: 100%;
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 12px 0 24px;
|
||
}
|
||
|
||
.editor-canvas {
|
||
width: 100%;
|
||
min-height: 400px;
|
||
padding: 0;
|
||
background: transparent;
|
||
outline: none;
|
||
}
|
||
|
||
.editor-canvas:focus {
|
||
box-shadow: none;
|
||
}
|
||
|
||
.doc-header h1 {
|
||
margin: 0 0 10px;
|
||
font-size: 22px;
|
||
text-align: center;
|
||
}
|
||
|
||
.doc-header p {
|
||
margin: 0 0 18px;
|
||
color: #555;
|
||
text-align: center;
|
||
}
|
||
|
||
.doc-block h2 {
|
||
margin: 18px 0 8px;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.doc-block p {
|
||
margin: 8px 0;
|
||
}
|
||
</style>
|