jisti-meet服务开启

This commit is contained in:
2025-12-26 18:55:54 +08:00
parent c2b37503fc
commit 0658b82f39
43 changed files with 3979 additions and 1208 deletions

View File

@@ -89,6 +89,8 @@
ref="chatRoomRef"
:messages="messages"
:current-user-id="loginDomain.user.userId"
:room-id="currentRoomId"
:workcase-id="currentWorkcaseId"
:room-name="currentRoom?.roomName"
:meeting-url="currentMeetingUrl"
:show-meeting="showMeetingIframe"
@@ -163,7 +165,7 @@
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ElButton, ElInput, ElDialog, ElMessage } from 'element-plus'
import { Search, FileText, MessageSquare, MessageCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { ChatRoom } from '@/components/chatRoom'
import ChatRoom from './chatRoom/ChatRoom.vue'
import WorkcaseDetail from '@/views/public/workcase/WorkcaseDetail/WorkcaseDetail.vue'
import { workcaseChatAPI } from '@/api/workcase'
import { fileAPI } from 'shared/api/file'
@@ -248,6 +250,7 @@ const showWorkcaseCreator = ref(false)
// Jitsi Meet会议相关
const currentMeetingUrl = ref('')
const showMeetingIframe = ref(false)
const currentMeetingId = ref<string | null>(null)
// ChatRoom组件引用
const chatRoomRef = ref<InstanceType<typeof ChatRoom> | null>(null)
@@ -512,10 +515,50 @@ const onWorkcaseCreated = (workcaseId: string) => {
const startMeeting = async () => {
if (!currentRoomId.value) return
// TODO: 调用后端API创建Jitsi会议
const meetingId = 'meeting-' + currentRoomId.value + '-' + Date.now()
currentMeetingUrl.value = `https://meet.jit.si/${meetingId}`
showMeetingIframe.value = true
try {
// 先检查是否有活跃会议
const activeResult = await workcaseChatAPI.getActiveMeeting(currentRoomId.value)
if (activeResult.success && activeResult.data) {
// 已有活跃会议,直接加入
currentMeetingId.value = activeResult.data.meetingId!
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!)
if (joinResult.success && joinResult.data?.iframeUrl) {
currentMeetingUrl.value = joinResult.data.iframeUrl
showMeetingIframe.value = true
} else {
ElMessage.error(joinResult.message || '加入会议失败')
}
return
}
// 没有活跃会议,创建新会议
const createResult = await workcaseChatAPI.createVideoMeeting({
roomId: currentRoomId.value,
meetingName: currentRoom.value?.roomName || '视频会议'
})
if (createResult.success && createResult.data) {
currentMeetingId.value = createResult.data.meetingId!
// 开始会议
await workcaseChatAPI.startVideoMeeting(currentMeetingId.value!)
// 加入会议获取iframe URL
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!)
if (joinResult.success && joinResult.data?.iframeUrl) {
currentMeetingUrl.value = joinResult.data.iframeUrl
showMeetingIframe.value = true
ElMessage.success('会议已创建')
} else {
ElMessage.error(joinResult.message || '获取会议链接失败')
}
} else {
ElMessage.error(createResult.message || '创建会议失败')
}
} catch (error) {
console.error('发起会议失败:', error)
ElMessage.error('发起会议失败')
}
}
// 滚动聊天消息到底部

View File

@@ -0,0 +1,100 @@
.meeting-card {
padding: 12px 16px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background-color: #fff;
transition: all 0.3s;
&:hover {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
&--scheduled {
border-left: 4px solid #409eff;
}
&--ongoing {
border-left: 4px solid #67c23a;
}
&--ended {
border-left: 4px solid #909399;
background-color: #f5f7fa;
}
.meeting-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.meeting-card-title {
font-size: 16px;
font-weight: 600;
color: #303133;
flex: 1;
}
.meeting-card-status {
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-scheduled {
background-color: #ecf5ff;
color: #409eff;
}
.status-ongoing {
background-color: #f0f9ff;
color: #67c23a;
}
.status-ended {
background-color: #f4f4f5;
color: #909399;
}
}
}
.meeting-card-time {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
font-size: 14px;
color: #606266;
div {
line-height: 1.5;
}
}
.meeting-card-content {
margin-bottom: 12px;
.meeting-card-desc {
font-size: 14px;
color: #909399;
line-height: 1.5;
}
}
.meeting-card-action {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid #ebeef5;
.meeting-card-countdown {
font-size: 14px;
color: #409eff;
font-weight: 500;
}
}
}

View File

@@ -0,0 +1,195 @@
<template>
<!-- 消息会议卡片 -->
<div class="meeting-card" :class="`meeting-card--${meeting.status}`">
<div class="meeting-card-header">
<div class="meeting-card-title">{{ meeting.meetingName }}</div>
<div class="meeting-card-status">
<span v-if="meeting.status === 'scheduled'" class="status-badge status-scheduled">预定</span>
<span v-else-if="meeting.status === 'ongoing'" class="status-badge status-ongoing">进行中</span>
<span v-else-if="meeting.status === 'ended'" class="status-badge status-ended">已结束</span>
</div>
</div>
<div class="meeting-card-time">
<div>开始时间{{ formatDateTime(meeting.startTime) }}</div>
<div>结束时间{{ formatDateTime(meeting.endTime) }}</div>
<div v-if="meeting.advance">提前入会{{ meeting.advance }}分钟</div>
</div>
<div v-if="meeting.description" class="meeting-card-content">
<div class="meeting-card-desc">{{ meeting.description }}</div>
</div>
<div class="meeting-card-action">
<span class="meeting-card-countdown">{{ countdownText }}</span>
<ElButton
type="primary"
:disabled="!canJoinMeeting"
@click="handleJoinMeeting"
>
{{ buttonText }}
</ElButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ElButton, ElMessage } from 'element-plus'
import type { VideoMeetingVO } from '@/types'
import { computed, ref, onMounted, onUnmounted } from 'vue'
interface Props {
meeting: VideoMeetingVO
}
const props = defineProps<Props>()
const emit = defineEmits<{
join: [meetingId: string]
}>()
// 当前时间,每秒更新
const currentTime = ref(Date.now())
let timer: number | null = null
onMounted(() => {
// 每秒更新当前时间
timer = window.setInterval(() => {
currentTime.value = Date.now()
}, 1000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
/**
* 格式化日期时间
*/
function formatDateTime(dateStr?: string): string {
if (!dateStr) return ''
const date = new Date(dateStr)
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
/**
* 计算倒计时文本
*/
const countdownText = computed(() => {
const { meeting } = props
if (!meeting || !meeting.startTime || !meeting.endTime) {
return ''
}
const advanceMinutes = meeting.advance || 0
const now = currentTime.value
const startTime = new Date(meeting.startTime).getTime()
const endTime = new Date(meeting.endTime).getTime()
// 检查时间解析是否有效
if (isNaN(startTime) || isNaN(endTime)) {
return ''
}
const advanceTime = startTime - advanceMinutes * 60 * 1000
if (meeting.status === 'ended') {
return '会议已结束'
}
if (now < advanceTime) {
// 未到提前入会时间
const leftMs = advanceTime - now
const leftMinutes = Math.floor(leftMs / 60000)
const leftSeconds = Math.floor((leftMs % 60000) / 1000)
if (leftMinutes >= 60) {
const hours = Math.floor(leftMinutes / 60)
const mins = leftMinutes % 60
return `距离入会:${hours}小时${mins}分钟`
} else if (leftMinutes > 0) {
return `距离入会:${leftMinutes}${leftSeconds}`
} else {
return `距离入会:${leftSeconds}`
}
} else if (now < startTime) {
// 在提前入会时间窗口内,但未到开始时间
return '可以入会'
} else if (now < endTime) {
// 会议进行中
if (meeting.status === 'ongoing') {
return '会议进行中'
} else {
return '可以入会'
}
} else {
// 已超过结束时间
return '会议已超时'
}
})
/**
* 是否可以加入会议
*/
const canJoinMeeting = computed(() => {
const { meeting } = props
if (!meeting || !meeting.startTime || !meeting.endTime) {
return false
}
if (meeting.status === 'ended') {
return false
}
const advanceMinutes = meeting.advance || 0
const now = currentTime.value
const startTime = new Date(meeting.startTime).getTime()
const endTime = new Date(meeting.endTime).getTime()
// 检查时间解析是否有效
if (isNaN(startTime) || isNaN(endTime)) {
return false
}
const advanceTime = startTime - advanceMinutes * 60 * 1000
// 在允许入会的时间窗口内(提前入会时间 ~ 结束时间)
return now >= advanceTime && now <= endTime
})
/**
* 按钮文本
*/
const buttonText = computed(() => {
const { meeting } = props
if (meeting.status === 'ended') {
return '会议已结束'
}
if (!canJoinMeeting.value) {
return '未到入会时间'
}
return '加入会议'
})
/**
* 加入会议
*/
async function handleJoinMeeting() {
if (!props.meeting.meetingId) {
ElMessage.error('会议ID不存在')
return
}
if (!canJoinMeeting.value) {
return
}
// 发出事件让父组件处理
emit('join', props.meeting.meetingId)
}
</script>
<style scoped lang="scss">
@import './MeetingCard.scss';
</style>

View File

@@ -0,0 +1,239 @@
<template>
<ElDialog
v-model="dialogVisible"
title="创建视频会议"
width="500px"
:close-on-click-modal="false"
@close="handleClose"
>
<ElForm
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<ElFormItem label="会议名称" prop="meetingName">
<ElInput
v-model="formData.meetingName"
placeholder="请输入会议名称"
clearable
/>
</ElFormItem>
<ElFormItem label="开始时间" prop="startTime" required>
<ElDatePicker
v-model="formData.startTime"
type="datetime"
placeholder="选择开始时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
:disabled-date="disabledDate"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="结束时间" prop="endTime" required>
<ElDatePicker
v-model="formData.endTime"
type="datetime"
placeholder="选择结束时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
:disabled-date="disabledDate"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="提前入会" prop="advance">
<ElInputNumber
v-model="formData.advance"
:min="0"
:max="60"
placeholder="提前入会时间(分钟)"
style="width: 100%"
/>
<div class="form-tip">用户可在会议开始前N分钟加入</div>
</ElFormItem>
<ElFormItem label="会议密码" prop="meetingPassword">
<ElInput
v-model="formData.meetingPassword"
placeholder="可选,留空则无密码"
clearable
show-password
/>
</ElFormItem>
<ElFormItem label="最大人数" prop="maxParticipants">
<ElInputNumber
v-model="formData.maxParticipants"
:min="2"
:max="100"
placeholder="最大参与人数"
style="width: 100%"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="handleClose">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
创建会议
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import {
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElDatePicker,
ElButton,
ElMessage,
type FormInstance,
type FormRules
} from 'element-plus'
import { workcaseChatAPI } from '@/api/workcase'
import type { CreateMeetingParam } from '@/types'
interface Props {
modelValue: boolean
roomId: string
workcaseId: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
success: [meetingId: string]
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
// 对话框显示状态
const dialogVisible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 表单数据
const formData = reactive<CreateMeetingParam>({
roomId: props.roomId,
workcaseId: props.workcaseId,
meetingName: '',
startTime: '',
endTime: '',
advance: 5,
meetingPassword: '',
maxParticipants: 10
})
// 表单验证规则
const formRules: FormRules = {
meetingName: [
{ max: 50, message: '会议名称不能超过50个字符', trigger: 'blur' }
],
startTime: [
{ required: true, message: '请选择开始时间', trigger: 'change' }
],
endTime: [
{ required: true, message: '请选择结束时间', trigger: 'change' },
{
validator: (rule, value, callback) => {
if (value && formData.startTime) {
const start = new Date(formData.startTime).getTime()
const end = new Date(value).getTime()
if (end <= start) {
callback(new Error('结束时间必须晚于开始时间'))
} else if (end - start < 5 * 60 * 1000) {
callback(new Error('会议时长不能少于5分钟'))
} else if (end - start > 24 * 60 * 60 * 1000) {
callback(new Error('会议时长不能超过24小时'))
} else {
callback()
}
} else {
callback()
}
},
trigger: 'change'
}
],
advance: [
{ type: 'number', min: 0, max: 60, message: '提前入会时间范围为0-60分钟', trigger: 'blur' }
],
meetingPassword: [
{ max: 20, message: '密码不能超过20个字符', trigger: 'blur' }
],
maxParticipants: [
{ type: 'number', min: 2, max: 100, message: '参与人数范围为2-100人', trigger: 'blur' }
]
}
// 禁用过去的日期
const disabledDate = (date: Date) => {
return date.getTime() < Date.now() - 24 * 60 * 60 * 1000
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
// 验证表单
await formRef.value.validate()
submitting.value = true
console.log(props.roomId)
// 调用API创建会议
const result = await workcaseChatAPI.createVideoMeeting({
...formData,
roomId: props.roomId,
workcaseId: props.workcaseId
})
if (result.success && result.data) {
ElMessage.success('会议创建成功')
emit('success', result.data.meetingId!)
handleClose()
} else {
ElMessage.error(result.message || '创建会议失败')
}
} catch (error) {
console.error('创建会议失败:', error)
if (error instanceof Error && error.message !== 'Validation failed') {
ElMessage.error('创建会议失败,请重试')
}
} finally {
submitting.value = false
}
}
// 关闭对话框
const handleClose = () => {
// 重置表单
formRef.value?.resetFields()
formData.meetingName = ''
formData.startTime = ''
formData.endTime = ''
formData.advance = 5
formData.meetingPassword = ''
formData.maxParticipants = 10
dialogVisible.value = false
}
</script>
<style scoped lang="scss">
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,604 @@
// 品牌色
$brand-color: #0055AA;
$brand-color-light: #EBF5FF;
$brand-color-hover: #004488;
.chat-room-main {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
// ==================== 加载更多 ====================
.loading-more {
text-align: center;
padding: 12px;
font-size: 13px;
color: #94a3b8;
}
// ==================== 聊天室头部 ====================
.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 {
width: 100%;
height: 500px;
margin-bottom: 16px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e5e7eb;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
.meeting-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 500;
font-size: 14px;
}
.close-meeting-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
}
.meeting-iframe {
width: 100%;
height: calc(100% - 48px);
border: none;
}
}
// 按钮禁用状态
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
&:hover {
border-color: #e2e8f0;
color: #64748b;
background: transparent;
}
}
// ==================== 消息列表 ====================
.messages-list {
width: 100%;
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;
background: $brand-color-light;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-text {
font-size: 14px;
font-weight: 600;
color: $brand-color;
}
}
.sender-name {
font-size: 12px;
color: #64748b;
margin-bottom: 4px;
}
.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;
}
}
}
}
// ==================== Markdown样式 ====================
.message-bubble {
// 粗体
strong {
font-weight: 600;
color: inherit;
}
// 斜体
em {
font-style: italic;
}
// 行内代码
.inline-code {
background: rgba(0, 0, 0, 0.05);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
color: #e53e3e;
}
// 代码块
.code-block {
background: rgba(0, 0, 0, 0.05);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
margin: 8px 0;
code {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
color: #334155;
}
}
// 链接
.md-link {
color: $brand-color;
text-decoration: underline;
&:hover {
color: $brand-color-hover;
}
}
// 标题
.md-h1 {
font-size: 20px;
font-weight: 700;
margin: 12px 0 8px;
color: inherit;
}
.md-h2 {
font-size: 18px;
font-weight: 600;
margin: 10px 0 6px;
color: inherit;
}
.md-h3 {
font-size: 16px;
font-weight: 600;
margin: 8px 0 4px;
color: inherit;
}
// 列表
.md-ul, .md-ol {
margin: 8px 0;
padding-left: 20px;
}
.md-li {
margin: 4px 0;
line-height: 1.6;
}
}
// ==================== 视频会议弹窗 ====================
.meeting-modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.meeting-modal {
width: 90vw;
max-width: 1200px;
height: 80vh;
background: #fff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
animation: modalFadeIn 0.3s ease;
}
@keyframes modalFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.meeting-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
flex-shrink: 0;
}
.meeting-modal-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
}
.meeting-modal-actions {
display: flex;
align-items: center;
gap: 8px;
}
.minimize-btn,
.meeting-modal .close-meeting-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 8px;
color: white;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
}
.meeting-modal .close-meeting-btn:hover {
background: rgba(239, 68, 68, 0.8);
}
.meeting-modal-body {
flex: 1;
overflow: hidden;
.meeting-iframe {
width: 100%;
height: 100%;
border: none;
}
}
// ==================== 最小化悬浮按钮 ====================
.meeting-float-btn {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 50px;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
cursor: pointer;
z-index: 999;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
animation: floatPulse 2s infinite;
&:hover {
transform: scale(1.05);
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.5);
}
}
@keyframes floatPulse {
0%, 100% {
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
}
50% {
box-shadow: 0 4px 30px rgba(102, 126, 234, 0.6);
}
}

View File

@@ -0,0 +1,467 @@
<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" @scroll="handleScroll">
<!-- 加载更多提示 -->
<div v-if="loadingMore" class="loading-more">加载中...</div>
<div v-else-if="!hasMore" class="loading-more">没有更多消息了</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>
<!-- 头像 -->
<div class="message-avatar">
<img v-if="message.senderAvatar" :src="FILE_DOWNLOAD_URL + message.senderAvatar" />
<span v-else class="avatar-text">{{ message.senderName?.charAt(0) || '?' }}</span>
</div>
<div class="sender-name">{{ message.senderName || '未知用户' }}</div>
</div>
<!-- 消息内容 -->
<div class="message-content-wrapper">
<!-- 会议消息卡片 -->
<template v-if="message.messageType === 'meet'">
<MeetingCard :meeting="getMeetingData(message.contentExtra)" @join="handleJoinMeeting" />
<div class="message-time">{{ formatTime(message.sendTime) }}</div>
</template>
<!-- 普通消息气泡 -->
<template v-else>
<div class="message-bubble">
<div
class="message-text"
v-html="renderMarkdown(message.content || '')"
></div>
<!-- 文件列表 -->
<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>
</template>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<footer class="input-area">
<!-- 操作按钮区域 -->
<div class="action-buttons">
<!-- 发起会议按钮 -->
<button
class="action-btn"
:disabled="meetingLoading || showMeeting"
@click="handleStartMeeting"
>
<Video :size="18" />
{{ showMeeting ? '会议进行中' : '发起会议' }}
</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>
<!-- 创建会议对话框 -->
<MeetingCreate
v-model="showMeetingCreate"
:room-id="roomId"
:workcase-id="workcaseId || ''"
@success="handleMeetingCreated"
/>
<!-- 视频会议弹窗 -->
<Teleport to="body">
<div v-if="showMeeting && meetingUrl" class="meeting-modal-mask">
<div class="meeting-modal">
<div class="meeting-modal-header">
<span class="meeting-modal-title">
<Video :size="18" />
视频会议进行中
</span>
<div class="meeting-modal-actions">
<button class="minimize-btn" @click="minimizeMeeting" title="最小化">
<Minus :size="18" />
</button>
<button class="close-meeting-btn" @click="handleEndMeeting" title="结束会议">
<X :size="18" />
</button>
</div>
</div>
<div class="meeting-modal-body">
<IframeView :src="meetingUrl" class="meeting-iframe" />
</div>
</div>
</div>
</Teleport>
<!-- 最小化的会议悬浮按钮 -->
<div v-if="meetingMinimized && meetingUrl" class="meeting-float-btn" @click="restoreMeeting">
<Video :size="20" />
<span>返回会议</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted } from 'vue'
import { FileText, Video, Paperclip, Send, X, Minus } from 'lucide-vue-next'
import IframeView from 'shared/components/iframe/IframeView.vue'
import MeetingCreate from '../MeetingCreate/MeetingCreate.vue'
import MeetingCard from '../MeetingCard/MeetingCard.vue'
import type { ChatRoomMessageVO, VideoMeetingVO } from '@/types/workcase'
import { workcaseChatAPI } from '@/api/workcase'
interface Props {
messages: ChatRoomMessageVO[]
currentUserId: string
roomId: string
roomName?: string
workcaseId?: string
fileDownloadUrl?: string
hasMore?: boolean
loadingMore?: boolean
}
const props = withDefaults(defineProps<Props>(), {
roomName: '聊天室',
fileDownloadUrl: '',
hasMore: true,
loadingMore: false
})
const FILE_DOWNLOAD_URL = props.fileDownloadUrl
const emit = defineEmits<{
'send-message': [content: string, files: File[]]
'download-file': [fileId: string]
'load-more': []
}>()
// 会议相关状态
const showMeeting = ref(false)
const meetingUrl = ref('')
const currentMeetingId = ref('')
const meetingLoading = ref(false)
const showMeetingCreate = ref(false)
const meetingMinimized = ref(false)
// 最小化会议
const minimizeMeeting = () => {
meetingMinimized.value = true
showMeeting.value = false
}
// 恢复会议窗口
const restoreMeeting = () => {
meetingMinimized.value = false
showMeeting.value = true
}
// 打开创建会议对话框
const handleStartMeeting = () => {
// 先检查是否有活跃会议
checkActiveMeeting().then(() => {
if (!showMeeting.value) {
// 没有活跃会议,打开创建对话框
showMeetingCreate.value = true
}
})
}
// 会议创建成功回调
// 定时会议创建后不自动进入,只显示成功消息
const handleMeetingCreated = async (meetingId: string) => {
showMeetingCreate.value = false
// 定时会议创建成功后不自动加入,用户可以在会议开始时间前后手动加入
// 会议消息会通过后端发送到聊天室,用户可以点击消息卡片加入
}
// 结束会议
const handleEndMeeting = async () => {
if (!currentMeetingId.value) return
try {
await workcaseChatAPI.endVideoMeeting(currentMeetingId.value)
showMeeting.value = false
meetingUrl.value = ''
currentMeetingId.value = ''
meetingMinimized.value = false
} catch (error) {
console.error('结束会议失败:', error)
}
}
// 检查是否有活跃会议
const checkActiveMeeting = async () => {
try {
const res = await workcaseChatAPI.getActiveMeeting(props.roomId)
if (res.code === 0 && res.data) {
currentMeetingId.value = res.data.meetingId
meetingUrl.value = res.data.iframeUrl
showMeeting.value = true
}
} catch (error) {
console.log('无活跃会议')
}
}
// 组件挂载时检查是否有活跃会议
onMounted(() => {
checkActiveMeeting()
})
// 滚动到顶部加载更多
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
if (target.scrollTop < 50 && props.hasMore && !props.loadingMore) {
emit('load-more')
}
}
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) => {
if (!time) return ''
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' })
}
// 处理从MeetingCard发出的加入会议事件
const handleJoinMeeting = async (meetingId: string) => {
try {
// 调用加入会议接口获取iframe URL
const joinRes = await workcaseChatAPI.joinVideoMeeting(meetingId)
if (joinRes.success && joinRes.data) {
// 检查会议状态
const meetingData = joinRes.data
if (meetingData.status === 'ended') {
// 会议已结束,提示用户
alert('该会议已结束')
return
}
if (!meetingData.iframeUrl) {
console.error('加入会议失败: 未获取到会议地址')
alert('加入会议失败:未获取到会议地址')
return
}
currentMeetingId.value = meetingId
meetingUrl.value = meetingData.iframeUrl
showMeeting.value = true
meetingMinimized.value = false
} else {
console.error('加入会议失败:', joinRes.message)
alert(joinRes.message || '加入会议失败')
}
} catch (error) {
console.error('加入会议失败:', error)
alert('加入会议失败,请稍后重试')
}
}
// 获取会议数据将contentExtra转换为VideoMeetingVO
function getMeetingData(contentExtra: Record<string, any> | undefined): VideoMeetingVO {
if (!contentExtra) {
return {} as VideoMeetingVO
}
return contentExtra as VideoMeetingVO
}
// Markdown渲染函数
const renderMarkdown = (text: string): string => {
if (!text) return ''
// 转义HTML标签
let html = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 处理代码块(```语法)
html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => {
return `<pre class="code-block"><code class="language-${lang || 'text'}">${code.trim()}</code></pre>`
})
// 处理行内代码(`语法)
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
// 处理粗体(**语法)
html = html.replace(/\*\*([^\*]+)\*\*/g, '<strong>$1</strong>')
// 处理斜体(*语法)
html = html.replace(/\*([^\*]+)\*/g, '<em>$1</em>')
// 处理链接([text](url)语法)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" class="md-link">$1</a>')
// 处理标题(# ## ###等)
html = html.replace(/^### (.+)$/gm, '<h3 class="md-h3">$1</h3>')
html = html.replace(/^## (.+)$/gm, '<h2 class="md-h2">$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1 class="md-h1">$1</h1>')
// 处理无序列表(- 或 * 开头)
html = html.replace(/^[*-] (.+)$/gm, '<li class="md-li">$1</li>')
html = html.replace(/(<li class="md-li">.*<\/li>)/s, '<ul class="md-ul">$1</ul>')
// 处理有序列表(数字. 开头)
html = html.replace(/^\d+\. (.+)$/gm, '<li class="md-li">$1</li>')
// 处理换行
html = html.replace(/\n/g, '<br>')
return html
}
// 暴露方法给父组件
defineExpose({
scrollToBottom,
handleStartMeeting,
handleEndMeeting
})
</script>
<style scoped lang="scss">
@import url("./ChatRoom.scss");
</style>