优化页面、修复BUG
This commit is contained in:
310
client/src/utils/articleExport.ts
Normal file
310
client/src/utils/articleExport.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 文章多格式导出工具
|
||||
* 支持 Markdown、纯文本、PDF、PNG 四种格式
|
||||
*/
|
||||
import html2canvas from 'html2canvas'
|
||||
import { jsPDF } from 'jspdf'
|
||||
import type { Post } from '../services/api'
|
||||
import { buildExportBasename, downloadBlob, downloadText } from './fileDownload'
|
||||
import '../styles/articleExport.css'
|
||||
|
||||
/** 导出视觉主题:浅色适合打印,深色与页面一致 */
|
||||
export type ExportTheme = 'light' | 'dark'
|
||||
|
||||
/** 支持的导出格式 */
|
||||
export type ExportFormat = 'md' | 'txt' | 'pdf' | 'png'
|
||||
|
||||
/** 导出操作结果 */
|
||||
export interface ExportResult {
|
||||
success: boolean
|
||||
warning?: string
|
||||
}
|
||||
|
||||
const EXPORT_CONTAINER_WIDTH = 800
|
||||
|
||||
/**
|
||||
* 构建 Markdown frontmatter 元信息块
|
||||
* @param post 文章数据
|
||||
*/
|
||||
function buildMarkdownFrontmatter(post: Post): string {
|
||||
const tags = post.tags?.map((t) => t.name).join(', ') || ''
|
||||
const lines = [
|
||||
'---',
|
||||
`title: ${JSON.stringify(post.title)}`,
|
||||
`date: ${post.date || ''}`,
|
||||
`category: ${JSON.stringify(post.categoryName || '未分类')}`,
|
||||
]
|
||||
if (tags) lines.push(`tags: [${post.tags?.map((t) => JSON.stringify(t.name)).join(', ')}]`)
|
||||
if (post.userName) lines.push(`author: ${JSON.stringify(post.userName)}`)
|
||||
lines.push('---', '')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Markdown 转为可读纯文本(轻量剥离语法)
|
||||
* @param markdown 原始 Markdown 内容
|
||||
*/
|
||||
function markdownToPlainText(markdown: string): string {
|
||||
let text = markdown
|
||||
// 代码块:保留内容,去掉围栏
|
||||
text = text.replace(/```[\s\S]*?```/g, (block) =>
|
||||
block.replace(/^```[\w-]*\n?/, '').replace(/```$/, '').trim()
|
||||
)
|
||||
// 行内代码
|
||||
text = text.replace(/`([^`]+)`/g, '$1')
|
||||
// 图片:保留 alt 文本
|
||||
text = text.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
|
||||
// 链接:保留文字
|
||||
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
// 标题前缀
|
||||
text = text.replace(/^#{1,6}\s+/gm, '')
|
||||
// 粗体/斜体
|
||||
text = text.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
||||
text = text.replace(/(\*|_)(.*?)\1/g, '$2')
|
||||
// 引用
|
||||
text = text.replace(/^>\s+/gm, '')
|
||||
// 无序列表
|
||||
text = text.replace(/^[-*+]\s+/gm, '• ')
|
||||
// HTML 标签
|
||||
text = text.replace(/<[^>]+>/g, '')
|
||||
// 多余空行
|
||||
text = text.replace(/\n{3,}/g, '\n\n')
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建纯文本元信息头部
|
||||
* @param post 文章数据
|
||||
*/
|
||||
function buildTextHeader(post: Post): string {
|
||||
const lines = [
|
||||
post.title,
|
||||
'='.repeat(Math.min(post.title.length, 40)),
|
||||
`日期:${post.date || '未知'}`,
|
||||
`分类:${post.categoryName || '未分类'}`,
|
||||
]
|
||||
if (post.userName) lines.push(`作者:${post.userName}`)
|
||||
if (post.tags?.length) lines.push(`标签:${post.tags.map((t) => t.name).join('、')}`)
|
||||
lines.push('', '---', '')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建离屏导出 DOM 容器(PDF/PNG 共用)
|
||||
* @param post 文章数据
|
||||
* @param renderedHtml 已渲染的正文 HTML
|
||||
* @param theme 导出主题
|
||||
*/
|
||||
function buildExportContainer(post: Post, renderedHtml: string, theme: ExportTheme): HTMLDivElement {
|
||||
const container = document.createElement('div')
|
||||
container.className = `article-export-root export-theme-${theme}`
|
||||
container.style.position = 'fixed'
|
||||
container.style.left = '-9999px'
|
||||
container.style.top = '0'
|
||||
container.style.width = `${EXPORT_CONTAINER_WIDTH}px`
|
||||
container.style.zIndex = '-1'
|
||||
|
||||
const tagNames = post.tags?.map((t) => t.name).join(' · ') || ''
|
||||
const metaParts = [post.date, post.categoryName || '未分类', tagNames].filter(Boolean)
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="export-header">
|
||||
<div class="export-header-meta">
|
||||
<span class="export-category">${escapeHtml(post.categoryName || '未分类')}</span>
|
||||
<span class="export-meta">${escapeHtml(metaParts.join(' · '))}</span>
|
||||
</div>
|
||||
<h1 class="export-title">${escapeHtml(post.title)}</h1>
|
||||
${post.userName ? `<div class="export-meta export-author">作者:${escapeHtml(post.userName)}</div>` : ''}
|
||||
</div>
|
||||
<div class="export-body">${renderedHtml}</div>
|
||||
`
|
||||
|
||||
// 移除交互元素,避免截图出现按钮
|
||||
container.querySelectorAll('.copy-btn').forEach((el) => el.remove())
|
||||
|
||||
document.body.appendChild(container)
|
||||
return container
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 特殊字符转义(用于拼接模板字符串)
|
||||
* @param str 原始字符串
|
||||
*/
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 html2canvas 将 DOM 转为 Canvas
|
||||
* @param element 待截图元素
|
||||
*/
|
||||
async function domToCanvas(element: HTMLElement): Promise<{ canvas: HTMLCanvasElement; hasImageWarning: boolean }> {
|
||||
let hasImageWarning = false
|
||||
|
||||
const canvas = await html2canvas(element, {
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
scale: 2,
|
||||
backgroundColor: null,
|
||||
logging: false,
|
||||
scrollY: -window.scrollY,
|
||||
scrollX: -window.scrollX,
|
||||
onclone: (_doc, clonedEl) => {
|
||||
// 检测跨域图片是否可能加载失败
|
||||
const imgs = clonedEl.querySelectorAll('img')
|
||||
imgs.forEach((img) => {
|
||||
if (img.src && !img.src.startsWith(window.location.origin) && !img.complete) {
|
||||
hasImageWarning = true
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return { canvas, hasImageWarning }
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Canvas 保存为 PNG 文件
|
||||
* @param canvas 画布
|
||||
* @param filename 文件名
|
||||
*/
|
||||
function canvasToPng(canvas: HTMLCanvasElement, filename: string): void {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) downloadBlob(blob, filename)
|
||||
}, 'image/png')
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Canvas 分页写入 PDF 并下载
|
||||
* @param canvas 画布
|
||||
* @param filename 文件名
|
||||
*/
|
||||
function canvasToPdf(canvas: HTMLCanvasElement, filename: string): void {
|
||||
const pdf = new jsPDF('p', 'mm', 'a4')
|
||||
const pageWidth = pdf.internal.pageSize.getWidth()
|
||||
const pageHeight = pdf.internal.pageSize.getHeight()
|
||||
|
||||
const imgData = canvas.toDataURL('image/png')
|
||||
const imgWidth = pageWidth
|
||||
const imgHeight = (canvas.height * imgWidth) / canvas.width
|
||||
|
||||
let heightLeft = imgHeight
|
||||
let position = 0
|
||||
|
||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
|
||||
heightLeft -= pageHeight
|
||||
|
||||
while (heightLeft > 0) {
|
||||
position -= pageHeight
|
||||
pdf.addPage()
|
||||
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight)
|
||||
heightLeft -= pageHeight
|
||||
}
|
||||
|
||||
pdf.save(filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出文章为 Markdown 文件
|
||||
* @param post 文章数据
|
||||
*/
|
||||
export function exportAsMarkdown(post: Post): ExportResult {
|
||||
const content = post.content || ''
|
||||
const frontmatter = buildMarkdownFrontmatter(post)
|
||||
const basename = buildExportBasename(post.title, post.date)
|
||||
downloadText(frontmatter + content, `${basename}.md`, 'text/markdown;charset=utf-8')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出文章为纯文本文件
|
||||
* @param post 文章数据
|
||||
*/
|
||||
export function exportAsTxt(post: Post): ExportResult {
|
||||
const header = buildTextHeader(post)
|
||||
const body = markdownToPlainText(post.content || '')
|
||||
const basename = buildExportBasename(post.title, post.date)
|
||||
downloadText(header + body, `${basename}.txt`, 'text/plain;charset=utf-8')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出文章为 PNG 长图
|
||||
* @param post 文章数据
|
||||
* @param renderedHtml 已渲染正文 HTML
|
||||
* @param theme 导出主题
|
||||
*/
|
||||
export async function exportAsPng(
|
||||
post: Post,
|
||||
renderedHtml: string,
|
||||
theme: ExportTheme
|
||||
): Promise<ExportResult> {
|
||||
const container = buildExportContainer(post, renderedHtml, theme)
|
||||
try {
|
||||
const { canvas, hasImageWarning } = await domToCanvas(container)
|
||||
const basename = buildExportBasename(post.title, post.date)
|
||||
canvasToPng(canvas, `${basename}.png`)
|
||||
return {
|
||||
success: true,
|
||||
warning: hasImageWarning ? '部分图片可能因跨域限制未能导出' : undefined,
|
||||
}
|
||||
} finally {
|
||||
container.remove()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出文章为 PDF 文件
|
||||
* @param post 文章数据
|
||||
* @param renderedHtml 已渲染正文 HTML
|
||||
* @param theme 导出主题
|
||||
*/
|
||||
export async function exportAsPdf(
|
||||
post: Post,
|
||||
renderedHtml: string,
|
||||
theme: ExportTheme
|
||||
): Promise<ExportResult> {
|
||||
const container = buildExportContainer(post, renderedHtml, theme)
|
||||
try {
|
||||
const { canvas, hasImageWarning } = await domToCanvas(container)
|
||||
const basename = buildExportBasename(post.title, post.date)
|
||||
canvasToPdf(canvas, `${basename}.pdf`)
|
||||
return {
|
||||
success: true,
|
||||
warning: hasImageWarning ? '部分图片可能因跨域限制未能导出' : undefined,
|
||||
}
|
||||
} finally {
|
||||
container.remove()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一导出入口
|
||||
* @param format 导出格式
|
||||
* @param post 文章数据
|
||||
* @param renderedHtml 已渲染正文 HTML(PDF/PNG 需要)
|
||||
* @param theme 导出主题(PDF/PNG 需要)
|
||||
*/
|
||||
export async function exportArticle(
|
||||
format: ExportFormat,
|
||||
post: Post,
|
||||
renderedHtml: string,
|
||||
theme: ExportTheme = 'light'
|
||||
): Promise<ExportResult> {
|
||||
switch (format) {
|
||||
case 'md':
|
||||
return exportAsMarkdown(post)
|
||||
case 'txt':
|
||||
return exportAsTxt(post)
|
||||
case 'png':
|
||||
return exportAsPng(post, renderedHtml, theme)
|
||||
case 'pdf':
|
||||
return exportAsPdf(post, renderedHtml, theme)
|
||||
default:
|
||||
return { success: false, warning: '不支持的导出格式' }
|
||||
}
|
||||
}
|
||||
56
client/src/utils/fileDownload.ts
Normal file
56
client/src/utils/fileDownload.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 通用文件下载工具
|
||||
*/
|
||||
|
||||
/**
|
||||
* 清理文件名,去除 Windows/Unix 非法字符并限制长度
|
||||
* @param title 原始标题
|
||||
* @param maxLength 最大字符数,默认 80
|
||||
*/
|
||||
export function sanitizeFilename(title: string, maxLength = 80): string {
|
||||
const cleaned = title
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, maxLength)
|
||||
return cleaned || 'untitled'
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据标题和日期生成导出文件名(不含扩展名)
|
||||
* @param title 文章标题
|
||||
* @param date 发布日期字符串
|
||||
*/
|
||||
export function buildExportBasename(title: string, date?: string): string {
|
||||
const safeTitle = sanitizeFilename(title)
|
||||
const safeDate = date ? date.replace(/[^\d-]/g, '').slice(0, 10) : ''
|
||||
return safeDate ? `${safeTitle}-${safeDate}` : safeTitle
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发浏览器下载 Blob 文件
|
||||
* @param blob 文件内容
|
||||
* @param filename 完整文件名(含扩展名)
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文本内容下载为指定扩展名的文件
|
||||
* @param content 文本内容
|
||||
* @param filename 完整文件名
|
||||
* @param mimeType MIME 类型,默认 text/plain
|
||||
*/
|
||||
export function downloadText(content: string, filename: string, mimeType = 'text/plain;charset=utf-8'): void {
|
||||
const blob = new Blob([content], { type: mimeType })
|
||||
downloadBlob(blob, filename)
|
||||
}
|
||||
Reference in New Issue
Block a user