Files
aue/src/components/pdfReader/AdvancedPDFReader.vue
2026-06-14 14:56:54 +08:00

776 lines
21 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="pdf-reader-container" :class="className">
<!-- 主阅读区域 - 更宽的展示空间 -->
<div class="pdf-reader-content">
<!-- PDF内容展示 -->
<div class="pdf-viewer-container">
<PDFPage
:page-number="currentPage"
:scale="scale"
:highlights="showHighlights ? annotations.highlights : []"
:selected-highlight-id="selectedHighlight?.id || null"
:on-highlight-click="handleHighlightClick"
:on-add-highlight="handleAddHighlight"
:on-text-content="handleTextContent"
:read-only="readOnly"
:pdf-source="file"
/>
</div>
<!-- 右侧批注面板 - 文档视图分离 -->
<div v-if="showSidebar" class="annotation-sidebar">
<div class="annotation-sidebar-header">
<h4 class="annotation-sidebar-title">批注管理</h4>
<button
@click="showSidebar = false"
class="annotation-sidebar-close"
>
×
</button>
</div>
<div class="annotation-sidebar-content">
<!-- 高亮批注列表 -->
<div v-if="annotations.highlights.length > 0" class="annotation-section">
<div class="annotation-section-title">高亮批注</div>
<div class="annotation-list">
<div
v-for="highlight in annotations.highlights"
:key="highlight.id"
class="annotation-item"
:style="{ borderLeft: `3px solid ${highlight.color}` }"
@click="selectHighlight(highlight)"
>
<div class="annotation-item-content">
<div class="annotation-item-text">
{{ highlight.content?.substring(0, 50) }}{{ highlight.content?.length > 50 ? '...' : '' }}
</div>
<div class="annotation-item-meta">
<span>第{{ highlight.pageNumber }}页</span>
<button
@click.stop="handleDeleteHighlight(highlight.id)"
class="annotation-delete-btn"
>
删除
</button>
</div>
</div>
<!-- 关联的评论 -->
<div v-if="getHighlightComments(highlight.id).length > 0" class="annotation-comments">
<div
v-for="comment in getHighlightComments(highlight.id)"
:key="comment.id"
class="annotation-comment-item"
>
<div class="annotation-comment-text">{{ comment.text }}</div>
<div class="annotation-comment-meta">
{{ new Date(comment.createdAt).toLocaleString() }}
<button
@click.stop="handleDeleteComment(comment.id)"
class="annotation-delete-btn"
>
删除
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 独立评论列表 -->
<div v-if="independentComments.length > 0" class="annotation-section">
<div class="annotation-section-title">独立评论</div>
<div class="annotation-list">
<div
v-for="comment in independentComments"
:key="comment.id"
class="annotation-item"
@click="goToCommentPage(comment.pageNumber)"
>
<div class="annotation-item-content">
<div class="annotation-item-text">{{ comment.text }}</div>
<div class="annotation-item-meta">
<span>第{{ comment.pageNumber || '未知' }}页</span>
<button
@click.stop="handleDeleteComment(comment.id)"
class="annotation-delete-btn"
>
删除
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="annotations.highlights.length === 0 && independentComments.length === 0" class="annotation-empty">
<p>暂无批注</p>
<p class="annotation-empty-text">选择文本添加高亮或批注</p>
</div>
</div>
</div>
</div>
<!-- 批注弹窗 -->
<CommentPopup
v-if="showCommentPopup && selectedHighlight"
:comments="getHighlightComments(selectedHighlight.id)"
:position="commentPopupPosition"
:on-add-comment="handleAddComment"
:on-delete-comment="handleDeleteComment"
:on-close="() => {
showCommentPopup = false
selectedHighlight = null
}"
:on-delete-highlight="handleDeleteHighlight"
:highlight-content="selectedHighlight.content"
/>
<!-- 操作按钮(朗读、MinerU提取等) -->
<div v-if="showCommentPopup && selectedHighlight?.content" class="action-buttons-container">
<button
@click="handleSpeak"
class="action-button"
:disabled="!isSpeechAvailable"
>
{{ isSpeaking ? '停止朗读' : '朗读' }}
</button>
<button
@click="() => handleMineruExtract(selectedHighlight.pageNumber)"
class="action-button action-button-success"
:disabled="isMineruProcessing"
>
{{ isMineruProcessing ? '提取中...' : 'MinerU提取' }}
</button>
<div v-if="mineruError" class="error-message error-message-danger">
{{ mineruError }}
</div>
<div v-if="mineruExtractedContent[selectedHighlight.pageNumber]" class="error-message error-message-success">
<strong>MinerU提取结果:</strong>
<p>{{ mineruExtractedContent[selectedHighlight.pageNumber].substring(0, 100) }}...</p>
</div>
<div v-if="errorMessage" class="error-message error-message-danger">
{{ errorMessage }}
</div>
</div>
<!-- 侧边栏切换按钮 -->
<button
@click="showSidebar = !showSidebar"
class="sidebar-toggle-button"
>
{{ showSidebar ? '收起批注' : '显示批注' }}
</button>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
import CommentPopup from './CommentPopup.vue'
import PDFPage from './PDFPage.vue'
import {
createAnnotationData,
addHighlight,
removeHighlight,
addComment,
removeComment,
exportAnnotations,
importAnnotations
} from '../../utils/annotationUtils'
import { useSpeechSynthesis } from '../../hooks/useSpeechSynthesis'
const props = defineProps({
file: {
type: [String, Object, ArrayBuffer],
required: true
},
initialAnnotations: {
type: Object,
default: null
},
onAnnotationsChange: {
type: Function,
default: null
},
readOnly: {
type: Boolean,
default: false
},
className: {
type: String,
default: ''
},
currentMode: {
type: String,
default: 'select'
},
selectedColor: {
type: String,
default: null
},
currentPage: {
type: Number,
default: undefined
},
numPages: {
type: Number,
default: undefined
},
scale: {
type: Number,
default: undefined
},
onPageChange: {
type: Function,
default: null
},
onScaleChange: {
type: Function,
default: null
},
onNumPagesChange: {
type: Function,
default: null
},
onHighlightClick: {
type: Function,
default: null
},
showHighlights: {
type: Boolean,
default: true
},
isReading: {
type: Boolean,
default: false
},
readingSpeed: {
type: Number,
default: 1
},
readingRange: {
type: String,
default: 'current'
},
onReadingStatusChange: {
type: Function,
default: null
},
onReadingProgressChange: {
type: Function,
default: null
},
onReadingTextChange: {
type: Function,
default: null
}
})
const migrateInitialAnnotations = (data) => {
if (!data) return createAnnotationData()
const migratedHighlights = data.highlights?.map((h) => {
if ('positions' in h) {
return h
}
if ('position' in h) {
return {
...h,
positions: [h.position]
}
}
return h
}) || []
return {
...data,
highlights: migratedHighlights,
version: '2.0.0'
}
}
const numPages = ref(props.numPages || 0)
const currentPage = ref(props.currentPage || 1)
const scale = ref(props.scale || 1)
const annotations = ref(migrateInitialAnnotations(props.initialAnnotations))
const selectedHighlight = ref(null)
const commentPopupPosition = reactive({ x: 0, y: 0 })
const showCommentPopup = ref(false)
const showSidebar = ref(false)
const showControls = ref(false)
const pageTextContent = reactive({})
const mineruExtractedContent = reactive({})
const isMineruProcessing = ref(false)
const mineruError = ref(null)
// 计算属性:独立评论(没有关联高亮的评论)
const independentComments = computed(() => {
return annotations.value.comments.filter(comment => !comment.highlightId)
})
// 选择高亮并跳转到对应页面
const selectHighlight = (highlight) => {
selectedHighlight.value = highlight
currentPage.value = highlight.pageNumber
if (props.onPageChange) {
props.onPageChange(highlight.pageNumber)
}
}
// 跳转到评论所在页面
const goToCommentPage = (pageNumber) => {
if (pageNumber) {
currentPage.value = pageNumber
if (props.onPageChange) {
props.onPageChange(pageNumber)
}
}
}
// 按ID删除高亮
const handleDeleteHighlight = (highlightId) => {
const highlightToDelete = highlightId || (selectedHighlight.value?.id)
if (highlightToDelete) {
updateAnnotations(removeHighlight(annotations.value, highlightToDelete))
showCommentPopup.value = false
selectedHighlight.value = null
}
}
const {
speak,
pause,
resume,
stop,
isSpeaking,
isPaused,
speakingProgress,
setRate,
setVolume,
isSpeechAvailable,
errorMessage
} = useSpeechSynthesis()
watch(() => props.currentPage, (newPage) => {
if (newPage !== undefined) {
currentPage.value = newPage
}
})
watch(() => props.numPages, (newPages) => {
if (newPages !== undefined) {
numPages.value = newPages
}
})
watch(() => props.scale, (newScale) => {
if (newScale !== undefined) {
scale.value = newScale
}
})
const updateAnnotations = (newData) => {
annotations.value = newData
if (props.onAnnotationsChange) {
props.onAnnotationsChange(newData)
}
}
const handleTextContent = (pageNum, textContent) => {
pageTextContent[pageNum] = textContent
}
const extractTextFromPage = (pageNum) => {
const content = pageTextContent[pageNum]
if (!content) return ''
return content.items.map(item => item.str).join(' ')
}
const handleReadingToggle = () => {
console.log('handleReadingToggle called, isSpeaking:', isSpeaking.value)
if (isSpeaking.value) {
stop()
if (props.onReadingStatusChange) {
props.onReadingStatusChange(false)
}
} else {
let text = ''
let description = ''
console.log('Current page:', currentPage.value)
console.log('Reading range:', props.readingRange)
if (props.readingRange === 'selected') {
if (selectedHighlight.value?.content) {
text = selectedHighlight.value.content
description = '选中文本'
} else {
console.log('No text selected')
if (props.onReadingTextChange) {
props.onReadingTextChange('请先选择要朗读的文本')
}
return
}
} else if (props.readingRange === 'current') {
text = extractTextFromPage(currentPage.value)
description = `第${currentPage.value}页`
} else if (props.readingRange === 'chapter') {
console.log('Page text content:', pageTextContent)
const chapterPages = []
for (let i = currentPage.value; i <= Math.min(currentPage.value + 8, numPages.value); i++) {
chapterPages.push(i)
}
chapterPages.forEach(pageNum => {
text += extractTextFromPage(pageNum) + ' '
})
description = `第${currentPage.value}-${Math.min(currentPage.value + 8, numPages.value)}页`
} else if (props.readingRange === 'full') {
console.log('Page text content:', pageTextContent)
for (let i = 1; i <= numPages.value; i++) {
text += extractTextFromPage(i) + ' '
}
description = `全文(共${numPages.value}页)`
}
console.log('Extracted text:', text)
console.log('Description:', description)
if (text && text.trim().length > 0) {
if (props.onReadingTextChange) {
props.onReadingTextChange(`正在朗读${description}`)
}
console.log('Calling speak with text length:', text.length)
console.log('First 100 characters:', text.substring(0, 100))
const maxTextLength = 3000
const speakingText = text.length > maxTextLength ? text.substring(0, maxTextLength) + '...' : text
speak(speakingText, {
rate: props.readingSpeed,
onProgress: (progress) => {
console.log('Reading progress:', progress)
if (props.onReadingProgressChange) {
props.onReadingProgressChange(progress)
}
}
})
if (props.onReadingStatusChange) {
props.onReadingStatusChange(true)
}
} else {
console.log('No text extracted, cannot speak')
if (props.onReadingTextChange) {
props.onReadingTextChange('无法提取文本,请检查PDF文件')
}
}
}
}
watch([() => props.isReading, isSpeaking], ([newIsReading, newIsSpeaking]) => {
if (newIsReading && !newIsSpeaking) {
handleReadingToggle()
} else if (!newIsReading && newIsSpeaking) {
stop()
if (props.onReadingStatusChange) {
props.onReadingStatusChange(false)
}
}
})
const handleReadingPause = () => {
if (isSpeaking.value) {
pause()
} else if (isPaused.value) {
resume()
}
}
const handleReadingStop = () => {
stop()
}
const handleZoomIn = () => {
const newScale = Math.min(scale.value + 0.25, 3)
scale.value = newScale
if (props.onScaleChange) {
props.onScaleChange(newScale)
}
}
const handleZoomOut = () => {
const newScale = Math.max(scale.value - 0.25, 0.5)
scale.value = newScale
if (props.onScaleChange) {
props.onScaleChange(newScale)
}
}
const handleZoomReset = () => {
scale.value = 1
if (props.onScaleChange) {
props.onScaleChange(1)
}
}
const handlePreviousPage = () => {
const newPage = Math.max(currentPage.value - 1, 1)
currentPage.value = newPage
if (props.onPageChange) {
props.onPageChange(newPage)
}
}
const handleNextPage = () => {
const newPage = Math.min(currentPage.value + 1, numPages.value)
currentPage.value = newPage
if (props.onPageChange) {
props.onPageChange(newPage)
}
}
const handlePageChange = (page) => {
const newPage = parseInt(page)
if (!isNaN(newPage) && newPage >= 1 && newPage <= numPages.value) {
currentPage.value = newPage
if (props.onPageChange) {
props.onPageChange(newPage)
}
}
}
const handleAddHighlight = (highlight) => {
if (props.readOnly) return
if (props.currentMode === 'highlight') {
const highlightWithColor = {
...highlight,
color: props.selectedColor || '#ffeb3b'
}
updateAnnotations(addHighlight(annotations.value, highlightWithColor))
} else if (props.currentMode === 'comment') {
selectedHighlight.value = highlight
const position = highlight.screenPosition || { x: window.innerWidth / 2, y: window.innerHeight / 2 }
const adjustedX = Math.max(20, Math.min(position.x, window.innerWidth - 320))
const adjustedY = Math.max(20, Math.min(position.y, window.innerHeight - 500))
commentPopupPosition.x = adjustedX
commentPopupPosition.y = adjustedY
showCommentPopup.value = true
}
}
const handleHighlightClick = (highlight, e) => {
selectedHighlight.value = highlight
commentPopupPosition.x = e.clientX
commentPopupPosition.y = e.clientY
showCommentPopup.value = true
if (props.onHighlightClick) {
props.onHighlightClick(highlight)
}
}
const handleAddComment = (text, shouldAddHighlight = false, highlightColor = '#ffeb3b') => {
let newAnnotations = annotations.value
let highlightId = null
if (shouldAddHighlight && selectedHighlight.value) {
const highlightWithColor = {
...selectedHighlight.value,
color: highlightColor
}
newAnnotations = addHighlight(newAnnotations, highlightWithColor)
highlightId = newAnnotations.highlights[newAnnotations.highlights.length - 1].id
} else if (selectedHighlight.value && selectedHighlight.value.id) {
highlightId = selectedHighlight.value.id
}
if (highlightId) {
const updatedAnnotations = addComment(newAnnotations, highlightId, text, selectedHighlight.value?.pageNumber, selectedHighlight.value?.content)
updateAnnotations(updatedAnnotations)
} else {
const updatedAnnotations = addComment(newAnnotations, null, text, currentPage.value, null)
updateAnnotations(updatedAnnotations)
}
}
const handleDeleteComment = (commentId) => {
updateAnnotations(removeComment(annotations.value, commentId))
}
const handleExport = () => {
exportAnnotations(annotations.value)
}
const handleImport = async (file) => {
try {
const data = await importAnnotations(file)
updateAnnotations(data)
} catch (err) {
console.error('Failed to import annotations:', err)
alert('导入批注失败,请检查文件格式')
}
}
const handleMineruExtract = async (pageNumber) => {
isMineruProcessing.value = true
mineruError.value = null
try {
console.log('开始使用MinerU提取第', pageNumber, '页内容')
await new Promise(resolve => setTimeout(resolve, 2000))
const mockContent = `这是使用MinerU提取的第${pageNumber}页内容。\n\nMinerU能够智能识别PDF文档的结构,包括标题、段落、表格等元素,并将其转换为结构化的文本格式。\n\n这种方式比传统的OCR更加准确,能够更好地保留文档的原始结构和格式。`
mineruExtractedContent[pageNumber] = mockContent
console.log('MinerU提取完成:', mockContent)
} catch (error) {
console.error('MinerU提取失败:', error)
mineruError.value = '内容提取失败,请检查MinerU服务是否运行'
} finally {
isMineruProcessing.value = false
}
}
const handleSpeak = () => {
if (selectedHighlight.value?.content) {
console.log('Speaking selected highlight content:', selectedHighlight.value.content)
speak(selectedHighlight.value.content, {
onProgress: (progress) => {
console.log('Selected text reading progress:', progress)
}
})
}
}
const getHighlightComments = (highlightId) => {
return annotations.value.comments.filter(c => c.highlightId === highlightId)
}
defineExpose({
handleZoomIn,
handleZoomOut,
handleZoomReset,
handlePreviousPage,
handleNextPage,
handlePageChange,
handleReadingToggle,
handleReadingPause,
handleReadingStop,
handleExport,
handleImport
})
</script>
<style scoped>
@import './pdfReader.css';
.pdf-reader-container {
display: flex;
flex-direction: column;
min-height: 100%;
background: var(--bg-page);
}
.pdf-reader-content {
flex: 1;
display: flex;
min-height: 100%;
}
.pdf-viewer-container {
flex: 1;
padding: var(--space-lg);
overflow: hidden;
display: flex;
justify-content: center;
max-width: 100%;
min-height: 100%;
}
.annotation-sidebar,
.sidebar-toggle-button,
.action-buttons-container {
display: none !important;
}
.action-button {
padding: var(--space-sm) var(--space-md);
background: var(--color-primary);
color: var(--text-inverse);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--font-md);
}
.action-button:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-button-success {
background: var(--color-success);
}
.action-button-success:hover:not(:disabled) {
background: var(--color-success-hover);
}
.error-message {
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-size: var(--font-xs);
margin-top: var(--space-xs);
max-width: 300px;
text-align: center;
}
.error-message-danger {
background: var(--bg-error);
color: var(--color-error);
border: 1px solid var(--border-error);
}
.error-message-success {
background: var(--bg-success);
color: var(--color-success);
border: 1px solid var(--border-success);
max-width: 400px;
}
.error-message p {
margin: var(--space-xs) 0 0 0;
font-size: var(--font-xs);
line-height: 1.4;
}
.sidebar-toggle-button {
position: fixed;
bottom: var(--space-lg);
right: var(--space-lg);
padding: var(--space-sm) var(--space-md);
background: var(--color-primary);
color: var(--text-inverse);
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
font-size: var(--font-md);
z-index: 1000;
}
.sidebar-toggle-button:hover {
background: var(--color-primary-hover);
}
</style>