|
|
|
@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
|
|
<div class="rich-input-container">
|
|
|
|
|
|
|
|
<!-- 内容显示区域 -->
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
|
|
ref="editableDiv"
|
|
|
|
|
|
|
|
class="rich-input-editable"
|
|
|
|
|
|
|
|
contenteditable="true"
|
|
|
|
|
|
|
|
@input="handleInput"
|
|
|
|
|
|
|
|
@paste="handlePaste"
|
|
|
|
|
|
|
|
@dragover.prevent="handleDragOver"
|
|
|
|
|
|
|
|
@drop.prevent="handleDrop"
|
|
|
|
|
|
|
|
@click="focusEditableArea"
|
|
|
|
|
|
|
|
:placeholder="placeholder"
|
|
|
|
|
|
|
|
></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 文件/图片预览区域 -->
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
|
|
v-if="attachments.length > 0"
|
|
|
|
|
|
|
|
class="preview-area mt-2"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
|
|
v-for="(item, index) in attachments"
|
|
|
|
|
|
|
|
:key="item.id"
|
|
|
|
|
|
|
|
class="preview-item"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<!-- 图片预览 -->
|
|
|
|
|
|
|
|
<el-image
|
|
|
|
|
|
|
|
v-if="item.type.startsWith('image/')"
|
|
|
|
|
|
|
|
:src="item.url"
|
|
|
|
|
|
|
|
fit="cover"
|
|
|
|
|
|
|
|
class="preview-image"
|
|
|
|
|
|
|
|
:preview-src-list="[item.url]"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<template #error>
|
|
|
|
|
|
|
|
<div class="image-error">图片加载失败</div>
|
|
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
</el-image>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 文件预览 -->
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
|
|
v-else
|
|
|
|
|
|
|
|
class="file-preview"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<el-icon
|
|
|
|
|
|
|
|
:size="40"
|
|
|
|
|
|
|
|
class="file-icon"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<Document />
|
|
|
|
|
|
|
|
</el-icon>
|
|
|
|
|
|
|
|
<div class="file-name">{{ item.name }}</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 删除按钮 -->
|
|
|
|
|
|
|
|
<el-icon
|
|
|
|
|
|
|
|
class="remove-icon"
|
|
|
|
|
|
|
|
@click="removeAttachment(index)"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<Close />
|
|
|
|
|
|
|
|
</el-icon>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 操作按钮 -->
|
|
|
|
|
|
|
|
<div class="action-buttons mt-2">
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
|
|
@click="handleSubmit"
|
|
|
|
|
|
|
|
>提交</el-button
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<el-button
|
|
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
|
|
@click="clearAll"
|
|
|
|
|
|
|
|
>清空</el-button
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<el-upload
|
|
|
|
|
|
|
|
v-model:file-list="fileList"
|
|
|
|
|
|
|
|
action="#"
|
|
|
|
|
|
|
|
:auto-upload="false"
|
|
|
|
|
|
|
|
:show-file-list="false"
|
|
|
|
|
|
|
|
:multiple="true"
|
|
|
|
|
|
|
|
:on-change="handleFileChange"
|
|
|
|
|
|
|
|
class="ml-2"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<template #trigger>
|
|
|
|
|
|
|
|
<el-button size="small">选择文件</el-button>
|
|
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
</el-upload>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
|
|
import { ref, onMounted, nextTick } from 'vue';
|
|
|
|
|
|
|
|
import { Document, Close } from '@element-plus/icons-vue';
|
|
|
|
|
|
|
|
import { ElMessage } from 'element-plus';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
|
|
|
placeholder: {
|
|
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
|
|
default: '请输入内容...',
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
modelValue: {
|
|
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
|
|
default: '',
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits(['update:modelValue', 'submit']);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const editableDiv = ref(null);
|
|
|
|
|
|
|
|
const attachments = ref([]);
|
|
|
|
|
|
|
|
const fileList = ref([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 生成唯一ID
|
|
|
|
|
|
|
|
const generateId = () => {
|
|
|
|
|
|
|
|
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 处理输入
|
|
|
|
|
|
|
|
const handleInput = () => {
|
|
|
|
|
|
|
|
emit('update:modelValue', editableDiv.value.innerHTML);
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const handlePaste = async (e) => {
|
|
|
|
|
|
|
|
const items = e.clipboardData?.items;
|
|
|
|
|
|
|
|
if (!items) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let hasFile = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 首先阻止默认粘贴行为,避免浏览器插入默认图片
|
|
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 处理纯文本粘贴
|
|
|
|
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
|
|
|
|
|
|
const item = items[i];
|
|
|
|
|
|
|
|
if (item.kind === 'string' && item.type === 'text/plain') {
|
|
|
|
|
|
|
|
item.getAsString((text) => {
|
|
|
|
|
|
|
|
document.execCommand('insertText', false, text);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 处理文件粘贴
|
|
|
|
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
|
|
|
|
|
|
const item = items[i];
|
|
|
|
|
|
|
|
if (item.kind === 'file') {
|
|
|
|
|
|
|
|
hasFile = true;
|
|
|
|
|
|
|
|
const file = item.getAsFile();
|
|
|
|
|
|
|
|
await processFile(file);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 处理拖拽悬停
|
|
|
|
|
|
|
|
const handleDragOver = (e) => {
|
|
|
|
|
|
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 处理拖拽放置
|
|
|
|
|
|
|
|
const handleDrop = async (e) => {
|
|
|
|
|
|
|
|
const files = e.dataTransfer.files;
|
|
|
|
|
|
|
|
if (files.length > 0) {
|
|
|
|
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
|
|
|
|
|
|
await processFile(files[i]);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
focusEditableArea();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 处理文件上传组件的文件变化
|
|
|
|
|
|
|
|
const handleFileChange = (file) => {
|
|
|
|
|
|
|
|
processFile(file.raw);
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 处理文件
|
|
|
|
|
|
|
|
const processFile = (file) => {
|
|
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
|
|
const fileType = file.type;
|
|
|
|
|
|
|
|
const fileId = generateId();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (fileType.startsWith('image/')) {
|
|
|
|
|
|
|
|
// 处理图片文件
|
|
|
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
|
|
|
reader.onload = (e) => {
|
|
|
|
|
|
|
|
attachments.value.push({
|
|
|
|
|
|
|
|
id: fileId,
|
|
|
|
|
|
|
|
type: fileType,
|
|
|
|
|
|
|
|
name: file.name,
|
|
|
|
|
|
|
|
file: file,
|
|
|
|
|
|
|
|
url: e.target.result,
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
insertPlaceholder(fileId);
|
|
|
|
|
|
|
|
resolve();
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
// 处理其他文件
|
|
|
|
|
|
|
|
attachments.value.push({
|
|
|
|
|
|
|
|
id: fileId,
|
|
|
|
|
|
|
|
type: fileType,
|
|
|
|
|
|
|
|
name: file.name,
|
|
|
|
|
|
|
|
file: file,
|
|
|
|
|
|
|
|
url: null,
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
insertPlaceholder(fileId);
|
|
|
|
|
|
|
|
resolve();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 在编辑区域插入占位符
|
|
|
|
|
|
|
|
const insertPlaceholder = (fileId) => {
|
|
|
|
|
|
|
|
const placeholder = `[file:${fileId}]`;
|
|
|
|
|
|
|
|
const selection = window.getSelection();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (selection.rangeCount > 0) {
|
|
|
|
|
|
|
|
const range = selection.getRangeAt(0);
|
|
|
|
|
|
|
|
range.deleteContents();
|
|
|
|
|
|
|
|
range.insertNode(document.createTextNode(placeholder));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 移动光标到占位符后面
|
|
|
|
|
|
|
|
const newRange = document.createRange();
|
|
|
|
|
|
|
|
newRange.setStartAfter(range.endContainer);
|
|
|
|
|
|
|
|
newRange.collapse(true);
|
|
|
|
|
|
|
|
selection.removeAllRanges();
|
|
|
|
|
|
|
|
selection.addRange(newRange);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
emit('update:modelValue', editableDiv.value.innerHTML);
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 移除附件
|
|
|
|
|
|
|
|
const removeAttachment = (index) => {
|
|
|
|
|
|
|
|
const removed = attachments.value.splice(index, 1)[0];
|
|
|
|
|
|
|
|
if (removed) {
|
|
|
|
|
|
|
|
// 从编辑内容中移除对应的占位符
|
|
|
|
|
|
|
|
const content = editableDiv.value.innerHTML;
|
|
|
|
|
|
|
|
const updatedContent = content.replace(`[file:${removed.id}]`, '');
|
|
|
|
|
|
|
|
editableDiv.value.innerHTML = updatedContent;
|
|
|
|
|
|
|
|
emit('update:modelValue', updatedContent);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 聚焦到编辑区域
|
|
|
|
|
|
|
|
const focusEditableArea = () => {
|
|
|
|
|
|
|
|
if (editableDiv.value) {
|
|
|
|
|
|
|
|
editableDiv.value.focus();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 提交内容
|
|
|
|
|
|
|
|
const handleSubmit = () => {
|
|
|
|
|
|
|
|
if (!editableDiv.value.textContent.trim() && attachments.value.length === 0) {
|
|
|
|
|
|
|
|
ElMessage.warning('请输入内容或添加文件');
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const content = {
|
|
|
|
|
|
|
|
text: editableDiv.value.innerHTML,
|
|
|
|
|
|
|
|
attachments: [...attachments.value],
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
emit('submit', content);
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 清空所有内容
|
|
|
|
|
|
|
|
const clearAll = () => {
|
|
|
|
|
|
|
|
editableDiv.value.innerHTML = '';
|
|
|
|
|
|
|
|
attachments.value = [];
|
|
|
|
|
|
|
|
emit('update:modelValue', '');
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
|
|
if (props.modelValue) {
|
|
|
|
|
|
|
|
editableDiv.value.innerHTML = props.modelValue;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
|
|
|
.rich-input-container {
|
|
|
|
|
|
|
|
@apply border border-gray-300 rounded p-3 bg-white;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.rich-input-editable {
|
|
|
|
|
|
|
|
@apply min-h-[100px] p-2 outline-none;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
&:empty::before {
|
|
|
|
|
|
|
|
content: attr(placeholder);
|
|
|
|
|
|
|
|
@apply text-gray-400;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
&:focus {
|
|
|
|
|
|
|
|
@apply ring-1 ring-blue-500;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.preview-area {
|
|
|
|
|
|
|
|
@apply flex flex-wrap gap-2;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.preview-item {
|
|
|
|
|
|
|
|
@apply relative border border-gray-200 rounded p-1 w-[100px] h-[100px] flex items-center justify-center;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.preview-image {
|
|
|
|
|
|
|
|
@apply w-full h-full object-cover;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.file-preview {
|
|
|
|
|
|
|
|
@apply flex flex-col items-center;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.file-icon {
|
|
|
|
|
|
|
|
@apply text-gray-400;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.file-name {
|
|
|
|
|
|
|
|
@apply text-xs text-center text-gray-600 truncate w-full px-1;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.remove-icon {
|
|
|
|
|
|
|
|
@apply absolute top-0 right-0 bg-white rounded-full p-1 cursor-pointer opacity-0 transition-opacity;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
|
|
@apply text-red-500;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
&:hover .remove-icon {
|
|
|
|
|
|
|
|
@apply opacity-100;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.action-buttons {
|
|
|
|
|
|
|
|
@apply flex items-center;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.image-error {
|
|
|
|
|
|
|
|
@apply w-full h-full flex items-center justify-center text-xs text-gray-500 bg-gray-100;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
</style>
|