feat: 增强情绪博物馆项目功能 - 新增用户评论和帖子功能,优化前端架构和WebSocket通信 - 更新文档和部署配置

This commit is contained in:
2025-07-29 07:38:47 +08:00
parent cc886cd4d5
commit 2f3d39fb00
142 changed files with 45645 additions and 0 deletions
@@ -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>