Browse Source
- 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.main
12 changed files with 423 additions and 62 deletions
@ -0,0 +1,89 @@ |
|||
<script setup lang="ts"> |
|||
import { readPostPreviewDraft, type PostPreviewDraftPayload } from '../../../../utils/post-preview-draft' |
|||
import { renderSafeMarkdown } from '../../../../utils/render-markdown' |
|||
|
|||
const route = useRoute() |
|||
const draft = ref<PostPreviewDraftPayload | null>(null) |
|||
const previewStatus = ref<'loading' | 'missing_key' | 'invalid' | 'ready'>('loading') |
|||
|
|||
const visibilityLabel = computed(() => { |
|||
if (!draft.value) { |
|||
return '' |
|||
} |
|||
if (draft.value.visibility === 'private') { |
|||
return '私密预览' |
|||
} |
|||
if (draft.value.visibility === 'unlisted') { |
|||
return '仅链接预览' |
|||
} |
|||
return '公开预览' |
|||
}) |
|||
|
|||
const renderedBody = computed(() => { |
|||
if (!draft.value) { |
|||
return '' |
|||
} |
|||
return renderSafeMarkdown(draft.value.bodyMarkdown) |
|||
}) |
|||
|
|||
onMounted(() => { |
|||
const key = typeof route.query.key === 'string' ? route.query.key : '' |
|||
if (!key) { |
|||
previewStatus.value = 'missing_key' |
|||
return |
|||
} |
|||
const payload = readPostPreviewDraft(key) |
|||
if (!payload) { |
|||
previewStatus.value = 'invalid' |
|||
return |
|||
} |
|||
draft.value = payload |
|||
previewStatus.value = 'ready' |
|||
}) |
|||
|
|||
usePageTitle(() => { |
|||
const t = draft.value?.title?.trim() |
|||
return t ? [t, '预览'] : ['草稿预览'] |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<UContainer class="py-10 space-y-6"> |
|||
<div v-if="previewStatus === 'loading'" class="text-sm text-muted"> |
|||
预览加载中… |
|||
</div> |
|||
<template v-if="draft"> |
|||
<div class="flex flex-wrap items-center gap-2"> |
|||
<UBadge color="neutral" variant="soft"> |
|||
{{ visibilityLabel }} |
|||
</UBadge> |
|||
<UBadge color="primary" variant="outline"> |
|||
草稿 |
|||
</UBadge> |
|||
</div> |
|||
|
|||
<h1 class="text-2xl font-semibold"> |
|||
{{ draft.title || '未命名文章' }} |
|||
</h1> |
|||
<p v-if="draft.excerpt" class="text-muted"> |
|||
{{ draft.excerpt }} |
|||
</p> |
|||
<article |
|||
class="prose prose-neutral dark:prose-invert max-w-none prose-a:text-primary prose-img:rounded-lg prose-headings:text-highlighted prose-p:text-default prose-strong:text-highlighted markdown-body green" |
|||
v-html="renderedBody" |
|||
/> |
|||
</template> |
|||
<UAlert |
|||
v-else-if="previewStatus === 'missing_key'" |
|||
color="neutral" |
|||
title="请从编辑页打开预览" |
|||
description="当前地址缺少预览参数,请返回新建/编辑页点击“预览(新窗口)”。" |
|||
/> |
|||
<UAlert |
|||
v-else-if="previewStatus === 'invalid'" |
|||
color="warning" |
|||
title="预览内容不存在或已失效" |
|||
description="请返回新建/编辑页,重新点击预览。" |
|||
/> |
|||
</UContainer> |
|||
</template> |
|||
@ -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<StoredPostPreviewDraftPayload> |
|||
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<StoredPostPreviewDraftPayload> |
|||
if (typeof parsed.createdAt !== 'number' || now - parsed.createdAt > PREVIEW_DRAFT_TTL_MS) { |
|||
localStorage.removeItem(key) |
|||
} |
|||
} catch { |
|||
localStorage.removeItem(key) |
|||
} |
|||
} |
|||
} |
|||
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
@ -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" |
|||
Loading…
Reference in new issue