diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
deleted file mode 100644
index ea0dbb9..0000000
--- a/IMPLEMENTATION_PLAN.md
+++ /dev/null
@@ -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
\ No newline at end of file
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 74764d5..0365ddd 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -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 && \
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 \
gcc \
default-libmysqlclient-dev \
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/*
# 复制依赖文件
COPY requirements.txt .
-# 使用清华源安装 Python 依赖
+# 安装 Python 依赖
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 复制项目文件
diff --git a/backend/app/api/v1/preview.py b/backend/app/api/v1/preview.py
index 756c1a7..be7bae9 100644
--- a/backend/app/api/v1/preview.py
+++ b/backend/app/api/v1/preview.py
@@ -2,12 +2,13 @@
项目预览相关 API(支持公开和私密项目)
"""
from fastapi import APIRouter, Depends, HTTPException, Header
-from fastapi.responses import FileResponse
+from fastapi.responses import FileResponse, StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
import mimetypes
+from pathlib import Path
from app.core.database import get_db
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.schemas.response import success_response
from app.services.storage import storage_service
+from app.services.pdf_service import pdf_service
router = APIRouter()
@@ -231,3 +233,85 @@ async def get_preview_document(
content_type, _ = mimetypes.guess_type(str(file_path))
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"
+ }
+ )
+
diff --git a/backend/app/services/pdf_service.py b/backend/app/services/pdf_service.py
new file mode 100644
index 0000000..2cea1d0
--- /dev/null
+++ b/backend/app/services/pdf_service.py
@@ -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 SC,Docker 用 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"""
+
+
+
+
+ {title}
+
+
+ {html_content}
+
+
+ """
+
+ 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()
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 5c21ed7..d7d6095 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -44,4 +44,6 @@ uvicorn==0.27.0
uvloop==0.22.1
watchfiles==1.1.1
websockets==15.0.1
-Whoosh==2.7.4
\ No newline at end of file
+Whoosh==2.7.4
+markdown==3.5.2
+weasyprint==61.2
diff --git a/frontend/src/api/share.js b/frontend/src/api/share.js
index 1a776b0..512e69e 100644
--- a/frontend/src/api/share.js
+++ b/frontend/src/api/share.js
@@ -78,3 +78,11 @@ export function getPreviewDocumentUrl(projectId, path) {
const encodedPath = path.split('/').map(part => encodeURIComponent(part)).join('/')
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}`
+}
diff --git a/frontend/src/components/ModernSidebar/ModernSidebar.css b/frontend/src/components/ModernSidebar/ModernSidebar.css
index b22ec6b..43e8d18 100644
--- a/frontend/src/components/ModernSidebar/ModernSidebar.css
+++ b/frontend/src/components/ModernSidebar/ModernSidebar.css
@@ -33,10 +33,10 @@
/* Collapse Trigger */
.collapse-trigger {
position: absolute;
- right: -12px;
- top: 32px;
- width: 24px;
- height: 24px;
+ right: -14px;
+ top: 28px;
+ width: 28px;
+ height: 28px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 50%;
@@ -45,14 +45,24 @@
justify-content: center;
cursor: pointer;
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);
- 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 {
- color: #1677ff;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ color: #fff;
+ background: #1677ff;
+ border-color: #1677ff;
+ box-shadow: 0 4px 12px rgba(22, 119, 255, 0.35);
+ transform: scale(1.1);
}
/* Menu Area */
diff --git a/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx b/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx
index 4978844..5e3ed62 100644
--- a/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx
+++ b/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx
@@ -7,6 +7,7 @@ import {
VerticalAlignTopOutlined,
LeftOutlined,
RightOutlined,
+ CloudDownloadOutlined,
} from '@ant-design/icons'
import 'react-pdf/dist/Page/AnnotationLayer.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 (
{/* 工具栏 */}
@@ -187,6 +197,13 @@ function VirtualPDFViewer({ url, filename }) {
>
回到顶部
+
}
+ onClick={handleDownload}
+ size="small"
+ >
+ 下载PDF
+
diff --git a/frontend/src/pages/Preview/PreviewPage.css b/frontend/src/pages/Preview/PreviewPage.css
index f86bb0f..5827e10 100644
--- a/frontend/src/pages/Preview/PreviewPage.css
+++ b/frontend/src/pages/Preview/PreviewPage.css
@@ -366,4 +366,6 @@
.markdown-body code {
font-size: 13px;
}
-}
\ No newline at end of file
+}
+
+/* 打印样式优化已移除,转向后端生成方案 */
\ No newline at end of file
diff --git a/frontend/src/pages/Preview/PreviewPage.jsx b/frontend/src/pages/Preview/PreviewPage.jsx
index 771a452..3366814 100644
--- a/frontend/src/pages/Preview/PreviewPage.jsx
+++ b/frontend/src/pages/Preview/PreviewPage.jsx
@@ -1,20 +1,17 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
-import { Layout, Menu, Spin, FloatButton, Button, Modal, Input, message, Drawer, Anchor, Empty, Tooltip } from 'antd'
-import { VerticalAlignTopOutlined, MenuOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, LockOutlined, SearchOutlined, CloseOutlined } from '@ant-design/icons'
-import { Viewer } from '@bytemd/react'
-import gfm from '@bytemd/plugin-gfm'
-import highlight from '@bytemd/plugin-highlight'
-import breaks from '@bytemd/plugin-breaks'
-import frontmatter from '@bytemd/plugin-frontmatter'
-import gemoji from '@bytemd/plugin-gemoji'
-import 'bytemd/dist/index.css'
+import { Layout, Menu, Spin, FloatButton, 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 ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import rehypeHighlight from 'rehype-highlight'
import rehypeSlug from 'rehype-slug'
import 'highlight.js/styles/github.css'
import Mark from 'mark.js'
import Highlighter from 'react-highlight-words'
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 VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import './PreviewPage.css'
@@ -74,18 +71,6 @@ function PreviewPage() {
const contentRef = useRef(null)
const viewerRef = useRef(null)
- // ByteMD 插件配置
- const plugins = useMemo(() => [
- gfm(),
- highlight(),
- breaks(),
- frontmatter(),
- gemoji(),
- {
- rehype: (p) => p.use(rehypeSlug)
- }
- ], [])
-
// mark.js 高亮
useEffect(() => {
if (viewerRef.current && viewMode === 'markdown') {
@@ -183,14 +168,14 @@ function PreviewPage() {
}
} catch (error) {
console.error('Load project info error:', error)
- message.error('项目不存在或已被删除')
+ Toast.error('加载失败', '项目不存在或已被删除')
}
}
// 验证密码
const handleVerifyPassword = async () => {
if (!password.trim()) {
- message.warning('请输入访问密码')
+ Toast.warning('提示', '请输入访问密码')
return
}
@@ -199,9 +184,9 @@ function PreviewPage() {
setAccessPassword(password)
setPasswordModalVisible(false)
loadFileTree(password)
- message.success('验证成功')
+ Toast.success('验证成功')
} catch (error) {
- message.error('访问密码错误')
+ Toast.error('访问密码错误')
}
}
@@ -214,7 +199,7 @@ function PreviewPage() {
} catch (error) {
console.error('Load file tree error:', error)
if (error.response?.status === 403) {
- message.error('访问密码错误或已过期')
+ Toast.error('访问密码错误或已过期')
setPasswordModalVisible(true)
}
}
@@ -247,6 +232,7 @@ function PreviewPage() {
setOpenKeys(Array.from(keysToExpand))
} catch (error) {
console.error('Search error:', error)
+ Toast.error('搜索失败', '请稍后重试')
} finally {
setIsSearching(false)
}
@@ -332,10 +318,11 @@ function PreviewPage() {
} catch (error) {
console.error('Load markdown error:', error)
if (error.response?.status === 403) {
- message.error('访问密码错误或已过期')
+ Toast.error('访问密码错误或已过期')
setPasswordModalVisible(true)
} else {
- setMarkdownContent('# 文档加载失败\n\n无法加载该文档,请稍后重试。')
+ Toast.error('加载失败', '文档加载失败,请稍后重试')
+ setMarkdownContent('')
}
} finally {
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)
return (
@@ -600,25 +619,40 @@ function PreviewPage() {
/>
) : (
-
+
+ {markdownContent}
+
)}
{viewMode === 'markdown' && (
- }
- type="primary"
- style={{ right: tocCollapsed || isMobile ? 24 : 280 }}
- onClick={() => {
- if (contentRef.current) {
- contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
- }
- }}
- />
+ }
+ style={{ right: !isMobile && !tocCollapsed ? 280 : 24 }}
+ >
+
+ }
+ onClick={handleExportPDF}
+ />
+
+
+ }
+ onClick={() => {
+ if (contentRef.current) {
+ contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
+ }
+ }}
+ />
+
+
)}
@@ -699,4 +733,4 @@ function PreviewPage() {
)
}
-export default PreviewPage
\ No newline at end of file
+export default PreviewPage
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index ddb959e..e3306c2 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -1,18 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
-import legacy from '@vitejs/plugin-legacy'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
- plugins: [
- react(),
- legacy({
- targets: ['chrome >= 64', 'safari >= 11', 'ios >= 11', 'android >= 9'],
- additionalLegacyPolyfills: ['regenerator-runtime/runtime'],
- modernPolyfills: true,
- }),
- ],
+ plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),