优化页面、修复BUG

This commit is contained in:
李琦
2026-07-04 17:21:46 +08:00
parent 734f82b2c6
commit e4baa4b585
8 changed files with 975 additions and 4 deletions

View File

@@ -0,0 +1,190 @@
<script setup lang="ts">
/**
* 文章导出下拉菜单
* 支持 Markdown / 纯文本 / PDF / PNG 四种格式PDF 与 PNG 可选深浅色主题
*/
import { ref, onMounted, onUnmounted } from 'vue'
import Icon from './Icon.vue'
import { useToast } from '../composables/useToast'
import type { Post } from '../services/api'
import { exportArticle, type ExportFormat, type ExportTheme } from '../utils/articleExport'
interface Props {
/** 文章完整数据 */
post: Post
/** 已渲染的正文 HTML */
renderedHtml: string
}
const props = defineProps<Props>()
const toast = useToast()
const isOpen = ref(false)
const isExporting = ref(false)
/** 当前展开二级菜单的格式pdf / png */
const expandedFormat = ref<ExportFormat | null>(null)
const menuRef = ref<HTMLElement | null>(null)
/** 导出格式配置 */
const formatOptions: Array<{
format: ExportFormat
label: string
icon: string
needsTheme: boolean
description: string
}> = [
{ format: 'md', label: 'Markdown', icon: 'file-text', needsTheme: false, description: '含元信息的 .md 文件' },
{ format: 'txt', label: '纯文本', icon: 'file-type', needsTheme: false, description: '去除 Markdown 语法' },
{ format: 'pdf', label: 'PDF', icon: 'file-down', needsTheme: true, description: '分页 PDF 文档' },
{ format: 'png', label: 'PNG 图片', icon: 'image', needsTheme: true, description: '完整文章长图' },
]
/** 主题选项 */
const themeOptions: Array<{ theme: ExportTheme; label: string }> = [
{ theme: 'light', label: '浅色风格' },
{ theme: 'dark', label: '深色风格' },
]
/** 切换主菜单显示 */
const toggleMenu = () => {
if (isExporting.value) return
isOpen.value = !isOpen.value
if (!isOpen.value) expandedFormat.value = null
}
/** 点击外部关闭菜单 */
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
isOpen.value = false
expandedFormat.value = null
}
}
/**
* 执行导出
* @param format 导出格式
* @param theme 视觉主题PDF/PNG 使用)
*/
const handleExport = async (format: ExportFormat, theme: ExportTheme = 'light') => {
if (isExporting.value) return
const formatLabel = formatOptions.find((o) => o.format === format)?.label || format
isExporting.value = true
isOpen.value = false
expandedFormat.value = null
toast.info(`正在生成 ${formatLabel}`)
try {
const result = await exportArticle(format, props.post, props.renderedHtml, theme)
if (result.success) {
toast.success('导出成功')
if (result.warning) toast.warning(result.warning)
} else {
toast.error(result.warning || '导出失败')
}
} catch (err) {
console.error('Export failed:', err)
toast.error(err instanceof Error ? err.message : '导出失败,请稍后重试')
} finally {
isExporting.value = false
}
}
/** 点击需要主题的格式项,展开二级选项 */
const handleFormatClick = (format: ExportFormat, needsTheme: boolean) => {
if (needsTheme) {
expandedFormat.value = expandedFormat.value === format ? null : format
} else {
void handleExport(format)
}
}
onMounted(() => document.addEventListener('click', handleClickOutside))
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
</script>
<template>
<div ref="menuRef" class="relative inline-block">
<!-- 主导出按钮 -->
<button
type="button"
:disabled="isExporting"
class="flex items-center gap-2 rounded-full border border-white/10 bg-white/5 backdrop-blur-md px-3 py-1.5 md:px-4 md:py-2 text-xs md:text-sm text-art-muted hover:text-white hover:bg-white/10 hover:border-art-accent/30 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
@click.stop="toggleMenu"
>
<Icon
:name="isExporting ? 'loader-2' : 'download'"
:size="14"
:class="isExporting ? 'animate-spin' : ''"
/>
<span class="font-medium">{{ isExporting ? '导出中…' : '导出' }}</span>
<Icon v-if="!isExporting" name="chevron-down" :size="12" class="opacity-60" />
</button>
<!-- 下拉菜单 -->
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 scale-95 -translate-y-1"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95 -translate-y-1"
>
<div
v-if="isOpen"
class="absolute right-0 top-full mt-2 w-56 rounded-xl border border-white/10 bg-[#141414]/95 backdrop-blur-xl shadow-2xl z-50 overflow-hidden"
@click.stop
>
<div class="px-3 py-2 border-b border-white/5">
<span class="text-[10px] font-bold text-white/40 uppercase tracking-wider">选择导出格式</span>
</div>
<ul class="py-1">
<li v-for="option in formatOptions" :key="option.format">
<!-- 格式主项 -->
<button
type="button"
class="w-full flex items-center gap-3 px-3 py-2.5 text-left text-sm text-art-muted hover:text-white hover:bg-white/5 transition-colors"
@click="handleFormatClick(option.format, option.needsTheme)"
>
<Icon :name="option.icon" :size="16" class="text-art-accent shrink-0" />
<div class="flex-1 min-w-0">
<div class="font-medium text-white/90">{{ option.label }}</div>
<div class="text-[10px] text-white/40 truncate">{{ option.description }}</div>
</div>
<Icon
v-if="option.needsTheme"
:name="expandedFormat === option.format ? 'chevron-up' : 'chevron-right'"
:size="14"
class="opacity-40 shrink-0"
/>
</button>
<!-- PDF / PNG 二级主题选项 -->
<div
v-if="option.needsTheme && expandedFormat === option.format"
class="bg-white/[0.02] border-t border-white/5"
>
<button
v-for="themeOpt in themeOptions"
:key="themeOpt.theme"
type="button"
class="w-full flex items-center gap-2 pl-10 pr-3 py-2 text-xs text-art-muted hover:text-white hover:bg-white/5 transition-colors"
@click="handleExport(option.format, themeOpt.theme)"
>
<span
class="w-3 h-3 rounded-full border shrink-0"
:class="themeOpt.theme === 'light'
? 'bg-white border-gray-300'
: 'bg-[#050505] border-white/30'"
></span>
{{ themeOpt.label }}
</button>
</div>
</li>
</ul>
</div>
</Transition>
</div>
</template>

View File

@@ -165,9 +165,12 @@
<div class="flex-1 min-w-0 max-w-full order-2">
<!-- 文章头图与信息 -->
<div class="border-b border-white/5 pb-6 md:pb-8 mb-6 md:mb-10">
<div class="flex flex-wrap items-center gap-2 md:gap-3 mb-4 md:mb-6">
<span class="px-2 py-0.5 md:px-3 md:py-1 border border-white/20 rounded-full text-[10px] md:text-xs font-mono text-art-accent uppercase tracking-wider">{{ post.categoryName || '未分类' }}</span>
<span class="text-xs md:text-sm text-art-muted">{{ post.date }}</span>
<div class="flex flex-wrap items-center justify-between gap-2 md:gap-3 mb-4 md:mb-6">
<div class="flex flex-wrap items-center gap-2 md:gap-3">
<span class="px-2 py-0.5 md:px-3 md:py-1 border border-white/20 rounded-full text-[10px] md:text-xs font-mono text-art-accent uppercase tracking-wider">{{ post.categoryName || '未分类' }}</span>
<span class="text-xs md:text-sm text-art-muted">{{ post.date }}</span>
</div>
<ArticleExportMenu :post="post" :rendered-html="renderedContent" />
</div>
<h1 class="font-serif text-2xl md:text-5xl lg:text-6xl text-white leading-snug md:leading-tight mb-6 md:mb-8 break-words">{{ post.title }}</h1>
<AuthorInfo
@@ -405,6 +408,7 @@ import { renderMarkdown, preloadMarkdownRenderer } from '../utils/markdownRender
import Icon from '../components/Icon.vue'
import AuthorInfo from '../components/AuthorInfo.vue'
import SnippetModal from '../components/SnippetModal.vue'
import ArticleExportMenu from '../components/ArticleExportMenu.vue'
import 'highlight.js/styles/atom-one-dark.css'
const route = useRoute()

View File

@@ -140,7 +140,7 @@
<h2 class="font-serif text-5xl text-white group-hover:italic transition-all">返回作品列表</h2>
</div>
</div>
<footer class="py-8 text-center border-t border-white/5 relative z-10 bg-[#050505]"><p class="text-art-muted text-xs font-mono tracking-widest opacity-50">&copy; 2024 年糕崽崽. 保留所有权利.</p></footer>
<!-- <footer class="py-8 text-center border-t border-white/5 relative z-10 bg-[#050505]"><p class="text-art-muted text-xs font-mono tracking-widest opacity-50">&copy; 2024 年糕崽崽. 保留所有权利.</p></footer>-->
</div>
</div>
</div>

View File

@@ -0,0 +1,252 @@
/**
* 文章离屏导出样式PDF / PNG 共用)
* 支持浅色打印风格与深色页面风格
*/
.article-export-root {
width: 800px;
padding: 48px 40px;
box-sizing: border-box;
font-family: 'Noto Sans SC', 'Inter', system-ui, sans-serif;
line-height: 1.75;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* ---------- 浅色导出主题 ---------- */
.export-theme-light {
background: #ffffff;
color: #1f2937;
}
.export-theme-light .export-header {
border-bottom: 1px solid #e5e7eb;
padding-bottom: 24px;
margin-bottom: 32px;
}
.export-theme-light .export-meta {
color: #6b7280;
font-size: 13px;
}
.export-theme-light .export-category {
color: #92400e;
background: #fef3c7;
border: 1px solid #fde68a;
}
.export-theme-light .export-title {
color: #111827;
}
.export-theme-light .export-body h1,
.export-theme-light .export-body h2,
.export-theme-light .export-body h3,
.export-theme-light .export-body h4 {
color: #111827;
font-family: 'Playfair Display', Georgia, serif;
}
.export-theme-light .export-body h2 {
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.4em;
}
.export-theme-light .export-body a {
color: #b45309;
text-decoration: underline;
}
.export-theme-light .export-body blockquote {
border-left: 3px solid #d4b383;
background: #f9fafb;
color: #4b5563;
padding: 0.8rem 1.2rem;
border-radius: 0 0.5rem 0.5rem 0;
}
.export-theme-light .export-body :not(pre) > code {
color: #92400e;
background: #fef3c7;
padding: 0.15em 0.35em;
border-radius: 0.25em;
}
.export-theme-light .export-body .ios-code-container {
background: #f3f4f6 !important;
border: 1px solid #e5e7eb !important;
}
.export-theme-light .export-body .mac-window-header {
background: #e5e7eb !important;
border-bottom: 1px solid #d1d5db !important;
}
.export-theme-light .export-body .line-numbers-wrapper,
.export-theme-light .export-body .code-block-wrapper {
background: #f9fafb !important;
}
.export-theme-light .export-body .line-numbers-wrapper {
border-right: 1px solid #e5e7eb !important;
color: #9ca3af !important;
}
.export-theme-light .export-body img {
max-width: 100%;
border-radius: 8px;
}
.export-theme-light .export-body table {
border-collapse: collapse;
width: 100%;
}
.export-theme-light .export-body th,
.export-theme-light .export-body td {
border: 1px solid #e5e7eb;
padding: 8px 12px;
}
/* ---------- 深色导出主题 ---------- */
.export-theme-dark {
background: #050505;
color: rgba(255, 255, 255, 0.75);
}
.export-theme-dark .export-header {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 24px;
margin-bottom: 32px;
}
.export-theme-dark .export-meta {
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
}
.export-theme-dark .export-category {
color: #d4b383;
background: rgba(212, 179, 131, 0.1);
border: 1px solid rgba(212, 179, 131, 0.3);
}
.export-theme-dark .export-title {
color: #ffffff;
}
.export-theme-dark .export-body h1,
.export-theme-dark .export-body h2,
.export-theme-dark .export-body h3,
.export-theme-dark .export-body h4 {
color: #ffffff;
font-family: 'Playfair Display', Georgia, serif;
}
.export-theme-dark .export-body h2 {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 0.4em;
}
.export-theme-dark .export-body h3 {
color: rgba(255, 255, 255, 0.9);
}
.export-theme-dark .export-body a {
color: #d4b383;
text-decoration: underline;
}
.export-theme-dark .export-body blockquote {
border-left: 3px solid #d4b383;
background: rgba(255, 255, 255, 0.02);
color: rgba(255, 255, 255, 0.7);
padding: 0.8rem 1.2rem;
border-radius: 0 0.5rem 0.5rem 0;
}
.export-theme-dark .export-body :not(pre) > code {
color: #d4b383;
background: rgba(212, 179, 131, 0.1);
padding: 0.15em 0.35em;
border-radius: 0.25em;
}
.export-theme-dark .export-body .ios-code-container {
background: #1e1e1e !important;
border: 1px solid rgba(255, 255, 255, 0.05) !important;
}
.export-theme-dark .export-body .mac-window-header {
background: #282828 !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.05) !important;
}
.export-theme-dark .export-body .line-numbers-wrapper,
.export-theme-dark .export-body .code-block-wrapper {
background: #1e1e1e !important;
}
.export-theme-dark .export-body .line-numbers-wrapper {
border-right: 1px solid rgba(255, 255, 255, 0.05) !important;
color: rgba(255, 255, 255, 0.2) !important;
}
.export-theme-dark .export-body img {
max-width: 100%;
border-radius: 8px;
}
.export-theme-dark .export-body table {
border-collapse: collapse;
width: 100%;
}
.export-theme-dark .export-body th,
.export-theme-dark .export-body td {
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 8px 12px;
}
/* ---------- 公共结构 ---------- */
.export-header-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.export-category {
display: inline-block;
padding: 2px 10px;
border-radius: 9999px;
font-size: 11px;
font-family: ui-monospace, monospace;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.export-title {
font-size: 32px;
font-weight: 600;
line-height: 1.25;
margin: 0 0 12px;
}
.export-author {
font-size: 14px;
}
.export-body {
font-size: 16px;
}
.export-body h1 { font-size: 1.75rem; margin-top: 1.5em; margin-bottom: 0.6em; }
.export-body h2 { font-size: 1.5rem; margin-top: 1.5em; margin-bottom: 0.6em; }
.export-body h3 { font-size: 1.25rem; margin-top: 1.25em; margin-bottom: 0.5em; }
.export-body p { margin: 0.75em 0; }
.export-body ul, .export-body ol { padding-left: 1.5em; margin: 0.75em 0; }
.export-body pre { margin: 0; overflow: visible; white-space: pre-wrap; word-break: break-word; }
.export-body img { display: block; margin: 1em auto; }

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/**
* 使用 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 已渲染正文 HTMLPDF/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: '不支持的导出格式' }
}
}

View 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)
}