Files
schoolNews/schoolNewsWeb/src/components/text/RichTextComponent.vue
2025-10-22 18:00:27 +08:00

686 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>