Files
aue/src/components/UniversalFileReader.vue
2026-06-03 13:16:30 +08:00

859 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="universal-file-reader">
<div v-if="loading" class="loading-container">
<a-spin size="large" />
<p>正在加载文件...</p>
</div>
<div v-else-if="error" class="error-container">
<CloseCircleOutlined style="font-size: 48px; color: #ff4d4f;" />
<h2>加载失败</h2>
<p>{{ error }}</p>
<a-button type="primary" @click="$emit('back')">返回列表</a-button>
</div>
<div v-else class="file-content-container">
<!-- PDF Viewer -->
<div v-if="fileType === 'pdf'" class="pdf-container">
<AdvancedPDFReader
:file="fileData"
:initial-annotations="annotations"
:current-mode="currentMode"
:selected-color="selectedColor"
:current-page="currentPage"
:num-pages="numPages"
:scale="scale"
:on-page-change="handlePageChange"
:on-num-pages-change="handleNumPagesChange"
:on-scale-change="handleScaleChange"
:on-highlight-click="onHighlightClick"
:show-highlights="showHighlights"
:is-reading="isReading"
:reading-speed="readingSpeed"
:reading-range="readingRange"
:on-reading-status-change="onReadingStatusChange"
:on-reading-progress-change="onReadingProgressChange"
:on-reading-text-change="onReadingTextChange"
/>
</div>
<!-- Image Viewer -->
<div v-else-if="fileType === 'image'" class="image-viewer">
<div class="image-container" @wheel="handleImageWheel">
<img
:src="fileData"
:style="{ transform: `scale(${imageScale})`}"
@load="onImageLoad"
alt="图片预览"
/>
</div>
</div>
<!-- Text Viewer -->
<div v-else-if="fileType === 'text'" class="text-viewer">
<div class="search-controls" v-if="textSearchQuery && textSearchMatches.length > 0">
<a-button size="small" @click="prevSearchMatch">
<UpOutlined />
</a-button>
<span>{{ textSearchCurrentIndex + 1 }} / {{ textSearchMatches.length }}</span>
<a-button size="small" @click="nextSearchMatch">
<DownOutlined />
</a-button>
</div>
<pre class="text-content" :class="{ 'word-wrap': wordWrap }" v-html="textContentWithHighlight"></pre>
</div>
<!-- DOCX Viewer -->
<div v-else-if="fileType === 'docx'" class="docx-viewer">
<div class="docx-container" ref="docxContainer" :style="{ transform: `scale(${docxScale})`}"></div>
</div>
<!-- XLSX Viewer -->
<div v-else-if="fileType === 'xlsx'" class="xlsx-viewer">
<div class="xlsx-container">
<table class="xlsx-table" v-if="sheetData.length > 0">
<thead>
<tr>
<th v-for="(cell, colIndex) in sheetData[0]" :key="colIndex">
{{ getColumnName(colIndex) }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in sheetData" :key="rowIndex">
<td
v-for="(cell, colIndex) in row"
:key="colIndex"
:class="getCellClass(cell)"
>
{{ formatCellValue(cell) }}
</td>
</tr>
</tbody>
</table>
<div v-else class="empty-sheet">此工作表没有数据</div>
</div>
</div>
<!-- Unsupported Format -->
<div v-else class="unsupported-viewer">
<FileUnknownOutlined style="font-size: 64px; color: #faad14;" />
<h2>不支持的文件格式</h2>
<p>当前文件格式: {{ fileName || fileExtension || '未知' }}</p>
<p class="supported-list">
<span>支持格式:</span>
<span>• PDF (.pdf)</span>
<span>• Word (.docx, .doc)</span>
<span>• Excel (.xlsx, .xls)</span>
<span>• 图片 (.jpg, .jpeg, .png, .gif, .webp, .bmp, .svg)</span>
<span>• 文本 (.txt, .json, .xml, .csv, .md, .html, .log, .js, .css)</span>
</p>
<a-button type="primary" @click="downloadFile">
<DownloadOutlined /> 下载文件
</a-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { message } from 'ant-design-vue'
import {
CloseCircleOutlined,
CopyOutlined,
DownloadOutlined,
FileUnknownOutlined,
SearchOutlined,
PlusOutlined,
MinusOutlined,
UpOutlined,
DownOutlined
} from '@ant-design/icons-vue'
import AdvancedPDFReader from './pdfReader/AdvancedPDFReader.vue'
import { fileAPI } from '../api/file.js'
import * as XLSX from 'xlsx'
import { renderAsync } from 'docx-preview'
const props = defineProps({
fileId: {
type: [Number, String],
required: true
},
fileName: {
type: String,
default: ''
},
fileExtension: {
type: String,
default: ''
},
annotations: {
type: Object,
default: null
},
currentMode: {
type: String,
default: 'comment'
},
selectedColor: {
type: String,
default: null
},
currentPage: {
type: Number,
default: 1
},
numPages: {
type: Number,
default: 1
},
scale: {
type: Number,
default: 1
},
showHighlights: {
type: Boolean,
default: true
},
isReading: {
type: Boolean,
default: false
},
readingSpeed: {
type: Number,
default: 1
},
readingRange: {
type: String,
default: 'current'
}
})
const emit = defineEmits([
'back',
'page-change',
'num-pages-change',
'scale-change',
'annotations-change',
'highlight-click',
'reading-status-change',
'reading-progress-change',
'reading-text-change'
])
const loading = ref(true)
const error = ref(null)
const fileData = ref(null)
const textContent = ref('')
const fileType = ref('unsupported')
const imageScale = ref(1)
const docxScale = ref(1)
const wordWrap = ref(true)
const docxContainer = ref(null)
const sheetNames = ref([])
const sheetData = ref([])
const activeSheet = ref('')
const textSearchQuery = ref('')
const textSearchMatches = ref([])
const textSearchCurrentIndex = ref(0)
const SUPPORTED_PDF = ['.pdf']
const SUPPORTED_IMAGES = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg']
const SUPPORTED_DOCX = ['.docx', '.doc']
const SUPPORTED_XLSX = ['.xlsx', '.xls']
const SUPPORTED_TXT = ['.txt', '.json', '.xml', '.csv', '.md', '.html', '.htm', '.log', '.js', '.css']
const getFileType = (ext) => {
let lowerExt = (ext || '').toLowerCase()
// 确保扩展名有前缀点
if (lowerExt && !lowerExt.startsWith('.')) {
lowerExt = '.' + lowerExt
}
console.log('getFileType 输入:', ext, '处理后:', lowerExt)
if (SUPPORTED_PDF.includes(lowerExt)) return 'pdf'
if (SUPPORTED_IMAGES.includes(lowerExt)) return 'image'
if (SUPPORTED_TXT.includes(lowerExt)) return 'text'
if (SUPPORTED_DOCX.includes(lowerExt)) return 'docx'
if (SUPPORTED_XLSX.includes(lowerExt)) return 'xlsx'
return 'unsupported'
}
const getFileExtension = (filename) => {
if (!filename) return ''
const lastDot = filename.lastIndexOf('.')
return lastDot !== -1 ? filename.substring(lastDot).toLowerCase() : ''
}
const textContentWithHighlight = computed(() => {
if (!textSearchQuery.value || textSearchQuery.value.trim() === '') {
return escapeHtml(textContent.value)
}
let content = escapeHtml(textContent.value)
const query = escapeRegExp(textSearchQuery.value)
const regex = new RegExp(query, 'gi')
return content.replace(regex, (match, offset) => {
const idx = textSearchMatches.value.findIndex(m => m.start === offset)
if (idx !== -1 && idx === textSearchCurrentIndex.value) {
return `<mark class="current-match">${match}</mark>`
} else if (idx !== -1) {
return `<mark>${match}</mark>`
}
return match
})
})
const escapeHtml = (text) => {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
const escapeRegExp = (string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
const loadFile = async () => {
loading.value = true
error.value = null
fileData.value = null
textContent.value = ''
textSearchQuery.value = ''
textSearchMatches.value = []
textSearchCurrentIndex.value = 0
sheetNames.value = []
sheetData.value = []
try {
const ext = props.fileExtension || getFileExtension(props.fileName)
fileType.value = getFileType(ext)
if (fileType.value === 'unsupported') {
loading.value = false
return
}
if (fileType.value === 'text') {
const response = await fileAPI.getTextFileContent(props.fileId)
if (response.data?.code === 200) {
textContent.value = response.data.data
} else {
throw new Error(response.data?.message || '获取文本内容失败')
}
} else if (fileType.value === 'xlsx') {
const response = await fileAPI.previewFile(props.fileId)
const data = await response.data.arrayBuffer()
loading.value = false
await nextTick()
parseExcelFile(data)
return
} else if (fileType.value === 'docx') {
console.log('开始加载DOCX文件')
const response = await fileAPI.previewFile(props.fileId)
console.log('API响应:', response)
console.log('响应数据类型:', typeof response.data)
console.log('响应数据size:', response.data.size)
console.log('响应数据是Blob吗?:', response.data instanceof Blob)
// 先将 loading 设为 false, 让模板渲染容器!
loading.value = false
await nextTick()
console.log('docxContainer.value:', docxContainer.value)
await renderDocxFile(response.data)
return // 已经设为 false, 不需要后面再设了!
} else {
const response = await fileAPI.previewFile(props.fileId)
const blobUrl = URL.createObjectURL(response.data)
fileData.value = blobUrl
}
loading.value = false
} catch (err) {
console.error('加载文件失败:', err)
error.value = err.message || '加载文件失败'
loading.value = false
}
}
const searchText = () => {
if (!textSearchQuery.value || textSearchQuery.value.trim() === '') {
textSearchMatches.value = []
textSearchCurrentIndex.value = 0
return
}
const query = textSearchQuery.value.trim()
const regex = new RegExp(escapeRegExp(query), 'gi')
const matches = []
let match
while ((match = regex.exec(textContent.value)) !== null) {
matches.push({
start: match.index,
end: regex.lastIndex
})
}
textSearchMatches.value = matches
textSearchCurrentIndex.value = 0
if (matches.length > 0) {
scrollToCurrentMatch()
}
}
const prevSearchMatch = () => {
if (textSearchMatches.value.length === 0) return
textSearchCurrentIndex.value = (textSearchCurrentIndex.value - 1 + textSearchMatches.value.length) % textSearchMatches.value.length
scrollToCurrentMatch()
}
const nextSearchMatch = () => {
if (textSearchMatches.value.length === 0) return
textSearchCurrentIndex.value = (textSearchCurrentIndex.value + 1) % textSearchMatches.value.length
scrollToCurrentMatch()
}
const scrollToCurrentMatch = () => {
nextTick(() => {
const marks = document.querySelectorAll('.text-content .current-match')
if (marks.length > 0) {
marks[0].scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
}
const renderDocxFile = async (data) => {
console.log('renderDocxFile 开始执行')
console.log('传入的数据:', data)
console.log('数据类型:', typeof data)
if (!docxContainer.value) {
console.log('docxContainer 还未准备好,等待 nextTick')
await nextTick()
}
console.log('docxContainer.value 现在:', docxContainer.value)
if (docxContainer.value) {
docxContainer.value.innerHTML = ''
console.log('docxContainer 已清空')
try {
console.log('开始调用 renderAsync')
console.log('renderAsync:', renderAsync)
await renderAsync(data, docxContainer.value, null, {
className: 'docx-content',
inWrapper: true,
ignoreWidth: true,
ignoreHeight: false,
ignoreFonts: false,
breakPages: true,
ignoreLastRenderedPageBreak: true,
experimental: false,
trimXmlDeclaration: true,
useBase64URL: true,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
renderEndnotes: true
})
console.log('renderAsync 执行完成')
console.log('docxContainer.innerHTML 长度:', docxContainer.value.innerHTML.length)
console.log('docxContainer.innerHTML 前200字符:', docxContainer.value.innerHTML.substring(0, 200))
} catch (err) {
console.error('渲染DOCX失败:', err)
console.error('错误堆栈:', err.stack)
throw new Error('DOCX文件渲染失败: ' + err.message)
}
} else {
console.error('无法获取 docxContainer 引用')
}
}
const parseExcelFile = (arrayBuffer) => {
try {
const workbook = XLSX.read(arrayBuffer, { type: 'array' })
sheetNames.value = workbook.SheetNames
if (sheetNames.value.length > 0) {
activeSheet.value = sheetNames.value[0]
loadSheet(activeSheet.value, workbook)
}
} catch (err) {
console.error('解析Excel失败:', err)
throw new Error('Excel文件解析失败')
}
}
const loadSheet = (sheetName, workbook) => {
if (!workbook) {
const data = localStorage.getItem(`xlsx_${props.fileId}`)
if (data) {
workbook = XLSX.read(new Uint8Array(JSON.parse(data)), { type: 'array' })
}
}
if (workbook && workbook.Sheets[sheetName]) {
const sheet = workbook.Sheets[sheetName]
sheetData.value = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' })
}
}
const getColumnName = (index) => {
let name = ''
let num = index
while (num >= 0) {
name = String.fromCharCode(65 + (num % 26)) + name
num = Math.floor(num / 26) - 1
}
return name
}
const formatCellValue = (cell) => {
if (cell === null || cell === undefined) return ''
if (typeof cell === 'object') {
return cell.v || cell.w || JSON.stringify(cell)
}
return String(cell)
}
const getCellClass = (cell) => {
if (typeof cell === 'number') return 'numeric'
return ''
}
const zoomIn = () => {
if (fileType.value === 'image') {
imageScale.value = Math.min(imageScale.value + 0.25, 3)
} else if (fileType.value === 'docx') {
docxScale.value = Math.min(docxScale.value + 0.1, 2)
}
}
const zoomOut = () => {
if (fileType.value === 'image') {
imageScale.value = Math.max(imageScale.value - 0.25, 0.25)
} else if (fileType.value === 'docx') {
docxScale.value = Math.max(docxScale.value - 0.1, 0.5)
}
}
const resetImageZoom = () => {
imageScale.value = 1
}
const handleImageWheel = (e) => {
if (e.ctrlKey) {
e.preventDefault()
if (e.deltaY < 0) {
zoomIn()
} else {
zoomOut()
}
}
}
const onImageLoad = () => {
console.log('图片加载完成')
}
const copyText = async () => {
try {
await navigator.clipboard.writeText(textContent.value)
message.success('内容已复制到剪贴板')
} catch (err) {
message.error('复制失败')
}
}
const toggleWordWrap = () => {
wordWrap.value = !wordWrap.value
}
const downloadFile = async () => {
try {
const response = await fileAPI.downloadFile(props.fileId)
const blob = new Blob([response.data])
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = props.fileName || 'download'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err) {
message.error('下载失败')
}
}
const handlePageChange = (page) => {
emit('page-change', page)
}
const handleNumPagesChange = (pages) => {
emit('num-pages-change', pages)
}
const handleScaleChange = (newScale) => {
emit('scale-change', newScale)
}
const onAnnotationsChange = (data) => {
emit('annotations-change', data)
}
const onHighlightClick = (annotation) => {
emit('highlight-click', annotation)
}
const onReadingStatusChange = (status) => {
emit('reading-status-change', status)
}
const onReadingProgressChange = (progress) => {
emit('reading-progress-change', progress)
}
const onReadingTextChange = (text) => {
emit('reading-text-change', text)
}
watch(() => props.fileId, () => {
loadFile()
}, { immediate: true })
const setActiveSheet = (sheetName) => {
if (sheetNames.value.includes(sheetName)) {
activeSheet.value = sheetName
}
}
watch(activeSheet, (newSheet) => {
if (newSheet) {
const cachedData = localStorage.getItem(`xlsx_workbook_${props.fileId}`)
if (cachedData) {
const workbook = XLSX.read(new Uint8Array(JSON.parse(cachedData)), { type: 'array' })
loadSheet(newSheet, workbook)
}
}
})
onUnmounted(() => {
if (fileData.value && fileData.value.startsWith('blob:')) {
URL.revokeObjectURL(fileData.value)
}
})
// 对外暴露方法和属性
defineExpose({
fileType,
imageScale,
docxScale,
activeSheet,
sheetNames,
textSearchQuery,
wordWrap,
zoomIn,
zoomOut,
resetImageZoom,
searchText,
prevSearchMatch,
nextSearchMatch,
copyText,
toggleWordWrap,
setActiveSheet
})
</script>
<style scoped>
.universal-file-reader {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.loading-container,
.error-container,
.unsupported-viewer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 40px;
text-align: center;
}
.loading-container p,
.error-container p {
margin-top: 16px;
color: #666;
}
.error-container h2,
.unsupported-viewer h2 {
margin-top: 16px;
color: #333;
}
.unsupported-viewer p {
color: #666;
}
.unsupported-viewer .supported-list {
color: #999;
font-size: 13px;
margin: 16px 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.file-content-container {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.pdf-container {
flex: 1;
overflow: hidden;
height: 100%;
}
.image-viewer {
flex: 1;
display: flex;
flex-direction: column;
background: #2c2c2c;
}
.image-toolbar,
.docx-toolbar,
.xlsx-toolbar,
.text-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
}
.image-toolbar {
justify-content: center;
}
.image-toolbar .zoom-level,
.docx-toolbar .zoom-level {
min-width: 50px;
text-align: center;
font-weight: 500;
}
.image-container {
flex: 1;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.image-container img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transition: transform 0.2s ease;
}
.text-viewer {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
}
.toolbar-actions {
display: flex;
gap: 8px;
}
.search-controls {
display: flex;
gap: 8px;
padding: 8px 16px;
background: #fafafa;
border-bottom: 1px solid #e8e8e8;
}
.text-content {
flex: 1;
margin: 0;
padding: 20px;
overflow: auto;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
background: #fafafa;
white-space: pre;
tab-size: 4;
}
.text-content.word-wrap {
white-space: pre-wrap;
word-wrap: break-word;
}
.text-content mark {
background: #ffeb3b;
padding: 0 2px;
border-radius: 2px;
}
.text-content .current-match {
background: #ff9800;
}
.docx-viewer {
flex: 1;
display: flex;
flex-direction: column;
background: #f0f0f0;
}
.docx-container {
flex: 1;
overflow: auto;
padding: 20px;
background: #f0f0f0;
display: flex;
justify-content: center;
}
.docx-container :deep(.docx-content) {
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin: 0 auto;
}
.xlsx-viewer {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
}
.xlsx-container {
flex: 1;
overflow: auto;
padding: 16px;
}
.xlsx-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.xlsx-table th,
.xlsx-table td {
border: 1px solid #e8e8e8;
padding: 8px 12px;
text-align: left;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.xlsx-table th {
background: #fafafa;
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
}
.xlsx-table td.numeric {
text-align: right;
font-family: 'Consolas', monospace;
}
.xlsx-table tr:hover td {
background: #f5f5f5;
}
.empty-sheet {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: #999;
}
</style>