预览页面增加了pdf导出
parent
e6794a1952
commit
cbef580776
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
# 复制项目文件
|
# 复制项目文件
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
<!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()
|
||||||
|
|
@ -44,4 +44,6 @@ uvicorn==0.27.0
|
||||||
uvloop==0.22.1
|
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
|
||||||
|
|
|
||||||
|
|
@ -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}`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -366,4 +366,6 @@
|
||||||
.markdown-body code {
|
.markdown-body code {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 打印样式优化已移除,转向后端生成方案 */
|
||||||
|
|
@ -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
|
<FloatButton.Group
|
||||||
icon={<VerticalAlignTopOutlined />}
|
trigger="hover"
|
||||||
type="primary"
|
type="primary"
|
||||||
style={{ right: tocCollapsed || isMobile ? 24 : 280 }}
|
icon={<MenuOutlined />}
|
||||||
onClick={() => {
|
style={{ right: !isMobile && !tocCollapsed ? 280 : 24 }}
|
||||||
if (contentRef.current) {
|
>
|
||||||
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
<Tooltip title="导出 PDF" placement="left">
|
||||||
}
|
<FloatButton
|
||||||
}}
|
icon={<FilePdfOutlined />}
|
||||||
/>
|
onClick={handleExportPDF}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="回到顶部" placement="left">
|
||||||
|
<FloatButton
|
||||||
|
icon={<VerticalAlignTopOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
contentRef.current.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</FloatButton.Group>
|
||||||
)}
|
)}
|
||||||
</Content>
|
</Content>
|
||||||
|
|
||||||
|
|
@ -699,4 +733,4 @@ function PreviewPage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PreviewPage
|
export default PreviewPage
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue