feat: 完善内容管理通用富文本编辑器

This commit is contained in:
2026-08-27 17:01:48 +08:00
parent ed81f24892
commit d93486cd9a
21 changed files with 1245 additions and 30 deletions

View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
import { toDisplayHtml } from '~/utils/rich-text'
const props = defineProps<{ content: string }>()
const html = computed(() => toDisplayHtml(props.content))
</script>
<template>
<div class="rich-text-content" v-html="html" />
</template>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { TextAlign } from '@tiptap/extension-text-align'
import { TextStyleKit } from '@tiptap/extension-text-style'
import Image from '@tiptap/extension-image'
import StarterKit from '@tiptap/starter-kit'
import { EditorContent, useEditor } from '@tiptap/vue-3'
import { toEditorHtml } from '~/utils/rich-text'
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const linkMessage = ref('')
const imageInput = ref<HTMLInputElement>()
const imageMessage = ref('')
const imageBusy = ref(false)
const editor = useEditor({
content: toEditorHtml(props.modelValue),
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
link: {
openOnClick: false,
autolink: true,
HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' },
},
}),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TextStyleKit,
Image.configure({ allowBase64: false }),
],
editorProps: {
attributes: {
class: 'rich-text-surface',
'aria-label': '正文编辑区域',
spellcheck: 'true',
},
},
onUpdate: ({ editor: instance }) => {
emit('update:modelValue', instance.isEmpty ? '' : instance.getHTML())
},
})
watch(() => props.modelValue, (value) => {
if (!editor.value) return
const next = toEditorHtml(value)
if (editor.value.getHTML() !== next) editor.value.commands.setContent(next, { emitUpdate: false })
})
function selectBlock(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value === 'paragraph') editor.value?.chain().focus().setParagraph().run()
else editor.value?.chain().focus().toggleHeading({ level: Number(value) as 2 | 3 }).run()
}
function selectFontFamily(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value) editor.value?.chain().focus().setFontFamily(value).run()
else editor.value?.chain().focus().unsetFontFamily().run()
}
function selectFontSize(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value) editor.value?.chain().focus().setFontSize(value).run()
else editor.value?.chain().focus().unsetFontSize().run()
}
function selectLineHeight(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value) editor.value?.chain().focus().setLineHeight(value).run()
else editor.value?.chain().focus().unsetLineHeight().run()
}
function setColor(event: Event, background = false) {
const value = (event.target as HTMLInputElement).value
const chain = editor.value?.chain().focus()
if (background) chain?.setBackgroundColor(value).run()
else chain?.setColor(value).run()
}
function setLink() {
const previous = editor.value?.getAttributes('link').href || ''
const href = window.prompt('请输入链接地址http、https、mailto 或 tel', previous)
if (href === null) return
if (!href.trim()) {
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
if (!/^(https?:\/\/|mailto:|tel:)/i.test(href.trim())) {
linkMessage.value = '链接需以 http://、https://、mailto: 或 tel: 开头'
return
}
linkMessage.value = ''
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: href.trim(), target: '_blank', rel: 'noopener noreferrer' }).run()
}
function insertImageUrl() {
const src = window.prompt('请输入图片地址(建议使用素材库中的图片地址)')
if (!src?.trim()) return
if (!/^https?:\/\//i.test(src.trim()) && !src.trim().startsWith('/')) {
imageMessage.value = '图片地址需以 http://、https:// 或 / 开头'
return
}
imageMessage.value = ''
editor.value?.chain().focus().setImage({ src: src.trim() }).run()
}
async function uploadImage(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
imageBusy.value = true
imageMessage.value = ''
try {
const body = new FormData()
body.append('file', file)
const result = await $fetch<{ url: string }>('/api/admin/media', { method: 'POST', body })
editor.value?.chain().focus().setImage({ src: result.url, alt: file.name }).run()
} catch (error: any) {
imageMessage.value = error?.data?.statusMessage || '图片上传失败,请检查图片格式和大小'
} finally {
imageBusy.value = false
;(event.target as HTMLInputElement).value = ''
}
}
</script>
<template>
<div class="rich-text-editor">
<div v-if="editor" class="rich-text-toolbar" role="toolbar" aria-label="正文排版工具栏">
<select aria-label="段落样式" title="段落样式" @change="selectBlock">
<option value="paragraph">正文</option>
<option value="2">二级标题</option>
<option value="3">三级标题</option>
</select>
<select aria-label="字体" title="字体" @change="selectFontFamily">
<option value="">默认字体</option>
<option value="Noto Serif SC, serif">宋体风格</option>
<option value="Noto Sans SC, sans-serif">黑体风格</option>
<option value="Arial, sans-serif">Arial</option>
</select>
<select aria-label="字号" title="字号" @change="selectFontSize">
<option value="">默认字号</option>
<option v-for="size in [14,16,18,20,24,28,32]" :key="size" :value="`${size}px`">{{ size }} px</option>
</select>
<select aria-label="行距" title="行距" @change="selectLineHeight">
<option value="">默认行距</option>
<option value="1.5">1.5 </option>
<option value="1.75">1.75 </option>
<option value="2">2 </option>
<option value="2.25">2.25 </option>
</select>
<span class="toolbar-divider" />
<button type="button" :class="{ active: editor.isActive('bold') }" title="加粗" aria-label="加粗" @click="editor.chain().focus().toggleBold().run()"><b>B</b></button>
<button type="button" :class="{ active: editor.isActive('italic') }" title="斜体" aria-label="斜体" @click="editor.chain().focus().toggleItalic().run()"><i>I</i></button>
<button type="button" :class="{ active: editor.isActive('underline') }" title="下划线" aria-label="下划线" @click="editor.chain().focus().toggleUnderline().run()"><u>U</u></button>
<button type="button" :class="{ active: editor.isActive('strike') }" title="删除线" aria-label="删除线" @click="editor.chain().focus().toggleStrike().run()"><s>S</s></button>
<label class="toolbar-color" title="文字颜色">A<input type="color" value="#17364e" aria-label="文字颜色" @input="setColor($event)"></label>
<label class="toolbar-color highlight" title="背景颜色">A<input type="color" value="#fff1c9" aria-label="背景颜色" @input="setColor($event, true)"></label>
<span class="toolbar-divider" />
<button type="button" :class="{ active: editor.isActive({ textAlign: 'left' }) }" title="左对齐" aria-label="左对齐" @click="editor.chain().focus().setTextAlign('left').run()"></button>
<button type="button" :class="{ active: editor.isActive({ textAlign: 'center' }) }" title="居中对齐" aria-label="居中对齐" @click="editor.chain().focus().setTextAlign('center').run()"></button>
<button type="button" :class="{ active: editor.isActive({ textAlign: 'right' }) }" title="右对齐" aria-label="右对齐" @click="editor.chain().focus().setTextAlign('right').run()"></button>
<button type="button" :class="{ active: editor.isActive({ textAlign: 'justify' }) }" title="两端对齐" aria-label="两端对齐" @click="editor.chain().focus().setTextAlign('justify').run()"></button>
<button type="button" :class="{ active: editor.isActive('bulletList') }" title="项目符号" aria-label="项目符号" @click="editor.chain().focus().toggleBulletList().run()"> 列表</button>
<button type="button" :class="{ active: editor.isActive('orderedList') }" title="编号列表" aria-label="编号列表" @click="editor.chain().focus().toggleOrderedList().run()">1. 列表</button>
<button type="button" :class="{ active: editor.isActive('blockquote') }" title="引用" aria-label="引用" @click="editor.chain().focus().toggleBlockquote().run()">引用</button>
<button type="button" :class="{ active: editor.isActive('link') }" title="添加或编辑链接" aria-label="添加或编辑链接" @click="setLink">链接</button>
<button type="button" title="插入图片地址" aria-label="插入图片地址" @click="insertImageUrl">图片地址</button>
<button type="button" title="上传图片" aria-label="上传图片" :disabled="imageBusy" @click="imageInput?.click()">{{ imageBusy ? '上传中' : '上传图片' }}</button>
<input ref="imageInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/webp,image/gif" @change="uploadImage">
<button type="button" :class="{ active: editor.isActive('codeBlock') }" title="代码块" aria-label="代码块" @click="editor.chain().focus().toggleCodeBlock().run()">代码</button>
<button type="button" title="分隔线" aria-label="分隔线" @click="editor.chain().focus().setHorizontalRule().run()">分隔线</button>
<button type="button" title="清除格式" aria-label="清除格式" @click="editor.chain().focus().clearNodes().unsetAllMarks().run()">清除格式</button>
<button type="button" title="撤销" aria-label="撤销" :disabled="!editor.can().undo()" @click="editor.chain().focus().undo().run()"></button>
<button type="button" title="重做" aria-label="重做" :disabled="!editor.can().redo()" @click="editor.chain().focus().redo().run()"></button>
</div>
<EditorContent :editor="editor" />
<p v-if="linkMessage" class="rich-text-message">{{ linkMessage }}</p>
<p v-if="imageMessage" class="rich-text-message">{{ imageMessage }}</p>
<div class="rich-text-footer"><span>支持从 Word 粘贴发布时会自动清理不安全代码</span><span>{{ modelValue.length }} 字符</span></div>
</div>
</template>

View File

@@ -6,16 +6,17 @@ const toggle = ref<HTMLButtonElement>()
const navigation = ref<HTMLElement>()
const props = withDefaults(defineProps<{ logo?: string; brandName?: string; englishName?: string }>(), {
brandName: '慧遇书院',
englishName: 'HUIYU ACADEMY',
})
const route = useRoute()
const links = computed(() => [
{ label: '首页', href: '/#home' },
{ label: `关于${props.brandName}`, href: '/#about' },
{ label: '卢慧老师', href: '/luhui' },
{ label: '课程与产品', href: '/products' },
{ label: '官方信息', href: '/#official' },
{ label: '品牌动态', href: '/news' },
{ label: '联系我们', href: '/#contact' },
{ label: '首页', englishLabel: 'HOME', href: '/#home' },
{ label: `关于${props.brandName}`, englishLabel: `ABOUT ${props.englishName}`, href: '/#about' },
{ label: '卢慧老师', englishLabel: 'LU HUI', href: '/luhui' },
{ label: '课程与产品', englishLabel: 'COURSES & PRODUCTS', href: '/products' },
{ label: '官方信息', englishLabel: 'OFFICIAL INFO', href: '/#official' },
{ label: '品牌动态', englishLabel: 'BRAND NEWS', href: '/news' },
{ label: '联系我们', englishLabel: 'CONTACT US', href: '/#contact' },
])
function closeMenu(returnFocus = false) {
@@ -47,7 +48,10 @@ onBeforeUnmount(() => {
<List v-else :size="24" />
</button>
<nav id="site-navigation" ref="navigation" :class="['main-nav', { open }]" aria-label="主导航" @keydown.esc="closeMenu(true)">
<a v-for="link in links" :key="link.href" :href="link.href" :aria-current="isActive(link.href) ? 'page' : undefined" @click="closeMenu()">{{ link.label }}</a>
<a v-for="link in links" :key="link.href" :href="link.href" :aria-current="isActive(link.href) ? 'page' : undefined" @click="closeMenu()">
<span class="nav-label">{{ link.label }}</span>
<span class="nav-english" lang="en">{{ link.englishLabel }}</span>
</a>
</nav>
<a class="header-cta" href="/#official">
官方渠道