feat: 增强情绪博物馆项目功能 - 新增用户评论和帖子功能,优化前端架构和WebSocket通信 - 更新文档和部署配置
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
<template>
|
||||
<div class="rich-text-editor">
|
||||
<!-- 工具栏 -->
|
||||
<div v-if="showToolbar" class="editor-toolbar">
|
||||
<div class="toolbar-group">
|
||||
<!-- 文本格式 -->
|
||||
<el-button-group size="small">
|
||||
<el-button @click="execCommand('bold')" :class="{ active: isActive('bold') }">
|
||||
<el-icon><Bold /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="execCommand('italic')" :class="{ active: isActive('italic') }">
|
||||
<el-icon><Italic /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="execCommand('underline')" :class="{ active: isActive('underline') }">
|
||||
<el-icon><Underline /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="execCommand('strikeThrough')" :class="{ active: isActive('strikeThrough') }">
|
||||
<el-icon><Strikethrough /></el-icon>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
|
||||
<!-- 对齐方式 -->
|
||||
<el-button-group size="small">
|
||||
<el-button @click="execCommand('justifyLeft')" :class="{ active: isActive('justifyLeft') }">
|
||||
<el-icon><AlignLeft /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="execCommand('justifyCenter')" :class="{ active: isActive('justifyCenter') }">
|
||||
<el-icon><AlignCenter /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="execCommand('justifyRight')" :class="{ active: isActive('justifyRight') }">
|
||||
<el-icon><AlignRight /></el-icon>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-button-group size="small">
|
||||
<el-button @click="execCommand('insertUnorderedList')" :class="{ active: isActive('insertUnorderedList') }">
|
||||
<el-icon><List /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="execCommand('insertOrderedList')" :class="{ active: isActive('insertOrderedList') }">
|
||||
<el-icon><Numbered /></el-icon>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
|
||||
<!-- 其他功能 -->
|
||||
<el-button-group size="small">
|
||||
<el-button @click="insertLink">
|
||||
<el-icon><Link /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="insertImage">
|
||||
<el-icon><Picture /></el-icon>
|
||||
</el-button>
|
||||
<el-button @click="insertTable">
|
||||
<el-icon><Grid /></el-icon>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
|
||||
<!-- 颜色 -->
|
||||
<div class="color-picker">
|
||||
<el-color-picker
|
||||
v-model="textColor"
|
||||
size="small"
|
||||
@change="changeTextColor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 清除格式 -->
|
||||
<el-button size="small" @click="clearFormat">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器内容区 -->
|
||||
<div
|
||||
ref="editorRef"
|
||||
class="editor-content"
|
||||
:style="{ height: height }"
|
||||
contenteditable
|
||||
@input="handleInput"
|
||||
@paste="handlePaste"
|
||||
@keydown="handleKeydown"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
v-html="content"
|
||||
/>
|
||||
|
||||
<!-- 字数统计 -->
|
||||
<div v-if="showWordCount" class="editor-footer">
|
||||
<span class="word-count">{{ wordCount }} 字</span>
|
||||
<span v-if="maxLength" class="word-limit">/ {{ maxLength }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 插入链接对话框 -->
|
||||
<el-dialog
|
||||
v-model="linkDialogVisible"
|
||||
title="插入链接"
|
||||
width="400px"
|
||||
>
|
||||
<el-form :model="linkForm" label-width="80px">
|
||||
<el-form-item label="链接文本">
|
||||
<el-input v-model="linkForm.text" placeholder="请输入链接文本" />
|
||||
</el-form-item>
|
||||
<el-form-item label="链接地址">
|
||||
<el-input v-model="linkForm.url" placeholder="请输入链接地址" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="linkDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmInsertLink">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 插入图片对话框 -->
|
||||
<el-dialog
|
||||
v-model="imageDialogVisible"
|
||||
title="插入图片"
|
||||
width="500px"
|
||||
>
|
||||
<el-tabs v-model="imageTabActive">
|
||||
<el-tab-pane label="上传图片" name="upload">
|
||||
<ImageUpload
|
||||
:limit="1"
|
||||
:multiple="false"
|
||||
@success="handleImageUpload"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="网络图片" name="url">
|
||||
<el-form :model="imageForm" label-width="80px">
|
||||
<el-form-item label="图片地址">
|
||||
<el-input v-model="imageForm.url" placeholder="请输入图片地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="替代文本">
|
||||
<el-input v-model="imageForm.alt" placeholder="请输入替代文本" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="imageDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmInsertImage">确定</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
List,
|
||||
Link,
|
||||
Picture,
|
||||
Grid,
|
||||
Delete
|
||||
} from '@element-plus/icons-vue'
|
||||
import ImageUpload from '@/components/upload/ImageUpload.vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
height?: string
|
||||
maxLength?: number
|
||||
showToolbar?: boolean
|
||||
showWordCount?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
placeholder: '请输入内容...',
|
||||
height: '300px',
|
||||
maxLength: 0,
|
||||
showToolbar: true,
|
||||
showWordCount: true,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'change', value: string): void
|
||||
(e: 'focus', event: FocusEvent): void
|
||||
(e: 'blur', event: FocusEvent): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 响应式数据
|
||||
const editorRef = ref<HTMLElement>()
|
||||
const content = ref(props.modelValue)
|
||||
const textColor = ref('#000000')
|
||||
const linkDialogVisible = ref(false)
|
||||
const imageDialogVisible = ref(false)
|
||||
const imageTabActive = ref('upload')
|
||||
const focused = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const linkForm = ref({
|
||||
text: '',
|
||||
url: ''
|
||||
})
|
||||
|
||||
const imageForm = ref({
|
||||
url: '',
|
||||
alt: ''
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const wordCount = computed(() => {
|
||||
const text = editorRef.value?.innerText || ''
|
||||
return text.length
|
||||
})
|
||||
|
||||
// 方法
|
||||
const handleInput = () => {
|
||||
if (!editorRef.value) return
|
||||
|
||||
const html = editorRef.value.innerHTML
|
||||
content.value = html
|
||||
|
||||
// 检查字数限制
|
||||
if (props.maxLength && wordCount.value > props.maxLength) {
|
||||
ElMessage.warning(`内容不能超过 ${props.maxLength} 字`)
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:modelValue', html)
|
||||
emit('change', html)
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
const clipboardData = event.clipboardData
|
||||
if (!clipboardData) return
|
||||
|
||||
// 获取纯文本内容
|
||||
const text = clipboardData.getData('text/plain')
|
||||
|
||||
// 插入文本
|
||||
execCommand('insertText', text)
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
// Ctrl+B 加粗
|
||||
if (event.ctrlKey && event.key === 'b') {
|
||||
event.preventDefault()
|
||||
execCommand('bold')
|
||||
}
|
||||
|
||||
// Ctrl+I 斜体
|
||||
if (event.ctrlKey && event.key === 'i') {
|
||||
event.preventDefault()
|
||||
execCommand('italic')
|
||||
}
|
||||
|
||||
// Ctrl+U 下划线
|
||||
if (event.ctrlKey && event.key === 'u') {
|
||||
event.preventDefault()
|
||||
execCommand('underline')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = (event: FocusEvent) => {
|
||||
focused.value = true
|
||||
emit('focus', event)
|
||||
}
|
||||
|
||||
const handleBlur = (event: FocusEvent) => {
|
||||
focused.value = false
|
||||
emit('blur', event)
|
||||
}
|
||||
|
||||
const execCommand = (command: string, value?: string) => {
|
||||
document.execCommand(command, false, value)
|
||||
editorRef.value?.focus()
|
||||
handleInput()
|
||||
}
|
||||
|
||||
const isActive = (command: string): boolean => {
|
||||
return document.queryCommandState(command)
|
||||
}
|
||||
|
||||
const changeTextColor = (color: string) => {
|
||||
execCommand('foreColor', color)
|
||||
}
|
||||
|
||||
const insertLink = () => {
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.toString()) {
|
||||
linkForm.value.text = selection.toString()
|
||||
}
|
||||
linkDialogVisible.value = true
|
||||
}
|
||||
|
||||
const confirmInsertLink = () => {
|
||||
if (!linkForm.value.url) {
|
||||
ElMessage.warning('请输入链接地址')
|
||||
return
|
||||
}
|
||||
|
||||
const linkHtml = `<a href="${linkForm.value.url}" target="_blank">${linkForm.value.text || linkForm.value.url}</a>`
|
||||
execCommand('insertHTML', linkHtml)
|
||||
|
||||
linkDialogVisible.value = false
|
||||
linkForm.value = { text: '', url: '' }
|
||||
}
|
||||
|
||||
const insertImage = () => {
|
||||
imageDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleImageUpload = (response: any) => {
|
||||
const imageHtml = `<img src="${response.url}" alt="上传图片" style="max-width: 100%; height: auto;" />`
|
||||
execCommand('insertHTML', imageHtml)
|
||||
imageDialogVisible.value = false
|
||||
}
|
||||
|
||||
const confirmInsertImage = () => {
|
||||
if (!imageForm.value.url) {
|
||||
ElMessage.warning('请输入图片地址')
|
||||
return
|
||||
}
|
||||
|
||||
const imageHtml = `<img src="${imageForm.value.url}" alt="${imageForm.value.alt}" style="max-width: 100%; height: auto;" />`
|
||||
execCommand('insertHTML', imageHtml)
|
||||
|
||||
imageDialogVisible.value = false
|
||||
imageForm.value = { url: '', alt: '' }
|
||||
}
|
||||
|
||||
const insertTable = () => {
|
||||
const tableHtml = `
|
||||
<table border="1" style="border-collapse: collapse; width: 100%;">
|
||||
<tr>
|
||||
<td style="padding: 8px;">单元格1</td>
|
||||
<td style="padding: 8px;">单元格2</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px;">单元格3</td>
|
||||
<td style="padding: 8px;">单元格4</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
execCommand('insertHTML', tableHtml)
|
||||
}
|
||||
|
||||
const clearFormat = () => {
|
||||
execCommand('removeFormat')
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
editorRef.value?.focus()
|
||||
}
|
||||
|
||||
const blur = () => {
|
||||
editorRef.value?.blur()
|
||||
}
|
||||
|
||||
const getContent = () => {
|
||||
return content.value
|
||||
}
|
||||
|
||||
const setContent = (html: string) => {
|
||||
content.value = html
|
||||
if (editorRef.value) {
|
||||
editorRef.value.innerHTML = html
|
||||
}
|
||||
}
|
||||
|
||||
// 监听外部值变化
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (newValue !== content.value) {
|
||||
content.value = newValue
|
||||
nextTick(() => {
|
||||
if (editorRef.value) {
|
||||
editorRef.value.innerHTML = newValue
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
if (editorRef.value && props.modelValue) {
|
||||
editorRef.value.innerHTML = props.modelValue
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
focus,
|
||||
blur,
|
||||
getContent,
|
||||
setContent,
|
||||
execCommand
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rich-text-editor {
|
||||
@apply border border-gray-300 rounded-lg overflow-hidden;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
@apply bg-gray-50 border-b border-gray-300 p-2;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
@apply flex items-center space-x-2 flex-wrap;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
@apply flex items-center;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
@apply p-4 outline-none overflow-y-auto;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.editor-content:empty:before {
|
||||
content: attr(placeholder);
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
@apply bg-gray-50 border-t border-gray-300 px-4 py-2 text-right text-sm text-gray-500;
|
||||
}
|
||||
|
||||
.word-count {
|
||||
@apply mr-1;
|
||||
}
|
||||
|
||||
.word-limit {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
@apply flex justify-end space-x-2 mt-4;
|
||||
}
|
||||
|
||||
:deep(.el-button.active) {
|
||||
@apply bg-blue-500 text-white;
|
||||
}
|
||||
|
||||
:deep(.editor-content img) {
|
||||
@apply max-w-full h-auto;
|
||||
}
|
||||
|
||||
:deep(.editor-content table) {
|
||||
@apply border-collapse w-full;
|
||||
}
|
||||
|
||||
:deep(.editor-content table td) {
|
||||
@apply border border-gray-300 p-2;
|
||||
}
|
||||
|
||||
:deep(.editor-content a) {
|
||||
@apply text-blue-500 underline;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user