前端项目初始化提交

This commit is contained in:
2026-06-03 13:16:30 +08:00
commit 0910ba9cbe
163 changed files with 110032 additions and 0 deletions

View File

@@ -0,0 +1,858 @@
<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>