优化大pdf加载
parent
1319310b5a
commit
bb323f89e4
|
|
@ -341,16 +341,20 @@ async def upload_document(
|
||||||
async def get_document_file(
|
async def get_document_file(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
path: str,
|
path: str,
|
||||||
|
request: Request,
|
||||||
current_user: User = Depends(get_user_from_token_or_query),
|
current_user: User = Depends(get_user_from_token_or_query),
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取文档文件(PDF等)- 返回文件流
|
获取文档文件(PDF等)- 支持 HTTP Range 请求
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_id: 项目ID
|
project_id: 项目ID
|
||||||
path: 文件相对路径(如 "manual.pdf" 或 "docs/guide.pdf")
|
path: 文件相对路径(如 "manual.pdf" 或 "docs/guide.pdf")
|
||||||
"""
|
"""
|
||||||
|
import re
|
||||||
|
import aiofiles
|
||||||
|
|
||||||
project = await check_project_access(project_id, current_user, db)
|
project = await check_project_access(project_id, current_user, db)
|
||||||
|
|
||||||
# 获取文件路径
|
# 获取文件路径
|
||||||
|
|
@ -364,14 +368,56 @@ async def get_document_file(
|
||||||
if not content_type:
|
if not content_type:
|
||||||
content_type = "application/octet-stream"
|
content_type = "application/octet-stream"
|
||||||
|
|
||||||
# 返回文件流
|
# 获取文件大小
|
||||||
|
file_size = file_path.stat().st_size
|
||||||
|
|
||||||
|
# 检查是否有 Range 请求头
|
||||||
|
range_header = request.headers.get("range")
|
||||||
|
|
||||||
|
if range_header:
|
||||||
|
# 解析 Range 头: bytes=start-end
|
||||||
|
range_match = re.match(r"bytes=(\d+)-(\d*)", range_header)
|
||||||
|
if range_match:
|
||||||
|
start = int(range_match.group(1))
|
||||||
|
end = int(range_match.group(2)) if range_match.group(2) else file_size - 1
|
||||||
|
end = min(end, file_size - 1)
|
||||||
|
|
||||||
|
# 读取指定范围的文件内容
|
||||||
|
async def file_iterator():
|
||||||
|
async with aiofiles.open(file_path, 'rb') as f:
|
||||||
|
await f.seek(start)
|
||||||
|
chunk_size = 1024 * 1024 # 1MB chunks
|
||||||
|
remaining = end - start + 1
|
||||||
|
while remaining > 0:
|
||||||
|
chunk = await f.read(min(chunk_size, remaining))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
remaining -= len(chunk)
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": str(end - start + 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
file_iterator(),
|
||||||
|
status_code=206, # Partial Content
|
||||||
|
headers=headers,
|
||||||
|
media_type=content_type
|
||||||
|
)
|
||||||
|
|
||||||
|
# 正常请求,返回完整文件(添加 Accept-Ranges 头)
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path=str(file_path),
|
path=str(file_path),
|
||||||
media_type=content_type,
|
media_type=content_type,
|
||||||
filename=file_path.name
|
filename=file_path.name,
|
||||||
|
headers={"Accept-Ranges": "bytes"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/assets/{subfolder}/{filename}")
|
@router.get("/{project_id}/assets/{subfolder}/{filename}")
|
||||||
async def get_asset_file(
|
async def get_asset_file(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|
|
||||||
|
|
@ -309,10 +309,8 @@ class StorageService:
|
||||||
detail=f"不支持的文件类型: {file_ext}。允许的类型: {', '.join(allowed_extensions)}"
|
detail=f"不支持的文件类型: {file_ext}。允许的类型: {', '.join(allowed_extensions)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 生成唯一文件名(保留原始文件名+时间戳)
|
# 使用原始文件名(不添加时间戳)
|
||||||
original_name = Path(file.filename).stem
|
unique_filename = file.filename
|
||||||
timestamp = uuid.uuid4().hex[:8]
|
|
||||||
unique_filename = f"{original_name}_{timestamp}{file_ext}"
|
|
||||||
|
|
||||||
# 目标路径
|
# 目标路径
|
||||||
if target_dir:
|
if target_dir:
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-pdf": "^10.2.0",
|
"react-pdf": "^10.2.0",
|
||||||
"react-router-dom": "^6.20.1",
|
"react-router-dom": "^6.20.1",
|
||||||
|
"react-virtuoso": "^4.18.1",
|
||||||
|
"react-window": "^2.2.3",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"rehype-slug": "^6.0.0",
|
"rehype-slug": "^6.0.0",
|
||||||
|
|
|
||||||
|
|
@ -129,4 +129,3 @@ export function getDocumentUrl(projectId, path) {
|
||||||
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
|
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||||
return `/api/v1/files/${projectId}/document/${encodedPath}`
|
return `/api/v1/files/${projectId}/document/${encodedPath}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,4 +78,3 @@ export function getPreviewDocumentUrl(projectId, path) {
|
||||||
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
|
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||||
return `/api/v1/preview/${projectId}/document/${encodedPath}`
|
return `/api/v1/preview/${projectId}/document/${encodedPath}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.trigger:hover {
|
.trigger:hover {
|
||||||
color: #b8178d;
|
color: #1677ff;
|
||||||
background: rgba(184, 23, 141, 0.06);
|
background: rgba(22, 119, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 右侧区域 */
|
/* 右侧区域 */
|
||||||
|
|
@ -83,8 +83,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-icon:hover {
|
.header-icon:hover {
|
||||||
color: #b8178d;
|
color: #1677ff;
|
||||||
background: rgba(184, 23, 141, 0.06);
|
background: rgba(22, 119, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-link {
|
.header-link {
|
||||||
|
|
@ -100,8 +100,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-link:hover {
|
.header-link:hover {
|
||||||
color: #b8178d;
|
color: #1677ff;
|
||||||
background: rgba(184, 23, 141, 0.06);
|
background: rgba(22, 119, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info:hover {
|
.user-info:hover {
|
||||||
background: rgba(184, 23, 141, 0.06);
|
background: rgba(22, 119, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ function AppHeader({ collapsed, onToggle }) {
|
||||||
<div className="header-left">
|
<div className="header-left">
|
||||||
{/* Logo 区域 */}
|
{/* Logo 区域 */}
|
||||||
<div className="header-logo">
|
<div className="header-logo">
|
||||||
<h2 style={{ margin: 0, color: '#1890ff', fontWeight: 'bold' }}>NEX Docus</h2>
|
<h2 style={{ margin: 0, color: '#1677ff', fontWeight: 'bold' }}>NEX Docus</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 折叠按钮 */}
|
{/* 折叠按钮 */}
|
||||||
|
|
@ -98,11 +98,16 @@ function AppHeader({ collapsed, onToggle }) {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 消息中心 */}
|
{/* 消息中心 */}
|
||||||
<Badge count={5} size="small" offset={[-3, 3]}>
|
<div
|
||||||
<div className="header-icon" title="消息中心">
|
className="header-link"
|
||||||
|
title="消息中心"
|
||||||
|
onClick={() => handleHeaderMenuClick('messages')}
|
||||||
|
>
|
||||||
|
<Badge count={5} size="small" offset={[4, -2]}>
|
||||||
<BellOutlined />
|
<BellOutlined />
|
||||||
</div>
|
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<span className="ml-1">消息</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 用户下拉菜单 */}
|
{/* 用户下拉菜单 */}
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
.virtual-pdf-viewer-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-virtual-list {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-wrapper canvas {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
background: white;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-number {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 600px;
|
||||||
|
gap: 8px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 600px;
|
||||||
|
background: #fafafa;
|
||||||
|
border: 1px dashed #d9d9d9;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-skeleton {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
min-height: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-error {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 400px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-error p {
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-error {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文本层样式优化 */
|
||||||
|
.react-pdf__Page__textContent {
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 注释层样式优化 */
|
||||||
|
.react-pdf__Page__annotations {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Document容器样式 - 确保不限制高度 */
|
||||||
|
.react-pdf__Document {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,271 @@
|
||||||
|
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import { Document, Page, pdfjs } from 'react-pdf'
|
||||||
|
import { Button, Space, InputNumber, message, Spin } from 'antd'
|
||||||
|
import {
|
||||||
|
ZoomInOutlined,
|
||||||
|
ZoomOutOutlined,
|
||||||
|
VerticalAlignTopOutlined,
|
||||||
|
LeftOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import 'react-pdf/dist/Page/AnnotationLayer.css'
|
||||||
|
import 'react-pdf/dist/Page/TextLayer.css'
|
||||||
|
import './VirtualPDFViewer.css'
|
||||||
|
|
||||||
|
// 配置 PDF.js worker
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf-worker/pdf.worker.min.mjs'
|
||||||
|
|
||||||
|
function VirtualPDFViewer({ url, filename }) {
|
||||||
|
const [numPages, setNumPages] = useState(null)
|
||||||
|
const [scale, setScale] = useState(1.0)
|
||||||
|
const [pdfOriginalSize, setPdfOriginalSize] = useState({ width: 595, height: 842 }) // 默认 A4
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [visiblePages, setVisiblePages] = useState(new Set([1]))
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const pageRefs = useRef({})
|
||||||
|
|
||||||
|
// 使用 useMemo 避免不必要的重新加载
|
||||||
|
const fileConfig = useMemo(() => ({ url }), [url])
|
||||||
|
|
||||||
|
// Memoize PDF.js options to prevent unnecessary reloads
|
||||||
|
const pdfOptions = useMemo(() => ({
|
||||||
|
cMapUrl: 'https://unpkg.com/pdfjs-dist@5.4.296/cmaps/',
|
||||||
|
cMapPacked: true,
|
||||||
|
standardFontDataUrl: 'https://unpkg.com/pdfjs-dist@5.4.296/standard_fonts/',
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
// 根据 PDF 实际宽高和缩放比例计算页面高度
|
||||||
|
const pageHeight = useMemo(() => {
|
||||||
|
// 计算内容高度:缩放后的 PDF 高度 + 上下 padding (40px) + 页码文字区域 (20px)
|
||||||
|
return Math.ceil(pdfOriginalSize.height * scale) + 60
|
||||||
|
}, [scale, pdfOriginalSize.height])
|
||||||
|
|
||||||
|
const onDocumentLoadError = (error) => {
|
||||||
|
console.error('[PDF] Document load error:', error)
|
||||||
|
message.error('PDF文件加载失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Handle scroll to update visible pages
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
if (!containerRef.current || !numPages) return
|
||||||
|
|
||||||
|
const container = containerRef.current
|
||||||
|
const scrollTop = container.scrollTop
|
||||||
|
const containerHeight = container.clientHeight
|
||||||
|
|
||||||
|
// Calculate which pages are visible
|
||||||
|
// Add small tolerance (1px) to handle browser scroll precision issues
|
||||||
|
const pageIndex = scrollTop / pageHeight
|
||||||
|
let firstVisiblePage = Math.max(1, Math.ceil(pageIndex + 0.001))
|
||||||
|
|
||||||
|
// Special case: if scrolled to bottom, show last page
|
||||||
|
const isAtBottom = scrollTop + containerHeight >= container.scrollHeight - 1
|
||||||
|
if (isAtBottom) {
|
||||||
|
firstVisiblePage = numPages
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastVisiblePage = Math.min(numPages, Math.ceil((scrollTop + containerHeight) / pageHeight))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Add buffer pages (2 before and 2 after)
|
||||||
|
const newVisiblePages = new Set()
|
||||||
|
for (let i = Math.max(1, firstVisiblePage - 2); i <= Math.min(numPages, lastVisiblePage + 2); i++) {
|
||||||
|
newVisiblePages.add(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisiblePages(newVisiblePages)
|
||||||
|
setCurrentPage(firstVisiblePage)
|
||||||
|
}, [numPages, pageHeight])
|
||||||
|
|
||||||
|
const onDocumentLoadSuccess = useCallback(async (pdf) => {
|
||||||
|
setNumPages(pdf.numPages)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取第一页的原始尺寸,用于计算初始缩放
|
||||||
|
const page = await pdf.getPage(1)
|
||||||
|
const viewport = page.getViewport({ scale: 1.0 })
|
||||||
|
const { width, height } = viewport
|
||||||
|
setPdfOriginalSize({ width, height })
|
||||||
|
|
||||||
|
// 自动适应宽度:仅当 PDF 宽度超过容器时才进行缩放
|
||||||
|
if (containerRef.current) {
|
||||||
|
const containerWidth = containerRef.current.clientWidth - 40 // 减去左右内边距
|
||||||
|
if (width > containerWidth) {
|
||||||
|
const autoScale = Math.floor((containerWidth / width) * 10) / 10 // 保留一位小数
|
||||||
|
setScale(Math.min(Math.max(autoScale, 0.5), 1.0)) // 限制缩放比例,最高不超 1.0
|
||||||
|
} else {
|
||||||
|
setScale(1.0) // 宽度足够则保持 100%
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error calculating initial scale:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initially show first 3 pages
|
||||||
|
setVisiblePages(new Set([1, 2, 3]))
|
||||||
|
|
||||||
|
// Trigger scroll calculation after a short delay to ensure DOM is ready
|
||||||
|
setTimeout(() => {
|
||||||
|
handleScroll()
|
||||||
|
}, 200)
|
||||||
|
}, [handleScroll])
|
||||||
|
|
||||||
|
// Attach scroll listener
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
container.addEventListener('scroll', handleScroll)
|
||||||
|
return () => container.removeEventListener('scroll', handleScroll)
|
||||||
|
}, [handleScroll, numPages, pageHeight])
|
||||||
|
|
||||||
|
const zoomIn = () => {
|
||||||
|
setScale((prev) => Math.min(prev + 0.2, 3.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomOut = () => {
|
||||||
|
setScale((prev) => Math.max(prev - 0.2, 0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (value) => {
|
||||||
|
if (value >= 1 && value <= numPages && containerRef.current) {
|
||||||
|
const scrollTop = (value - 1) * pageHeight
|
||||||
|
const container = containerRef.current
|
||||||
|
container.scrollTo({ top: scrollTop, behavior: 'auto' })
|
||||||
|
|
||||||
|
// Manually trigger handleScroll after scrolling to ensure page number updates
|
||||||
|
setTimeout(() => {
|
||||||
|
handleScroll()
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToTop = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="virtual-pdf-viewer-container">
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<div className="pdf-toolbar">
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={<LeftOutlined />}
|
||||||
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Space.Compact>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={numPages || 1}
|
||||||
|
value={currentPage}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
size="small"
|
||||||
|
style={{ width: 60 }}
|
||||||
|
/>
|
||||||
|
<Button size="small" disabled>
|
||||||
|
/ {numPages || 0}
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
<Button
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage >= (numPages || 0)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<VerticalAlignTopOutlined />}
|
||||||
|
onClick={scrollToTop}
|
||||||
|
size="small"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
回到顶部
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ZoomOutOutlined />} onClick={zoomOut} size="small">
|
||||||
|
缩小
|
||||||
|
</Button>
|
||||||
|
<span style={{ minWidth: 50, textAlign: 'center' }}>
|
||||||
|
{Math.round(scale * 100)}%
|
||||||
|
</span>
|
||||||
|
<Button icon={<ZoomInOutlined />} onClick={zoomIn} size="small">
|
||||||
|
放大
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PDF内容区 - 自定义虚拟滚动 */}
|
||||||
|
<div className="pdf-content" ref={containerRef}>
|
||||||
|
<Document
|
||||||
|
file={fileConfig}
|
||||||
|
onLoadSuccess={onDocumentLoadSuccess}
|
||||||
|
onLoadError={onDocumentLoadError}
|
||||||
|
loading={
|
||||||
|
<div className="pdf-loading">
|
||||||
|
<Spin size="large" />
|
||||||
|
<div style={{ marginTop: 16 }}>正在加载PDF...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
error={<div className="pdf-error">PDF加载失败,请稍后重试</div>}
|
||||||
|
options={pdfOptions}
|
||||||
|
>
|
||||||
|
{numPages && (
|
||||||
|
<div style={{ height: numPages * pageHeight, position: 'relative' }}>
|
||||||
|
{Array.from({ length: numPages }, (_, index) => {
|
||||||
|
const pageNumber = index + 1
|
||||||
|
const isVisible = visiblePages.has(pageNumber)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={pageNumber}
|
||||||
|
ref={el => pageRefs.current[pageNumber] = el}
|
||||||
|
className="pdf-page-wrapper"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: index * pageHeight,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: pageHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isVisible ? (
|
||||||
|
<>
|
||||||
|
<Page
|
||||||
|
pageNumber={pageNumber}
|
||||||
|
scale={scale}
|
||||||
|
renderTextLayer={true}
|
||||||
|
renderAnnotationLayer={true}
|
||||||
|
loading={
|
||||||
|
<div className="pdf-page-loading">
|
||||||
|
<Spin size="small" />
|
||||||
|
<div>加载第 {pageNumber} 页...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="pdf-page-number">第 {pageNumber} 页</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="pdf-page-placeholder">
|
||||||
|
<div>第 {pageNumber} 页</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Document>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VirtualPDFViewer
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
/* 覆盖MainLayout的content-wrapper padding */
|
|
||||||
.document-editor-page {
|
.document-editor-page {
|
||||||
height: calc(90vh);
|
height: calc(100vh - 64px);
|
||||||
/* width: calc(100% + 32px); */
|
/* width: calc(100% + 32px); */
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
@ -14,44 +13,84 @@
|
||||||
|
|
||||||
.document-sider {
|
.document-sider {
|
||||||
border-right: 1px solid #f0f0f0;
|
border-right: 1px solid #f0f0f0;
|
||||||
overflow-y: auto;
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sider-header {
|
.sider-header {
|
||||||
padding: 16px;
|
padding: 16px 20px;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sider-header h3 {
|
.sider-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sider-actions {
|
.sider-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sider-actions .ant-btn {
|
.mode-toggle-btn {
|
||||||
|
border-radius: 6px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
height: 32px !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
padding: 0 12px !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle-btn.exit-edit {
|
||||||
|
background: linear-gradient(135deg, #00b96b 0%, #52c41a 100%) !important;
|
||||||
|
color: white !important;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 185, 107, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-toggle-btn.exit-edit:hover {
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 185, 107, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-actions .ant-btn:not(.mode-toggle-btn) {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
color: rgba(0, 0, 0, 0.65);
|
color: rgba(0, 0, 0, 0.65);
|
||||||
background: #f5f5f5;
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sider-actions .ant-btn:hover {
|
.sider-actions .ant-btn:not(.mode-toggle-btn):hover {
|
||||||
color: #1890ff;
|
background: #fff;
|
||||||
background: #e6f7ff;
|
border-color: #d9d9d9;
|
||||||
border-color: #91d5ff;
|
color: #1677ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-tree {
|
.file-tree {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 修复Tree组件文档名过长的显示问题 */
|
/* 修复Tree组件文档名过长的显示问题 */
|
||||||
|
|
@ -74,7 +113,8 @@
|
||||||
/* 确保Tree节点标题区域不折行 */
|
/* 确保Tree节点标题区域不折行 */
|
||||||
.file-tree .ant-tree-node-content-wrapper .ant-tree-title {
|
.file-tree .ant-tree-node-content-wrapper .ant-tree-title {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: calc(100% - 24px); /* 预留图标空间 */
|
max-width: calc(100% - 24px);
|
||||||
|
/* 预留图标空间 */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap !important;
|
white-space: nowrap !important;
|
||||||
|
|
@ -86,7 +126,8 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex: 1; /* Ensure it allows children to fill width */
|
flex: 1;
|
||||||
|
/* Ensure it allows children to fill width */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Increase hit area for context menu */
|
/* Increase hit area for context menu */
|
||||||
|
|
@ -114,7 +155,8 @@
|
||||||
.file-tree .ant-menu-item,
|
.file-tree .ant-menu-item,
|
||||||
.file-tree .ant-menu-submenu-title {
|
.file-tree .ant-menu-submenu-title {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex !important; /* Ensure flex layout for item */
|
display: flex !important;
|
||||||
|
/* Ensure flex layout for item */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,7 +202,8 @@
|
||||||
.bytemd-wrapper {
|
.bytemd-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column; /* Changed to column to force child stretch width */
|
flex-direction: column;
|
||||||
|
/* Changed to column to force child stretch width */
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -196,7 +239,8 @@
|
||||||
border: 1px solid #d9d9d9;
|
border: 1px solid #d9d9d9;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-width: none !important; /* Ensure no max-width constraint */
|
max-width: none !important;
|
||||||
|
/* Ensure no max-width constraint */
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,7 +249,8 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-bottom: 1px solid #d9d9d9;
|
border-bottom: 1px solid #d9d9d9;
|
||||||
background-color: #fafafa;
|
background-color: #fafafa;
|
||||||
box-sizing: border-box; /* Added for consistent box model */
|
box-sizing: border-box;
|
||||||
|
/* Added for consistent box model */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 编辑和预览区域容器 */
|
/* 编辑和预览区域容器 */
|
||||||
|
|
@ -215,8 +260,10 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
box-sizing: border-box; /* Added for consistent box model */
|
box-sizing: border-box;
|
||||||
min-width: 0; /* Prevent flex item from overflowing */
|
/* Added for consistent box model */
|
||||||
|
min-width: 0;
|
||||||
|
/* Prevent flex item from overflowing */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 编辑区域 - 固定50%宽度 */
|
/* 编辑区域 - 固定50%宽度 */
|
||||||
|
|
@ -228,8 +275,10 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
max-width: 50% !important;
|
max-width: 50% !important;
|
||||||
box-sizing: border-box; /* Added for consistent box model */
|
box-sizing: border-box;
|
||||||
min-width: 0; /* Prevent flex item from overflowing */
|
/* Added for consistent box model */
|
||||||
|
min-width: 0;
|
||||||
|
/* Prevent flex item from overflowing */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 预览区域 - 固定50%宽度 */
|
/* 预览区域 - 固定50%宽度 */
|
||||||
|
|
@ -242,8 +291,10 @@
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
max-width: 50% !important;
|
max-width: 50% !important;
|
||||||
box-sizing: border-box; /* Added for consistent box model */
|
box-sizing: border-box;
|
||||||
min-width: 0; /* Prevent flex item from overflowing */
|
/* Added for consistent box model */
|
||||||
|
min-width: 0;
|
||||||
|
/* Prevent flex item from overflowing */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CodeMirror 容器 */
|
/* CodeMirror 容器 */
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ function DocumentEditor() {
|
||||||
const [openKeys, setOpenKeys] = useState([]) // Menu组件的展开项
|
const [openKeys, setOpenKeys] = useState([]) // Menu组件的展开项
|
||||||
const [uploadProgress, setUploadProgress] = useState(0) // 上传进度
|
const [uploadProgress, setUploadProgress] = useState(0) // 上传进度
|
||||||
const [uploading, setUploading] = useState(false) // 是否正在上传
|
const [uploading, setUploading] = useState(false) // 是否正在上传
|
||||||
|
const [fileList, setFileList] = useState([]) // 控制上传文件列表
|
||||||
const [selectedMenuKey, setSelectedMenuKey] = useState(null) // 当前选中的菜单项(文件或文件夹)
|
const [selectedMenuKey, setSelectedMenuKey] = useState(null) // 当前选中的菜单项(文件或文件夹)
|
||||||
const uploadingRef = useRef(false) // 使用ref防止重复上传
|
const uploadingRef = useRef(false) // 使用ref防止重复上传
|
||||||
const [isPdfSelected, setIsPdfSelected] = useState(false) // 是否选中了PDF文件
|
const [isPdfSelected, setIsPdfSelected] = useState(false) // 是否选中了PDF文件
|
||||||
|
|
@ -370,15 +371,19 @@ function DocumentEditor() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { fileList } = info
|
const { fileList: currentFileList } = info
|
||||||
|
|
||||||
|
// 立即更新state以显示当前选择的文件
|
||||||
|
setFileList(currentFileList)
|
||||||
|
|
||||||
// 过滤出有效文件
|
// 过滤出有效文件
|
||||||
const mdFiles = fileList.filter((f) => f.name.endsWith('.md'))
|
const mdFiles = currentFileList.filter((f) => f.name.endsWith('.md'))
|
||||||
const pdfFiles = fileList.filter((f) => f.name.toLowerCase().endsWith('.pdf'))
|
const pdfFiles = currentFileList.filter((f) => f.name.toLowerCase().endsWith('.pdf'))
|
||||||
const allFiles = [...mdFiles, ...pdfFiles]
|
const allFiles = [...mdFiles, ...pdfFiles]
|
||||||
|
|
||||||
if (allFiles.length === 0) {
|
if (allFiles.length === 0) {
|
||||||
Toast.warning('提示', '请选择.md或.pdf格式的文档')
|
Toast.warning('提示', '请选择.md或.pdf格式的文档')
|
||||||
|
setFileList([]) // 清空文件列表
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -457,6 +462,7 @@ function DocumentEditor() {
|
||||||
Toast.success('成功', `成功上传 ${successCount} 个文档`)
|
Toast.success('成功', `成功上传 ${successCount} 个文档`)
|
||||||
fetchTree()
|
fetchTree()
|
||||||
// 清除文件选择
|
// 清除文件选择
|
||||||
|
setFileList([])
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = ''
|
fileInputRef.current.value = ''
|
||||||
}
|
}
|
||||||
|
|
@ -783,16 +789,17 @@ function DocumentEditor() {
|
||||||
className="document-sider"
|
className="document-sider"
|
||||||
>
|
>
|
||||||
<div className="sider-header">
|
<div className="sider-header">
|
||||||
<h3>项目文档(编辑模式)</h3>
|
<h2>项目文档</h2>
|
||||||
<div className="sider-actions">
|
<div className="sider-actions">
|
||||||
<Tooltip title="返回浏览">
|
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="primary"
|
||||||
size="middle"
|
size="small"
|
||||||
|
className="mode-toggle-btn exit-edit"
|
||||||
icon={<EyeOutlined />}
|
icon={<EyeOutlined />}
|
||||||
onClick={() => navigate(`/projects/${projectId}/docs`)}
|
onClick={() => navigate(`/projects/${projectId}/docs`)}
|
||||||
/>
|
>
|
||||||
</Tooltip>
|
退出编辑
|
||||||
|
</Button>
|
||||||
<Tooltip title="添加文件">
|
<Tooltip title="添加文件">
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -813,6 +820,7 @@ function DocumentEditor() {
|
||||||
multiple
|
multiple
|
||||||
accept=".md,.pdf,application/pdf"
|
accept=".md,.pdf,application/pdf"
|
||||||
showUploadList={false}
|
showUploadList={false}
|
||||||
|
fileList={fileList}
|
||||||
beforeUpload={() => false}
|
beforeUpload={() => false}
|
||||||
onChange={handleImportDocuments}
|
onChange={handleImportDocuments}
|
||||||
>
|
>
|
||||||
|
|
@ -913,9 +921,9 @@ function DocumentEditor() {
|
||||||
<Modal
|
<Modal
|
||||||
title={
|
title={
|
||||||
operationType === 'create_file'
|
operationType === 'create_file'
|
||||||
? `创建文件 (dir: /${creationParentPath || '/'})`
|
? `创建文件 (dir: /${creationParentPath || ''})`
|
||||||
: operationType === 'create_dir'
|
: operationType === 'create_dir'
|
||||||
? `创建文件夹 (dir: /${creationParentPath || '/'})`
|
? `创建文件夹 (dir: /${creationParentPath || ''})`
|
||||||
: '重命名'
|
: '重命名'
|
||||||
}
|
}
|
||||||
open={modalVisible}
|
open={modalVisible}
|
||||||
|
|
|
||||||
|
|
@ -17,41 +17,61 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-sider-header {
|
.docs-sider-header {
|
||||||
padding: 16px;
|
padding: 16px 20px;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-sider-header h2 {
|
.docs-sider-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-sider-actions {
|
.docs-sider-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-sider-actions .ant-btn {
|
.mode-toggle-btn {
|
||||||
background: #f5f5f5;
|
border-radius: 6px !important;
|
||||||
border: 1px solid #e8e8e8;
|
font-weight: 500 !important;
|
||||||
|
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.2);
|
||||||
|
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%) !important;
|
||||||
|
border: none !important;
|
||||||
|
height: 32px !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
padding: 0 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-sider-actions .ant-btn-text {
|
.mode-toggle-btn:hover {
|
||||||
color: rgba(0, 0, 0, 0.65);
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 10px rgba(22, 119, 255, 0.3);
|
||||||
|
filter: brightness(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-sider-actions .ant-btn:hover {
|
.docs-sider-actions .ant-btn:not(.mode-toggle-btn) {
|
||||||
border-color: #91d5ff;
|
background: #f0f0f0;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-sider-actions .ant-btn-text:hover {
|
.docs-sider-actions .ant-btn-text:hover {
|
||||||
color: #1890ff;
|
background: #e6f4ff;
|
||||||
background: #e6f7ff;
|
border-color: #91caff;
|
||||||
|
color: #1677ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-menu {
|
.docs-menu {
|
||||||
|
|
@ -112,7 +132,8 @@
|
||||||
|
|
||||||
.toc-content .ant-anchor {
|
.toc-content .ant-anchor {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-bottom: 65px; /* 给Anchor组件添加底部内边距,避免最后一项被遮挡 */
|
padding-bottom: 65px;
|
||||||
|
/* 给Anchor组件添加底部内边距,避免最后一项被遮挡 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.toc-content .ant-anchor-link {
|
.toc-content .ant-anchor-link {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import rehypeHighlight from 'rehype-highlight'
|
||||||
import 'highlight.js/styles/github.css'
|
import 'highlight.js/styles/github.css'
|
||||||
import { getProjectTree, getFileContent, getDocumentUrl } from '@/api/file'
|
import { getProjectTree, getFileContent, getDocumentUrl } from '@/api/file'
|
||||||
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
||||||
import PDFViewer from '@/components/PDFViewer/PDFViewer'
|
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||||||
import './DocumentPage.css'
|
import './DocumentPage.css'
|
||||||
|
|
||||||
const { Sider, Content } = Layout
|
const { Sider, Content } = Layout
|
||||||
|
|
@ -335,18 +335,19 @@ function DocumentPage() {
|
||||||
{/* 左侧目录 */}
|
{/* 左侧目录 */}
|
||||||
<Sider width={280} className="docs-sider" theme="light">
|
<Sider width={280} className="docs-sider" theme="light">
|
||||||
<div className="docs-sider-header">
|
<div className="docs-sider-header">
|
||||||
<h2>项目文档(浏览模式)</h2>
|
<h2>项目文档</h2>
|
||||||
<div className="docs-sider-actions">
|
<div className="docs-sider-actions">
|
||||||
{/* 只有 owner/admin/editor 可以编辑 */}
|
{/* 只有 owner/admin/editor 可以编辑 */}
|
||||||
{userRole !== 'viewer' && (
|
{userRole !== 'viewer' && (
|
||||||
<Tooltip title="编辑模式">
|
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="primary"
|
||||||
size="middle"
|
size="small"
|
||||||
|
className="mode-toggle-btn"
|
||||||
icon={<EditOutlined />}
|
icon={<EditOutlined />}
|
||||||
onClick={handleEdit}
|
onClick={handleEdit}
|
||||||
/>
|
>
|
||||||
</Tooltip>
|
编辑模式
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Tooltip title="分享">
|
<Tooltip title="分享">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -389,7 +390,7 @@ function DocumentPage() {
|
||||||
</Spin>
|
</Spin>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === 'pdf' ? (
|
) : viewMode === 'pdf' ? (
|
||||||
<PDFViewer
|
<VirtualPDFViewer
|
||||||
url={pdfUrl}
|
url={pdfUrl}
|
||||||
filename={pdfFilename}
|
filename={pdfFilename}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import rehypeSlug from 'rehype-slug'
|
||||||
import rehypeHighlight from 'rehype-highlight'
|
import rehypeHighlight from 'rehype-highlight'
|
||||||
import 'highlight.js/styles/github.css'
|
import 'highlight.js/styles/github.css'
|
||||||
import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl } from '@/api/share'
|
import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl } from '@/api/share'
|
||||||
import PDFViewer from '@/components/PDFViewer/PDFViewer'
|
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||||||
import './PreviewPage.css'
|
import './PreviewPage.css'
|
||||||
|
|
||||||
const { Sider, Content } = Layout
|
const { Sider, Content } = Layout
|
||||||
|
|
@ -316,7 +316,7 @@ function PreviewPage() {
|
||||||
</Spin>
|
</Spin>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === 'pdf' ? (
|
) : viewMode === 'pdf' ? (
|
||||||
<PDFViewer
|
<VirtualPDFViewer
|
||||||
url={pdfUrl}
|
url={pdfUrl}
|
||||||
filename={pdfFilename}
|
filename={pdfFilename}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -560,7 +560,7 @@ function ProjectList({ type = 'my' }) {
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="分享项目"
|
title="分享设置"
|
||||||
open={shareModalVisible}
|
open={shareModalVisible}
|
||||||
onCancel={() => setShareModalVisible(false)}
|
onCancel={() => setShareModalVisible(false)}
|
||||||
footer={null}
|
footer={null}
|
||||||
|
|
@ -569,7 +569,7 @@ function ProjectList({ type = 'my' }) {
|
||||||
{shareInfo && (
|
{shareInfo && (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
<div>
|
<div>
|
||||||
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>分享链接</label>
|
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>链接:</label>
|
||||||
<Input
|
<Input
|
||||||
value={`${window.location.origin}${shareInfo.share_url}`}
|
value={`${window.location.origin}${shareInfo.share_url}`}
|
||||||
readOnly
|
readOnly
|
||||||
|
|
|
||||||
|
|
@ -4504,6 +4504,16 @@ react-router@6.30.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
"@remix-run/router" "1.23.1"
|
"@remix-run/router" "1.23.1"
|
||||||
|
|
||||||
|
react-virtuoso@^4.18.1:
|
||||||
|
version "4.18.1"
|
||||||
|
resolved "https://registry.npmmirror.com/react-virtuoso/-/react-virtuoso-4.18.1.tgz#3eb7078f2739a31b96c723374019e587deeb6ebc"
|
||||||
|
integrity sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==
|
||||||
|
|
||||||
|
react-window@^2.2.3:
|
||||||
|
version "2.2.3"
|
||||||
|
resolved "https://registry.npmmirror.com/react-window/-/react-window-2.2.3.tgz#f8ffdddbb612ccd3e1314b59fce79af85d3f15e3"
|
||||||
|
integrity sha512-gTRqQYC8ojbiXyd9duYFiSn2TJw0ROXCgYjenOvNKITWzK0m0eCvkUsEUM08xvydkMh7ncp+LE0uS3DeNGZxnQ==
|
||||||
|
|
||||||
react@^18.2.0:
|
react@^18.2.0:
|
||||||
version "18.3.1"
|
version "18.3.1"
|
||||||
resolved "https://registry.npmmirror.com/react/-/react-18.3.1.tgz"
|
resolved "https://registry.npmmirror.com/react/-/react-18.3.1.tgz"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue