修复pdf导出

main
mula.liu 2026-02-26 19:19:29 +08:00
parent cbef580776
commit dc112de840
6 changed files with 107 additions and 35 deletions

View File

@ -683,4 +683,43 @@ async def export_directory(
headers={ headers={
"Content-Disposition": f"attachment; filename={zip_filename}" "Content-Disposition": f"attachment; filename={zip_filename}"
} }
)
@router.get("/{project_id}/export-pdf")
async def export_pdf(
project_id: int,
path: str,
request: Request,
current_user: User = Depends(get_user_from_token_or_query),
db: AsyncSession = Depends(get_db)
):
"""已登录用户导出 Markdown 为 PDF"""
import re
from urllib.parse import quote
from app.services.pdf_service import pdf_service
project = await check_project_access(project_id, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
content = await storage_service.read_file(file_path)
filename = Path(path).stem + ".pdf"
# 将 API 图片 URL 转为文件系统相对路径
content = re.sub(r'/api/v1/files/\d+/assets/', '_assets/', content)
project_root = storage_service.get_secure_path(project.storage_key)
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
encoded_filename = quote(filename)
return StreamingResponse(
pdf_buffer,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
"Content-Type": "application/pdf"
}
) )

View File

@ -47,3 +47,4 @@ websockets==15.0.1
Whoosh==2.7.4 Whoosh==2.7.4
markdown==3.5.2 markdown==3.5.2
weasyprint==61.2 weasyprint==61.2
pydyf<0.11.0

View File

@ -129,3 +129,11 @@ 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}`
} }
/**
* 导出 Markdown PDF已登录用户
*/
export function getExportPdfUrl(projectId, path) {
const encodedPath = encodeURIComponent(path)
return `/api/v1/files/${projectId}/export-pdf?path=${encodedPath}`
}

View File

@ -0,0 +1,37 @@
import { FloatButton, Tooltip } from 'antd'
import { MenuOutlined, FilePdfOutlined, VerticalAlignTopOutlined } from '@ant-design/icons'
/**
* 文档浮动操作按钮组导出 PDF + 回到顶部
* @param {object} props
* @param {function} props.onExportPDF - 导出 PDF 回调
* @param {React.RefObject} props.scrollRef - 滚动容器 ref
* @param {number} [props.right=24] - 距右侧距离
*/
export default function DocFloatActions({ onExportPDF, scrollRef, right = 24 }) {
return (
<FloatButton.Group
trigger="hover"
type="primary"
icon={<MenuOutlined />}
style={{ right }}
>
<Tooltip title="导出 PDF" placement="left">
<FloatButton
icon={<FilePdfOutlined />}
onClick={onExportPDF}
/>
</Tooltip>
<Tooltip title="回到顶部" placement="left">
<FloatButton
icon={<VerticalAlignTopOutlined />}
onClick={() => {
if (scrollRef?.current) {
scrollRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}}
/>
</Tooltip>
</FloatButton.Group>
)
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space, Dropdown, Empty } from 'antd' import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space, Dropdown, Empty } from 'antd'
import { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons' import { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, CloseOutlined, MenuOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw' import rehypeRaw from 'rehype-raw'
@ -10,11 +10,12 @@ import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github.css' import 'highlight.js/styles/github.css'
import Highlighter from 'react-highlight-words' import Highlighter from 'react-highlight-words'
import GithubSlugger from 'github-slugger' import GithubSlugger from 'github-slugger'
import { getProjectTree, getFileContent, getDocumentUrl } from '@/api/file' import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file'
import { gitPull, gitPush, getGitRepos } from '@/api/project' import { gitPull, gitPush, getGitRepos } from '@/api/project'
import { getProjectShareInfo, updateShareSettings } from '@/api/share' import { getProjectShareInfo, updateShareSettings } from '@/api/share'
import { searchDocuments } from '@/api/search' import { searchDocuments } from '@/api/search'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
import Toast from '@/components/Toast/Toast' import Toast from '@/components/Toast/Toast'
import './DocumentPage.css' import './DocumentPage.css'
@ -866,16 +867,19 @@ function DocumentPage() {
)} )}
</div> </div>
{/* 返回顶部按钮 - 仅在markdown模式显示 */} {/* 浮动按钮组 - 仅在markdown模式显示 */}
{viewMode === 'markdown' && ( {viewMode === 'markdown' && (
<FloatButton <DocFloatActions
icon={<VerticalAlignTopOutlined />} scrollRef={contentRef}
type="primary" right={tocCollapsed ? 24 : 280}
style={{ right: tocCollapsed ? 24 : 280 }} onExportPDF={() => {
onClick={() => { if (!selectedFile) return
if (contentRef.current) { let url = getExportPdfUrl(projectId, selectedFile)
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) const token = localStorage.getItem('access_token')
if (token) {
url += `&token=${encodeURIComponent(token)}`
} }
window.open(url, '_blank')
}} }}
/> />
)} )}

View File

@ -1,8 +1,9 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useSearchParams, useNavigate } from 'react-router-dom' import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
import { Layout, Menu, Spin, FloatButton, Button, Modal, Input, Drawer, Anchor, Empty, Tooltip } from 'antd' import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Anchor, Empty, Tooltip } from 'antd'
import Toast from '@/components/Toast/Toast' import Toast from '@/components/Toast/Toast'
import { VerticalAlignTopOutlined, MenuOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, SearchOutlined, CloseOutlined, CloudDownloadOutlined } from '@ant-design/icons' import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
import { MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight' import rehypeHighlight from 'rehype-highlight'
@ -630,29 +631,11 @@ function PreviewPage() {
</div> </div>
{viewMode === 'markdown' && ( {viewMode === 'markdown' && (
<FloatButton.Group <DocFloatActions
trigger="hover" scrollRef={contentRef}
type="primary" right={!isMobile && !tocCollapsed ? 280 : 24}
icon={<MenuOutlined />} onExportPDF={handleExportPDF}
style={{ right: !isMobile && !tocCollapsed ? 280 : 24 }} />
>
<Tooltip title="导出 PDF" placement="left">
<FloatButton
icon={<FilePdfOutlined />}
onClick={handleExportPDF}
/>
</Tooltip>
<Tooltip title="回到顶部" placement="left">
<FloatButton
icon={<VerticalAlignTopOutlined />}
onClick={() => {
if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}}
/>
</Tooltip>
</FloatButton.Group>
)} )}
</Content> </Content>