859 lines
20 KiB
Vue
859 lines
20 KiB
Vue
<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>
|