Add article export to Word, PDF, and Markdown
Desktop Client Build / Resolve Build Metadata (push) Successful in 24s
Frontend CI / Frontend (push) Successful in 5m26s
Desktop Client Build / Build Desktop Client (push) Successful in 22m50s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 30s

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 23:59:19 +08:00
parent 53ba8ff4cf
commit 56400805f4
7 changed files with 1904 additions and 14 deletions
+3
View File
@@ -23,7 +23,10 @@
"@vueuse/core": "^14.2.1",
"ant-design-vue": "^4.2.6",
"dayjs": "^1.11.20",
"docx": "^9.6.1",
"dompurify": "^3.4.2",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"markdown-it": "^14.1.1",
"nanoid": "^5.1.7",
"pinia": "^3.0.4",
+16
View File
@@ -1078,6 +1078,22 @@ const enUS = {
noContent: 'This article does not have generated content yet.',
editor: {
publish: 'Publish',
download: 'Download',
downloadFormats: {
word: 'Word',
pdf: 'PDF',
markdown: 'Markdown',
},
downloadMessages: {
success: 'Download started.',
failed: 'Export failed. Please try again.',
},
markdownExportConfirm: {
title: 'Download Markdown',
description:
'Images will be removed from the Markdown file. Text content such as headings, tables, and links will be kept.',
confirm: 'Download',
},
titlePlaceholder: 'Enter article title',
leaveConfirm: {
title: 'Unsaved changes',
+15
View File
@@ -1025,6 +1025,21 @@ const zhCN = {
noContent: "当前文章还没有正文内容",
editor: {
publish: "发布",
download: "下载",
downloadFormats: {
word: "Word",
pdf: "PDF",
markdown: "Markdown",
},
downloadMessages: {
success: "文件已开始下载",
failed: "导出失败,请稍后重试",
},
markdownExportConfirm: {
title: "下载 Markdown",
description: "Markdown 文件将去除正文图片,仅保留标题、文字、表格和链接等文本内容。",
confirm: "确认下载",
},
titlePlaceholder: "请输入文章标题",
leaveConfirm: {
title: "当前文章还没保存",
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,11 @@
<script setup lang="ts">
import {
CloseOutlined,
DownOutlined,
DownloadOutlined,
FileMarkdownOutlined,
FilePdfOutlined,
FileWordOutlined,
LeftOutlined,
SafetyCertificateOutlined,
SearchOutlined,
@@ -15,6 +20,12 @@ import { useRoute, useRouter } from 'vue-router'
import ArticleEditorCanvas from '@/components/ArticleEditorCanvas.vue'
import PublishArticleModal from '@/components/PublishArticleModal.vue'
import { articlesApi, complianceApi } from '@/lib/api'
import {
downloadArticleDocument,
downloadArticlePdf,
hasArticleMarkdownImages,
type ArticleExportFormat,
} from '@/lib/article-export'
import { formatError } from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
@@ -34,10 +45,12 @@ const markdown = ref('')
const initialTitle = ref('')
const initialMarkdown = ref('')
const leaveModalOpen = ref(false)
const markdownExportConfirmOpen = ref(false)
const publishModalOpen = ref(false)
const compliancePanelOpen = ref(false)
const editorComplianceResult = ref<ComplianceCheckResult | null>(null)
const editorCanvasRef = ref<EditorCanvasPublic | null>(null)
const exportLoading = ref<ArticleExportFormat | null>(null)
const detailQuery = useQuery({
queryKey: computed(() => ['articles', 'detail', companyStore.currentBrandId, articleId.value]),
@@ -213,6 +226,63 @@ async function handleComplianceCheck(): Promise<void> {
}
}
async function handleDownload(format: ArticleExportFormat): Promise<void> {
if (!detail.value || exportLoading.value) {
return
}
if (format === 'markdown') {
if (hasArticleMarkdownImages(markdown.value)) {
markdownExportConfirmOpen.value = true
return
}
await runDownload(format)
return
}
await runDownload(format)
}
async function handleConfirmMarkdownDownload(): Promise<void> {
markdownExportConfirmOpen.value = false
await runDownload('markdown')
}
function handleCancelMarkdownDownload(): void {
markdownExportConfirmOpen.value = false
}
async function runDownload(format: ArticleExportFormat): Promise<void> {
if (!detail.value || exportLoading.value) {
return
}
exportLoading.value = format
try {
const options = {
title: title.value,
markdown: markdown.value,
untitledLabel: t('article.untitled'),
}
if (format === 'pdf') {
await downloadArticlePdf(options)
message.success(t('article.editor.downloadMessages.success'))
return
}
await downloadArticleDocument(format, options)
message.success(t('article.editor.downloadMessages.success'))
} catch (error) {
message.error(t('article.editor.downloadMessages.failed'))
} finally {
window.setTimeout(() => {
exportLoading.value = null
}, 200)
}
}
function closeCompliancePanel(): void {
compliancePanelOpen.value = false
}
@@ -448,6 +518,29 @@ onBeforeUnmount(() => {
</button>
<div class="article-editor-view__actions">
<a-dropdown :trigger="['click']" placement="bottomRight">
<a-button :disabled="!detail || Boolean(exportLoading)" :loading="Boolean(exportLoading)">
<template #icon><DownloadOutlined /></template>
{{ t('article.editor.download') }}
<DownOutlined v-if="!exportLoading" class="article-editor-view__download-caret" />
</a-button>
<template #overlay>
<a-menu class="article-editor-view__download-menu">
<a-menu-item key="word" @click="handleDownload('word')">
<FileWordOutlined class="article-editor-view__download-icon article-editor-view__download-icon--word" />
<span>{{ t('article.editor.downloadFormats.word') }}</span>
</a-menu-item>
<a-menu-item key="pdf" @click="handleDownload('pdf')">
<FilePdfOutlined class="article-editor-view__download-icon article-editor-view__download-icon--pdf" />
<span>{{ t('article.editor.downloadFormats.pdf') }}</span>
</a-menu-item>
<a-menu-item key="markdown" @click="handleDownload('markdown')">
<FileMarkdownOutlined class="article-editor-view__download-icon article-editor-view__download-icon--markdown" />
<span>{{ t('article.editor.downloadFormats.markdown') }}</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-button
:disabled="editorLocked"
:loading="complianceCheckLoading"
@@ -583,6 +676,20 @@ onBeforeUnmount(() => {
<a-empty v-else :description="t('common.noData')" />
<a-modal
:open="markdownExportConfirmOpen"
:title="t('article.editor.markdownExportConfirm.title')"
:ok-text="t('article.editor.markdownExportConfirm.confirm')"
:cancel-text="t('common.cancel')"
:confirm-loading="exportLoading === 'markdown'"
@ok="handleConfirmMarkdownDownload"
@cancel="handleCancelMarkdownDownload"
>
<p class="article-editor-view__leave-copy">
{{ t('article.editor.markdownExportConfirm.description') }}
</p>
</a-modal>
<a-modal
:open="leaveModalOpen"
:title="t('article.editor.leaveConfirm.title')"
@@ -663,6 +770,41 @@ onBeforeUnmount(() => {
gap: 12px;
}
.article-editor-view__download-caret {
margin-left: 4px;
color: #667085;
font-size: 10px;
}
.article-editor-view__download-menu :deep(.ant-dropdown-menu-item) {
min-width: 170px;
padding: 11px 14px;
gap: 12px;
color: #1f2937;
font-size: 15px;
}
.article-editor-view__download-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
color: #475467;
font-size: 18px;
}
.article-editor-view__download-icon--word {
color: #2563eb;
}
.article-editor-view__download-icon--pdf {
color: #f04438;
}
.article-editor-view__download-icon--markdown {
color: #3b82f6;
}
.article-editor-view__main {
height: calc(100vh - 180px);
overflow: hidden;
+30
View File
@@ -155,6 +155,36 @@ export default defineConfig(({ mode }) => {
return 'imaging'
}
if (
id.includes('/jspdf/') ||
id.includes('/html2canvas/') ||
id.includes('/canvg/') ||
id.includes('/css-line-break/') ||
id.includes('/text-segmentation/') ||
id.includes('/fflate/') ||
id.includes('/pako/') ||
id.includes('/base64-arraybuffer/') ||
id.includes('/fast-png/') ||
id.includes('/iobuffer/') ||
id.includes('/raf/') ||
id.includes('/rgbcolor/') ||
id.includes('/stackblur-canvas/') ||
id.includes('/svg-pathdata/') ||
id.includes('/utrie/')
) {
return 'pdf-export'
}
if (
id.includes('/docx/') ||
id.includes('/jszip/') ||
id.includes('/xml-js/') ||
id.includes('/xml/') ||
id.includes('/hash.js/')
) {
return 'word-export'
}
if (id.includes('/lodash') || id.includes('/dayjs')) {
return 'utils'
}