From 890db7c4f9a410b595e21e557fa3a5fcb72efe1d Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sat, 25 Apr 2026 00:27:19 +0800 Subject: [PATCH] feat(markdown): enhance Vditor integration and post preview functionality - Updated the Vditor configuration to support real-time rendering and improved upload handling with error management. - Introduced a new post preview draft feature, allowing users to preview posts in a new window before publishing. - Enhanced the user interface for mobile responsiveness and added utility functions for managing post preview drafts. These changes improve the markdown editing experience and streamline the post creation workflow. --- app/components/AppShell.vue | 8 +- app/components/PostBodyMarkdownEditor.vue | 96 ++++++++++++-------- ...post-body-markdown-editor-vditor-config.test.ts | 60 ++++++++++--- .../post-body-markdown-editor-vditor-config.ts | 58 +++++++++++- app/pages/me/posts/[id].vue | 28 +++++- app/pages/me/posts/new.vue | 25 +++++- app/pages/me/posts/preview/draft.vue | 89 ++++++++++++++++++ app/utils/post-preview-draft.ts | 99 +++++++++++++++++++++ package.json | 5 +- packages/drizzle-pkg/db.sqlite | Bin 163840 -> 163840 bytes public/upload/1777047996547-912573909-image.webp | Bin 0 -> 7928 bytes scripts/sync-vditor-assets.sh | 17 ++++ 12 files changed, 423 insertions(+), 62 deletions(-) create mode 100644 app/pages/me/posts/preview/draft.vue create mode 100644 app/utils/post-preview-draft.ts create mode 100644 public/upload/1777047996547-912573909-image.webp create mode 100644 scripts/sync-vditor-assets.sh diff --git a/app/components/AppShell.vue b/app/components/AppShell.vue index c86d8dc..9e6e93f 100644 --- a/app/components/AppShell.vue +++ b/app/components/AppShell.vue @@ -177,7 +177,7 @@ async function logout() { -
+
@@ -214,18 +214,18 @@ async function logout() {
- + - + {{ displayName }} - + diff --git a/app/components/PostBodyMarkdownEditor.vue b/app/components/PostBodyMarkdownEditor.vue index 99cef72..0d2049f 100644 --- a/app/components/PostBodyMarkdownEditor.vue +++ b/app/components/PostBodyMarkdownEditor.vue @@ -13,10 +13,21 @@ const emit = defineEmits<{ 'update:modelValue': [string] }>() -const { fetchData } = useClientApi() const toast = useToast() const mountEl = ref(null) const isMobileViewport = ref(false) +const editorContainerStyle = computed(() => { + if (isMobileViewport.value) { + return { + height: 'min(62vh, 560px)', + minHeight: '360px', + } + } + return { + height: 'min(78vh, 900px)', + minHeight: '520px', + } +}) let viewportMql: MediaQueryList | null = null let onViewportChange: ((event: MediaQueryListEvent) => void) | null = null @@ -32,7 +43,9 @@ const bridge = createPostBodyMarkdownEditorBridge({ value, isMobile: isMobileViewport.value, onInput, - uploadHandler: onUploadImg, + onUploadError: () => { + toast.add({ title: '图片上传失败', color: 'warning' }) + }, })) }, }) @@ -44,40 +57,6 @@ const vditorCtor = shallowRef(null) let unmounted = false -async function onUploadImg(files: File[]): Promise { - const form = new FormData() - for (const file of files) { - form.append('file', file) - } - try { - const { files: uploaded } = await fetchData<{ files: { url: string }[] }>('/api/file/upload', { - method: 'POST', - body: form, - }) - const succMap = Object.fromEntries(uploaded.map((item) => [item.url, item.url])) - toast.add({ title: '图片已上传', color: 'success' }) - return JSON.stringify({ - msg: '', - code: 0, - data: { - errFiles: [] as string[], - succMap, - }, - }) - } - catch { - toast.add({ title: '图片上传失败', color: 'warning' }) - return JSON.stringify({ - msg: 'upload failed', - code: 1, - data: { - errFiles: [] as string[], - succMap: {} as Record, - }, - }) - } -} - onMounted(async () => { if (!import.meta.client) { return @@ -137,8 +116,8 @@ onBeforeUnmount(() => {
+ + diff --git a/app/components/post-body-markdown-editor-vditor-config.test.ts b/app/components/post-body-markdown-editor-vditor-config.test.ts index 697d5c0..87020e7 100644 --- a/app/components/post-body-markdown-editor-vditor-config.test.ts +++ b/app/components/post-body-markdown-editor-vditor-config.test.ts @@ -5,15 +5,21 @@ import { } from './post-body-markdown-editor-vditor-config' describe('PostBodyMarkdownEditor Vditor config', () => { - test('桌面端使用完整工具栏与可预览模式', () => { + test('桌面端使用完整工具栏与即时渲染模式', () => { const options = buildPostBodyMarkdownEditorVditorOptions({ value: 'hello', isMobile: false, onInput: () => undefined, - uploadHandler: async () => '', + onUploadError: () => undefined, }) - expect(options.mode).toBe('sv') + expect(options.mode).toBe('ir') + expect(options.lang).toBe('zh_CN') + expect(options.cdn).toBe('/vditor') + expect(options.preview).toEqual({ + mode: 'editor', + actions: [], + }) expect(options.toolbar).toEqual(postBodyMarkdownEditorToolbarPresets.desktop) expect(postBodyMarkdownEditorToolbarPresets.desktop.includes('preview')).toBe(false) expect(postBodyMarkdownEditorToolbarPresets.desktop.length).toBeGreaterThan(postBodyMarkdownEditorToolbarPresets.mobile.length) @@ -24,7 +30,7 @@ describe('PostBodyMarkdownEditor Vditor config', () => { value: 'hello', isMobile: true, onInput: () => undefined, - uploadHandler: async () => '', + onUploadError: () => undefined, }) expect(options.mode).toBe('ir') @@ -45,17 +51,49 @@ describe('PostBodyMarkdownEditor Vditor config', () => { ]) }) - test('上传处理器透传到 upload.handler', async () => { - const uploadHandler = async () => 'ok' + test('上传配置会将服务端响应转换为 succMap 结构', async () => { + const options = buildPostBodyMarkdownEditorVditorOptions({ + value: '', + isMobile: false, + onInput: () => undefined, + onUploadError: () => undefined, + }) as { upload?: { format?: (files: File[], responseText: string) => string } } + + const files = [{ name: 'image.webp' }] as File[] + const result = options.upload?.format?.(files, JSON.stringify({ + code: 0, + data: { + files: [{ url: '/public/upload/abc.webp' }], + }, + })) + expect(result).toBe(JSON.stringify({ + msg: '', + code: 0, + data: { + errFiles: [], + succMap: { + 'image.webp': '/public/upload/abc.webp', + }, + }, + })) + }) + + test('上传配置解析失败时返回错误结构', () => { const options = buildPostBodyMarkdownEditorVditorOptions({ value: '', isMobile: false, onInput: () => undefined, - uploadHandler, - }) as { upload?: { handler?: (files: File[]) => Promise } } + onUploadError: () => undefined, + }) as { upload?: { format?: (files: File[], responseText: string) => string } } - expect(options.upload?.handler).toBeDefined() - const result = await options.upload?.handler?.([] as File[]) - expect(result).toBe('ok') + const result = options.upload?.format?.([] as File[], 'invalid json') + expect(result).toBe(JSON.stringify({ + msg: 'upload response parse failed', + code: 1, + data: { + errFiles: [], + succMap: {}, + }, + })) }) }) diff --git a/app/components/post-body-markdown-editor-vditor-config.ts b/app/components/post-body-markdown-editor-vditor-config.ts index b409b68..e500575 100644 --- a/app/components/post-body-markdown-editor-vditor-config.ts +++ b/app/components/post-body-markdown-editor-vditor-config.ts @@ -2,7 +2,7 @@ interface BuildVditorOptionsInput { value: string isMobile: boolean onInput: (value: string) => void - uploadHandler: (files: File[]) => Promise + onUploadError: () => void } const DESKTOP_TOOLBAR: ReadonlyArray = [ @@ -52,12 +52,64 @@ const MOBILE_TOOLBAR: ReadonlyArray = [ export function buildPostBodyMarkdownEditorVditorOptions(input: BuildVditorOptionsInput): Record { return { value: input.value, + lang: 'zh_CN', + cdn: '/vditor', cache: { enable: false }, - mode: input.isMobile ? 'ir' : 'sv', + mode: 'ir', + preview: { + mode: 'editor', + actions: [] as string[], + }, toolbar: input.isMobile ? MOBILE_TOOLBAR : DESKTOP_TOOLBAR, upload: { + url: '/api/file/upload', + fieldName: 'file', + multiple: true, accept: 'image/*', - handler: input.uploadHandler, + format(files: File[], responseText: string) { + try { + const parsed = JSON.parse(responseText) as { + files?: Array<{ url?: string }> + data?: { files?: Array<{ url?: string }> } + } + const nestedFiles = parsed?.data?.files + const rootFiles = parsed?.files + const uploaded = Array.isArray(nestedFiles) + ? nestedFiles + : (Array.isArray(rootFiles) ? rootFiles : []) + const succMap: Record = {} + uploaded.forEach((item, index) => { + const url = item?.url?.trim() + if (!url) { + return + } + const fallbackName = `upload-${index + 1}` + const sourceName = files[index]?.name?.trim() || fallbackName + succMap[sourceName] = url + }) + return JSON.stringify({ + msg: '', + code: 0, + data: { + errFiles: [] as string[], + succMap, + }, + }) + } + catch { + return JSON.stringify({ + msg: 'upload response parse failed', + code: 1, + data: { + errFiles: [] as string[], + succMap: {} as Record, + }, + }) + } + }, + error() { + input.onUploadError() + }, }, input(value: string) { input.onInput(value) diff --git a/app/pages/me/posts/[id].vue b/app/pages/me/posts/[id].vue index 6aaad97..dda32c0 100644 --- a/app/pages/me/posts/[id].vue +++ b/app/pages/me/posts/[id].vue @@ -1,6 +1,7 @@