jisti-meet服务开启
This commit is contained in:
@@ -1,79 +0,0 @@
|
||||
import { http } from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 创建视频会议参数
|
||||
*/
|
||||
export interface CreateMeetingParams {
|
||||
roomId: string
|
||||
workcaseId?: string
|
||||
meetingName: string
|
||||
maxParticipants?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频会议VO
|
||||
*/
|
||||
export interface VideoMeetingVO {
|
||||
meetingId: string
|
||||
roomId: string
|
||||
workcaseId?: string
|
||||
meetingName: string
|
||||
meetingPassword?: string
|
||||
jwtToken: string
|
||||
jitsiRoomName: string
|
||||
jitsiServerUrl: string
|
||||
status: string
|
||||
creatorId: string
|
||||
creatorType: string
|
||||
creatorName: string
|
||||
participantCount: number
|
||||
maxParticipants: number
|
||||
actualStartTime?: string
|
||||
actualEndTime?: string
|
||||
durationSeconds?: number
|
||||
durationFormatted?: string
|
||||
iframeUrl: string
|
||||
config?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建视频会议
|
||||
*/
|
||||
export const createVideoMeeting = (params: CreateMeetingParams) => {
|
||||
return http.post<VideoMeetingVO>('/workcase/chat/meeting/create', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会议信息
|
||||
*/
|
||||
export const getMeetingInfo = (meetingId: string) => {
|
||||
return http.get<VideoMeetingVO>(`/workcase/chat/meeting/${meetingId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天室活跃会议
|
||||
*/
|
||||
export const getActiveMeeting = (roomId: string) => {
|
||||
return http.get<VideoMeetingVO>(`/workcase/chat/meeting/room/${roomId}/active`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入会议(生成用户专属JWT)
|
||||
*/
|
||||
export const joinMeeting = (meetingId: string) => {
|
||||
return http.post<VideoMeetingVO>(`/workcase/chat/meeting/${meetingId}/join`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始会议
|
||||
*/
|
||||
export const startVideoMeeting = (meetingId: string) => {
|
||||
return http.post(`/workcase/chat/meeting/${meetingId}/start`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束会议
|
||||
*/
|
||||
export const endVideoMeeting = (meetingId: string) => {
|
||||
return http.post<VideoMeetingVO>(`/workcase/chat/meeting/${meetingId}/end`)
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import type {
|
||||
TbChatRoomMessageDTO,
|
||||
TbCustomerServiceDTO,
|
||||
TbWordCloudDTO,
|
||||
TbVideoMeetingDTO,
|
||||
ChatRoomVO,
|
||||
ChatMemberVO,
|
||||
ChatRoomMessageVO,
|
||||
CustomerServiceVO
|
||||
CustomerServiceVO,
|
||||
VideoMeetingVO
|
||||
} from '@/types/workcase'
|
||||
|
||||
/**
|
||||
@@ -220,5 +222,55 @@ export const workcaseChatAPI = {
|
||||
async getWordCloudPage(pageRequest: PageRequest<TbWordCloudDTO>): Promise<ResultDomain<TbWordCloudDTO>> {
|
||||
const response = await api.post<TbWordCloudDTO>(`${this.baseUrl}/wordcloud/page`, pageRequest)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// ====================== 视频会议管理(Jitsi Meet) ======================
|
||||
|
||||
/**
|
||||
* 创建视频会议
|
||||
*/
|
||||
async createVideoMeeting(meeting: TbVideoMeetingDTO): Promise<ResultDomain<VideoMeetingVO>> {
|
||||
const response = await api.post<VideoMeetingVO>(`${this.baseUrl}/meeting/create`, meeting)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取会议信息
|
||||
*/
|
||||
async getVideoMeetingInfo(meetingId: string): Promise<ResultDomain<VideoMeetingVO>> {
|
||||
const response = await api.get<VideoMeetingVO>(`${this.baseUrl}/meeting/${meetingId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取聊天室活跃会议
|
||||
*/
|
||||
async getActiveMeeting(roomId: string): Promise<ResultDomain<VideoMeetingVO>> {
|
||||
const response = await api.get<VideoMeetingVO>(`${this.baseUrl}/meeting/room/${roomId}/active`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 加入会议(生成用户专属JWT)
|
||||
*/
|
||||
async joinVideoMeeting(meetingId: string): Promise<ResultDomain<VideoMeetingVO>> {
|
||||
const response = await api.post<VideoMeetingVO>(`${this.baseUrl}/meeting/${meetingId}/join`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 开始会议
|
||||
*/
|
||||
async startVideoMeeting(meetingId: string): Promise<ResultDomain<boolean>> {
|
||||
const response = await api.post<boolean>(`${this.baseUrl}/meeting/${meetingId}/start`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 结束会议
|
||||
*/
|
||||
async endVideoMeeting(meetingId: string): Promise<ResultDomain<VideoMeetingVO>> {
|
||||
const response = await api.post<VideoMeetingVO>(`${this.baseUrl}/meeting/${meetingId}/end`)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default as ChatRoom } from './chatRoom/ChatRoom.vue';
|
||||
@@ -1 +0,0 @@
|
||||
export * from './chatRoom'
|
||||
@@ -71,6 +71,7 @@ export interface TbVideoMeetingDTO extends BaseDTO {
|
||||
workcaseId?: string
|
||||
meetingName?: string
|
||||
meetingPassword?: string
|
||||
description?: string
|
||||
jwtToken?: string
|
||||
jitsiRoomName?: string
|
||||
jitsiServerUrl?: string
|
||||
@@ -80,6 +81,12 @@ export interface TbVideoMeetingDTO extends BaseDTO {
|
||||
creatorName?: string
|
||||
participantCount?: number
|
||||
maxParticipants?: number
|
||||
/** 预定开始时间 */
|
||||
startTime?: string
|
||||
/** 预定结束时间 */
|
||||
endTime?: string
|
||||
/** 提前入会时间(分钟) */
|
||||
advance?: number
|
||||
actualStartTime?: string
|
||||
actualEndTime?: string
|
||||
durationSeconds?: number
|
||||
@@ -223,17 +230,23 @@ export interface VideoMeetingVO extends BaseVO {
|
||||
workcaseId?: string
|
||||
meetingName?: string
|
||||
meetingPassword?: string
|
||||
description?: string
|
||||
jwtToken?: string
|
||||
jitsiRoomName?: string
|
||||
jitsiServerUrl?: string
|
||||
status?: string
|
||||
creatorId?: string
|
||||
creatorType?: string
|
||||
creatorName?: string
|
||||
participantCount?: number
|
||||
maxParticipants?: number
|
||||
startTime?: string
|
||||
// 预定开始时间
|
||||
startTime?: string
|
||||
// 预定结束时间
|
||||
endTime?: string
|
||||
// 提前入会时间(分钟)
|
||||
advance?: number
|
||||
actualStartTime?: string
|
||||
actualEndTime?: string
|
||||
durationSeconds?: number
|
||||
durationFormatted?: string
|
||||
iframeUrl?: string
|
||||
@@ -279,6 +292,9 @@ export interface SendMessageParam {
|
||||
export interface CreateMeetingParam {
|
||||
roomId: string
|
||||
workcaseId: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
advance?: number
|
||||
meetingName?: string
|
||||
meetingPassword?: string
|
||||
maxParticipants?: number
|
||||
|
||||
@@ -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('发起会议失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动聊天消息到底部
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -469,3 +469,136 @@ $brand-color-hover: #004488;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -15,18 +15,6 @@
|
||||
<div v-if="loadingMore" class="loading-more">加载中...</div>
|
||||
<div v-else-if="!hasMore" class="loading-more">没有更多消息了</div>
|
||||
|
||||
<!-- Jitsi Meet会议iframe -->
|
||||
<div v-if="showMeeting && meetingUrl" class="meeting-container">
|
||||
<div class="meeting-header">
|
||||
<span>视频会议进行中</span>
|
||||
<button class="close-meeting-btn" @click="handleEndMeeting">
|
||||
<X :size="20" />
|
||||
结束会议
|
||||
</button>
|
||||
</div>
|
||||
<IframeView :src="meetingUrl" class="meeting-iframe" />
|
||||
</div>
|
||||
|
||||
<!-- 聊天消息列表 -->
|
||||
<div class="messages-list">
|
||||
<div
|
||||
@@ -46,30 +34,39 @@
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<div class="message-content-wrapper">
|
||||
<div class="message-bubble">
|
||||
<div
|
||||
class="message-text"
|
||||
v-html="renderMarkdown(message.content || '')"
|
||||
></div>
|
||||
<!-- 会议消息卡片 -->
|
||||
<template v-if="message.messageType === 'meet'">
|
||||
<MeetingCard :meeting="getMeetingData(message.contentExtra)" @join="handleJoinMeeting" />
|
||||
<div class="message-time">{{ formatTime(message.sendTime) }}</div>
|
||||
</template>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="message.files && message.files.length > 0" class="message-files">
|
||||
<!-- 普通消息气泡 -->
|
||||
<template v-else>
|
||||
<div class="message-bubble">
|
||||
<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>
|
||||
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>
|
||||
<div class="message-time">{{ formatTime(message.sendTime) }}</div>
|
||||
<div class="message-time">{{ formatTime(message.sendTime) }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,15 +129,56 @@
|
||||
</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 } from 'lucide-vue-next'
|
||||
import { FileText, Video, Paperclip, Send, X, Minus } from 'lucide-vue-next'
|
||||
import IframeView from 'shared/components/iframe/IframeView.vue'
|
||||
import type { ChatRoomMessageVO } from '@/types/workcase'
|
||||
import { createVideoMeeting, getActiveMeeting, endVideoMeeting } from '@/api/workcase/meeting'
|
||||
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[]
|
||||
@@ -173,32 +211,38 @@ const showMeeting = ref(false)
|
||||
const meetingUrl = ref('')
|
||||
const currentMeetingId = ref('')
|
||||
const meetingLoading = ref(false)
|
||||
const showMeetingCreate = ref(false)
|
||||
const meetingMinimized = ref(false)
|
||||
|
||||
// 创建并加入会议
|
||||
const handleStartMeeting = async () => {
|
||||
try {
|
||||
meetingLoading.value = true
|
||||
// 最小化会议
|
||||
const minimizeMeeting = () => {
|
||||
meetingMinimized.value = true
|
||||
showMeeting.value = false
|
||||
}
|
||||
|
||||
// 创建会议
|
||||
const createRes = await createVideoMeeting({
|
||||
roomId: props.roomId,
|
||||
workcaseId: props.workcaseId,
|
||||
meetingName: `工单 ${props.workcaseId || props.roomId} 技术支持`,
|
||||
maxParticipants: 10
|
||||
})
|
||||
// 恢复会议窗口
|
||||
const restoreMeeting = () => {
|
||||
meetingMinimized.value = false
|
||||
showMeeting.value = true
|
||||
}
|
||||
|
||||
if (createRes.code === 0 && createRes.data) {
|
||||
currentMeetingId.value = createRes.data.meetingId
|
||||
meetingUrl.value = createRes.data.iframeUrl
|
||||
showMeeting.value = true
|
||||
} else {
|
||||
console.error('创建会议失败:', createRes.message)
|
||||
// 打开创建会议对话框
|
||||
const handleStartMeeting = () => {
|
||||
// 先检查是否有活跃会议
|
||||
checkActiveMeeting().then(() => {
|
||||
if (!showMeeting.value) {
|
||||
// 没有活跃会议,打开创建对话框
|
||||
showMeetingCreate.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建会议异常:', error)
|
||||
} finally {
|
||||
meetingLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 会议创建成功回调
|
||||
// 定时会议创建后不自动进入,只显示成功消息
|
||||
const handleMeetingCreated = async (meetingId: string) => {
|
||||
showMeetingCreate.value = false
|
||||
// 定时会议创建成功后不自动加入,用户可以在会议开始时间前后手动加入
|
||||
// 会议消息会通过后端发送到聊天室,用户可以点击消息卡片加入
|
||||
}
|
||||
|
||||
// 结束会议
|
||||
@@ -206,10 +250,11 @@ const handleEndMeeting = async () => {
|
||||
if (!currentMeetingId.value) return
|
||||
|
||||
try {
|
||||
await endVideoMeeting(currentMeetingId.value)
|
||||
await workcaseChatAPI.endVideoMeeting(currentMeetingId.value)
|
||||
showMeeting.value = false
|
||||
meetingUrl.value = ''
|
||||
currentMeetingId.value = ''
|
||||
meetingMinimized.value = false
|
||||
} catch (error) {
|
||||
console.error('结束会议失败:', error)
|
||||
}
|
||||
@@ -218,7 +263,7 @@ const handleEndMeeting = async () => {
|
||||
// 检查是否有活跃会议
|
||||
const checkActiveMeeting = async () => {
|
||||
try {
|
||||
const res = await getActiveMeeting(props.roomId)
|
||||
const res = await workcaseChatAPI.getActiveMeeting(props.roomId)
|
||||
if (res.code === 0 && res.data) {
|
||||
currentMeetingId.value = res.data.meetingId
|
||||
meetingUrl.value = res.data.iframeUrl
|
||||
@@ -322,6 +367,48 @@ const formatTime = (time?: string) => {
|
||||
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 ''
|
||||
Reference in New Issue
Block a user