From dc112de8405db79ecb4c9fe03bca443e32f55b47 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Thu, 26 Feb 2026 19:19:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dpdf=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/files.py | 39 +++++++++++++++++++ backend/requirements.txt | 1 + frontend/src/api/file.js | 8 ++++ .../DocFloatActions/DocFloatActions.jsx | 37 ++++++++++++++++++ frontend/src/pages/Document/DocumentPage.jsx | 24 +++++++----- frontend/src/pages/Preview/PreviewPage.jsx | 33 ++++------------ 6 files changed, 107 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/DocFloatActions/DocFloatActions.jsx diff --git a/backend/app/api/v1/files.py b/backend/app/api/v1/files.py index 8c826ef..0dc4f41 100644 --- a/backend/app/api/v1/files.py +++ b/backend/app/api/v1/files.py @@ -683,4 +683,43 @@ async def export_directory( headers={ "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" + } ) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index d7d6095..4d00991 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -47,3 +47,4 @@ websockets==15.0.1 Whoosh==2.7.4 markdown==3.5.2 weasyprint==61.2 +pydyf<0.11.0 diff --git a/frontend/src/api/file.js b/frontend/src/api/file.js index 86b742d..26f0064 100644 --- a/frontend/src/api/file.js +++ b/frontend/src/api/file.js @@ -129,3 +129,11 @@ export function getDocumentUrl(projectId, path) { const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/') 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}` +} diff --git a/frontend/src/components/DocFloatActions/DocFloatActions.jsx b/frontend/src/components/DocFloatActions/DocFloatActions.jsx new file mode 100644 index 0000000..9a85079 --- /dev/null +++ b/frontend/src/components/DocFloatActions/DocFloatActions.jsx @@ -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 ( + } + style={{ right }} + > + + } + onClick={onExportPDF} + /> + + + } + onClick={() => { + if (scrollRef?.current) { + scrollRef.current.scrollTo({ top: 0, behavior: 'smooth' }) + } + }} + /> + + + ) +} diff --git a/frontend/src/pages/Document/DocumentPage.jsx b/frontend/src/pages/Document/DocumentPage.jsx index 64f6675..1f5a6e7 100644 --- a/frontend/src/pages/Document/DocumentPage.jsx +++ b/frontend/src/pages/Document/DocumentPage.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useMemo } from 'react' 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 { 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 remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' @@ -10,11 +10,12 @@ import rehypeHighlight from 'rehype-highlight' import 'highlight.js/styles/github.css' import Highlighter from 'react-highlight-words' 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 { getProjectShareInfo, updateShareSettings } from '@/api/share' import { searchDocuments } from '@/api/search' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' +import DocFloatActions from '@/components/DocFloatActions/DocFloatActions' import Toast from '@/components/Toast/Toast' import './DocumentPage.css' @@ -866,16 +867,19 @@ function DocumentPage() { )} - {/* 返回顶部按钮 - 仅在markdown模式显示 */} + {/* 浮动按钮组 - 仅在markdown模式显示 */} {viewMode === 'markdown' && ( - } - type="primary" - style={{ right: tocCollapsed ? 24 : 280 }} - onClick={() => { - if (contentRef.current) { - contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) + { + if (!selectedFile) return + let url = getExportPdfUrl(projectId, selectedFile) + const token = localStorage.getItem('access_token') + if (token) { + url += `&token=${encodeURIComponent(token)}` } + window.open(url, '_blank') }} /> )} diff --git a/frontend/src/pages/Preview/PreviewPage.jsx b/frontend/src/pages/Preview/PreviewPage.jsx index 3366814..65a7403 100644 --- a/frontend/src/pages/Preview/PreviewPage.jsx +++ b/frontend/src/pages/Preview/PreviewPage.jsx @@ -1,8 +1,9 @@ import { useState, useEffect, useRef, useMemo } from 'react' 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 { 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 remarkGfm from 'remark-gfm' import rehypeHighlight from 'rehype-highlight' @@ -630,29 +631,11 @@ function PreviewPage() { {viewMode === 'markdown' && ( - } - style={{ right: !isMobile && !tocCollapsed ? 280 : 24 }} - > - - } - onClick={handleExportPDF} - /> - - - } - onClick={() => { - if (contentRef.current) { - contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) - } - }} - /> - - + )}