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 = ` +
+

非遗资产评估报告

+

本页为在线编辑预览,内容来自 Word 模板渲染结果。

+
+
+

一、资产概览

+

资产名称:${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) + + + + +