useEditor 异步创建 vs loadItem 异步加载的 race condition: - mount 时 useEditor 用 props.modelValue='' 初始化 - watch modelValue 触发时 editor.value 还是 null 被跳过 - editor 实例化后 watch 不会再跑,最终内容为空 修复:增加 watch editor 实例的逻辑,编辑器创建后立即同步当前 modelValue。 影响范围:articles/[id]、blogs/[id] 编辑页(共用同一组件)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
208 行
7.9 KiB
Vue
208 行
7.9 KiB
Vue
<template>
|
||
<div class="rich-editor">
|
||
<div v-if="editor" class="rich-editor-toolbar">
|
||
<button type="button" :class="btn(editor.isActive('heading', { level: 2 }))" @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" title="大标题">H2</button>
|
||
<button type="button" :class="btn(editor.isActive('heading', { level: 3 }))" @click="editor.chain().focus().toggleHeading({ level: 3 }).run()" title="小标题">H3</button>
|
||
<button type="button" :class="btn(editor.isActive('paragraph'))" @click="editor.chain().focus().setParagraph().run()" title="正文">¶</button>
|
||
<span class="divider" />
|
||
<button type="button" :class="btn(editor.isActive('bold'))" @click="editor.chain().focus().toggleBold().run()" title="加粗"><b>B</b></button>
|
||
<button type="button" :class="btn(editor.isActive('italic'))" @click="editor.chain().focus().toggleItalic().run()" title="斜体"><i>I</i></button>
|
||
<button type="button" :class="btn(editor.isActive('strike'))" @click="editor.chain().focus().toggleStrike().run()" title="删除线"><s>S</s></button>
|
||
<span class="divider" />
|
||
<button type="button" :class="btn(editor.isActive('bulletList'))" @click="editor.chain().focus().toggleBulletList().run()" title="无序列表">• 列表</button>
|
||
<button type="button" :class="btn(editor.isActive('orderedList'))" @click="editor.chain().focus().toggleOrderedList().run()" title="有序列表">1. 列表</button>
|
||
<button type="button" :class="btn(editor.isActive('blockquote'))" @click="editor.chain().focus().toggleBlockquote().run()" title="引用">" 引用</button>
|
||
<span class="divider" />
|
||
<button type="button" :class="btn(editor.isActive('link'))" @click="setLink" title="链接">🔗 链接</button>
|
||
<button type="button" class="toolbar-btn" :disabled="uploading" @click="addImage" :title="uploading ? '上传中...' : '插入图片'">🖼 {{ uploading ? '上传中' : '图片' }}</button>
|
||
<button type="button" class="toolbar-btn" @click="addImageByUrl" title="插入网络图片URL">🌐 图片URL</button>
|
||
<span class="divider" />
|
||
<button type="button" class="toolbar-btn" @click="editor.chain().focus().undo().run()" title="撤销">↶</button>
|
||
<button type="button" class="toolbar-btn" @click="editor.chain().focus().redo().run()" title="重做">↷</button>
|
||
<span class="divider" />
|
||
<button type="button" class="toolbar-btn" :class="{ active: showHtml }" @click="showHtml = !showHtml" title="源代码视图"></> HTML</button>
|
||
</div>
|
||
|
||
<input ref="fileInput" type="file" accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml" style="display: none" @change="handleFileChange" />
|
||
|
||
<EditorContent v-show="!showHtml" :editor="editor" class="rich-editor-body" />
|
||
<textarea
|
||
v-show="showHtml"
|
||
:value="modelValue"
|
||
class="rich-editor-html"
|
||
rows="20"
|
||
@input="$emit('update:modelValue', $event.target.value); editor?.commands.setContent($event.target.value, false)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { EditorContent, useEditor } from '@tiptap/vue-3'
|
||
import StarterKit from '@tiptap/starter-kit'
|
||
import Link from '@tiptap/extension-link'
|
||
import Image from '@tiptap/extension-image'
|
||
import { watch, ref, onBeforeUnmount } from 'vue'
|
||
import { useMessage } from 'naive-ui'
|
||
|
||
const props = defineProps({
|
||
modelValue: { type: String, default: '' },
|
||
placeholder: { type: String, default: '开始写作...' },
|
||
})
|
||
const emit = defineEmits(['update:modelValue'])
|
||
|
||
const showHtml = ref(false)
|
||
const fileInput = ref(null)
|
||
const uploading = ref(false)
|
||
const message = useMessage()
|
||
const { adminFetch } = useAdmin()
|
||
|
||
const editor = useEditor({
|
||
content: props.modelValue,
|
||
extensions: [
|
||
StarterKit,
|
||
Link.configure({ openOnClick: false, HTMLAttributes: { rel: 'noopener', target: '_blank' } }),
|
||
Image,
|
||
],
|
||
onUpdate: ({ editor }) => {
|
||
emit('update:modelValue', editor.getHTML())
|
||
},
|
||
})
|
||
|
||
watch(() => props.modelValue, (val) => {
|
||
if (!editor.value) return
|
||
const current = editor.value.getHTML()
|
||
if (val !== current) editor.value.commands.setContent(val || '', false)
|
||
})
|
||
|
||
// editor 实例异步创建,若先于 modelValue 异步加载完成, watch modelValue 时 editor 还是 null 会跳过, 导致内容空白
|
||
watch(editor, (ed) => {
|
||
if (!ed) return
|
||
if (props.modelValue && ed.getHTML() !== props.modelValue) {
|
||
ed.commands.setContent(props.modelValue, false)
|
||
}
|
||
}, { immediate: true })
|
||
|
||
onBeforeUnmount(() => editor.value?.destroy())
|
||
|
||
function btn(active) { return { 'toolbar-btn': true, active } }
|
||
|
||
function setLink() {
|
||
const prev = editor.value.getAttributes('link').href
|
||
const url = window.prompt('链接 URL', prev || 'https://')
|
||
if (url === null) return
|
||
if (url === '') { editor.value.chain().focus().extendMarkRange('link').unsetLink().run(); return }
|
||
editor.value.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
||
}
|
||
|
||
function addImage() {
|
||
if (uploading.value) return
|
||
fileInput.value?.click()
|
||
}
|
||
|
||
function addImageByUrl() {
|
||
const url = window.prompt('图片 URL(可填 /uploads/ 或 /images/ 路径,或外部 https:// 链接)')
|
||
if (url) editor.value.chain().focus().setImage({ src: url.trim() }).run()
|
||
}
|
||
|
||
async function handleFileChange(e) {
|
||
const file = e.target.files?.[0]
|
||
e.target.value = ''
|
||
if (!file) return
|
||
if (file.size > 10 * 1024 * 1024) { message.error('文件过大,最大 10MB'); return }
|
||
|
||
uploading.value = true
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
const result = await adminFetch('/api/admin/upload', { method: 'POST', body: formData })
|
||
const url = result?.files?.[0]?.url
|
||
if (!url) throw new Error('上传响应缺少 url 字段')
|
||
editor.value.chain().focus().setImage({ src: url }).run()
|
||
message.success('图片已插入')
|
||
} catch (err) {
|
||
message.error(err?.data?.message || err?.message || `上传失败 (HTTP ${err?.statusCode ?? '?'})`)
|
||
} finally {
|
||
uploading.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.rich-editor {
|
||
border: 1px solid #d9d9d9;
|
||
border-radius: 4px;
|
||
background: #fff;
|
||
}
|
||
|
||
.rich-editor-toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
padding: 8px;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
background: #fafafa;
|
||
border-radius: 4px 4px 0 0;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
|
||
.toolbar-btn {
|
||
padding: 4px 10px;
|
||
font-size: 13px;
|
||
border: 1px solid #e5e7eb;
|
||
background: #fff;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
color: #374151;
|
||
transition: all 0.15s;
|
||
}
|
||
.toolbar-btn:hover { background: #f3f4f6; border-color: #3a7d44; }
|
||
.toolbar-btn.active { background: #3a7d44; color: #fff; border-color: #3a7d44; }
|
||
|
||
.divider {
|
||
width: 1px;
|
||
background: #e5e7eb;
|
||
margin: 2px 4px;
|
||
}
|
||
|
||
.rich-editor-body {
|
||
padding: 12px 14px;
|
||
min-height: 400px;
|
||
font-size: 14px;
|
||
line-height: 1.7;
|
||
color: #111827;
|
||
}
|
||
.rich-editor-body :deep(.ProseMirror) { outline: none; min-height: 380px; }
|
||
.rich-editor-body :deep(.ProseMirror:focus) { outline: none; }
|
||
.rich-editor-body :deep(h2) { font-size: 20px; font-weight: 600; margin: 18px 0 10px; color: #111827; }
|
||
.rich-editor-body :deep(h3) { font-size: 16px; font-weight: 600; margin: 14px 0 8px; color: #111827; }
|
||
.rich-editor-body :deep(p) { margin: 8px 0; }
|
||
.rich-editor-body :deep(ul), .rich-editor-body :deep(ol) { padding-left: 24px; margin: 8px 0; }
|
||
.rich-editor-body :deep(li) { margin: 4px 0; }
|
||
.rich-editor-body :deep(blockquote) {
|
||
border-left: 3px solid #3a7d44;
|
||
padding-left: 12px;
|
||
color: #6b7280;
|
||
margin: 12px 0;
|
||
}
|
||
.rich-editor-body :deep(a) { color: #3a7d44; text-decoration: underline; }
|
||
.rich-editor-body :deep(img) { max-width: 100%; height: auto; border-radius: 4px; }
|
||
.rich-editor-body :deep(strong) { font-weight: 600; }
|
||
|
||
.rich-editor-html {
|
||
width: 100%;
|
||
padding: 12px 14px;
|
||
border: none;
|
||
outline: none;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
resize: vertical;
|
||
min-height: 400px;
|
||
background: #1f2937;
|
||
color: #d1fae5;
|
||
border-radius: 0 0 4px 4px;
|
||
}
|
||
</style>
|