Files
nl-blogs/client/src/components/ArticleExportMenu.vue
2026-07-04 17:21:46 +08:00

191 lines
6.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>