diff --git a/web/package.json b/web/package.json
index 8fd26d8..00fdd6c 100644
--- a/web/package.json
+++ b/web/package.json
@@ -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",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index b060cf3..a7a409d 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -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
diff --git a/web/src/api/index.js b/web/src/api/index.js
index 6b790c9..51426b5 100644
--- a/web/src/api/index.js
+++ b/web/src/api/index.js
@@ -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),
}
diff --git a/web/src/router/routes/index.js b/web/src/router/routes/index.js
index 0d96f2e..d6286c1 100644
--- a/web/src/router/routes/index.js
+++ b/web/src/router/routes/index.js
@@ -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',
diff --git a/web/src/views/valuation/audit/components/CertificateModal.vue b/web/src/views/valuation/audit/components/CertificateModal.vue
index 0590b00..0fe8784 100644
--- a/web/src/views/valuation/audit/components/CertificateModal.vue
+++ b/web/src/views/valuation/audit/components/CertificateModal.vue
@@ -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 () => {
点击下载原版报告
+
+ 在线编辑
+
+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 = `
+
+
+
一、资产概览
+
资产名称:${pageMeta.value.title}
+
生成时间:${new Date().toLocaleDateString()}
+
+
+
二、正文示例
+
在这里可以直接修改文字样式、对齐方式,也可以粘贴从 Word 模板转换的 HTML,实现 1:1 的样式复刻。
+
正文段落采用 14px/1.8 行距,并限制版心宽度,便于接近 A4 排版。
+
+`
+
+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 ? `${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)
+
+
+
+
+
+
+
+
+
+
+
+
+