优化页面、修复BUG
This commit is contained in:
190
client/src/components/ArticleExportMenu.vue
Normal file
190
client/src/components/ArticleExportMenu.vue
Normal 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>
|
||||
Reference in New Issue
Block a user