聊天室和会议初始化

This commit is contained in:
2025-12-20 18:52:33 +08:00
parent 62850717eb
commit 37224e3f95
21 changed files with 3273 additions and 22 deletions

View File

@@ -0,0 +1,323 @@
// 品牌色
$brand-color: #0055AA;
$brand-color-light: #EBF5FF;
$brand-color-hover: #004488;
.chat-room-main {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
// ==================== 聊天室头部 ====================
.chat-header {
height: 64px;
display: flex;
align-items: center;
padding: 0 24px;
border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
.header-default {
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
}
}
// ==================== 消息容器 ====================
.messages-container {
flex: 1;
overflow-y: auto;
background: #f8fafc;
position: relative;
}
// ==================== Jitsi Meet会议容器 ====================
.meeting-container {
position: sticky;
top: 0;
z-index: 10;
height: 400px;
background: #000;
border-bottom: 2px solid $brand-color;
margin-bottom: 16px;
iframe {
width: 100%;
height: 100%;
border: none;
}
}
// ==================== 消息列表 ====================
.messages-list {
max-width: 900px;
margin: 0 auto;
padding: 24px 16px;
.message-row {
display: flex;
gap: 12px;
margin-bottom: 24px;
&.is-me {
flex-direction: row-reverse;
.message-bubble {
background: $brand-color;
color: #fff;
border-radius: 16px 16px 4px 16px;
.message-time {
text-align: right;
color: rgba(255, 255, 255, 0.7);
}
}
}
&.other {
.message-bubble {
background: #fff;
border: 1px solid #f1f5f9;
border-radius: 16px 16px 16px 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
}
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.message-content-wrapper {
max-width: 70%;
display: flex;
flex-direction: column;
gap: 8px;
}
.message-bubble {
padding: 12px 16px;
.message-text {
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
}
}
.message-files {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid rgba(255, 255, 255, 0.2);
&:hover {
background: rgba(255, 255, 255, 0.2);
}
.file-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.file-info {
.file-name {
font-size: 13px;
font-weight: 500;
}
}
}
.other .file-item {
background: #f8fafc;
border-color: #e2e8f0;
&:hover {
background: #f1f5f9;
}
.file-icon {
background: $brand-color-light;
color: $brand-color;
}
.file-info {
color: #374151;
}
}
.message-time {
font-size: 12px;
color: #94a3b8;
padding: 0 4px;
}
}
// ==================== 输入区域 ====================
.input-area {
padding: 16px 24px 24px;
background: #fff;
border-top: 1px solid #e2e8f0;
flex-shrink: 0;
.action-buttons {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
.action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: transparent;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
&:hover {
border-color: $brand-color;
color: $brand-color;
background: $brand-color-light;
}
}
}
.input-wrapper {
max-width: 900px;
margin: 0 auto;
}
.input-card {
background: #f8fafc;
border-radius: 12px;
border: 1px solid #e2e8f0;
overflow: hidden;
transition: all 0.2s;
&:focus-within {
border-color: $brand-color;
background: #fff;
}
}
.input-row {
padding: 12px 16px;
}
.chat-textarea {
width: 100%;
border: none;
outline: none;
resize: none;
font-size: 14px;
color: #374151;
background: transparent;
line-height: 1.5;
min-height: 60px;
max-height: 150px;
font-family: inherit;
&::placeholder {
color: #94a3b8;
}
}
.toolbar-row {
padding: 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid #e2e8f0;
background: #fff;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 4px;
}
.tool-btn {
padding: 8px;
color: #94a3b8;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: $brand-color;
background: $brand-color-light;
}
}
.send-btn {
padding: 8px 16px;
background: #e2e8f0;
color: #94a3b8;
border: none;
border-radius: 8px;
cursor: not-allowed;
transition: all 0.2s;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
&.active {
background: $brand-color;
color: #fff;
cursor: pointer;
box-shadow: 0 2px 8px rgba($brand-color, 0.3);
&:hover {
background: $brand-color-hover;
}
}
}
}

View File

@@ -0,0 +1,241 @@
<template>
<div class="chat-room-main">
<!-- 聊天室头部 -->
<header class="chat-header">
<slot name="header">
<div class="header-default">
<h3>{{ roomName }}</h3>
</div>
</slot>
</header>
<!-- 消息容器 -->
<div ref="messagesRef" class="messages-container">
<!-- Jitsi Meet会议iframe -->
<div v-if="showMeeting && meetingUrl" class="meeting-container">
<IframeView :src="meetingUrl" />
</div>
<!-- 聊天消息列表 -->
<div class="messages-list">
<div
v-for="message in messages"
:key="message.messageId"
class="message-row"
:class="message.senderId === currentUserId ? 'is-me' : 'other'"
>
<!-- 头像 -->
<div class="message-avatar">
<img :src="FILE_DOWNLOAD_URL + message.senderAvatar" />
</div>
<!-- 消息内容 -->
<div class="message-content-wrapper">
<div class="message-bubble">
<p class="message-text">{{ message.content }}</p>
<!-- 文件列表 -->
<div v-if="message.files && message.files.length > 0" class="message-files">
<div
v-for="file in message.files"
:key="file"
class="file-item"
@click="$emit('download-file', file)"
>
<div class="file-icon">
<FileText :size="16" />
</div>
<div class="file-info">
<div class="file-name">附件</div>
</div>
</div>
</div>
</div>
<div class="message-time">{{ formatTime(message.sendTime) }}</div>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<footer class="input-area">
<!-- 操作按钮区域 -->
<div class="action-buttons">
<!-- 发起会议按钮始终显示 -->
<button class="action-btn" @click="$emit('start-meeting')">
<Video :size="18" />
发起会议
</button>
<!-- 额外的操作按钮插槽 -->
<slot name="action-area"></slot>
</div>
<!-- 输入框 -->
<div class="input-wrapper">
<div class="input-card">
<div class="input-row">
<textarea
ref="textareaRef"
v-model="inputText"
@input="adjustHeight"
@keydown="handleKeyDown"
placeholder="输入消息..."
class="chat-textarea"
/>
</div>
<div class="toolbar-row">
<div class="toolbar-left">
<button class="tool-btn" @click="selectFiles" title="上传文件">
<Paperclip :size="18" />
</button>
<input
ref="fileInputRef"
type="file"
multiple
style="display: none"
@change="handleFileSelect"
/>
</div>
<button
class="send-btn"
:class="{ active: inputText.trim() }"
:disabled="!inputText.trim()"
@click="sendMessage"
>
<Send :size="18" />
发送
</button>
</div>
</div>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { FileText, Video, Paperclip, Send } from 'lucide-vue-next'
import IframeView from 'shared/components/iframe/IframeView.vue'
interface ChatMessageVO {
messageId: string
senderId: string
senderName: string
senderAvatar: string
content: string
files: string[]
sendTime: string
}
interface Props {
messages: ChatMessageVO[]
currentUserId: string
roomName?: string
meetingUrl?: string
showMeeting?: boolean
fileDownloadUrl?: string
}
const props = withDefaults(defineProps<Props>(), {
roomName: '聊天室',
showMeeting: false,
fileDownloadUrl: ''
})
const FILE_DOWNLOAD_URL = props.fileDownloadUrl
const emit = defineEmits<{
'send-message': [content: string, files: File[]]
'start-meeting': []
'download-file': [fileId: string]
}>()
defineSlots<{
header?: () => any
'action-area'?: () => any
}>()
const inputText = ref('')
const selectedFiles = ref<File[]>([])
const messagesRef = ref<HTMLElement | null>(null)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
// 发送消息
const sendMessage = () => {
if (!inputText.value.trim() && selectedFiles.value.length === 0) return
emit('send-message', inputText.value.trim(), selectedFiles.value)
inputText.value = ''
selectedFiles.value = []
if (textareaRef.value) {
textareaRef.value.style.height = 'auto'
}
scrollToBottom()
}
// 选择文件
const selectFiles = () => {
fileInputRef.value?.click()
}
// 处理文件选择
const handleFileSelect = (e: Event) => {
const files = (e.target as HTMLInputElement).files
if (!files || files.length === 0) return
selectedFiles.value = Array.from(files)
}
// 滚动到底部
const scrollToBottom = () => {
nextTick(() => {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
})
}
// 自动调整输入框高度
const adjustHeight = () => {
const el = textareaRef.value
if (el) {
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
}
}
// 键盘事件
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
// 格式化时间
const formatTime = (time: string) => {
const date = new Date(time)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
}
// 暴露方法给父组件
defineExpose({
scrollToBottom
})
</script>
<style scoped lang="scss">
@import url("./ChatRoom.scss");
</style>

View File

@@ -0,0 +1 @@
export { default as ChatRoom } from './ChatRoom.vue';

View File

@@ -2,5 +2,6 @@ export * from './base'
export * from './dynamicFormItem'
export * from './ai'
export * from './file'
export * from './chatRoom'
// 通用视图组件
export { default as IframeView } from './iframe/IframeView.vue'