Merge remote-tracking branch 'origin/main'

This commit is contained in:
LHD_HY 2025-11-28 14:28:08 +08:00
commit 32aecd8b1d
9 changed files with 1559 additions and 379 deletions

View File

@ -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)
}
}
},

View File

@ -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',
}
},

View File

@ -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) {

View File

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

View File

@ -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;
}
}

View File

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

View File

@ -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;
}

View File

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

View File

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