word 预览

This commit is contained in:
cwchen 2025-11-11 17:50:15 +08:00
parent 1f42b538aa
commit ebf890f579
3 changed files with 439 additions and 88 deletions

View File

@ -31,7 +31,7 @@
"clipboard": "2.0.8",
"core-js": "3.37.1",
"crypto-js": "^4.2.0",
"docx-preview": "^0.3.7",
"docx-preview": "^0.1.7",
"echarts": "5.4.0",
"element-ui": "2.15.14",
"file-saver": "2.0.5",
@ -40,6 +40,7 @@
"js-beautify": "1.13.0",
"js-cookie": "3.0.1",
"jsencrypt": "3.0.0-rc.1",
"jszip": "^3.10.1",
"lodash": "^4.17.21",
"mammoth": "^1.11.0",
"nprogress": "0.2.0",

View File

@ -24,8 +24,7 @@
<div class="viewer-container">
<div ref="docWrapper" class="doc-wrapper">
<vue-office-docx ref="docxViewer" v-if="docSrc" :src="docSrc" :request-options="docRequestOptions"
:options="docOptions" class="docx-viewer" @rendered="handleDocRendered" @error="handleDocError" />
<div ref="docxContainer" class="doc-content"></div>
</div>
<transition name="fade">
@ -38,7 +37,7 @@
<p>{{ error }}</p>
<el-button type="primary" @click="reload">重新加载</el-button>
</div>
<div v-else-if="!docSrc" key="no-url" class="overlay state-panel">
<div v-else-if="!docUrl" key="no-url" class="overlay state-panel">
<i class="el-icon-document state-icon"></i>
<p>暂未指定 Word 文件请通过路由参数 url 传入文件地址</p>
</div>
@ -48,28 +47,103 @@
</template>
<script>
import VueOfficeDocx from '@vue-office/docx'
import '@vue-office/docx/lib/index.css'
import * as docxPreview from 'docx-preview/dist/docx-preview.js'
const DOCX_CUSTOM_STYLE_ID = 'docx-preview-custom-style'
const DOCX_CUSTOM_STYLE = `
.docx-wrapper {
margin: 0 auto;
background: linear-gradient(180deg, #fdfdff 0%, #f6f8ff 100%);
border-radius: 16px;
box-shadow: 0 24px 48px rgba(25,64,158,0.16);
overflow: hidden;
border: 1px solid rgba(68,112,255,0.18);
}
.docx-fonts-resolver {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.docx-wrapper section {
background: transparent !important;
padding: 48px 56px !important;
line-height: 1.8;
font-size: 14px;
color: #1f2a62;
}
.docx-wrapper table {
width: 100% !important;
border-collapse: separate !important;
border-spacing: 0 !important;
margin: 24px 0 !important;
background: rgba(255,255,255,0.98) !important;
border-radius: 14px;
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(68,112,255,0.2);
}
.docx-wrapper table thead th {
background: linear-gradient(135deg, rgba(68,112,255,0.45), rgba(68,112,255,0.22)) !important;
color: #f5f7ff !important;
font-weight: 600 !important;
text-transform: uppercase;
letter-spacing: 0.6px;
padding: 14px 18px !important;
border-bottom: 1px solid rgba(36,71,182,0.3) !important;
}
.docx-wrapper table td,
.docx-wrapper table th {
border-left: 1px solid rgba(68,112,255,0.2) !important;
border-right: 1px solid rgba(68,112,255,0.2) !important;
padding: 14px 18px !important;
vertical-align: top !important;
background: transparent !important;
color: #27325d !important;
font-size: 13px !important;
}
.docx-wrapper table tr:nth-child(even) td {
background: rgba(242,245,255,0.9) !important;
}
.docx-wrapper table tr:nth-child(odd) td {
background: rgba(255,255,255,0.98) !important;
}
.docx-wrapper table tr:hover td {
background: rgba(68,112,255,0.16) !important;
color: #1e2a62 !important;
}
.docx-wrapper table td:first-child,
.docx-wrapper table th:first-child {
border-left: none !important;
}
.docx-wrapper table td:last-child,
.docx-wrapper table th:last-child {
border-right: none !important;
}
.docx-wrapper table tr:last-child td {
border-bottom: none !important;
}
.docx-wrapper table caption {
caption-side: top !important;
padding: 12px 0 !important;
font-weight: 600 !important;
color: #3f57c0 !important;
font-size: 15px !important;
}
.docx-wrapper table strong,
.docx-wrapper table b {
color: #2a3aa8 !important;
}
`
const DEFAULT_DOC_URL = 'http://192.168.0.14:9090/smart-bid/technicalSolutionDatabase/2025/11/11/887b35d28b2149b6a7555fb639be9411.docx'
export default {
name: 'DocumentSearchWord',
components: {
VueOfficeDocx,
},
data() {
return {
docUrl: '',
docSrc: '',
docOptions: {
inWrapper: true,
},
docRequestOptions: {
method: 'GET',
headers: {},
credentials: 'include',
},
keyword: '',
loading: false,
error: null,
@ -78,65 +152,104 @@ export default {
searching: false,
searchSegments: [],
docRendered: false,
abortController: null,
}
},
mounted() {
this.docUrl = this.$route.query.url || DEFAULT_DOC_URL
if (this.docUrl) {
this.prepareDocumentSource()
this.loadDocument()
}
},
beforeDestroy() {
this.cancelFetch()
},
watch: {
'$route.query.url'(newUrl, oldUrl) {
if (newUrl !== oldUrl) {
this.docUrl = newUrl || DEFAULT_DOC_URL
this.resetViewerState()
if (this.docUrl) {
this.prepareDocumentSource()
this.loadDocument()
}
}
},
},
methods: {
prepareDocumentSource() {
if (!this.docUrl) {
this.docSrc = ''
return
}
const headers = {}
const token = this.$store?.getters?.token || window?.sessionStorage?.getItem('token')
if (token) {
headers.Authorization = token.startsWith('Bearer ') ? token : `Bearer ${token}`
}
this.docRequestOptions = {
method: 'GET',
headers,
credentials: 'include',
cancelFetch() {
if (this.abortController) {
this.abortController.abort()
this.abortController = null
}
},
ensureDocxStyleInjected() {
if (document.getElementById(DOCX_CUSTOM_STYLE_ID)) return
const styleTag = document.createElement('style')
styleTag.id = DOCX_CUSTOM_STYLE_ID
styleTag.type = 'text/css'
styleTag.appendChild(document.createTextNode(DOCX_CUSTOM_STYLE))
document.head.appendChild(styleTag)
},
async loadDocument() {
if (!this.docUrl) return
this.loading = true
this.error = null
this.docRendered = false
this.docSrc = this.docUrl
this.prepareSearchSegments(true)
},
this.clearHighlights()
this.searchSegments = []
this.cancelFetch()
const container = this.$refs.docxContainer
if (container) {
container.innerHTML = ''
}
this.ensureDocxStyleInjected()
handleDocRendered() {
this.loading = false
this.error = null
this.docRendered = true
this.$nextTick(() => {
this.prepareSearchSegments()
if (this.keyword.trim()) {
this.highlightMatches()
try {
const headers = {}
const token = this.$store?.getters?.token || window?.sessionStorage?.getItem('token')
if (token) {
headers.Authorization = token.startsWith('Bearer ') ? token : `Bearer ${token}`
}
})
},
handleDocError(err) {
console.error('Word 文档预览失败:', err)
this.loading = false
this.error = err?.message || 'Word 文档加载失败,请稍后重试'
this.docRendered = false
this.abortController = new AbortController()
const response = await fetch(this.docUrl, {
headers,
credentials: 'include',
signal: this.abortController.signal,
})
if (!response.ok) {
throw new Error(`加载 Word 文档失败:${response.statusText}`)
}
const arrayBuffer = await response.arrayBuffer()
if (!container) {
throw new Error('未找到文档容器')
}
await docxPreview.renderAsync(arrayBuffer, container, null, {
className: 'docx-viewer',
inWrapper: true,
ignoreFonts: false,
breakPages: false,
})
this.wrapContentWithArticle(container)
this.injectFontResolver(container)
this.docRendered = true
this.loading = false
this.$nextTick(() => {
this.prepareSearchSegments()
if (this.keyword.trim()) {
this.highlightMatches()
}
})
} catch (error) {
if (error.name === 'AbortError') return
console.error('Word 文档预览失败:', error)
this.loading = false
this.error = error?.message || 'Word 文档加载失败,请稍后重试'
this.docRendered = false
} finally {
this.abortController = null
}
},
prepareSearchSegments(forceReset = false) {
@ -146,12 +259,24 @@ export default {
}
return
}
const wrapper = this.$refs.docWrapper
if (!wrapper) return
const root = wrapper.querySelector('.vue-office-docx-main') || wrapper
if (!root) return
const container = this.$refs.docxContainer
if (!container) return
const elements = Array.from(root.querySelectorAll('p, li, td, th, h1, h2, h3, h4, h5, h6, span'))
const selectors = [
'.docx-viewer p',
'.docx-viewer li',
'.docx-viewer td',
'.docx-viewer th',
'.docx-viewer h1',
'.docx-viewer h2',
'.docx-viewer h3',
'.docx-viewer h4',
'.docx-viewer h5',
'.docx-viewer h6',
'.docx-viewer span',
]
const elements = Array.from(container.querySelectorAll(selectors.join(',')))
.filter((el) => (el.textContent || '').trim())
elements.forEach((el, idx) => {
@ -170,7 +295,6 @@ export default {
this.resetSearch()
return
}
if (!this.docSrc) return
if (!this.docRendered) {
this.prepareSearchSegments()
}
@ -312,12 +436,45 @@ export default {
this.currentResultIndex = -1
this.searchSegments = []
this.docRendered = false
this.docSrc = ''
this.loading = !!this.docUrl
this.error = null
const container = this.$refs.docxContainer
if (container) {
container.innerHTML = ''
}
},
reload() {
if (!this.docUrl) return
this.prepareDocumentSource()
this.loadDocument()
},
injectFontResolver(container) {
if (!container || container.querySelector('.docx-fonts-resolver')) return
const resolver = document.createElement('div')
resolver.className = 'docx-fonts-resolver'
resolver.innerHTML = [
'微软雅黑', '宋体', '黑体', '仿宋', '楷体', '等线', 'Times New Roman', 'Arial'
].map(font => `<span style="font-family:${font};">${font}</span>`).join('')
container.appendChild(resolver)
},
wrapContentWithArticle(container) {
if (!container) return
const existingArticle = container.querySelector(':scope > article.docx-article-wrapper')
if (existingArticle) {
return
}
const article = document.createElement('article')
article.className = 'docx-article-wrapper'
const nodes = Array.from(container.childNodes)
.filter((node) => node.nodeType === Node.ELEMENT_NODE || (node.nodeType === Node.TEXT_NODE && node.textContent.trim()))
nodes.forEach((node) => {
if (node !== article) {
article.appendChild(node)
}
})
container.appendChild(article)
},
},
}
@ -333,6 +490,15 @@ export default {
box-sizing: border-box;
}
.document-search-word article.docx-article-wrapper {
max-width: 960px;
margin: 0 auto;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 18px 40px rgba(25, 64, 158, 0.12);
padding: 48px 56px;
}
.search-toolbar {
display: flex;
align-items: center;
@ -377,22 +543,217 @@ export default {
flex: 1;
overflow: auto;
padding: 24px;
background: #eaeaea;
background: #eef2ff;
position: relative;
}
::v-deep .vue-office-docx {
background: transparent !important;
box-shadow: 0 10px 30px rgba(25, 64, 158, 0.12);
border-radius: 8px;
margin: 0 auto;
.doc-content {
max-width: 960px;
margin: 0 auto;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 18px 40px rgba(25, 64, 158, 0.12);
padding: 48px 56px;
}
::v-deep .vue-office-docx-main {
background: #ffffff;
border-radius: 8px;
::v-deep .docx-wrapper {
margin: 0 auto;
background: linear-gradient(180deg, #fdfdff 0%, #f6f8ff 100%);
border-radius: 16px;
box-shadow: 0 24px 48px rgba(25, 64, 158, 0.16);
overflow: hidden;
border: 1px solid rgba(68, 112, 255, 0.18);
}
::v-deep .docx-wrapper section {
background: transparent !important;
padding: 48px 56px !important;
line-height: 1.75;
font-size: 14px;
color: #1f2a62;
}
::v-deep .docx-wrapper table {
width: 100% !important;
border-collapse: separate !important;
border-spacing: 0 !important;
margin: 24px 0 !important;
background: rgba(255, 255, 255, 0.96) !important;
border-radius: 14px;
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(68, 112, 255, 0.2);
}
::v-deep .docx-wrapper table thead th {
background: linear-gradient(135deg, rgba(68, 112, 255, 0.45), rgba(68, 112, 255, 0.22)) !important;
color: #f5f7ff !important;
font-weight: 600 !important;
text-transform: uppercase;
letter-spacing: 0.6px;
padding: 14px 18px !important;
border-bottom: 1px solid rgba(36, 71, 182, 0.3) !important;
}
::v-deep .docx-wrapper table td,
::v-deep .docx-wrapper table th {
border-left: 1px solid rgba(68, 112, 255, 0.22) !important;
border-right: 1px solid rgba(68, 112, 255, 0.22) !important;
padding: 14px 18px !important;
vertical-align: top !important;
background: transparent !important;
color: #27325d !important;
font-size: 13px !important;
}
::v-deep .docx-wrapper table tr:nth-child(even) td {
background: rgba(242, 245, 255, 0.9) !important;
}
::v-deep .docx-wrapper table tr:nth-child(odd) td {
background: rgba(255, 255, 255, 0.98) !important;
}
::v-deep .docx-wrapper table tr:hover td {
background: rgba(68, 112, 255, 0.16) !important;
color: #1e2a62 !important;
}
::v-deep .docx-wrapper table td:first-child,
::v-deep .docx-wrapper table th:first-child {
border-left: none !important;
}
::v-deep .docx-wrapper table td:last-child,
::v-deep .docx-wrapper table th:last-child {
border-right: none !important;
}
::v-deep .docx-wrapper table tr:last-child td {
border-bottom: none !important;
}
::v-deep .docx-wrapper table caption {
caption-side: top !important;
padding: 12px 0 !important;
font-weight: 600 !important;
color: #3f57c0 !important;
font-size: 15px !important;
}
::v-deep .docx-wrapper table strong,
::v-deep .docx-wrapper table b {
color: #2a3aa8 !important;
}
::v-deep .docx-wrapper h1,
::v-deep .docx-wrapper h2,
::v-deep .docx-wrapper h3,
::v-deep .docx-wrapper h4,
::v-deep .docx-wrapper h5,
::v-deep .docx-wrapper h6 {
color: #1b2f72;
}
::v-deep .docx-wrapper .docx span {
color: inherit;
}
.doc-content h1,
.doc-content h2,
.doc-content h3,
.doc-content h4,
.doc-content h5,
.doc-content h6 {
font-weight: 700;
margin: 24px 0 12px;
color: #1b2f72;
}
.doc-content h1 {
font-size: 28px;
}
.doc-content h2 {
font-size: 24px;
}
.doc-content h3 {
font-size: 20px;
}
.doc-content h4 {
font-size: 18px;
}
.doc-content h5 {
font-size: 16px;
}
.doc-content h6 {
font-size: 15px;
}
.doc-content p {
margin: 8px 0;
}
.doc-content ul,
.doc-content ol {
padding-left: 28px;
margin: 8px 0 12px;
}
.doc-content ul {
list-style: disc;
}
.doc-content ol {
list-style: decimal;
}
.doc-content blockquote {
margin: 12px 0;
padding: 12px 18px;
background: rgba(68, 112, 255, 0.08);
border-left: 4px solid rgba(68, 112, 255, 0.45);
color: #3b4d86;
}
.doc-content table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
background: #ffffff;
font-size: 13px;
}
.doc-content table td,
.doc-content table th {
border: 1px solid rgba(0, 0, 0, 0.18);
padding: 10px 12px;
vertical-align: top;
}
.doc-content table th {
background: rgba(68, 112, 255, 0.1);
font-weight: 600;
}
.doc-content img {
max-width: 100%;
height: auto;
display: block;
margin: 12px auto;
}
.doc-content hr {
border: none;
border-top: 1px solid rgba(0, 0, 0, 0.1);
margin: 24px 0;
}
.doc-content sup {
font-size: 12px;
}
.overlay {
@ -438,21 +799,5 @@ export default {
color: #ffffff;
}
::v-deep .vue-office-docx table {
border-collapse: collapse;
}
::v-deep .vue-office-docx table td,
::v-deep .vue-office-docx table th {
border: 1px solid rgba(0, 0, 0, 0.12);
}
::v-deep .vue-office-docx .page {
margin: 0 auto;
box-shadow: none;
}
::v-deep .vue-office-docx .page-body {
padding: 48px;
}
</style>

View File

@ -30,7 +30,7 @@ module.exports = {
assetsDir: 'static',
// 如果你不需要生产环境的 source map可以将其设置为 false 以加速生产环境构建。
productionSourceMap: false,
transpileDependencies: ['quill'],
transpileDependencies: ['quill','docx-preview'],
// webpack-dev-server 相关配置
devServer: {
host: '0.0.0.0',
@ -60,11 +60,16 @@ module.exports = {
}
}
},
chainWebpack: config => {
config.resolve.alias
.set('JSZip', require.resolve('jszip'))
},
configureWebpack: {
name: name,
resolve: {
alias: {
'@': resolve('src')
'@': resolve('src'),
'JSZip': require.resolve('jszip')
}
},
plugins: [