feat: 新增在线报告编辑器,支持模板渲染、编辑、保存草
This commit is contained in:
parent
cd8170ac02
commit
92943e84d3
@ -20,6 +20,7 @@
|
|||||||
"axios": "^1.4.0",
|
"axios": "^1.4.0",
|
||||||
"dayjs": "^1.11.9",
|
"dayjs": "^1.11.9",
|
||||||
"docxtemplater": "^3.67.5",
|
"docxtemplater": "^3.67.5",
|
||||||
|
"docx-preview": "^0.3.7",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"eslint": "^8.46.0",
|
"eslint": "^8.46.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
|
|||||||
10
web/pnpm-lock.yaml
generated
10
web/pnpm-lock.yaml
generated
@ -29,6 +29,9 @@ importers:
|
|||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.9
|
specifier: ^1.11.9
|
||||||
version: 1.11.12
|
version: 1.11.12
|
||||||
|
docx-preview:
|
||||||
|
specifier: ^0.3.7
|
||||||
|
version: 0.3.7
|
||||||
docxtemplater:
|
docxtemplater:
|
||||||
specifier: ^3.67.5
|
specifier: ^3.67.5
|
||||||
version: 3.67.5
|
version: 3.67.5
|
||||||
@ -963,6 +966,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
|
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
docx-preview@0.3.7:
|
||||||
|
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
|
||||||
|
|
||||||
docxtemplater@3.67.5:
|
docxtemplater@3.67.5:
|
||||||
resolution: {integrity: sha512-Jnh9rdMf5sDmrfONs3nVDhZwVFxLNdP3RVHkqLQYA6eZLkWA+kx5aYy0cVmEqJcVIaFYf5JJEFdClD7fn6ZUng==}
|
resolution: {integrity: sha512-Jnh9rdMf5sDmrfONs3nVDhZwVFxLNdP3RVHkqLQYA6eZLkWA+kx5aYy0cVmEqJcVIaFYf5JJEFdClD7fn6ZUng==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
@ -3513,6 +3519,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
esutils: 2.0.3
|
esutils: 2.0.3
|
||||||
|
|
||||||
|
docx-preview@0.3.7:
|
||||||
|
dependencies:
|
||||||
|
jszip: 3.10.1
|
||||||
|
|
||||||
docxtemplater@3.67.5:
|
docxtemplater@3.67.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@xmldom/xmldom': 0.9.8
|
'@xmldom/xmldom': 0.9.8
|
||||||
|
|||||||
@ -90,5 +90,14 @@ export default {
|
|||||||
updateValuationNotes: (data = {}) =>
|
updateValuationNotes: (data = {}) =>
|
||||||
request.put(`/valuations/${data.valuation_id || data.id}/admin-notes`, { admin_notes: data.admin_notes }),
|
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 }),
|
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),
|
sendSmsReport: (data = {}) => request.post('/sms/send-report', data),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,6 +116,29 @@ export const basicRoutes = [
|
|||||||
title: '下载报告',
|
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',
|
name: 'Login',
|
||||||
path: '/login',
|
path: '/login',
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
NImageGroup,
|
NImageGroup,
|
||||||
useMessage
|
useMessage
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
// 临时移除图标导入以解决模块解析问题
|
// 临时移除图标导入以解决模块解析问题
|
||||||
// import { DownloadIcon } from '@vicons/tabler'
|
// import { DownloadIcon } from '@vicons/tabler'
|
||||||
|
|
||||||
@ -34,6 +35,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['update:visible', 'confirm'])
|
const emit = defineEmits(['update:visible', 'confirm'])
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const reportFileList = ref([])
|
const reportFileList = ref([])
|
||||||
const certificateFileList = 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) => {
|
const handlePreview = (file) => {
|
||||||
// 对于非图片文件,显示提示
|
// 对于非图片文件,显示提示
|
||||||
@ -234,6 +252,9 @@ const handleSmsNotify = async () => {
|
|||||||
<NButton text type="primary" @click="handleDownloadReport">
|
<NButton text type="primary" @click="handleDownloadReport">
|
||||||
点击下载原版报告
|
点击下载原版报告
|
||||||
</NButton>
|
</NButton>
|
||||||
|
<NButton text type="info" @click="handleOpenEditor">
|
||||||
|
在线编辑
|
||||||
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-content">
|
<div class="upload-content">
|
||||||
<NUpload
|
<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