预览页面增加了pdf导出

main
mula.liu 2026-02-26 17:52:09 +08:00
parent e6794a1952
commit cbef580776
11 changed files with 338 additions and 83 deletions

View File

@ -1,17 +0,0 @@
## Stage 1: Auto-append .md Extension
**Goal**: Ensure new files created in the editor default to .md if no extension is provided.
**Files**: `frontend/src/pages/Document/DocumentEditor.jsx`
**Status**: In Progress
## Stage 2: Project Ownership Transfer
**Goal**: Allow project owners to transfer ownership to another user.
**Files**:
- `backend/app/api/v1/projects.py` (Add transfer API)
- `frontend/src/api/project.js` (Add frontend API method)
- `frontend/src/pages/ProjectList/ProjectList.jsx` (Add Transfer UI)
**Status**: Not Started
## Stage 3: Specific File Share Link
**Goal**: Add a context menu option to share specific files if the project is shared.
**Files**: `frontend/src/pages/Document/DocumentEditor.jsx`
**Status**: Not Started

View File

@ -14,17 +14,27 @@ ENV PYTHONUNBUFFERED=1 \
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \ RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources
# 安装系统依赖 # 安装系统依赖(含 WeasyPrint 渲染库 + CJK/等宽系统字体)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \ gcc \
default-libmysqlclient-dev \ default-libmysqlclient-dev \
pkg-config \ pkg-config \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf-2.0-0 \
libcairo2 \
libffi-dev \
shared-mime-info \
fontconfig \
fonts-dejavu-core \
fonts-wqy-microhei \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# 复制依赖文件 # 复制依赖文件
COPY requirements.txt . COPY requirements.txt .
# 使用清华源安装 Python 依赖 # 安装 Python 依赖
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 复制项目文件 # 复制项目文件

View File

@ -2,12 +2,13 @@
项目预览相关 API支持公开和私密项目 项目预览相关 API支持公开和私密项目
""" """
from fastapi import APIRouter, Depends, HTTPException, Header from fastapi import APIRouter, Depends, HTTPException, Header
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from typing import Optional from typing import Optional
import mimetypes import mimetypes
from pathlib import Path
from app.core.database import get_db from app.core.database import get_db
from app.core.deps import get_current_user_optional, security_optional from app.core.deps import get_current_user_optional, security_optional
@ -17,6 +18,7 @@ from app.models.project import Project, ProjectMember
from app.models.user import User from app.models.user import User
from app.schemas.response import success_response from app.schemas.response import success_response
from app.services.storage import storage_service from app.services.storage import storage_service
from app.services.pdf_service import pdf_service
router = APIRouter() router = APIRouter()
@ -231,3 +233,85 @@ async def get_preview_document(
content_type, _ = mimetypes.guess_type(str(file_path)) content_type, _ = mimetypes.guess_type(str(file_path))
return FileResponse(path=str(file_path), media_type=content_type, filename=file_path.name) return FileResponse(path=str(file_path), media_type=content_type, filename=file_path.name)
@router.get("/{project_id}/export-pdf")
async def export_preview_pdf(
project_id: int,
path: str,
password: Optional[str] = Header(None, alias="X-Access-Password"),
access_pass: Optional[str] = None, # 支持密码查询参数
token: Optional[str] = None, # 支持token查询参数
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_optional),
db: AsyncSession = Depends(get_db)
):
"""导出预览项目的文档为 PDF"""
# 获取当前用户支持header或query参数
current_user = None
token_str = None
if credentials:
token_str = credentials.credentials
elif token:
token_str = token
if token_str:
try:
user_id_from_redis = await TokenCache.get_user_id(token_str)
if user_id_from_redis:
payload = decode_access_token(token_str)
if payload:
user_id_str = payload.get("sub")
if user_id_str:
user_id = int(user_id_str)
result = await db.execute(select(User).where(User.id == user_id))
current_user = result.scalar_one_or_none()
except Exception:
pass
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查访问权限
await check_preview_access(project, current_user, db)
# 验证密码
provided_password = password or access_pass
if project.access_pass:
if not provided_password or project.access_pass != provided_password:
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
# 获取文件
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="文件不存在")
# 读取 Markdown 内容并转换为 PDF
content = await storage_service.read_file(file_path)
filename = Path(path).stem + ".pdf"
# 将 markdown 中的 API 图片 URL 转为文件系统相对路径,以便 WeasyPrint 解析
import re
content = re.sub(r'/api/v1/files/\d+/assets/', '_assets/', content)
# 生成 PDF 字节流,传入项目根目录作为 base_url
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))
# 中文文件名需要 RFC 5987 编码
from urllib.parse import quote
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

@ -0,0 +1,113 @@
"""
PDF 生成服务 - 基于 WeasyPrint + 系统字体
"""
import markdown
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
import io
class PDFService:
def __init__(self):
self.font_config = FontConfiguration()
def get_css(self):
return """
@page {
margin: 2cm;
@bottom-right {
content: counter(page);
font-size: 9pt;
}
}
/* 全局macOS Hiragino Sans GB / Heiti SCDocker WenQuanYi Micro Hei */
* {
font-family: "Hiragino Sans GB", "Heiti SC", "WenQuanYi Micro Hei",
"Arial Unicode MS", sans-serif !important;
}
body {
font-size: 11pt;
line-height: 1.6;
color: #333;
background-color: #fff;
}
h1, h2, h3, h4, h5, h6 {
border-bottom: 1px solid #eee;
padding-bottom: 0.3em;
margin-top: 1.5em;
margin-bottom: 1em;
font-weight: bold;
}
/* 代码块等宽字体优先中文回退到对应平台的 CJK 字体 */
pre, code, pre *, code * {
font-family: "Menlo", "DejaVu Sans Mono", "Courier New",
"Hiragino Sans GB", "Heiti SC", "WenQuanYi Micro Hei", monospace !important;
background-color: transparent;
}
pre {
background-color: #f6f8fa !important;
padding: 16px;
border-radius: 6px;
white-space: pre-wrap;
word-break: break-all;
display: block;
}
code {
background-color: rgba(175, 184, 193, 0.2);
padding: 0.2em 0.4em;
border-radius: 6px;
}
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1em;
}
th, td {
border: 1px solid #dfe2e5;
padding: 6px 13px;
}
tr:nth-child(2n) {
background-color: #f6f8fa;
}
img {
max-width: 100%;
}
blockquote {
border-left: 0.25em solid #dfe2e5;
color: #6a737d;
padding: 0 1em;
margin-left: 0;
}
"""
async def md_to_pdf(self, md_content: str, title: str = "Document", base_url: str = None) -> io.BytesIO:
"""将 Markdown 转换为 PDF 字节流"""
html_content = markdown.markdown(
md_content,
extensions=['extra', 'codehilite', 'toc', 'tables']
)
full_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{title}</title>
</head>
<body class="markdown-body">
{html_content}
</body>
</html>
"""
pdf_buffer = io.BytesIO()
css = CSS(string=self.get_css(), font_config=self.font_config)
HTML(string=full_html, base_url=base_url).write_pdf(
pdf_buffer,
stylesheets=[css],
font_config=self.font_config
)
pdf_buffer.seek(0)
return pdf_buffer
pdf_service = PDFService()

View File

@ -45,3 +45,5 @@ uvloop==0.22.1
watchfiles==1.1.1 watchfiles==1.1.1
websockets==15.0.1 websockets==15.0.1
Whoosh==2.7.4 Whoosh==2.7.4
markdown==3.5.2
weasyprint==61.2

View File

@ -78,3 +78,11 @@ 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}`
} }
/**
* 导出 PDF
*/
export function exportPDF(projectId, path) {
const encodedPath = encodeURIComponent(path)
return `/api/v1/preview/${projectId}/export-pdf?path=${encodedPath}`
}

View File

@ -33,10 +33,10 @@
/* Collapse Trigger */ /* Collapse Trigger */
.collapse-trigger { .collapse-trigger {
position: absolute; position: absolute;
right: -12px; right: -14px;
top: 32px; top: 28px;
width: 24px; width: 28px;
height: 24px; height: 28px;
background: var(--bg-color); background: var(--bg-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 50%; border-radius: 50%;
@ -45,14 +45,24 @@
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
z-index: 10; z-index: 10;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
color: var(--text-color-secondary); color: var(--text-color-secondary);
transition: all 0.3s; font-size: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
}
.modern-sidebar:hover .collapse-trigger,
.collapse-trigger:focus {
opacity: 1;
} }
.collapse-trigger:hover { .collapse-trigger:hover {
color: #1677ff; color: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); background: #1677ff;
border-color: #1677ff;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.35);
transform: scale(1.1);
} }
/* Menu Area */ /* Menu Area */

View File

@ -7,6 +7,7 @@ import {
VerticalAlignTopOutlined, VerticalAlignTopOutlined,
LeftOutlined, LeftOutlined,
RightOutlined, RightOutlined,
CloudDownloadOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import 'react-pdf/dist/Page/AnnotationLayer.css' import 'react-pdf/dist/Page/AnnotationLayer.css'
import 'react-pdf/dist/Page/TextLayer.css' import 'react-pdf/dist/Page/TextLayer.css'
@ -149,6 +150,15 @@ function VirtualPDFViewer({ url, filename }) {
} }
} }
const handleDownload = () => {
const link = document.createElement('a')
link.href = url
link.download = filename || 'document.pdf'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
return ( return (
<div className="virtual-pdf-viewer-container"> <div className="virtual-pdf-viewer-container">
{/* 工具栏 */} {/* 工具栏 */}
@ -187,6 +197,13 @@ function VirtualPDFViewer({ url, filename }) {
> >
回到顶部 回到顶部
</Button> </Button>
<Button
icon={<CloudDownloadOutlined />}
onClick={handleDownload}
size="small"
>
下载PDF
</Button>
</Space> </Space>
<Space> <Space>

View File

@ -367,3 +367,5 @@
font-size: 13px; font-size: 13px;
} }
} }
/* 打印样式优化已移除,转向后端生成方案 */

View File

@ -1,20 +1,17 @@
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, message, Drawer, Anchor, Empty, Tooltip } from 'antd' import { Layout, Menu, Spin, FloatButton, Button, Modal, Input, Drawer, Anchor, Empty, Tooltip } from 'antd'
import { VerticalAlignTopOutlined, MenuOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons' import Toast from '@/components/Toast/Toast'
import { Viewer } from '@bytemd/react' import { VerticalAlignTopOutlined, MenuOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, SearchOutlined, CloseOutlined, CloudDownloadOutlined } from '@ant-design/icons'
import gfm from '@bytemd/plugin-gfm' import ReactMarkdown from 'react-markdown'
import highlight from '@bytemd/plugin-highlight' import remarkGfm from 'remark-gfm'
import breaks from '@bytemd/plugin-breaks' import rehypeHighlight from 'rehype-highlight'
import frontmatter from '@bytemd/plugin-frontmatter'
import gemoji from '@bytemd/plugin-gemoji'
import 'bytemd/dist/index.css'
import rehypeSlug from 'rehype-slug' import rehypeSlug from 'rehype-slug'
import 'highlight.js/styles/github.css' import 'highlight.js/styles/github.css'
import Mark from 'mark.js' import Mark from 'mark.js'
import Highlighter from 'react-highlight-words' import Highlighter from 'react-highlight-words'
import GithubSlugger from 'github-slugger' import GithubSlugger from 'github-slugger'
import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl } from '@/api/share' import { getPreviewInfo, getPreviewTree, getPreviewFile, verifyAccessPassword, getPreviewDocumentUrl, exportPDF } 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 './PreviewPage.css' import './PreviewPage.css'
@ -74,18 +71,6 @@ function PreviewPage() {
const contentRef = useRef(null) const contentRef = useRef(null)
const viewerRef = useRef(null) const viewerRef = useRef(null)
// ByteMD
const plugins = useMemo(() => [
gfm(),
highlight(),
breaks(),
frontmatter(),
gemoji(),
{
rehype: (p) => p.use(rehypeSlug)
}
], [])
// mark.js // mark.js
useEffect(() => { useEffect(() => {
if (viewerRef.current && viewMode === 'markdown') { if (viewerRef.current && viewMode === 'markdown') {
@ -183,14 +168,14 @@ function PreviewPage() {
} }
} catch (error) { } catch (error) {
console.error('Load project info error:', error) console.error('Load project info error:', error)
message.error('项目不存在或已被删除') Toast.error('加载失败', '项目不存在或已被删除')
} }
} }
// //
const handleVerifyPassword = async () => { const handleVerifyPassword = async () => {
if (!password.trim()) { if (!password.trim()) {
message.warning('请输入访问密码') Toast.warning('提示', '请输入访问密码')
return return
} }
@ -199,9 +184,9 @@ function PreviewPage() {
setAccessPassword(password) setAccessPassword(password)
setPasswordModalVisible(false) setPasswordModalVisible(false)
loadFileTree(password) loadFileTree(password)
message.success('验证成功') Toast.success('验证成功')
} catch (error) { } catch (error) {
message.error('访问密码错误') Toast.error('访问密码错误')
} }
} }
@ -214,7 +199,7 @@ function PreviewPage() {
} catch (error) { } catch (error) {
console.error('Load file tree error:', error) console.error('Load file tree error:', error)
if (error.response?.status === 403) { if (error.response?.status === 403) {
message.error('访问密码错误或已过期') Toast.error('访问密码错误或已过期')
setPasswordModalVisible(true) setPasswordModalVisible(true)
} }
} }
@ -247,6 +232,7 @@ function PreviewPage() {
setOpenKeys(Array.from(keysToExpand)) setOpenKeys(Array.from(keysToExpand))
} catch (error) { } catch (error) {
console.error('Search error:', error) console.error('Search error:', error)
Toast.error('搜索失败', '请稍后重试')
} finally { } finally {
setIsSearching(false) setIsSearching(false)
} }
@ -332,10 +318,11 @@ function PreviewPage() {
} catch (error) { } catch (error) {
console.error('Load markdown error:', error) console.error('Load markdown error:', error)
if (error.response?.status === 403) { if (error.response?.status === 403) {
message.error('访问密码错误或已过期') Toast.error('访问密码错误或已过期')
setPasswordModalVisible(true) setPasswordModalVisible(true)
} else { } else {
setMarkdownContent('# 文档加载失败\n\n无法加载该文档请稍后重试。') Toast.error('加载失败', '文档加载失败,请稍后重试')
setMarkdownContent('')
} }
} finally { } finally {
setLoading(false) setLoading(false)
@ -466,6 +453,38 @@ function PreviewPage() {
} }
} }
// PDF
const handleExportPDF = () => {
if (viewMode === 'pdf') {
// PDF
const link = document.createElement('a')
link.href = pdfUrl
link.download = pdfFilename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} else {
// Markdown 使 PDF
let url = exportPDF(projectId, selectedFile)
const params = []
if (accessPassword) {
params.push(`access_pass=${encodeURIComponent(accessPassword)}`)
}
const token = localStorage.getItem('access_token')
if (token) {
params.push(`token=${encodeURIComponent(token)}`)
}
if (params.length > 0) {
url += (url.includes('?') ? '&' : '?') + params.join('&')
}
window.open(url, '_blank')
}
}
const menuItems = convertTreeToMenuItems(filteredTreeData) const menuItems = convertTreeToMenuItems(filteredTreeData)
return ( return (
@ -600,25 +619,40 @@ function PreviewPage() {
/> />
) : ( ) : (
<div className="markdown-body" onClick={handleContentClick} ref={viewerRef}> <div className="markdown-body" onClick={handleContentClick} ref={viewerRef}>
<Viewer <ReactMarkdown
value={markdownContent} remarkPlugins={[remarkGfm]}
plugins={plugins} rehypePlugins={[rehypeSlug, rehypeHighlight]}
/> >
{markdownContent}
</ReactMarkdown>
</div> </div>
)} )}
</div> </div>
{viewMode === 'markdown' && ( {viewMode === 'markdown' && (
<FloatButton.Group
trigger="hover"
type="primary"
icon={<MenuOutlined />}
style={{ right: !isMobile && !tocCollapsed ? 280 : 24 }}
>
<Tooltip title="导出 PDF" placement="left">
<FloatButton
icon={<FilePdfOutlined />}
onClick={handleExportPDF}
/>
</Tooltip>
<Tooltip title="回到顶部" placement="left">
<FloatButton <FloatButton
icon={<VerticalAlignTopOutlined />} icon={<VerticalAlignTopOutlined />}
type="primary"
style={{ right: tocCollapsed || isMobile ? 24 : 280 }}
onClick={() => { onClick={() => {
if (contentRef.current) { if (contentRef.current) {
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' }) contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
} }
}} }}
/> />
</Tooltip>
</FloatButton.Group>
)} )}
</Content> </Content>

View File

@ -1,18 +1,10 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import legacy from '@vitejs/plugin-legacy'
import path from 'path' import path from 'path'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [react()],
react(),
legacy({
targets: ['chrome >= 64', 'safari >= 11', 'ios >= 11', 'android >= 9'],
additionalLegacyPolyfills: ['regenerator-runtime/runtime'],
modernPolyfills: true,
}),
],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),