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' })
- }
- }}
- />
-
-
+
)}