686 lines
16 KiB
Vue
686 lines
16 KiB
Vue
<template>
|
||
<div class="rich-text-component">
|
||
<div class="rich-text-editor" :class="{ 'is-disabled': disabled, 'is-error': error }">
|
||
<!-- 工具栏 -->
|
||
<div ref="toolbarRef" class="editor-toolbar">
|
||
<!-- 字体样式 -->
|
||
<span class="ql-formats">
|
||
<select class="ql-header" title="标题">
|
||
<option value="1">标题1</option>
|
||
<option value="2">标题2</option>
|
||
<option value="3">标题3</option>
|
||
<option value="">正文</option>
|
||
</select>
|
||
|
||
<!-- 字体大小 -->
|
||
<select class="ql-size" title="字体大小">
|
||
<option value="small">小</option>
|
||
<option selected>正常</option>
|
||
<option value="large">大</option>
|
||
<option value="huge">特大</option>
|
||
</select>
|
||
</span>
|
||
|
||
<!-- 加粗、斜体、下划线 -->
|
||
<span class="ql-formats">
|
||
<button type="button" class="ql-bold" title="加粗"></button>
|
||
<button type="button" class="ql-italic" title="斜体"></button>
|
||
<button type="button" class="ql-underline" title="下划线"></button>
|
||
<button type="button" class="ql-strike" title="删除线"></button>
|
||
<button type="button" class="ql-code" title="行内代码"></button>
|
||
</span>
|
||
|
||
<!-- 文字颜色和背景色 -->
|
||
<span class="ql-formats">
|
||
<select class="ql-color" title="文字颜色"></select>
|
||
<select class="ql-background" title="背景颜色"></select>
|
||
</span>
|
||
|
||
<!-- 列表 -->
|
||
<span class="ql-formats">
|
||
<button type="button" class="ql-list" value="ordered" title="有序列表"></button>
|
||
<button type="button" class="ql-list" value="bullet" title="无序列表"></button>
|
||
</span>
|
||
|
||
<!-- 对齐方式 -->
|
||
<span class="ql-formats">
|
||
<select class="ql-align" title="对齐方式">
|
||
<option selected></option>
|
||
<option value="center">居中</option>
|
||
<option value="right">右对齐</option>
|
||
<option value="justify">两端对齐</option>
|
||
</select>
|
||
</span>
|
||
|
||
<!-- 链接、图片、视频 -->
|
||
<span class="ql-formats">
|
||
<button type="button" class="ql-link" title="插入链接"></button>
|
||
<button type="button" class="ql-image" title="插入图片"></button>
|
||
<button type="button" class="ql-video" title="插入视频"></button>
|
||
</span>
|
||
|
||
<!-- 代码块、引用 -->
|
||
<span class="ql-formats">
|
||
<button type="button" class="ql-code-block" title="代码块"></button>
|
||
<button type="button" class="ql-blockquote" title="引用"></button>
|
||
</span>
|
||
|
||
<!-- 清除格式 -->
|
||
<span class="ql-formats">
|
||
<button type="button" class="ql-clean" title="清除格式"></button>
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 编辑器内容区域 -->
|
||
<div
|
||
ref="editorRef"
|
||
class="editor-content"
|
||
:style="{ height: height }"
|
||
></div>
|
||
</div>
|
||
|
||
<!-- 字符统计 -->
|
||
<div v-if="showWordCount" class="word-count">
|
||
字数:{{ wordCount }} / {{ maxLength || '无限制' }}
|
||
</div>
|
||
|
||
<!-- 错误提示 -->
|
||
<div v-if="error" class="error-message">
|
||
{{ errorMessage }}
|
||
</div>
|
||
|
||
<!-- 文件上传对话框 -->
|
||
<FileUpload
|
||
v-model="uploadDialogVisible"
|
||
:title="uploadType === 'image' ? '上传图片' : '上传视频'"
|
||
:accept="uploadType === 'image' ? 'image/*' : 'video/*'"
|
||
:module="uploadModule"
|
||
:max-size="uploadType === 'image' ? 5 : 100"
|
||
@success="handleUploadSuccess"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue';
|
||
import Quill from 'quill';
|
||
import { FileUpload } from '@/components/file';
|
||
import type { SysFile } from '@/types';
|
||
import { FILE_DOWNLOAD_URL } from '@/config';
|
||
import { registerImageResize } from '@/utils/quill-resize';
|
||
// Quill 样式已在 main.ts 中全局引入
|
||
|
||
interface Props {
|
||
modelValue?: string;
|
||
placeholder?: string;
|
||
height?: string;
|
||
disabled?: boolean;
|
||
readOnly?: boolean;
|
||
maxLength?: number;
|
||
showWordCount?: boolean;
|
||
error?: boolean;
|
||
errorMessage?: string;
|
||
uploadModule?: string; // 上传文件的模块名
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
modelValue: '',
|
||
placeholder: '请输入内容...',
|
||
height: '300px',
|
||
disabled: false,
|
||
readOnly: false,
|
||
maxLength: 0,
|
||
showWordCount: false,
|
||
error: false,
|
||
errorMessage: '',
|
||
uploadModule: 'article'
|
||
});
|
||
|
||
const emit = defineEmits<{
|
||
'update:modelValue': [value: string];
|
||
'change': [value: string];
|
||
'blur': [];
|
||
'focus': [];
|
||
}>();
|
||
|
||
const toolbarRef = ref<HTMLElement>();
|
||
const editorRef = ref<HTMLElement>();
|
||
let quillInstance: Quill | null = null;
|
||
|
||
// 文件上传相关
|
||
const uploadDialogVisible = ref(false);
|
||
const uploadType = ref<'image' | 'video'>('image');
|
||
const uploadModule = computed(() => props.uploadModule);
|
||
let currentUploadRange: any = null; // 保存当前光标位置
|
||
|
||
// 字符统计
|
||
const wordCount = computed(() => {
|
||
if (!quillInstance) return 0;
|
||
return quillInstance.getText().trim().length;
|
||
});
|
||
|
||
onMounted(() => {
|
||
initQuill();
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
if (quillInstance) {
|
||
quillInstance = null;
|
||
}
|
||
});
|
||
|
||
// 监听外部值变化
|
||
watch(() => props.modelValue, (newValue) => {
|
||
if (quillInstance && newValue !== quillInstance.root.innerHTML) {
|
||
quillInstance.root.innerHTML = newValue;
|
||
}
|
||
});
|
||
|
||
// 监听禁用状态
|
||
watch(() => props.disabled, (newValue) => {
|
||
if (quillInstance) {
|
||
quillInstance.enable(!newValue);
|
||
}
|
||
});
|
||
|
||
// 监听只读状态
|
||
watch(() => props.readOnly, (newValue) => {
|
||
if (quillInstance) {
|
||
quillInstance.enable(!newValue);
|
||
}
|
||
});
|
||
|
||
function initQuill() {
|
||
if (!editorRef.value || !toolbarRef.value) return;
|
||
|
||
// 自定义视频 Blot(支持本地视频文件)
|
||
const BlockEmbed: any = Quill.import('blots/block/embed');
|
||
|
||
class VideoBlot extends BlockEmbed {
|
||
static blotName = 'customVideo';
|
||
static tagName = 'video';
|
||
|
||
static create(value: string) {
|
||
const node = super.create() as HTMLVideoElement;
|
||
node.setAttribute('src', value);
|
||
node.setAttribute('controls', 'true');
|
||
node.setAttribute('class', 'custom-video');
|
||
node.setAttribute('data-custom-video', 'true');
|
||
// 视频默认居中显示
|
||
node.setAttribute('style', 'max-width: 100%; display: block; margin: 0 auto;');
|
||
return node;
|
||
}
|
||
|
||
static value(node: HTMLVideoElement) {
|
||
return node.getAttribute('src');
|
||
}
|
||
}
|
||
|
||
// 自定义图片 Blot(与文字同行显示)
|
||
const InlineEmbed: any = Quill.import('blots/embed');
|
||
|
||
class CustomImageBlot extends InlineEmbed {
|
||
static blotName = 'customImage';
|
||
static tagName = 'img';
|
||
|
||
static create(value: string) {
|
||
const node = super.create() as HTMLImageElement;
|
||
node.setAttribute('src', value);
|
||
node.setAttribute('class', 'custom-image');
|
||
node.setAttribute('data-custom-image', 'true');
|
||
// 图片与文字同行显示
|
||
node.setAttribute('style', 'max-width: 100%; display: inline-block; vertical-align: bottom;');
|
||
return node;
|
||
}
|
||
|
||
static value(node: HTMLImageElement) {
|
||
return node.getAttribute('src');
|
||
}
|
||
}
|
||
|
||
Quill.register(VideoBlot);
|
||
Quill.register(CustomImageBlot);
|
||
|
||
// 注册图片/视频拉伸模块
|
||
registerImageResize();
|
||
|
||
// 配置选项
|
||
const options = {
|
||
theme: 'snow',
|
||
modules: {
|
||
toolbar: {
|
||
// 1. 指定工具栏 DOM 容器
|
||
container: toolbarRef.value,
|
||
// 2. 自定义处理器
|
||
handlers: {
|
||
// 自定义图片上传处理器
|
||
image: function() {
|
||
handleImageUpload();
|
||
},
|
||
// 自定义视频上传处理器
|
||
video: function() {
|
||
handleVideoUpload();
|
||
},
|
||
// 清除格式处理器
|
||
clean: function() {
|
||
if (quillInstance) {
|
||
const range = quillInstance.getSelection();
|
||
if (range) {
|
||
quillInstance.removeFormat(range.index, range.length);
|
||
} else {
|
||
quillInstance.removeFormat(0, quillInstance.getLength());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
clipboard: {
|
||
matchVisual: false
|
||
},
|
||
// 启用图片/视频缩放模块
|
||
imageResize: {
|
||
onResizeEnd: () => {
|
||
// 强制触发内容更新
|
||
if (quillInstance) {
|
||
const html = quillInstance.root.innerHTML;
|
||
emit('update:modelValue', html);
|
||
emit('change', html);
|
||
}
|
||
}
|
||
}
|
||
},
|
||
placeholder: props.placeholder,
|
||
readOnly: props.readOnly || props.disabled
|
||
};
|
||
|
||
// 创建编辑器实例
|
||
quillInstance = new Quill(editorRef.value, options);
|
||
|
||
// 设置初始内容
|
||
if (props.modelValue) {
|
||
quillInstance.root.innerHTML = props.modelValue;
|
||
}
|
||
|
||
// 监听内容变化
|
||
quillInstance.on('text-change', () => {
|
||
if (!quillInstance) return;
|
||
|
||
const html = quillInstance.root.innerHTML;
|
||
const text = quillInstance.getText().trim();
|
||
|
||
// 检查字数限制
|
||
if (props.maxLength && text.length > props.maxLength) {
|
||
quillInstance.deleteText(props.maxLength, text.length);
|
||
return;
|
||
}
|
||
|
||
emit('update:modelValue', html);
|
||
emit('change', html);
|
||
});
|
||
|
||
// 监听图片/视频尺寸变化
|
||
quillInstance.root.addEventListener('input', () => {
|
||
if (!quillInstance) return;
|
||
|
||
const html = quillInstance.root.innerHTML;
|
||
emit('update:modelValue', html);
|
||
emit('change', html);
|
||
});
|
||
|
||
// 监听焦点事件
|
||
quillInstance.on('selection-change', (range: any) => {
|
||
if (range) {
|
||
emit('focus');
|
||
} else {
|
||
emit('blur');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 获取纯文本内容
|
||
function getText(): string {
|
||
return quillInstance?.getText() || '';
|
||
}
|
||
|
||
// 获取HTML内容
|
||
function getHTML(): string {
|
||
return quillInstance?.root.innerHTML || '';
|
||
}
|
||
|
||
// 清空内容
|
||
function clear(): void {
|
||
quillInstance?.setText('');
|
||
}
|
||
|
||
// 设置内容
|
||
function setContent(content: string): void {
|
||
if (quillInstance) {
|
||
quillInstance.root.innerHTML = content;
|
||
}
|
||
}
|
||
|
||
// 聚焦
|
||
function focus(): void {
|
||
quillInstance?.focus();
|
||
}
|
||
|
||
// 失焦
|
||
function blur(): void {
|
||
quillInstance?.blur();
|
||
}
|
||
|
||
// 处理图片上传
|
||
function handleImageUpload() {
|
||
if (!quillInstance) return;
|
||
|
||
// 保存当前光标位置
|
||
currentUploadRange = quillInstance.getSelection();
|
||
|
||
// 设置上传类型并打开上传对话框
|
||
uploadType.value = 'image';
|
||
uploadDialogVisible.value = true;
|
||
}
|
||
|
||
// 处理视频上传
|
||
function handleVideoUpload() {
|
||
if (!quillInstance) return;
|
||
|
||
// 保存当前光标位置
|
||
currentUploadRange = quillInstance.getSelection();
|
||
|
||
// 设置上传类型并打开上传对话框
|
||
uploadType.value = 'video';
|
||
uploadDialogVisible.value = true;
|
||
}
|
||
|
||
// 处理上传成功
|
||
function handleUploadSuccess(files: SysFile[]) {
|
||
if (!quillInstance || !files || files.length === 0) return;
|
||
|
||
files.forEach(file => {
|
||
// 拼接下载URL
|
||
const downloadUrl = FILE_DOWNLOAD_URL + file.fileID;
|
||
|
||
// 获取插入位置(使用保存的光标位置,如果没有则使用当前光标)
|
||
const range = currentUploadRange || quillInstance!.getSelection() || { index: quillInstance!.getLength() };
|
||
|
||
// 根据类型插入内容
|
||
if (uploadType.value === 'image') {
|
||
// 插入自定义图片(与文字同行显示)
|
||
quillInstance!.insertEmbed(range.index, 'customImage', downloadUrl);
|
||
// 移动光标到图片后面
|
||
quillInstance!.setSelection(range.index + 1);
|
||
} else if (uploadType.value === 'video') {
|
||
// 插入自定义视频(单行居中显示)
|
||
quillInstance!.insertEmbed(range.index, 'customVideo', downloadUrl);
|
||
// 移动光标到视频后面
|
||
quillInstance!.setSelection(range.index + 1);
|
||
}
|
||
});
|
||
|
||
// 清空光标位置
|
||
currentUploadRange = null;
|
||
}
|
||
|
||
// 暴露方法给父组件
|
||
defineExpose({
|
||
getText,
|
||
getHTML,
|
||
clear,
|
||
setContent,
|
||
focus,
|
||
blur
|
||
});
|
||
</script>
|
||
|
||
<style lang="scss">
|
||
// 富文本内容样式(全局,可复用)
|
||
.rich-text-content,
|
||
.ql-editor {
|
||
line-height: 1.8;
|
||
color: #303133;
|
||
|
||
p {
|
||
margin: 0 0 12px 0;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
h1, h2, h3, h4, h5, h6 {
|
||
margin: 16px 0 12px 0;
|
||
font-weight: 600;
|
||
}
|
||
|
||
ul, ol {
|
||
padding-left: 1.5em;
|
||
margin: 12px 0;
|
||
}
|
||
|
||
blockquote {
|
||
border-left: 4px solid #C62828;
|
||
padding-left: 16px;
|
||
margin: 12px 0;
|
||
color: #606266;
|
||
}
|
||
|
||
// 代码块样式
|
||
pre {
|
||
background: #282c34;
|
||
color: #abb2bf;
|
||
padding: 12px;
|
||
border-radius: 4px;
|
||
overflow-x: auto;
|
||
margin: 12px 0;
|
||
|
||
code {
|
||
background: none;
|
||
padding: 0;
|
||
color: inherit;
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
|
||
// 行内代码样式(不在 pre 中的 code)
|
||
code:not(pre code) {
|
||
background: #f5f7fa;
|
||
padding: 2px 4px;
|
||
border-radius: 3px;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 0.9em;
|
||
color: #e83e8c;
|
||
}
|
||
|
||
a {
|
||
color: #C62828;
|
||
text-decoration: underline;
|
||
|
||
&:hover {
|
||
color: #A82020;
|
||
}
|
||
}
|
||
|
||
// 图片样式 - 默认内联显示,底部对齐
|
||
img {
|
||
max-width: 100%;
|
||
height: auto;
|
||
display: inline-block;
|
||
vertical-align: bottom;
|
||
}
|
||
|
||
// 视频容器样式 - 默认内联显示,底部对齐
|
||
iframe, video {
|
||
max-width: 100%;
|
||
height: auto;
|
||
display: inline-block;
|
||
vertical-align: bottom;
|
||
}
|
||
|
||
// 自定义视频默认样式 - 单行居中显示
|
||
.custom-video {
|
||
max-width: 100%;
|
||
height: auto;
|
||
display: block;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
// 自定义图片默认样式 - 与文字同行显示
|
||
.custom-image {
|
||
max-width: 100%;
|
||
height: auto;
|
||
display: inline-block;
|
||
vertical-align: bottom;
|
||
}
|
||
|
||
// Quill 视频包装器
|
||
.ql-video {
|
||
display: inline-block;
|
||
max-width: 100%;
|
||
}
|
||
|
||
// 支持对齐方式 - 图片和视频分别处理
|
||
.ql-align-center {
|
||
text-align: center !important;
|
||
|
||
// 视频始终居中显示
|
||
video, .custom-video {
|
||
display: block !important;
|
||
margin-left: auto !important;
|
||
margin-right: auto !important;
|
||
}
|
||
|
||
// 图片跟随文字对齐
|
||
img, .custom-image {
|
||
display: inline-block !important;
|
||
vertical-align: bottom !important;
|
||
}
|
||
}
|
||
|
||
.ql-align-right {
|
||
text-align: right !important;
|
||
|
||
// 视频始终居中显示
|
||
video, .custom-video {
|
||
display: block !important;
|
||
margin-left: auto !important;
|
||
margin-right: auto !important;
|
||
}
|
||
|
||
// 图片跟随文字对齐
|
||
img, .custom-image {
|
||
display: inline-block !important;
|
||
vertical-align: bottom !important;
|
||
}
|
||
}
|
||
|
||
.ql-align-left {
|
||
text-align: left !important;
|
||
|
||
// 视频始终居中显示
|
||
video, .custom-video {
|
||
display: block !important;
|
||
margin-left: auto !important;
|
||
margin-right: auto !important;
|
||
}
|
||
|
||
// 图片跟随文字对齐
|
||
img, .custom-image {
|
||
display: inline-block !important;
|
||
vertical-align: bottom !important;
|
||
}
|
||
}
|
||
|
||
.ql-align-justify {
|
||
text-align: justify;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<style lang="scss" scoped>
|
||
.rich-text-component {
|
||
width: 100%;
|
||
}
|
||
|
||
.rich-text-editor {
|
||
border: 1px solid #dcdfe6;
|
||
border-radius: 4px;
|
||
transition: border-color 0.2s;
|
||
|
||
&:hover {
|
||
border-color: #c0c4cc;
|
||
}
|
||
|
||
&.is-disabled {
|
||
background-color: #f5f7fa;
|
||
cursor: not-allowed;
|
||
|
||
:deep(.ql-toolbar),
|
||
:deep(.ql-container) {
|
||
cursor: not-allowed;
|
||
}
|
||
}
|
||
|
||
&.is-error {
|
||
border-color: #f56c6c;
|
||
}
|
||
}
|
||
|
||
.editor-toolbar {
|
||
border-bottom: 1px solid #dcdfe6;
|
||
background: #fafafa;
|
||
border-radius: 4px 4px 0 0;
|
||
padding: 8px;
|
||
|
||
:deep(.ql-formats) {
|
||
margin-right: 15px;
|
||
}
|
||
|
||
:deep(button) {
|
||
width: 28px;
|
||
height: 28px;
|
||
margin: 2px;
|
||
|
||
&:hover {
|
||
color: #C62828;
|
||
}
|
||
|
||
&.ql-active {
|
||
color: #C62828;
|
||
}
|
||
}
|
||
|
||
:deep(select) {
|
||
margin: 2px;
|
||
}
|
||
}
|
||
|
||
.editor-content {
|
||
border-radius: 0 0 4px 4px;
|
||
|
||
:deep(.ql-editor) {
|
||
min-height: 100%;
|
||
font-size: 14px;
|
||
|
||
&.ql-blank::before {
|
||
color: #c0c4cc;
|
||
font-style: normal;
|
||
}
|
||
}
|
||
}
|
||
|
||
.word-count {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: #909399;
|
||
text-align: right;
|
||
}
|
||
|
||
.error-message {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: #f56c6c;
|
||
}
|
||
</style>
|
||
|