feat: 新增在线报告编辑器,支持模板渲染、编辑、保存草
This commit is contained in:
parent
cd8170ac02
commit
92943e84d3
@ -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
10
web/pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
451
web/src/views/valuation/audit/editor/index.vue
Normal file
451
web/src/views/valuation/audit/editor/index.vue
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user