452 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>