feat: 新增在线报告编辑器,支持模板渲染、编辑、保存草

This commit is contained in:
Wei_佳 2025-12-02 18:01:32 +08:00
parent cd8170ac02
commit 92943e84d3
6 changed files with 515 additions and 0 deletions

View File

@ -20,6 +20,7 @@
"axios": "^1.4.0",
"dayjs": "^1.11.9",
"docxtemplater": "^3.67.5",
"docx-preview": "^0.3.7",
"dotenv": "^16.3.1",
"eslint": "^8.46.0",
"file-saver": "^2.0.5",

10
web/pnpm-lock.yaml generated
View File

@ -29,6 +29,9 @@ importers:
dayjs:
specifier: ^1.11.9
version: 1.11.12
docx-preview:
specifier: ^0.3.7
version: 0.3.7
docxtemplater:
specifier: ^3.67.5
version: 3.67.5
@ -963,6 +966,9 @@ packages:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'}
docx-preview@0.3.7:
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
docxtemplater@3.67.5:
resolution: {integrity: sha512-Jnh9rdMf5sDmrfONs3nVDhZwVFxLNdP3RVHkqLQYA6eZLkWA+kx5aYy0cVmEqJcVIaFYf5JJEFdClD7fn6ZUng==}
engines: {node: '>=0.10'}
@ -3513,6 +3519,10 @@ snapshots:
dependencies:
esutils: 2.0.3
docx-preview@0.3.7:
dependencies:
jszip: 3.10.1
docxtemplater@3.67.5:
dependencies:
'@xmldom/xmldom': 0.9.8

View File

@ -90,5 +90,14 @@ export default {
updateValuationNotes: (data = {}) =>
request.put(`/valuations/${data.valuation_id || data.id}/admin-notes`, { admin_notes: data.admin_notes }),
getValuationReport: (params = {}) => request.get(`/valuations/${params.valuation_id || params.id}/report`, { isRaw: true }),
renderReportTemplate: (params = {}) =>
request.get('/valuations/report-template/render', { params, isRaw: true }),
saveReportDraft: (data = {}) => request.post('/valuations/report-template/save', data),
exportReportTemplate: ({ valuation_id, template_id, format = 'pdf' } = {}) =>
request.get('/valuations/report-template/export', {
params: { valuation_id, template_id, format },
responseType: 'blob',
isRaw: true,
}),
sendSmsReport: (data = {}) => request.post('/sms/send-report', data),
}

View File

@ -116,6 +116,29 @@ export const basicRoutes = [
title: '下载报告',
},
},
{
name: 'ReportEditor',
path: '/valuation/audit/editor',
component: Layout,
isHidden: true,
meta: {
title: '报告在线编辑',
icon: 'mdi:file-document-edit-outline',
order: 99,
},
children: [
{
path: '',
component: () => import('@/views/valuation/audit/editor/index.vue'),
name: 'ReportEditorDefault',
meta: {
title: '报告在线编辑',
icon: 'mdi:file-document-edit-outline',
order: 99,
},
},
],
},
{
name: 'Login',
path: '/login',

View File

@ -10,6 +10,7 @@ import {
NImageGroup,
useMessage
} from 'naive-ui'
import { useRouter } from 'vue-router'
//
// import { DownloadIcon } from '@vicons/tabler'
@ -34,6 +35,7 @@ const props = defineProps({
const emit = defineEmits(['update:visible', 'confirm'])
const message = useMessage()
const router = useRouter()
const reportFileList = ref([])
const certificateFileList = ref([])
@ -179,6 +181,22 @@ const handleDownloadReport = async () => {
}
}
// 线
const handleOpenEditor = () => {
const detail = props.certificateData?.detailData
if (!detail?.id) {
message.error('缺少详情数据,无法打开编辑页')
return
}
const query = {
valuationId: detail.id,
templateId: detail.template_id || detail.templateId,
title: detail.asset_name || detail.institution || '评估报告'
}
router.push({ path: '/valuation/audit/editor', query })
}
//
const handlePreview = (file) => {
//
@ -234,6 +252,9 @@ const handleSmsNotify = async () => {
<NButton text type="primary" @click="handleDownloadReport">
点击下载原版报告
</NButton>
<NButton text type="info" @click="handleOpenEditor">
在线编辑
</NButton>
</div>
<div class="upload-content">
<NUpload

View File

@ -0,0 +1,451 @@
<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>