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
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:
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user