Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
32aecd8b1d
|
|
@ -78,7 +78,11 @@
|
|||
v-if="selectionShow && isSelectShow" />
|
||||
<el-table-column width="45" align="center" label="" v-if="isRadioShow">
|
||||
<template slot-scope="scope">
|
||||
<el-radio :label="getRowUniqueId(scope.row)" v-model="currentRowId">
|
||||
<el-radio
|
||||
:label="getRowUniqueId(scope.row, scope.$index)"
|
||||
v-model="currentRowId"
|
||||
@change="handleRadioChange(scope.row, scope.$index)"
|
||||
>
|
||||
<span></span>
|
||||
</el-radio>
|
||||
</template>
|
||||
|
|
@ -194,6 +198,11 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 单选框唯一标识的字段名(如 'id', 'proId', 'bidId' 等)
|
||||
radioKey: {
|
||||
type: String,
|
||||
default: 'id',
|
||||
},
|
||||
// 测试时使用的数据源
|
||||
testTableList: {
|
||||
type: Array,
|
||||
|
|
@ -429,10 +438,17 @@ export default {
|
|||
this.total = res.total
|
||||
// 数据刷新后,如果之前选中的行不在当前列表中,清空选中状态
|
||||
if (this.isRadioShow && this.currentRowId) {
|
||||
const exists = this.tableList.some(
|
||||
(row) =>
|
||||
this.getRowUniqueId(row) === this.currentRowId,
|
||||
)
|
||||
let exists = false
|
||||
for (let i = 0; i < this.tableList.length; i++) {
|
||||
const row = this.tableList[i]
|
||||
const uniqueId = this.getRowUniqueId(row, i)
|
||||
if (uniqueId === this.currentRowId) {
|
||||
exists = true
|
||||
// 更新当前行数据引用
|
||||
this.currentRowData = row
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!exists) {
|
||||
this.currentRowId = null
|
||||
this.currentRowData = null
|
||||
|
|
@ -546,16 +562,62 @@ export default {
|
|||
this.selectedData = e
|
||||
},
|
||||
|
||||
// 获取行的唯一标识(优先使用id,否则使用索引)
|
||||
getRowUniqueId(row) {
|
||||
return row.id || row.proId || row.bidId || JSON.stringify(row)
|
||||
// 获取行的唯一标识(优先使用 radioKey 指定的字段)
|
||||
getRowUniqueId(row, index) {
|
||||
// 优先使用 radioKey 指定的字段作为唯一标识
|
||||
if (this.radioKey && row[this.radioKey] !== undefined && row[this.radioKey] !== null && row[this.radioKey] !== '') {
|
||||
return String(row[this.radioKey])
|
||||
}
|
||||
// 如果 radioKey 指定的字段不存在,尝试使用常见的ID字段
|
||||
if (row.id !== undefined && row.id !== null && row.id !== '') {
|
||||
return String(row.id)
|
||||
}
|
||||
if (row.proId !== undefined && row.proId !== null && row.proId !== '') {
|
||||
return String(row.proId)
|
||||
}
|
||||
if (row.bidId !== undefined && row.bidId !== null && row.bidId !== '') {
|
||||
return String(row.bidId)
|
||||
}
|
||||
// 如果都没有,使用索引作为后备(确保唯一性)
|
||||
// 注意:如果数据没有唯一ID,使用索引可能导致分页切换时选中状态丢失
|
||||
const rowIndex = index !== undefined ? index : this.tableList.findIndex(r => r === row)
|
||||
// 使用行的内容生成一个稳定的哈希值作为唯一标识
|
||||
try {
|
||||
const rowStr = JSON.stringify(row)
|
||||
// 简单的哈希函数,确保相同内容返回相同值
|
||||
let hash = 0
|
||||
for (let i = 0; i < rowStr.length; i++) {
|
||||
const char = rowStr.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + char
|
||||
hash = hash & hash // Convert to 32bit integer
|
||||
}
|
||||
return `hash_${Math.abs(hash)}_${rowIndex}`
|
||||
} catch (e) {
|
||||
// 如果序列化失败,使用索引
|
||||
return `index_${rowIndex}`
|
||||
}
|
||||
},
|
||||
|
||||
// 处理单选框变化事件
|
||||
handleRadioChange(row, index) {
|
||||
if (this.isRadioShow && row) {
|
||||
const uniqueId = this.getRowUniqueId(row, index)
|
||||
// 只有当值真正改变时才更新,避免重复触发
|
||||
if (this.currentRowId !== uniqueId) {
|
||||
this.currentRowId = uniqueId
|
||||
this.currentRowData = row
|
||||
// 注意:这里不直接 emit,让 watch 中的监听器来处理,避免重复触发
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 处理表格当前行变化(用于高亮)
|
||||
handleCurrentChange(currentRow) {
|
||||
if (this.isRadioShow && currentRow) {
|
||||
this.currentRowId = this.getRowUniqueId(currentRow)
|
||||
const index = this.tableList.findIndex(r => r === currentRow)
|
||||
const uniqueId = this.getRowUniqueId(currentRow, index)
|
||||
this.currentRowId = uniqueId
|
||||
this.currentRowData = currentRow
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -613,12 +675,21 @@ export default {
|
|||
// 监听单选框选中值的变化
|
||||
currentRowId(newVal, oldVal) {
|
||||
if (this.isRadioShow && newVal && newVal !== oldVal) {
|
||||
// 找到对应的行数据
|
||||
const row = this.tableList.find(r => this.getRowUniqueId(r) === newVal)
|
||||
if (row) {
|
||||
const index = this.tableList.findIndex(r => this.getRowUniqueId(r) === newVal)
|
||||
this.currentRowData = row
|
||||
this.$emit('radio-change', row, index)
|
||||
// 找到对应的行数据和索引
|
||||
let foundRow = null
|
||||
let foundIndex = -1
|
||||
for (let i = 0; i < this.tableList.length; i++) {
|
||||
const row = this.tableList[i]
|
||||
const uniqueId = this.getRowUniqueId(row, i)
|
||||
if (uniqueId === newVal) {
|
||||
foundRow = row
|
||||
foundIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (foundRow) {
|
||||
this.currentRowData = foundRow
|
||||
this.$emit('radio-change', foundRow, foundIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@
|
|||
<TableModel :showSearch="false" :showOperation="false" :showRightTools="false"
|
||||
ref="detailTableRef" :columnsList="detailColumnsList" :request-api="getBidListAPI"
|
||||
:sendParams="sendParams" :handleColWidth="180" :isRadioShow="true"
|
||||
@radio-change="handleRadioChange" :indexNumShow="false" :isShowtableCardStyle="false">
|
||||
@radio-change="handleRadioChange" :indexNumShow="false" :isShowtableCardStyle="false" :radioKey="radioKey">
|
||||
<template slot="tableTitle">
|
||||
<div class="card-header">
|
||||
<img src="@/assets/enterpriseLibrary/basic-info.png" alt="标的信息">
|
||||
|
|
@ -171,7 +171,8 @@ export default {
|
|||
},
|
||||
rules: {},
|
||||
dialogVisible:false,
|
||||
fileData:{}
|
||||
fileData:{},
|
||||
radioKey: 'bidId',
|
||||
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -53,13 +53,13 @@ const HTML_TAG_REG = /<\/?[a-z][\s\S]*>/i
|
|||
const MAX_PARSE_DEPTH = 5
|
||||
|
||||
function formatSectionValue(raw, depth = 0) {
|
||||
const fallback = { content: '--', isHtml: false, tableRows: null }
|
||||
const fallback = { content: '--', isHtml: false, tableRows: null, hasScoreRange: false }
|
||||
if (raw === undefined || raw === null) {
|
||||
return fallback
|
||||
}
|
||||
if (depth > MAX_PARSE_DEPTH) {
|
||||
const content = String(raw)
|
||||
return { content, isHtml: HTML_TAG_REG.test(content), tableRows: null }
|
||||
return { content, isHtml: HTML_TAG_REG.test(content), tableRows: null, hasScoreRange: false }
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim()
|
||||
|
|
@ -70,13 +70,13 @@ function formatSectionValue(raw, depth = 0) {
|
|||
const parsed = JSON.parse(trimmed)
|
||||
return formatSectionValue(parsed, depth + 1)
|
||||
} catch (error) {
|
||||
return { content: trimmed, isHtml: HTML_TAG_REG.test(trimmed), tableRows: null }
|
||||
return { content: trimmed, isHtml: HTML_TAG_REG.test(trimmed), tableRows: null, hasScoreRange: false }
|
||||
}
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
const tableRows = buildTableRows(raw, depth)
|
||||
const { rows: tableRows, hasScoreRange } = buildTableRows(raw, depth)
|
||||
if (tableRows.length > 0) {
|
||||
return { content: '', isHtml: false, tableRows }
|
||||
return { content: '', isHtml: false, tableRows, hasScoreRange }
|
||||
}
|
||||
const lines = raw.map(item => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
|
|
@ -88,7 +88,7 @@ function formatSectionValue(raw, depth = 0) {
|
|||
return formatSectionValue(item, depth + 1).content
|
||||
}).filter(Boolean)
|
||||
const content = lines.length > 0 ? lines.join('\n') : '--'
|
||||
return { content, isHtml: false, tableRows: null }
|
||||
return { content, isHtml: false, tableRows: null, hasScoreRange: false }
|
||||
}
|
||||
if (typeof raw === 'object') {
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'value')) {
|
||||
|
|
@ -99,10 +99,14 @@ function formatSectionValue(raw, depth = 0) {
|
|||
return `${key}:${formatted.content}`
|
||||
}).filter(Boolean)
|
||||
const content = lines.length > 0 ? lines.join('\n') : '--'
|
||||
return { content, isHtml: false, tableRows: null }
|
||||
return { content, isHtml: false, tableRows: null, hasScoreRange: false }
|
||||
}
|
||||
const content = String(raw)
|
||||
return { content, isHtml: HTML_TAG_REG.test(content), tableRows: null }
|
||||
return { content, isHtml: HTML_TAG_REG.test(content), tableRows: null, hasScoreRange: false }
|
||||
}
|
||||
|
||||
function hasScoreValue(value) {
|
||||
return value !== undefined && value !== null && value !== ''
|
||||
}
|
||||
|
||||
function buildTableRows(items, depth) {
|
||||
|
|
@ -111,7 +115,7 @@ function buildTableRows(items, depth) {
|
|||
return null
|
||||
}
|
||||
const title = item.name ?? item.label ?? item.title ?? item.key ?? ''
|
||||
if (!title && title !== 0) {
|
||||
if (title === '' || title === undefined) {
|
||||
return null
|
||||
}
|
||||
const valueSource = item.content ?? item.value ?? item.text ?? item.desc ?? item.detail
|
||||
|
|
@ -120,13 +124,24 @@ function buildTableRows(items, depth) {
|
|||
? formatted.tableRows.map(row => `${row.title}:${row.value}`).join('\n')
|
||||
: ''
|
||||
const value = nestedTable || formatted.content || '--'
|
||||
const minScore = item.minScore ?? item.scoreMin ?? item.min ?? item.lowScore
|
||||
const maxScore = item.maxScore ?? item.scoreMax ?? item.max ?? item.highScore
|
||||
const scoreRange = item.scoreRange ?? item.range ?? item.score ?? null
|
||||
return {
|
||||
title,
|
||||
value,
|
||||
isHtml: formatted.isHtml
|
||||
isHtml: formatted.isHtml,
|
||||
minScore,
|
||||
maxScore,
|
||||
scoreRange
|
||||
}
|
||||
}).filter(Boolean)
|
||||
return rows
|
||||
const hasScoreRange = rows.some(row =>
|
||||
hasScoreValue(row.minScore) ||
|
||||
hasScoreValue(row.maxScore) ||
|
||||
hasScoreValue(row.scoreRange)
|
||||
)
|
||||
return { rows, hasScoreRange }
|
||||
}
|
||||
|
||||
const collectSectionsFromNode = (node) => {
|
||||
|
|
@ -140,7 +155,8 @@ const collectSectionsFromNode = (node) => {
|
|||
title: target.name,
|
||||
content: formatted.content,
|
||||
isHtml: formatted.isHtml,
|
||||
tableRows: formatted.tableRows
|
||||
tableRows: formatted.tableRows,
|
||||
hasScoreRange: formatted.hasScoreRange
|
||||
})
|
||||
}
|
||||
if (target.children && target.children.length) {
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@
|
|||
<table class="section-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th>内容</th>
|
||||
<th>{{ hasScoreRange(section) ? '名称' : '标题' }}</th>
|
||||
<th>{{ hasScoreRange(section) ? '详情' : '内容' }}</th>
|
||||
<th v-if="hasScoreRange(section)">分值范围</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -34,6 +35,9 @@
|
|||
v-html="formatPlainText(row.value)"></div>
|
||||
<div v-else v-html="row.value"></div>
|
||||
</td>
|
||||
<td class="cell-score" v-if="hasScoreRange(section)">
|
||||
{{ formatScoreRange(row) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -60,8 +64,9 @@
|
|||
<table class="section-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th>内容</th>
|
||||
<th>{{ hasScoreRange(section) ? '名称' : '标题' }}</th>
|
||||
<th>{{ hasScoreRange(section) ? '详情' : '内容' }}</th>
|
||||
<th v-if="hasScoreRange(section)">分值范围</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -73,6 +78,9 @@
|
|||
v-html="formatPlainText(row.value)"></div>
|
||||
<div v-else v-html="row.value"></div>
|
||||
</td>
|
||||
<td class="cell-score" v-if="hasScoreRange(section)">
|
||||
{{ formatScoreRange(row) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -107,8 +115,9 @@
|
|||
<table class="section-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th>内容</th>
|
||||
<th>{{ hasScoreRange(section) ? '名称' : '标题' }}</th>
|
||||
<th>{{ hasScoreRange(section) ? '详情' : '内容' }}</th>
|
||||
<th v-if="hasScoreRange(section)">分值范围</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -120,6 +129,9 @@
|
|||
v-html="formatPlainText(row.value)"></div>
|
||||
<div v-else v-html="row.value"></div>
|
||||
</td>
|
||||
<td class="cell-score" v-if="hasScoreRange(section)">
|
||||
{{ formatScoreRange(row) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -146,8 +158,9 @@
|
|||
<table class="section-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th>内容</th>
|
||||
<th>{{ hasScoreRange(section) ? '名称' : '标题' }}</th>
|
||||
<th>{{ hasScoreRange(section) ? '详情' : '内容' }}</th>
|
||||
<th v-if="hasScoreRange(section)">分值范围</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -159,6 +172,9 @@
|
|||
v-html="formatPlainText(row.value)"></div>
|
||||
<div v-else v-html="row.value"></div>
|
||||
</td>
|
||||
<td class="cell-score" v-if="hasScoreRange(section)">
|
||||
{{ formatScoreRange(row) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -289,11 +305,34 @@ export default {
|
|||
hasTable(rows) {
|
||||
return Array.isArray(rows) && rows.length > 0
|
||||
},
|
||||
hasScoreRange(section) {
|
||||
return Boolean(section && section.hasScoreRange)
|
||||
},
|
||||
formatPlainText(text) {
|
||||
if (!text) {
|
||||
return '--'
|
||||
}
|
||||
return text.replace(/\n/g, '<br/>')
|
||||
},
|
||||
formatScoreRange(row) {
|
||||
if (!row) {
|
||||
return '--'
|
||||
}
|
||||
if (row.scoreRange !== undefined && row.scoreRange !== null && row.scoreRange !== '') {
|
||||
return row.scoreRange
|
||||
}
|
||||
const hasMin = row.minScore !== undefined && row.minScore !== null && row.minScore !== ''
|
||||
const hasMax = row.maxScore !== undefined && row.maxScore !== null && row.maxScore !== ''
|
||||
if (hasMin && hasMax) {
|
||||
return `${row.minScore} ~ ${row.maxScore}`
|
||||
}
|
||||
if (hasMin) {
|
||||
return `${row.minScore}`
|
||||
}
|
||||
if (hasMax) {
|
||||
return `${row.maxScore}`
|
||||
}
|
||||
return '--'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -585,6 +624,13 @@ export default {
|
|||
color: #606266;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.cell-score {
|
||||
width: 140px;
|
||||
color: #303133;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,157 +1,174 @@
|
|||
<!-- 文档预览面板 -->
|
||||
<template>
|
||||
<div class="document-preview-panel">
|
||||
<div class="panel-header">
|
||||
<div class="header-actions">
|
||||
<el-button
|
||||
type="text"
|
||||
icon="el-icon-search"
|
||||
class="search-btn"
|
||||
@click="handleSearch"
|
||||
></el-button>
|
||||
</div>
|
||||
<div class="document-switch">
|
||||
<el-button
|
||||
:type="activeDocType === 'tender' ? 'primary' : 'default'"
|
||||
class="doc-btn"
|
||||
@click="switchDocument('tender')"
|
||||
>
|
||||
招标文件
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="activeDocType === 'bid' ? 'primary' : 'default'"
|
||||
class="doc-btn"
|
||||
@click="switchDocument('bid')"
|
||||
>
|
||||
标段文件
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div v-if="documentUrl" class="document-viewer">
|
||||
<OnlyOfficeViewer
|
||||
v-if="showOnlyOffice"
|
||||
:document-url="documentUrl"
|
||||
:document-title="documentTitle"
|
||||
:document-key="documentKey"
|
||||
:mode="viewMode"
|
||||
:type="viewType"
|
||||
@document-ready="handleDocumentReady"
|
||||
@app-ready="handleAppReady"
|
||||
@error="handleError"
|
||||
/>
|
||||
<div v-else class="document-placeholder">
|
||||
<div class="placeholder-content">
|
||||
<i class="el-icon-document placeholder-icon"></i>
|
||||
<p class="placeholder-text">文档预览</p>
|
||||
<p class="placeholder-desc">{{ documentTitle || '暂无文档' }}</p>
|
||||
<!-- 文档预览区域 -->
|
||||
<div class="document-viewer-wrapper">
|
||||
<!-- 左侧文档切换栏 -->
|
||||
<div class="document-sidebar">
|
||||
<div
|
||||
v-for="(doc, index) in documents"
|
||||
:key="doc.id"
|
||||
class="document-item"
|
||||
:class="{ active: currentIndex === index }"
|
||||
@click="switchDocument(index)"
|
||||
>
|
||||
{{ doc.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<i class="el-icon-document-empty empty-icon"></i>
|
||||
<p class="empty-text">暂无文档</p>
|
||||
<div v-if="currentDocument" class="document-viewer">
|
||||
<div v-if="currentDocument.type === 'pdf'" class="embedded-viewer" :key="`pdf-${currentDocument.id}`">
|
||||
<DocumentSearch :file-url="currentDocument.url" :key="`pdf-viewer-${currentDocument.id}`" />
|
||||
</div>
|
||||
<div v-else-if="currentDocument.type === 'word'" class="embedded-viewer" :key="`word-${currentDocument.id}`">
|
||||
<DocumentSearchWord :file-url="currentDocument.url" :key="`word-viewer-${currentDocument.id}`" />
|
||||
</div>
|
||||
<div v-else-if="currentDocument.type === 'excel'" class="embedded-viewer" :key="`excel-${currentDocument.id}`">
|
||||
<DocumentExcel :file-url="currentDocument.url" :key="`excel-viewer-${currentDocument.id}`" />
|
||||
</div>
|
||||
<div v-else class="document-placeholder">
|
||||
<div class="placeholder-content">
|
||||
<i class="el-icon-document placeholder-icon"></i>
|
||||
<p class="placeholder-text">暂不支持的文件类型</p>
|
||||
<p class="placeholder-desc">{{ currentDocument.title || currentDocument.url }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<i class="el-icon-document-empty empty-icon"></i>
|
||||
<p class="empty-text">暂无文档</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import OnlyOfficeViewer from '@/views/common/OnlyOfficeViewer.vue'
|
||||
import DocumentSearch from '@/views/common/DocumentSearch.vue'
|
||||
import DocumentSearchWord from '@/views/common/DocumentSearchWord.vue'
|
||||
import DocumentExcel from '@/views/common/DocumentExcel.vue'
|
||||
|
||||
const WORD_EXTS = ['.doc', '.docx']
|
||||
const PDF_EXTS = ['.pdf']
|
||||
const EXCEL_EXTS = ['.xls', '.xlsx', '.csv', '.xlsm']
|
||||
|
||||
const DEFAULT_DOCUMENTS = [
|
||||
{
|
||||
id: 'sample-pdf',
|
||||
title: '示例 PDF 文档',
|
||||
url: 'http://192.168.0.14:9090/smart-bid/technicalSolutionDatabase/2025/10/30/fe5b46ea37554516a71e7c0c486d3715.pdf',
|
||||
type: 'pdf'
|
||||
},
|
||||
{
|
||||
id: 'sample-excel',
|
||||
title: '示例 Excel 文档',
|
||||
url: 'http://192.168.0.14:9090/smart-bid/technicalSolutionDatabase/2025/11/12/928206a2fdc74c349781f441d9c010f8.xlsx',
|
||||
type: 'excel'
|
||||
},
|
||||
{
|
||||
id: 'sample-word',
|
||||
title: '示例 Word 文档',
|
||||
url: 'http://192.168.0.14:9090/smart-bid/technicalSolutionDatabase/2025/11/11/887b35d28b2149b6a7555fb639be9411.docx',
|
||||
type: 'word'
|
||||
}
|
||||
]
|
||||
|
||||
export default {
|
||||
name: 'DocumentPreviewPanel',
|
||||
components: {
|
||||
OnlyOfficeViewer
|
||||
DocumentSearch,
|
||||
DocumentSearchWord,
|
||||
DocumentExcel
|
||||
},
|
||||
props: {
|
||||
// 招标文件URL
|
||||
tenderDocumentUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 招标文件标题
|
||||
tenderDocumentTitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 招标文件Key
|
||||
tenderDocumentKey: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 标段文件URL
|
||||
bidDocumentUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 标段文件标题
|
||||
bidDocumentTitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 标段文件Key
|
||||
bidDocumentKey: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 查看模式
|
||||
viewMode: {
|
||||
type: String,
|
||||
default: 'view'
|
||||
},
|
||||
// 查看类型
|
||||
viewType: {
|
||||
type: String,
|
||||
default: 'desktop'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeDocType: 'tender', // 'tender' 或 'bid'
|
||||
showOnlyOffice: false
|
||||
documents: [],
|
||||
currentIndex: -1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
documentUrl() {
|
||||
return this.activeDocType === 'tender' ? this.tenderDocumentUrl : this.bidDocumentUrl
|
||||
},
|
||||
documentTitle() {
|
||||
return this.activeDocType === 'tender' ? this.tenderDocumentTitle : this.bidDocumentTitle
|
||||
},
|
||||
documentKey() {
|
||||
return this.activeDocType === 'tender' ? this.tenderDocumentKey : this.bidDocumentKey
|
||||
currentDocument() {
|
||||
return this.documents[this.currentIndex] || null
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.buildDocuments()
|
||||
},
|
||||
watch: {
|
||||
documentUrl(newVal) {
|
||||
if (newVal) {
|
||||
this.showOnlyOffice = true
|
||||
}
|
||||
tenderDocumentUrl() {
|
||||
this.buildDocuments()
|
||||
},
|
||||
bidDocumentUrl() {
|
||||
this.buildDocuments()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
switchDocument(type) {
|
||||
this.activeDocType = type
|
||||
this.showOnlyOffice = false
|
||||
this.$nextTick(() => {
|
||||
if (this.documentUrl) {
|
||||
this.showOnlyOffice = true
|
||||
}
|
||||
})
|
||||
this.$emit('document-switch', type)
|
||||
switchDocument(index) {
|
||||
if (index >= 0 && index < this.documents.length) {
|
||||
this.currentIndex = index
|
||||
const current = this.currentDocument
|
||||
this.$emit('document-switch', current?.id || 'none')
|
||||
}
|
||||
},
|
||||
handleSearch() {
|
||||
this.$emit('search')
|
||||
resolveViewerType(url, title) {
|
||||
const value = (url || title || '').toLowerCase()
|
||||
if (!value) {
|
||||
return 'none'
|
||||
}
|
||||
if (PDF_EXTS.some(ext => value.includes(ext))) {
|
||||
return 'pdf'
|
||||
}
|
||||
if (WORD_EXTS.some(ext => value.includes(ext))) {
|
||||
return 'word'
|
||||
}
|
||||
if (EXCEL_EXTS.some(ext => value.includes(ext))) {
|
||||
return 'excel'
|
||||
}
|
||||
return 'unsupported'
|
||||
},
|
||||
handleDocumentReady() {
|
||||
this.$emit('document-ready')
|
||||
},
|
||||
handleAppReady() {
|
||||
this.$emit('app-ready')
|
||||
},
|
||||
handleError(error) {
|
||||
this.$emit('error', error)
|
||||
buildDocuments() {
|
||||
const docs = []
|
||||
if (this.tenderDocumentUrl) {
|
||||
docs.push({
|
||||
id: 'tender',
|
||||
title: this.tenderDocumentTitle || '招标文件',
|
||||
url: this.tenderDocumentUrl,
|
||||
type: this.resolveViewerType(this.tenderDocumentUrl, this.tenderDocumentTitle)
|
||||
})
|
||||
}
|
||||
if (this.bidDocumentUrl) {
|
||||
docs.push({
|
||||
id: 'bid',
|
||||
title: this.bidDocumentTitle || '标段文件',
|
||||
url: this.bidDocumentUrl,
|
||||
type: this.resolveViewerType(this.bidDocumentUrl, this.bidDocumentTitle)
|
||||
})
|
||||
}
|
||||
if (!docs.length) {
|
||||
DEFAULT_DOCUMENTS.forEach(doc => docs.push({ ...doc }))
|
||||
}
|
||||
this.documents = docs
|
||||
this.currentIndex = docs.length ? 0 : -1
|
||||
const current = this.currentDocument
|
||||
this.$emit('document-switch', current?.id || 'none')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -166,68 +183,110 @@ export default {
|
|||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #EBEEF5;
|
||||
background: #fff;
|
||||
|
||||
.header-actions {
|
||||
.search-btn {
|
||||
padding: 8px;
|
||||
font-size: 18px;
|
||||
color: #606266;
|
||||
|
||||
&:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.document-switch {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.doc-btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.el-button--primary {
|
||||
background: #409EFF;
|
||||
border-color: #409EFF;
|
||||
}
|
||||
|
||||
&.el-button--default {
|
||||
background: #F5F7FA;
|
||||
border-color: #DCDFE6;
|
||||
color: #606266;
|
||||
|
||||
&:hover {
|
||||
background: #ECF5FF;
|
||||
border-color: #B3D8FF;
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.document-sidebar {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 80px;
|
||||
bottom: 16px;
|
||||
width: 140px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
|
||||
.document-item {
|
||||
padding: 7px 16px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #303133;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
color: #409EFF;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #409EFF;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.document-viewer-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.document-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
::v-deep .onlyoffice-container {
|
||||
.embedded-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
::v-deep .embedded-viewer .document-search,
|
||||
::v-deep .embedded-viewer .document-search-word,
|
||||
::v-deep .embedded-viewer .document-excel {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
::v-deep .embedded-viewer .viewer-container {
|
||||
min-height: auto;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,16 +10,16 @@
|
|||
<template slot="tableActions">
|
||||
<el-button @click="handleAdd" v-hasPermi="['analysis:analysis:add']" class="add-btn"><i
|
||||
class="el-icon-plus"></i> 新建项目</el-button>
|
||||
<el-button @click="handleOnlyOffice">预览文档</el-button>
|
||||
<!-- <el-button @click="handleOnlyOffice">预览文档</el-button>
|
||||
<el-button @click="handleDocumentSearch">文档搜索功能</el-button>
|
||||
<el-button @click="handleDocumentSearchWord">Word文档搜索功能</el-button>
|
||||
<el-button @click="handleDocumentExcel">Excel文档查看</el-button>
|
||||
<el-button @click="handleTestMQ">测试MQ</el-button>
|
||||
<el-button @click="handleTestMQ">测试MQ</el-button> -->
|
||||
</template>
|
||||
<template slot="analysisStatus" slot-scope="{ data }">
|
||||
<el-tag v-if="data.analysisStatus === 0" type="info">解析中</el-tag>
|
||||
<el-tag v-else-if="data.analysisStatus === 1" type="success">解析成功</el-tag>
|
||||
<el-tag v-else-if="data.analysisStatus === 2" type="danger">解析失败</el-tag>
|
||||
<el-tag v-if="data.analysisStatus === '0'" type="info">解析中</el-tag>
|
||||
<el-tag v-else-if="data.analysisStatus === '1'" type="success">解析成功</el-tag>
|
||||
<el-tag v-else-if="data.analysisStatus === '2'" type="danger">解析失败</el-tag>
|
||||
</template>
|
||||
<template slot="handle" slot-scope="{ data }">
|
||||
<el-button type="text" v-hasPermi="['enterpriseLibrary:analysis:detail']" class="action-btn"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ const DEFAULT_EXCEL_URL = 'http://192.168.0.14:9090/smart-bid/technicalSolutionD
|
|||
|
||||
export default {
|
||||
name: 'DocumentExcel',
|
||||
props: {
|
||||
fileUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
|
|
@ -43,20 +49,27 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
routeExcelUrl() {
|
||||
return this.$route.query.url || this.$route.params.url;
|
||||
return this.$route?.query?.url || this.$route?.params?.url || ''
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
fileUrl: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.applyExcelUrl(newVal)
|
||||
} else if (!this.excelUrl && !this.routeExcelUrl) {
|
||||
this.applyExcelUrl(DEFAULT_EXCEL_URL)
|
||||
}
|
||||
}
|
||||
},
|
||||
routeExcelUrl: {
|
||||
immediate: true,
|
||||
handler(newUrl) {
|
||||
if (newUrl) {
|
||||
this.excelUrl = newUrl;
|
||||
this.loadExcel();
|
||||
} else {
|
||||
// 如果没有传入URL,使用默认URL
|
||||
this.excelUrl = DEFAULT_EXCEL_URL;
|
||||
this.loadExcel();
|
||||
handler(newVal) {
|
||||
if (this.fileUrl) return
|
||||
const target = newVal || DEFAULT_EXCEL_URL
|
||||
if (target && target !== this.excelUrl) {
|
||||
this.applyExcelUrl(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -69,6 +82,17 @@ export default {
|
|||
this.destroyLuckySheet();
|
||||
},
|
||||
methods: {
|
||||
applyExcelUrl(url) {
|
||||
if (!url || url === this.excelUrl) {
|
||||
this.excelUrl = url
|
||||
if (url) {
|
||||
this.loadExcel()
|
||||
}
|
||||
return
|
||||
}
|
||||
this.excelUrl = url
|
||||
this.loadExcel()
|
||||
},
|
||||
// 加载 Excel 文件
|
||||
async loadExcel() {
|
||||
if (!this.excelUrl) {
|
||||
|
|
@ -329,7 +353,7 @@ export default {
|
|||
|
||||
<style scoped>
|
||||
.document-excel {
|
||||
height: 90vh;
|
||||
height: 100%;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,36 @@
|
|||
<template>
|
||||
<div class="document-search">
|
||||
<div class="search-toolbar">
|
||||
<el-input v-model="keyword" class="search-input" placeholder="输入关键字搜索" clearable
|
||||
@keyup.enter.native="handleSearch" @clear="resetSearch">
|
||||
<el-button slot="append" class="search-btn" icon="el-icon-search" @click="handleSearch"
|
||||
:loading="searching" :disabled="searching">
|
||||
<span v-if="!searching">搜索</span>
|
||||
<span v-else>搜索中...</span>
|
||||
</el-button>
|
||||
</el-input>
|
||||
<div class="search-status" v-if="searchResults.length">
|
||||
<span class="result-indicator">{{ currentResultIndex + 1 }}/{{ searchResults.length }}</span>
|
||||
<el-button class="nav-btn" type="text" icon="el-icon-arrow-up" :disabled="!searchResults.length"
|
||||
@click="goToPrevious">
|
||||
上一处
|
||||
</el-button>
|
||||
<el-button class="nav-btn" type="text" icon="el-icon-arrow-down" :disabled="!searchResults.length"
|
||||
@click="goToNext">
|
||||
下一处
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viewer-container">
|
||||
<transition name="slide-fade">
|
||||
<div v-if="showSearchBar" class="floating-search">
|
||||
<div class="search-toolbar" :class="{ 'is-searching': searching }">
|
||||
<div class="search-box">
|
||||
<el-input v-model="keyword" class="search-input" placeholder="输入关键字搜索" clearable
|
||||
ref="keywordInput" @keyup.enter.native="handleSearch" @clear="resetSearch">
|
||||
</el-input>
|
||||
<button class="icon-btn search-icon" :disabled="searching" @click="handleSearch">
|
||||
<i class="el-icon-search" v-if="!searching"></i>
|
||||
<i class="el-icon-loading" v-else></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-status">
|
||||
<span class="result-indicator">{{ resultDisplay }}</span>
|
||||
<button class="icon-btn" :disabled="!searchResults.length" @click="goToPrevious">
|
||||
<i class="el-icon-arrow-up"></i>
|
||||
</button>
|
||||
<button class="icon-btn" :disabled="!searchResults.length" @click="goToNext">
|
||||
<i class="el-icon-arrow-down"></i>
|
||||
</button>
|
||||
<button class="icon-btn" @click="handleCloseSearch">
|
||||
<i class="el-icon-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<button v-if="!showSearchBar" class="floating-search-btn" @click="toggleSearchBar">
|
||||
<i class="el-icon-search"></i>
|
||||
</button>
|
||||
<div ref="pdfWrapper" class="pdf-wrapper"></div>
|
||||
|
||||
<transition name="fade">
|
||||
|
|
@ -65,10 +73,17 @@ if (resolvedWorkerSrc) {
|
|||
pdfjsLib.GlobalWorkerOptions.workerSrc = null
|
||||
}
|
||||
|
||||
const DEFAULT_SCALE = 1
|
||||
const DEFAULT_SCALE = 1.1
|
||||
const DEFAULT_PDF_URL = 'http://192.168.0.14:9090/smart-bid/technicalSolutionDatabase/2025/10/30/fe5b46ea37554516a71e7c0c486d3715.pdf'
|
||||
|
||||
export default {
|
||||
name: 'DocumentSearch',
|
||||
props: {
|
||||
fileUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
overlayType() {
|
||||
if (this.loading) {
|
||||
|
|
@ -82,6 +97,12 @@ export default {
|
|||
}
|
||||
return null
|
||||
},
|
||||
resultDisplay() {
|
||||
const total = this.searchResults.length
|
||||
if (!total) return '0/0'
|
||||
const current = this.currentResultIndex >= 0 ? this.currentResultIndex + 1 : 0
|
||||
return `${current}/${total}`
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -113,6 +134,7 @@ export default {
|
|||
pdfAssetsBase: '',
|
||||
searching: false,
|
||||
scrollAnimationFrame: null,
|
||||
showSearchBar: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
|
@ -135,9 +157,9 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
this.pdfUrl = this.$route.query.url || 'http://192.168.0.14:9090/smart-bid/technicalSolutionDatabase/2025/10/30/fe5b46ea37554516a71e7c0c486d3715.pdf'
|
||||
if (this.pdfUrl) {
|
||||
this.loadDocument()
|
||||
if (!this.pdfUrl) {
|
||||
const initialUrl = this.fileUrl || this.$route?.query?.url || DEFAULT_PDF_URL
|
||||
this.applyPdfUrl(initialUrl)
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
|
|
@ -145,17 +167,51 @@ export default {
|
|||
this.cancelScrollAnimation()
|
||||
},
|
||||
watch: {
|
||||
'$route.query.url'(newUrl, oldUrl) {
|
||||
if (newUrl !== oldUrl) {
|
||||
this.pdfUrl = newUrl || ''
|
||||
this.resetViewerState()
|
||||
if (this.pdfUrl) {
|
||||
this.loadDocument()
|
||||
fileUrl: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.applyPdfUrl(newVal)
|
||||
}
|
||||
}
|
||||
},
|
||||
'$route.query.url'(newUrl, oldUrl) {
|
||||
if (this.fileUrl) return
|
||||
if (newUrl !== oldUrl) {
|
||||
const target = newUrl || DEFAULT_PDF_URL
|
||||
this.applyPdfUrl(target)
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleSearchBar() {
|
||||
this.showSearchBar = true
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.keywordInput) {
|
||||
this.$refs.keywordInput.focus()
|
||||
}
|
||||
})
|
||||
},
|
||||
handleCloseSearch() {
|
||||
this.keyword = ''
|
||||
this.resetSearch()
|
||||
this.showSearchBar = false
|
||||
},
|
||||
applyPdfUrl(url) {
|
||||
const resolved = url || ''
|
||||
if (resolved === this.pdfUrl) {
|
||||
if (resolved) {
|
||||
this.loadDocument()
|
||||
}
|
||||
return
|
||||
}
|
||||
this.pdfUrl = resolved
|
||||
if (this.pdfUrl) {
|
||||
this.loadDocument()
|
||||
} else {
|
||||
this.resetViewerState()
|
||||
}
|
||||
},
|
||||
async loadDocument() {
|
||||
if (!this.pdfUrl) return
|
||||
this.loading = true
|
||||
|
|
@ -212,6 +268,16 @@ export default {
|
|||
this.isPrefetching = false
|
||||
this.initialPreloadedCount = 0
|
||||
|
||||
// 预先获取第一页的 viewport,用于设置所有容器的初始尺寸
|
||||
let defaultViewport = null
|
||||
try {
|
||||
const firstPage = await this.pdfDoc.getPage(1)
|
||||
defaultViewport = firstPage.getViewport({ scale: this.scale })
|
||||
this.pageCache.set(1, firstPage)
|
||||
} catch (error) {
|
||||
console.warn('获取第一页 viewport 失败', error)
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment()
|
||||
for (let pageNumber = 1; pageNumber <= this.totalPages; pageNumber += 1) {
|
||||
const placeholder = document.createElement('div')
|
||||
|
|
@ -220,10 +286,24 @@ export default {
|
|||
placeholder.style.position = 'relative';
|
||||
placeholder.dataset.page = pageNumber
|
||||
placeholder.dataset.status = 'placeholder'
|
||||
|
||||
// 如果获取到了默认 viewport,预先设置容器尺寸,避免滚动时修改
|
||||
if (defaultViewport) {
|
||||
placeholder.style.width = `${defaultViewport.width}px`
|
||||
placeholder.style.height = `${defaultViewport.height}px`
|
||||
placeholder.style.backgroundColor = '#fff'
|
||||
}
|
||||
|
||||
fragment.appendChild(placeholder)
|
||||
this.pageContainers.push(placeholder)
|
||||
}
|
||||
container.appendChild(fragment)
|
||||
|
||||
// 设置 wrapper 的最小宽度
|
||||
if (defaultViewport && this.$refs.pdfWrapper) {
|
||||
this.$refs.pdfWrapper.style.minWidth = `${Math.ceil(defaultViewport.width)}px`
|
||||
}
|
||||
|
||||
container.scrollTop = 0
|
||||
|
||||
this.setupIntersectionObserver()
|
||||
|
|
@ -233,11 +313,17 @@ export default {
|
|||
this.isDocumentReady = true
|
||||
},
|
||||
|
||||
ensureContainerDimensions(pageNumber, viewport) {
|
||||
ensureContainerDimensions(pageNumber, viewport, force = false) {
|
||||
const index = pageNumber - 1
|
||||
const container = this.pageContainers[index]
|
||||
if (!container) return
|
||||
|
||||
// 如果容器已经有尺寸且不是强制更新,则跳过,避免滚动时修改尺寸导致滚动位置跳动
|
||||
const hasDimensions = container.style.width && container.style.height && container.style.width !== '0px' && container.style.height !== '0px'
|
||||
if (hasDimensions && !force && container.dataset.status !== 'placeholder') {
|
||||
return
|
||||
}
|
||||
|
||||
container.style.width = `${viewport.width}px`
|
||||
container.style.height = `${viewport.height}px`
|
||||
container.style.margin = '0px auto 10px'
|
||||
|
|
@ -249,10 +335,6 @@ export default {
|
|||
if (container.dataset.status !== 'rendered') {
|
||||
container.classList.add('prefetched')
|
||||
}
|
||||
|
||||
if (this.$refs.pdfWrapper && !this.$refs.pdfWrapper.style.minWidth) {
|
||||
this.$refs.pdfWrapper.style.minWidth = `${Math.ceil(viewport.width)}px`
|
||||
}
|
||||
},
|
||||
|
||||
async ensurePageCached(pageNumber) {
|
||||
|
|
@ -274,13 +356,19 @@ export default {
|
|||
if (!container) return null
|
||||
|
||||
const viewport = page.getViewport({ scale: this.scale })
|
||||
this.ensureContainerDimensions(pageNumber, viewport)
|
||||
container.classList.add('is-loading')
|
||||
|
||||
// 渲染时允许更新尺寸(如果 viewport 与预设不同)
|
||||
this.ensureContainerDimensions(pageNumber, viewport, true)
|
||||
|
||||
// 先移除旧 canvas(如果存在)
|
||||
const oldCanvas = container.querySelector('.pdf-canvas')
|
||||
if (oldCanvas) {
|
||||
oldCanvas.remove()
|
||||
}
|
||||
|
||||
// 添加 is-loading 类并显示 loading
|
||||
container.classList.add('is-loading')
|
||||
// 确保显示 loading 图标
|
||||
this.showPageLoading(container)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.className = 'pdf-canvas'
|
||||
|
|
@ -318,6 +406,8 @@ export default {
|
|||
|
||||
this.renderedPages.set(pageNumber, { container, viewport })
|
||||
container.classList.remove('is-loading')
|
||||
// canvas 渲染完成,隐藏 loading
|
||||
this.hidePageLoading(container)
|
||||
return { page, viewport, container }
|
||||
},
|
||||
|
||||
|
|
@ -388,6 +478,8 @@ export default {
|
|||
const container = this.pageContainers[pageNumber - 1]
|
||||
if (container) {
|
||||
container.classList.add('is-loading', 'is-loading-text')
|
||||
// 显示 loading
|
||||
this.showPageLoading(container)
|
||||
}
|
||||
try {
|
||||
await this.renderTextLayer(pageNumber, { visible: true, force: true })
|
||||
|
|
@ -402,6 +494,13 @@ export default {
|
|||
scheduleRender(pageNumber, { priority = false } = {}) {
|
||||
if (!pageNumber || pageNumber > this.totalPages || this.renderedPages.has(pageNumber)) return
|
||||
if (this.renderQueue.includes(pageNumber)) return
|
||||
|
||||
// 在加入渲染队列时显示 loading
|
||||
const container = this.pageContainers[pageNumber - 1]
|
||||
if (container && !container.querySelector('.pdf-canvas')) {
|
||||
this.showPageLoading(container)
|
||||
}
|
||||
|
||||
if (priority) {
|
||||
this.renderQueue.unshift(pageNumber)
|
||||
} else {
|
||||
|
|
@ -416,7 +515,16 @@ export default {
|
|||
try {
|
||||
while (this.renderQueue.length) {
|
||||
const pageNumber = this.renderQueue.shift()
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||||
// 使用 requestIdleCallback 优化性能,在浏览器空闲时渲染
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
await new Promise((resolve) => {
|
||||
window.requestIdleCallback(() => {
|
||||
requestAnimationFrame(resolve)
|
||||
}, { timeout: 100 })
|
||||
})
|
||||
} else {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||||
}
|
||||
await this.renderSinglePage(pageNumber)
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -441,8 +549,8 @@ export default {
|
|||
try {
|
||||
const page = await this.pdfDoc.getPage(pageNumber)
|
||||
this.pageCache.set(pageNumber, page)
|
||||
const viewport = page.getViewport({ scale: this.scale })
|
||||
this.ensureContainerDimensions(pageNumber, viewport)
|
||||
// 预取时只缓存页面对象,不修改容器尺寸,避免滚动时影响滚动位置
|
||||
// 容器尺寸会在实际渲染时通过 ensureContainerDimensions 设置
|
||||
} catch (error) {
|
||||
console.warn(`预取第 ${pageNumber} 页失败`, error)
|
||||
}
|
||||
|
|
@ -452,9 +560,11 @@ export default {
|
|||
const schedule = () => {
|
||||
this.prefetchScheduled = true
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
this.prefetchHandle = window.requestIdleCallback(fetchNext, { timeout: 1000 })
|
||||
// 增加超时时间,减少对滚动的影响
|
||||
this.prefetchHandle = window.requestIdleCallback(fetchNext, { timeout: 2000 })
|
||||
} else {
|
||||
this.prefetchHandle = window.setTimeout(fetchNext, 80)
|
||||
// 增加延迟,减少对滚动的影响
|
||||
this.prefetchHandle = window.setTimeout(fetchNext, 150)
|
||||
}
|
||||
}
|
||||
schedule()
|
||||
|
|
@ -466,10 +576,10 @@ export default {
|
|||
this.isPrefetching = true
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
this.prefetchScheduled = true
|
||||
this.prefetchHandle = window.requestIdleCallback(fetchNext, { timeout: 800 })
|
||||
this.prefetchHandle = window.requestIdleCallback(fetchNext, { timeout: 1500 })
|
||||
} else {
|
||||
this.prefetchScheduled = true
|
||||
this.prefetchHandle = window.setTimeout(fetchNext, 120)
|
||||
this.prefetchHandle = window.setTimeout(fetchNext, 200)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -525,16 +635,24 @@ export default {
|
|||
container.classList.add('prefetched')
|
||||
},
|
||||
|
||||
handleSearch: debounce(async function () {
|
||||
handleSearch() {
|
||||
// 点击时立即显示 loading 状态
|
||||
this.searching = true
|
||||
// 调用防抖的搜索函数
|
||||
this._handleSearchDebounced()
|
||||
},
|
||||
_handleSearchDebounced: debounce(async function () {
|
||||
const keyword = this.keyword.trim()
|
||||
if (!keyword) {
|
||||
this.resetSearch()
|
||||
this.searching = false
|
||||
return
|
||||
}
|
||||
if (!this.pdfDoc) {
|
||||
this.searching = false
|
||||
return
|
||||
}
|
||||
if (!this.pdfDoc) return
|
||||
|
||||
// if (this.searching) return
|
||||
this.searching = true
|
||||
await this.$nextTick()
|
||||
try {
|
||||
const allPrepared = this.pageTextDivs.length === this.totalPages && this.pageTextDivs.every(items => items && items.length)
|
||||
|
|
@ -546,7 +664,6 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
await this.highlightMatches()
|
||||
} finally {
|
||||
this.searching = false
|
||||
|
|
@ -761,6 +878,9 @@ export default {
|
|||
|
||||
this.currentResultIndex = index
|
||||
this.scheduleActiveHighlightRefresh()
|
||||
|
||||
// 等待高亮更新完成
|
||||
await this.$nextTick()
|
||||
|
||||
const performScroll = () => {
|
||||
const target = this.searchResults[this.currentResultIndex]?.element
|
||||
|
|
@ -769,22 +889,84 @@ export default {
|
|||
if (!wrapper) return
|
||||
const container = target.closest('.pdf-page') || target
|
||||
if (!container) return
|
||||
|
||||
// 手动计算位置并滚动,确保目标元素在视口中可见
|
||||
if (!useSmoothScroll) {
|
||||
// 获取目标元素和容器的位置信息
|
||||
const targetRect = target.getBoundingClientRect()
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
|
||||
// 计算目标元素相对于 wrapper 的绝对位置
|
||||
const targetTop = container.offsetTop + (targetRect.top - containerRect.top)
|
||||
const targetLeft = container.offsetLeft + (targetRect.left - containerRect.left)
|
||||
const targetHeight = targetRect.height || 20
|
||||
const targetWidth = targetRect.width || 50
|
||||
|
||||
// 计算视口尺寸和边距
|
||||
const viewportHeight = wrapper.clientHeight
|
||||
const viewportWidth = wrapper.clientWidth
|
||||
const verticalPadding = 80 // 上下留出空间
|
||||
const horizontalPadding = 40 // 左右留出空间
|
||||
|
||||
// 垂直滚动:确保目标元素在视口中
|
||||
let scrollTop = wrapper.scrollTop
|
||||
const targetTopInViewport = targetTop - wrapper.scrollTop
|
||||
|
||||
if (targetTopInViewport < verticalPadding) {
|
||||
// 目标元素在视口上方,向上滚动
|
||||
scrollTop = targetTop - verticalPadding
|
||||
} else if (targetTopInViewport + targetHeight > viewportHeight - verticalPadding) {
|
||||
// 目标元素在视口下方,向下滚动
|
||||
scrollTop = targetTop + targetHeight - viewportHeight + verticalPadding
|
||||
}
|
||||
|
||||
// 水平滚动:确保目标元素在视口中(如果 canvas 很宽)
|
||||
let scrollLeft = wrapper.scrollLeft
|
||||
const targetLeftInViewport = targetLeft - wrapper.scrollLeft
|
||||
|
||||
if (targetLeftInViewport < horizontalPadding) {
|
||||
// 目标元素在视口左侧,向左滚动
|
||||
scrollLeft = targetLeft - horizontalPadding
|
||||
} else if (targetLeftInViewport + targetWidth > viewportWidth - horizontalPadding) {
|
||||
// 目标元素在视口右侧,向右滚动
|
||||
scrollLeft = targetLeft + targetWidth - viewportWidth + horizontalPadding
|
||||
}
|
||||
|
||||
// 直接跳转到目标位置
|
||||
this.cancelScrollAnimation()
|
||||
wrapper.scrollTop = Math.max(0, Math.min(scrollTop, wrapper.scrollHeight - viewportHeight))
|
||||
wrapper.scrollLeft = Math.max(0, Math.min(scrollLeft, wrapper.scrollWidth - viewportWidth))
|
||||
return
|
||||
}
|
||||
|
||||
// 平滑滚动
|
||||
const wrapperOffsetTop = container.offsetTop
|
||||
const containerHeight = container.offsetHeight || target.offsetHeight || 0
|
||||
const desired = wrapperOffsetTop - Math.max((wrapper.clientHeight - containerHeight) / 2, 0)
|
||||
if (useSmoothScroll) {
|
||||
this.smoothScrollTo(wrapper, desired)
|
||||
} else {
|
||||
this.cancelScrollAnimation()
|
||||
wrapper.scrollTop = desired
|
||||
}
|
||||
this.smoothScrollTo(wrapper, desired)
|
||||
}
|
||||
|
||||
if (useSmoothScroll) {
|
||||
this.$nextTick(() => performScroll())
|
||||
} else {
|
||||
performScroll()
|
||||
}
|
||||
// 等待 DOM 更新和元素渲染完成后再滚动
|
||||
await this.$nextTick()
|
||||
// 使用双重 requestAnimationFrame 确保元素位置已完全计算
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
// 再次检查元素是否存在(可能在渲染过程中被更新)
|
||||
const currentTarget = this.searchResults[this.currentResultIndex]?.element
|
||||
if (currentTarget && currentTarget.isConnected) {
|
||||
performScroll()
|
||||
} else {
|
||||
// 如果元素不存在,尝试从 elements 数组中获取
|
||||
const result = this.searchResults[this.currentResultIndex]
|
||||
if (result && result.elements && result.elements.length > 0) {
|
||||
// 更新 element 引用
|
||||
result.element = result.elements[0]
|
||||
performScroll()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
applyHighlightsToPage(pageIndex) {
|
||||
|
|
@ -1040,15 +1222,28 @@ export default {
|
|||
|
||||
const options = {
|
||||
root: this.$refs.pdfWrapper,
|
||||
rootMargin: '120px 0px',
|
||||
threshold: 0.01,
|
||||
rootMargin: '200px 0px', // 增加预加载范围,减少滚动时的加载延迟
|
||||
threshold: [0, 0.1, 0.5, 1], // 使用多个阈值,更精确地检测可见性
|
||||
}
|
||||
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
// 使用 requestAnimationFrame 批量处理 IntersectionObserver 回调,提升性能
|
||||
let rafScheduled = false
|
||||
const pendingEntries = []
|
||||
|
||||
const processEntries = () => {
|
||||
rafScheduled = false
|
||||
const entries = [...pendingEntries]
|
||||
pendingEntries.length = 0
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const page = Number(entry.target.dataset.page)
|
||||
if (page) {
|
||||
const container = entry.target
|
||||
// 如果页面还未渲染,显示 loading
|
||||
if (!this.renderedPages.has(page) && !container.querySelector('.pdf-canvas')) {
|
||||
this.showPageLoading(container)
|
||||
}
|
||||
this.renderTextLayer(page, { visible: true, force: true })
|
||||
this.scheduleRender(page, { priority: true })
|
||||
}
|
||||
|
|
@ -1059,6 +1254,14 @@ export default {
|
|||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
pendingEntries.push(...entries)
|
||||
if (!rafScheduled) {
|
||||
rafScheduled = true
|
||||
requestAnimationFrame(processEntries)
|
||||
}
|
||||
}, options)
|
||||
|
||||
this.pageContainers.forEach((container) => this.observer.observe(container))
|
||||
|
|
@ -1083,6 +1286,127 @@ export default {
|
|||
})
|
||||
},
|
||||
|
||||
showPageLoading(container) {
|
||||
if (!container) return
|
||||
const pageNumber = Number(container.dataset.page)
|
||||
|
||||
// 如果页面已经渲染完成,不显示 loading
|
||||
if (pageNumber && this.renderedPages.has(pageNumber)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已经有 canvas,如果有则不显示 loading(表示已经渲染完成)
|
||||
const existingCanvas = container.querySelector('.pdf-canvas')
|
||||
if (existingCanvas) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经有 loading 在显示,不重复创建
|
||||
const existingLoading = container.querySelector('.page-loading-wrapper')
|
||||
if (existingLoading && getComputedStyle(existingLoading).display !== 'none') {
|
||||
return
|
||||
}
|
||||
|
||||
// 确保容器有尺寸,如果没有则设置最小高度
|
||||
const containerHeight = container.offsetHeight || parseInt(container.style.height) || 0
|
||||
const containerWidth = container.offsetWidth || parseInt(container.style.width) || 0
|
||||
|
||||
if (containerHeight < 100 || containerWidth < 100) {
|
||||
// 尝试从缓存中获取 viewport 来设置尺寸
|
||||
if (this.pageCache.has(pageNumber)) {
|
||||
try {
|
||||
const page = this.pageCache.get(pageNumber)
|
||||
const viewport = page.getViewport({ scale: this.scale })
|
||||
container.style.width = `${viewport.width}px`
|
||||
container.style.height = `${viewport.height}px`
|
||||
} catch (e) {
|
||||
// 如果获取失败,设置最小尺寸
|
||||
if (!container.style.width) container.style.width = '800px'
|
||||
if (!container.style.height) container.style.minHeight = '1000px'
|
||||
}
|
||||
} else {
|
||||
// 如果没有缓存,设置默认尺寸
|
||||
if (!container.style.width) container.style.width = '800px'
|
||||
if (!container.style.height) container.style.minHeight = '1000px'
|
||||
}
|
||||
}
|
||||
|
||||
// 确保容器是 relative 定位
|
||||
if (getComputedStyle(container).position === 'static') {
|
||||
container.style.position = 'relative'
|
||||
}
|
||||
|
||||
// 创建或获取 loading 容器和图标
|
||||
let loadingWrapper = container.querySelector('.page-loading-wrapper')
|
||||
if (!loadingWrapper) {
|
||||
loadingWrapper = document.createElement('div')
|
||||
loadingWrapper.className = 'page-loading-wrapper'
|
||||
|
||||
const loadingIcon = document.createElement('i')
|
||||
loadingIcon.className = 'el-icon-loading page-loading-icon'
|
||||
loadingWrapper.appendChild(loadingIcon)
|
||||
|
||||
container.appendChild(loadingWrapper)
|
||||
} else {
|
||||
// 如果已存在,确保图标也存在
|
||||
let loadingIcon = loadingWrapper.querySelector('.page-loading-icon')
|
||||
if (!loadingIcon) {
|
||||
loadingIcon = document.createElement('i')
|
||||
loadingIcon.className = 'el-icon-loading page-loading-icon'
|
||||
loadingWrapper.appendChild(loadingIcon)
|
||||
}
|
||||
}
|
||||
|
||||
// 强制设置所有样式,确保显示 - 简单的灰色旋转图标
|
||||
loadingWrapper.style.cssText = `
|
||||
position: absolute !important;
|
||||
top: 33% !important;
|
||||
left: 50% !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
z-index: 1000 !important;
|
||||
display: flex !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
pointer-events: none !important;
|
||||
`
|
||||
|
||||
// 确保图标样式和动画启动
|
||||
const icon = loadingWrapper.querySelector('.page-loading-icon')
|
||||
if (icon) {
|
||||
icon.style.cssText = `
|
||||
font-size: 24px !important;
|
||||
color: #909399 !important;
|
||||
display: block !important;
|
||||
animation: pdf-page-spin 1s linear infinite !important;
|
||||
`
|
||||
}
|
||||
|
||||
// 使用 requestAnimationFrame 确保动画能正常启动
|
||||
this.$nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (loadingWrapper && loadingWrapper.parentNode) {
|
||||
// 触发重排,确保动画开始
|
||||
void loadingWrapper.offsetHeight
|
||||
// 确保图标动画也启动
|
||||
if (icon) {
|
||||
// 强制重新应用动画
|
||||
icon.style.animation = 'none'
|
||||
void icon.offsetHeight
|
||||
icon.style.setProperty('animation', 'pdf-page-spin 1s linear infinite', 'important')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
hidePageLoading(container) {
|
||||
if (!container) return
|
||||
const loadingWrapper = container.querySelector('.page-loading-wrapper')
|
||||
if (loadingWrapper) {
|
||||
loadingWrapper.style.setProperty('display', 'none', 'important')
|
||||
}
|
||||
},
|
||||
disconnectObserver() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect()
|
||||
|
|
@ -1103,7 +1427,7 @@ export default {
|
|||
height: calc(100vh - 84px);
|
||||
overflow: hidden;
|
||||
background: #f4f7ff;
|
||||
padding: 16px;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
|
|
@ -1113,38 +1437,171 @@ export default {
|
|||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.floating-search {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: auto;
|
||||
max-width: calc(100% - 40px);
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.floating-search-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #2b68ff;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10px 26px rgba(31, 114, 234, 0.25);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.floating-search-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 30px rgba(31, 114, 234, 0.3);
|
||||
}
|
||||
|
||||
.search-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
box-shadow: 0 8px 26px rgba(31, 114, 234, 0.18);
|
||||
border: 1px solid rgba(226, 231, 239, 0.8);
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-toolbar > * {
|
||||
margin-left: 0;
|
||||
.search-toolbar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-toolbar.is-searching::after {
|
||||
border-color: rgba(43, 104, 255, 0.4);
|
||||
animation: search-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes search-pulse {
|
||||
0% {
|
||||
opacity: 0.25;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-fade-enter-active,
|
||||
.slide-fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.slide-fade-enter,
|
||||
.slide-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e7f0;
|
||||
border-radius: 999px;
|
||||
padding: 0 6px 0 10px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 0 0 360px;
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.search-input ::v-deep .el-input__inner {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
height: 30px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-input ::v-deep .el-input__suffix,
|
||||
.search-input ::v-deep .el-input__prefix {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6c7388;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: rgba(64, 158, 255, 0.12);
|
||||
color: #2b68ff;
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
background: #2b68ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.search-icon:hover {
|
||||
background: #1d4fd8;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.search-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #506dff;
|
||||
gap: 6px;
|
||||
color: #4f5875;
|
||||
font-size: 12px;
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid #e4e8f2;
|
||||
}
|
||||
|
||||
.result-indicator {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0 8px;
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.viewer-container {
|
||||
|
|
@ -1161,13 +1618,14 @@ export default {
|
|||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
// background: linear-gradient(180deg, #eef3ff 0%, #ffffff 100%);
|
||||
background: #eaeaea;
|
||||
position: relative;
|
||||
scroll-behavior: smooth;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overflow-x: auto;
|
||||
/* 性能优化 */
|
||||
will-change: scroll-position;
|
||||
}
|
||||
|
||||
.pdf-page {
|
||||
|
|
@ -1175,10 +1633,11 @@ export default {
|
|||
margin: 0px auto 10px !important;
|
||||
box-shadow: 0 10px 30px rgba(25, 64, 158, 0.12);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
background: #ffffff;
|
||||
display: block;
|
||||
will-change: transform, opacity;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.pdf-page:first-of-type::before,
|
||||
|
|
@ -1234,10 +1693,43 @@ export default {
|
|||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.page-loading-wrapper {
|
||||
position: absolute;
|
||||
top: 33%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 100;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-loading-icon {
|
||||
font-size: 24px;
|
||||
color: #909399;
|
||||
animation: pdf-page-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.pdf-page.placeholder .page-loading-wrapper,
|
||||
.pdf-page.prefetched .page-loading-wrapper,
|
||||
.pdf-page.is-loading .page-loading-wrapper,
|
||||
.pdf-page[data-status="placeholder"] .page-loading-wrapper,
|
||||
.pdf-page[data-status="prefetched"] .page-loading-wrapper,
|
||||
.pdf-page[data-status="text-ready"] .page-loading-wrapper,
|
||||
.pdf-page .page-loading-wrapper[style*="display: flex"] {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.pdf-canvas {
|
||||
width: 100%;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
/* 性能优化 */
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: crisp-edges;
|
||||
will-change: contents;
|
||||
}
|
||||
|
||||
.textLayer {
|
||||
|
|
@ -1249,6 +1741,8 @@ export default {
|
|||
pointer-events: auto;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
/* 性能优化 */
|
||||
will-change: contents;
|
||||
}
|
||||
|
||||
.textLayer>span {
|
||||
|
|
|
|||
|
|
@ -1,28 +1,36 @@
|
|||
<template>
|
||||
<div class="document-search-word">
|
||||
<div class="search-toolbar">
|
||||
<el-input v-model="keyword" class="search-input" placeholder="请输入关键字" clearable
|
||||
@keyup.enter.native="handleSearch" @clear="resetSearch">
|
||||
<el-button slot="append" class="search-btn" icon="el-icon-search" @click="handleSearch"
|
||||
:loading="searching" :disabled="searching">
|
||||
<span v-if="!searching">搜索</span>
|
||||
<span v-else>搜索中...</span>
|
||||
</el-button>
|
||||
</el-input>
|
||||
<div class="search-status" v-if="searchResults.length">
|
||||
<span class="result-indicator">{{ currentResultIndex + 1 }}/{{ searchResults.length }}</span>
|
||||
<el-button class="nav-btn" type="text" icon="el-icon-arrow-up" :disabled="!searchResults.length"
|
||||
@click="goToPrevious">
|
||||
上一处
|
||||
</el-button>
|
||||
<el-button class="nav-btn" type="text" icon="el-icon-arrow-down" :disabled="!searchResults.length"
|
||||
@click="goToNext">
|
||||
下一处
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viewer-container">
|
||||
<transition name="slide-fade">
|
||||
<div v-if="showSearchBar" class="floating-search">
|
||||
<div class="search-toolbar" :class="{ 'is-searching': searching }">
|
||||
<div class="search-box">
|
||||
<el-input v-model="keyword" class="search-input" placeholder="输入关键字搜索" clearable
|
||||
ref="keywordInput" @keyup.enter.native="handleSearch" @clear="resetSearch">
|
||||
</el-input>
|
||||
<button class="icon-btn search-icon" :disabled="searching" @click="handleSearch">
|
||||
<i class="el-icon-search" v-if="!searching"></i>
|
||||
<i class="el-icon-loading" v-else></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-status">
|
||||
<span class="result-indicator">{{ resultDisplay }}</span>
|
||||
<button class="icon-btn" :disabled="!searchResults.length" @click="goToPrevious">
|
||||
<i class="el-icon-arrow-up"></i>
|
||||
</button>
|
||||
<button class="icon-btn" :disabled="!searchResults.length" @click="goToNext">
|
||||
<i class="el-icon-arrow-down"></i>
|
||||
</button>
|
||||
<button class="icon-btn" @click="handleCloseSearch">
|
||||
<i class="el-icon-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<button v-if="!showSearchBar" class="floating-search-btn" @click="toggleSearchBar">
|
||||
<i class="el-icon-search"></i>
|
||||
</button>
|
||||
<div ref="docWrapper" class="doc-wrapper">
|
||||
<div ref="docxContainer" class="doc-content"></div>
|
||||
</div>
|
||||
|
|
@ -53,6 +61,20 @@ const DEFAULT_DOC_URL = 'http://192.168.0.14:9090/smart-bid/technicalSolutionDat
|
|||
|
||||
export default {
|
||||
name: 'DocumentSearchWord',
|
||||
props: {
|
||||
fileUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
resultDisplay() {
|
||||
const total = this.searchResults.length
|
||||
if (!total) return '0/0'
|
||||
const current = this.currentResultIndex >= 0 ? this.currentResultIndex + 1 : 0
|
||||
return `${current}/${total}`
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
docUrl: '',
|
||||
|
|
@ -66,11 +88,16 @@ export default {
|
|||
docRendered: false,
|
||||
abortController: null,
|
||||
scrollAnimationFrame: null,
|
||||
showSearchBar: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.docUrl = this.$route.query.url || DEFAULT_DOC_URL
|
||||
if (this.docUrl) {
|
||||
// 优先使用 fileUrl prop,然后是路由参数,最后是默认 URL
|
||||
const initialUrl = this.fileUrl || this.$route?.query?.url || DEFAULT_DOC_URL
|
||||
if (initialUrl && initialUrl !== this.docUrl) {
|
||||
this.applyDocUrl(initialUrl)
|
||||
} else if (this.docUrl && !this.docRendered) {
|
||||
// 如果 docUrl 已设置但文档未渲染,重新加载
|
||||
this.loadDocument()
|
||||
}
|
||||
},
|
||||
|
|
@ -79,17 +106,62 @@ export default {
|
|||
this.cancelScrollAnimation()
|
||||
},
|
||||
watch: {
|
||||
'$route.query.url'(newUrl, oldUrl) {
|
||||
if (newUrl !== oldUrl) {
|
||||
this.docUrl = newUrl || DEFAULT_DOC_URL
|
||||
this.resetViewerState()
|
||||
if (this.docUrl) {
|
||||
this.loadDocument()
|
||||
fileUrl: {
|
||||
immediate: true,
|
||||
handler(newVal, oldVal) {
|
||||
// 当 fileUrl 变化时,总是重新加载文档
|
||||
if (newVal && newVal !== oldVal) {
|
||||
this.applyDocUrl(newVal)
|
||||
} else if (newVal && !this.docUrl) {
|
||||
// 如果新值存在但 docUrl 为空,也加载
|
||||
this.applyDocUrl(newVal)
|
||||
} else if (!newVal && this.docUrl) {
|
||||
// 如果新值为空但 docUrl 有值,重置状态
|
||||
this.resetViewerState()
|
||||
}
|
||||
}
|
||||
},
|
||||
'$route.query.url'(newUrl, oldUrl) {
|
||||
if (this.fileUrl) return
|
||||
if (newUrl !== oldUrl) {
|
||||
const target = newUrl || DEFAULT_DOC_URL
|
||||
this.applyDocUrl(target)
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleSearchBar() {
|
||||
this.showSearchBar = true
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.keywordInput) {
|
||||
this.$refs.keywordInput.focus()
|
||||
}
|
||||
})
|
||||
},
|
||||
handleCloseSearch() {
|
||||
this.keyword = ''
|
||||
this.resetSearch()
|
||||
this.showSearchBar = false
|
||||
},
|
||||
applyDocUrl(url) {
|
||||
const resolved = url || ''
|
||||
// 如果 URL 相同且文档已渲染,不重复加载
|
||||
if (resolved === this.docUrl && this.docRendered) {
|
||||
return
|
||||
}
|
||||
// 如果 URL 变化,先取消之前的请求和重置状态
|
||||
if (resolved !== this.docUrl) {
|
||||
this.cancelFetch()
|
||||
this.cancelScrollAnimation()
|
||||
this.resetViewerState()
|
||||
}
|
||||
this.docUrl = resolved
|
||||
if (this.docUrl) {
|
||||
this.loadDocument()
|
||||
} else {
|
||||
this.resetViewerState()
|
||||
}
|
||||
},
|
||||
cancelFetch() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort()
|
||||
|
|
@ -270,10 +342,18 @@ export default {
|
|||
if (!this.docRendered) {
|
||||
this.prepareSearchSegments()
|
||||
}
|
||||
// 立即设置 searching 状态,确保动画能够显示
|
||||
this.searching = true
|
||||
// 使用 requestAnimationFrame 确保 DOM 更新后再执行搜索
|
||||
await this.$nextTick()
|
||||
await new Promise(resolve => requestAnimationFrame(() => {
|
||||
requestAnimationFrame(resolve)
|
||||
}))
|
||||
try {
|
||||
this.highlightMatches()
|
||||
// 执行搜索,确保有足够时间显示动画
|
||||
const searchPromise = Promise.resolve(this.highlightMatches())
|
||||
const minDelayPromise = new Promise(resolve => setTimeout(resolve, 300))
|
||||
await Promise.all([searchPromise, minDelayPromise])
|
||||
} finally {
|
||||
this.searching = false
|
||||
}
|
||||
|
|
@ -854,59 +934,254 @@ export default {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 84px);
|
||||
background: linear-gradient(180deg, #f4f7ff 0%, #ffffff 100%);
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.document-search-word article.docx-article-wrapper {
|
||||
display: block;
|
||||
margin: 0 auto 32px;
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 48px rgba(25, 64, 158, 0.12);
|
||||
padding: 48px 56px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
color: #1f2a62;
|
||||
line-height: 1.75;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
line-height: 1.8;
|
||||
font-size: 15px;
|
||||
// 启用文本选择和复制
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.floating-search {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: auto;
|
||||
max-width: calc(100% - 40px);
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
max-width: calc(100% - 24px);
|
||||
}
|
||||
}
|
||||
|
||||
.floating-search-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #2b68ff;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(43, 104, 255, 0.3);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 5;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.floating-search-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(43, 104, 255, 0.4);
|
||||
background: #1d4fd8;
|
||||
}
|
||||
|
||||
.floating-search-btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px rgba(43, 104, 255, 0.3);
|
||||
}
|
||||
|
||||
.search-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
box-shadow: 0 8px 26px rgba(31, 114, 234, 0.18);
|
||||
border: 1px solid rgba(226, 231, 239, 0.8);
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-toolbar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-toolbar.is-searching {
|
||||
&::after {
|
||||
border-color: rgba(43, 104, 255, 0.4);
|
||||
animation: search-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
// 确保搜索图标在搜索时显示加载动画
|
||||
.search-icon {
|
||||
i.el-icon-loading {
|
||||
display: inline-block !important;
|
||||
animation: rotating 1s linear infinite !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes search-pulse {
|
||||
0% {
|
||||
opacity: 0.25;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-fade-enter-active,
|
||||
.slide-fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.slide-fade-enter,
|
||||
.slide-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e7f0;
|
||||
border-radius: 999px;
|
||||
padding: 0 6px 0 10px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 0 0 360px;
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.search-input ::v-deep .el-input__inner {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
height: 30px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-input ::v-deep .el-input__suffix,
|
||||
.search-input ::v-deep .el-input__prefix {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6c7388;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: rgba(64, 158, 255, 0.12);
|
||||
color: #2b68ff;
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
background: #2b68ff;
|
||||
color: #fff;
|
||||
|
||||
// 确保加载图标有旋转动画
|
||||
i.el-icon-loading {
|
||||
display: inline-block;
|
||||
animation: rotating 1s linear infinite;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
i.el-icon-search {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon:hover {
|
||||
background: #1d4fd8;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.search-icon:disabled {
|
||||
opacity: 0.8;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.search-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #506dff;
|
||||
}
|
||||
|
||||
.result-indicator {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
color: #4f5875;
|
||||
padding-left: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
.viewer-container {
|
||||
flex: 1;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
|
@ -918,43 +1193,205 @@ export default {
|
|||
|
||||
.doc-wrapper {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
background: #eef2ff;
|
||||
padding: 40px 20px;
|
||||
background: #f5f7fa;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
scroll-behavior: auto;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
will-change: scroll-position;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
// 启用文本选择
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
|
||||
// 自定义滚动条
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.doc-content {
|
||||
max-width: 960px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 18px 40px rgba(25, 64, 158, 0.12);
|
||||
padding: 48px 56px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
padding: 60px 80px;
|
||||
min-height: calc(100vh - 200px);
|
||||
// 启用文本选择和复制
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
// 优化选中文本的样式
|
||||
::selection {
|
||||
background: rgba(64, 158, 255, 0.2);
|
||||
color: #303133;
|
||||
}
|
||||
::-moz-selection {
|
||||
background: rgba(64, 158, 255, 0.2);
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
padding: 50px 60px;
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 40px 30px;
|
||||
max-width: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
::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);
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
overflow: visible;
|
||||
border: none;
|
||||
// 启用文本选择
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
}
|
||||
|
||||
::v-deep .docx-viewer-wrapper {
|
||||
background: transparent;
|
||||
// 启用文本选择
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
}
|
||||
|
||||
::v-deep .docx-viewer-wrapper>section.docx-viewer {
|
||||
background: white;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
margin-bottom: 5px !important;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
margin-bottom: 0 !important;
|
||||
padding: 0;
|
||||
// 启用文本选择
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
}
|
||||
|
||||
::v-deep .docx-viewer {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
color: #303133;
|
||||
line-height: 1.8;
|
||||
// 启用文本选择和复制
|
||||
user-select: text !important;
|
||||
-webkit-user-select: text !important;
|
||||
-moz-user-select: text !important;
|
||||
-ms-user-select: text !important;
|
||||
// 优化选中文本的样式
|
||||
::selection {
|
||||
background: rgba(64, 158, 255, 0.25) !important;
|
||||
color: #303133 !important;
|
||||
}
|
||||
::-moz-selection {
|
||||
background: rgba(64, 158, 255, 0.25) !important;
|
||||
color: #303133 !important;
|
||||
}
|
||||
|
||||
// 确保所有子元素都可以选择文本
|
||||
* {
|
||||
user-select: text !important;
|
||||
-webkit-user-select: text !important;
|
||||
-moz-user-select: text !important;
|
||||
-ms-user-select: text !important;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.8em 0;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 1.2em 0 0.8em 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
cursor: text;
|
||||
|
||||
td, th {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f5f7fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: 0.8em 0;
|
||||
padding-left: 2em;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.4em 0;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 1em auto;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// 确保链接可以选择文本
|
||||
a {
|
||||
cursor: pointer;
|
||||
user-select: text !important;
|
||||
}
|
||||
|
||||
// 确保代码块可以选择文本
|
||||
code, pre {
|
||||
user-select: text !important;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -964,20 +1401,37 @@ export default {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(245, 247, 255, 0.92);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-panel {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
color: #6072a1;
|
||||
gap: 16px;
|
||||
color: #606266;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.state-icon {
|
||||
font-size: 28px;
|
||||
color: #1f72ea;
|
||||
font-size: 24px;
|
||||
color: #409EFF;
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
|
|
@ -991,17 +1445,32 @@ export default {
|
|||
}
|
||||
|
||||
::v-deep .search-highlight {
|
||||
background: rgba(255, 241, 168, 0.9);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 241, 168, 0.85);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s ease;
|
||||
// 确保高亮标记内的文本可以选择
|
||||
user-select: text !important;
|
||||
-webkit-user-select: text !important;
|
||||
-moz-user-select: text !important;
|
||||
-ms-user-select: text !important;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
::v-deep .search-highlight.is-active,
|
||||
::v-deep .search-highlight.is-current {
|
||||
background: #206dff !important;
|
||||
background: linear-gradient(135deg, #409EFF 0%, #1d4fd8 100%) !important;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 0 0 2px rgba(32, 109, 255, 0.35);
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.4);
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||
padding: 2px 4px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
// 确保激活的高亮标记内的文本可以选择
|
||||
user-select: text !important;
|
||||
-webkit-user-select: text !important;
|
||||
-moz-user-select: text !important;
|
||||
-ms-user-select: text !important;
|
||||
cursor: text;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue