hulai-website/components/admin/RichTextEditor.vue
wx b5c13d5f24 fix(blog): 后台博客图片支持本地上传 + 封面图上传错误信息暴露
正文编辑器:
- 新增"图片"按钮: 点击弹文件选择 -> 调 /api/admin/upload -> 自动插入 <img src=/uploads/...>
- 保留"图片URL"按钮兼容外链/已有路径(原来唯一的 prompt 路径)
- 上传中按钮 disabled, 失败用 naive-ui message 提示

封面图 ImageUpload:
- 改用 useAdmin().adminFetch 替代裸 \$fetch + useState 取 token 的双轨写法
- 错误信息从单一字符串"上传失败"改为 e.data?.message || e.message || HTTP statusCode, 让线上"上传失败"自暴露真实根因

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:19:07 +08:00

197 行
7.6 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<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="源代码视图">&lt;/&gt; 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)
})
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;
}
.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>