前端项目初始化提交

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,779 @@
<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%;
}
.action-buttons-container {
position: fixed;
bottom: var(--space-3xl);
left: 50%;
transform: translateX(-50%);
display: flex;
gap: var(--space-sm);
z-index: 1001;
}
.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>