@@ -147,3 +126,44 @@ 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 @@
@@ -170,10 +191,10 @@ async function copyShareUrl() {
-
+
操作
+
+ 预览(新窗口)
+
保存文章
diff --git a/app/pages/me/posts/new.vue b/app/pages/me/posts/new.vue
index 299ac05..e734977 100644
--- a/app/pages/me/posts/new.vue
+++ b/app/pages/me/posts/new.vue
@@ -1,5 +1,6 @@
@@ -79,10 +97,10 @@ async function submit() {
-
+
操作
+
+ 预览(新窗口)
+
创建文章
diff --git a/app/pages/me/posts/preview/draft.vue b/app/pages/me/posts/preview/draft.vue
new file mode 100644
index 0000000..fae64c3
--- /dev/null
+++ b/app/pages/me/posts/preview/draft.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
+ 预览加载中…
+
+
+
+
+ {{ visibilityLabel }}
+
+
+ 草稿
+
+
+
+
+ {{ draft.title || '未命名文章' }}
+
+
+ {{ draft.excerpt }}
+
+
+
+
+
+
+
diff --git a/app/utils/post-preview-draft.ts b/app/utils/post-preview-draft.ts
new file mode 100644
index 0000000..78d420c
--- /dev/null
+++ b/app/utils/post-preview-draft.ts
@@ -0,0 +1,99 @@
+export type PostPreviewDraftPayload = {
+ title: string
+ excerpt: string
+ bodyMarkdown: string
+ visibility: 'private' | 'unlisted' | 'public'
+}
+
+const PREVIEW_DRAFT_KEY_PREFIX = 'post-preview-draft:'
+const PREVIEW_DRAFT_TTL_MS = 10 * 60 * 1000
+
+type StoredPostPreviewDraftPayload = PostPreviewDraftPayload & {
+ createdAt: number
+}
+
+function isPostPreviewDraftKey(key: string): boolean {
+ return key.startsWith(PREVIEW_DRAFT_KEY_PREFIX)
+}
+
+export function createPostPreviewDraft(payload: PostPreviewDraftPayload): string {
+ const uniqueId = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
+ ? crypto.randomUUID()
+ : `${Date.now()}-${Math.random().toString(16).slice(2)}`
+ const key = `${PREVIEW_DRAFT_KEY_PREFIX}${uniqueId}`
+ const storedPayload: StoredPostPreviewDraftPayload = {
+ ...payload,
+ createdAt: Date.now(),
+ }
+ localStorage.setItem(key, JSON.stringify(storedPayload))
+ return uniqueId
+}
+
+export function readPostPreviewDraft(uniqueId: string): PostPreviewDraftPayload | null {
+ const key = `${PREVIEW_DRAFT_KEY_PREFIX}${uniqueId}`
+ const raw = localStorage.getItem(key)
+ if (!raw) {
+ return null
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as Partial
+ if (
+ typeof parsed.title !== 'string' ||
+ typeof parsed.excerpt !== 'string' ||
+ typeof parsed.bodyMarkdown !== 'string' ||
+ typeof parsed.createdAt !== 'number' ||
+ (parsed.visibility !== 'private' && parsed.visibility !== 'unlisted' && parsed.visibility !== 'public')
+ ) {
+ localStorage.removeItem(key)
+ return null
+ }
+
+ if (Date.now() - parsed.createdAt > PREVIEW_DRAFT_TTL_MS) {
+ localStorage.removeItem(key)
+ return null
+ }
+
+ return {
+ title: parsed.title,
+ excerpt: parsed.excerpt,
+ bodyMarkdown: parsed.bodyMarkdown,
+ visibility: parsed.visibility,
+ }
+ } catch {
+ localStorage.removeItem(key)
+ return null
+ }
+}
+
+export function clearPostPreviewDrafts(options?: { onlyExpired?: boolean }) {
+ const onlyExpired = options?.onlyExpired === true
+ const now = Date.now()
+
+ for (let index = localStorage.length - 1; index >= 0; index -= 1) {
+ const key = localStorage.key(index)
+ if (!key || !isPostPreviewDraftKey(key)) {
+ continue
+ }
+
+ if (!onlyExpired) {
+ localStorage.removeItem(key)
+ continue
+ }
+
+ const raw = localStorage.getItem(key)
+ if (!raw) {
+ localStorage.removeItem(key)
+ continue
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as Partial
+ if (typeof parsed.createdAt !== 'number' || now - parsed.createdAt > PREVIEW_DRAFT_TTL_MS) {
+ localStorage.removeItem(key)
+ }
+ } catch {
+ localStorage.removeItem(key)
+ }
+ }
+}
diff --git a/package.json b/package.json
index 26f1de4..f724c4b 100644
--- a/package.json
+++ b/package.json
@@ -7,8 +7,9 @@
],
"private": true,
"scripts": {
- "build": "nuxt build && bun run cp:db && bun --elide-lines=0 --filter drizzle-pkg build",
- "dev": "nuxt dev",
+ "build": "bun run sync:vditor && nuxt build && bun run cp:db && bun --elide-lines=0 --filter drizzle-pkg build",
+ "dev": "bun run sync:vditor && nuxt dev",
+ "sync:vditor": "sh scripts/sync-vditor-assets.sh",
"cp:db": "cp build-files/run.sh .output/run.sh && cp .env.example .output/.env && cp -r build-files/migrate/* .output/server/ && cp build-files/seed.js .output/server/seed.js",
"migrate:test": "sh scripts/migrate-test.sh",
"db:migrate": "bun --elide-lines=0 --filter drizzle-pkg migrate",
diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite
index 053f98e..1430fcb 100644
Binary files a/packages/drizzle-pkg/db.sqlite and b/packages/drizzle-pkg/db.sqlite differ
diff --git a/public/upload/1777047996547-912573909-image.webp b/public/upload/1777047996547-912573909-image.webp
new file mode 100644
index 0000000..25b6d7d
Binary files /dev/null and b/public/upload/1777047996547-912573909-image.webp differ
diff --git a/scripts/sync-vditor-assets.sh b/scripts/sync-vditor-assets.sh
new file mode 100644
index 0000000..fede100
--- /dev/null
+++ b/scripts/sync-vditor-assets.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env sh
+set -eu
+
+ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
+SRC_DIR="$ROOT_DIR/node_modules/vditor/dist"
+DST_DIR="$ROOT_DIR/public/vditor/dist"
+
+if [ ! -d "$SRC_DIR" ]; then
+ echo "skip sync: $SRC_DIR not found"
+ exit 0
+fi
+
+mkdir -p "$DST_DIR"
+rm -rf "$DST_DIR"
+mkdir -p "$DST_DIR"
+cp -R "$SRC_DIR"/. "$DST_DIR"/
+echo "synced vditor assets to $DST_DIR"