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 }) { > 回到顶部 + 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'),