Files
urbanLifeline/urbanLifelineServ/workcase/Jitsi会议独立页面实现方案.md
2025-12-27 15:36:40 +08:00

531 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Jitsi 会议独立页面实现方案
## 问题背景
1. **Web端问题**ChatRoom 弹窗关闭按钮不应该触发 `endMeeting`,只有在 Jitsi iframe 中点击"结束会议"才应该结束会议
2. **主持人问题**:主持人应该是数据库中的会议创建人,而不是第一个进入会议的人
3. **小程序问题**:无法捕捉结束会议事件
## 解决方案
### 1. 后端修改(已完成)
#### 1.1 新增路由配置 (`initDataPermission.sql`)
```sql
-- Jitsi视频会议独立页面支持URL参数token认证用于小程序和外部链接访问
('VIEW-W003', 'view_workcase_jitsi_meeting', 'Jitsi视频会议', NULL, '/meeting/:meetingId',
'public/Meeting/JitsiMeetingView.vue', 'Video', 1,
'route', NULL, 'workcase', 'BlankLayout', 25,
'Jitsi视频会议独立页面支持token参数认证', 'system', now(), false);
```
#### 1.2 新增 API 接口
| 接口 | 方法 | 说明 |
|------|------|------|
| `/meeting/{meetingId}/entry?token=xxx` | GET | 会议入口支持URL参数token认证 |
| `/meeting/{meetingId}/share-url` | GET | 生成会议分享URL |
#### 1.3 Gateway 白名单
```yaml
whitelist:
- /urban-lifeline/workcase/meeting/*/entry
```
#### 1.4 主持人逻辑
主持人判断逻辑已正确实现:`userId.equals(meeting.getCreator())`
### 2. 前端实现(需要在前端仓库实现)
#### 2.1 创建 `JitsiMeetingView.vue`
路径:`src/views/public/Meeting/JitsiMeetingView.vue`
```vue
<template>
<div class="jitsi-meeting-container">
<!-- 加载状态 -->
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<p>正在加载会议...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<h2>无法加入会议</h2>
<p>{{ error }}</p>
<button @click="retry">重试</button>
</div>
<!-- Jitsi 会议容器 -->
<div v-else id="jitsi-container" ref="jitsiContainer"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getMeetingEntry, endMeeting } from '@/api/meeting'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const loading = ref(true)
const error = ref(null)
const jitsiApi = ref(null)
const meetingInfo = ref(null)
// 获取会议信息
const fetchMeetingInfo = async () => {
loading.value = true
error.value = null
try {
const meetingId = route.params.meetingId
// 优先从URL参数获取token其次从store获取
const token = route.query.token || userStore.token
if (!token) {
// 如果没有token尝试从localStorage恢复登录状态
await tryRestoreLogin()
}
const response = await getMeetingEntry(meetingId, token)
if (response.success) {
meetingInfo.value = response.data
initJitsi()
} else {
error.value = response.message
}
} catch (e) {
error.value = e.message || '加载会议失败'
} finally {
loading.value = false
}
}
// 尝试从token恢复登录状态
const tryRestoreLogin = async () => {
const token = route.query.token
if (token) {
try {
// 调用refresh接口验证token并获取用户信息
const response = await refreshToken(token)
if (response.success) {
userStore.setLoginInfo(response.data)
}
} catch (e) {
console.error('恢复登录状态失败:', e)
}
}
}
// 初始化 Jitsi Meet
const initJitsi = () => {
if (!meetingInfo.value?.iframeUrl) {
error.value = '会议URL无效'
return
}
// 解析iframe URL获取配置
const url = new URL(meetingInfo.value.iframeUrl)
const roomName = url.pathname.substring(1) // 去掉开头的 /
const jwt = url.searchParams.get('jwt')
// 使用 Jitsi External API
const domain = url.hostname + (url.port ? ':' + url.port : '')
const options = {
roomName: roomName,
jwt: jwt,
parentNode: document.getElementById('jitsi-container'),
width: '100%',
height: '100%',
configOverwrite: {
startWithAudioMuted: false,
startWithVideoMuted: false,
enableWelcomePage: false,
prejoinPageEnabled: false,
disableDeepLinking: true,
enableChat: true,
enableScreenSharing: true,
},
interfaceConfigOverwrite: {
SHOW_JITSI_WATERMARK: false,
SHOW_WATERMARK_FOR_GUESTS: false,
DISABLE_JOIN_LEAVE_NOTIFICATIONS: false,
DEFAULT_BACKGROUND: '#474747',
TOOLBAR_BUTTONS: [
'microphone', 'camera', 'closedcaptions', 'desktop',
'fullscreen', 'fodeviceselection', 'hangup', 'chat',
'recording', 'livestreaming', 'etherpad', 'sharedvideo',
'settings', 'raisehand', 'videoquality', 'filmstrip',
'invite', 'feedback', 'stats', 'shortcuts', 'tileview',
'videobackgroundblur', 'download', 'help', 'mute-everyone',
'security'
],
},
}
// 创建 Jitsi API 实例
jitsiApi.value = new JitsiMeetExternalAPI(domain, options)
// 监听会议事件
setupEventListeners()
}
// 设置事件监听
const setupEventListeners = () => {
if (!jitsiApi.value) return
// 监听会议结束事件(主持人点击"结束会议"
jitsiApi.value.addListener('readyToClose', async () => {
console.log('会议准备关闭')
await handleMeetingEnd()
})
// 监听参与者离开事件
jitsiApi.value.addListener('participantLeft', (participant) => {
console.log('参与者离开:', participant)
})
// 监听视频会议结束事件
jitsiApi.value.addListener('videoConferenceLeft', async (data) => {
console.log('离开视频会议:', data)
// 只有主持人离开时才结束会议
if (meetingInfo.value?.creator === userStore.userId) {
await handleMeetingEnd()
} else {
// 普通参与者离开,直接关闭页面
handleClose()
}
})
// 监听挂断事件
jitsiApi.value.addListener('hangup', async () => {
console.log('挂断')
// 检查是否是主持人
if (meetingInfo.value?.creator === userStore.userId) {
// 主持人挂断,询问是否结束会议
const shouldEnd = confirm('是否结束会议?')
if (shouldEnd) {
await handleMeetingEnd()
} else {
handleClose()
}
} else {
handleClose()
}
})
}
// 处理会议结束
const handleMeetingEnd = async () => {
try {
const meetingId = route.params.meetingId
await endMeeting(meetingId)
console.log('会议已结束')
} catch (e) {
console.error('结束会议失败:', e)
} finally {
handleClose()
}
}
// 处理关闭
const handleClose = () => {
// 如果是在iframe中打开的发送消息给父窗口
if (window.parent !== window) {
window.parent.postMessage({ type: 'MEETING_CLOSED', meetingId: route.params.meetingId }, '*')
}
// 尝试关闭窗口
window.close()
// 如果无法关闭不是通过window.open打开的则跳转回聊天室
setTimeout(() => {
router.push('/chatroom')
}, 100)
}
// 重试
const retry = () => {
fetchMeetingInfo()
}
// 清理
const cleanup = () => {
if (jitsiApi.value) {
jitsiApi.value.dispose()
jitsiApi.value = null
}
}
onMounted(() => {
// 加载 Jitsi External API 脚本
const script = document.createElement('script')
script.src = `${import.meta.env.VITE_JITSI_SERVER_URL}/external_api.js`
script.onload = fetchMeetingInfo
script.onerror = () => {
error.value = '加载Jitsi脚本失败'
loading.value = false
}
document.head.appendChild(script)
})
onUnmounted(() => {
cleanup()
})
</script>
<style scoped>
.jitsi-meeting-container {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
background: #000;
}
#jitsi-container {
width: 100%;
height: 100%;
}
.loading-overlay {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #fff;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 3px solid #333;
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #fff;
text-align: center;
padding: 20px;
}
.error-container button {
margin-top: 20px;
padding: 10px 30px;
background: #4a90d9;
color: #fff;
border: none;
border-radius: 5px;
cursor: pointer;
}
</style>
```
#### 2.2 API 接口定义
```typescript
// src/api/meeting.ts
import request from '@/utils/request'
// 获取会议入口信息支持token参数
export function getMeetingEntry(meetingId: string, token?: string) {
return request({
url: `/workcase/meeting/${meetingId}/entry`,
method: 'get',
params: { token },
// 不使用默认的Authorization header
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
})
}
// 结束会议
export function endMeeting(meetingId: string) {
return request({
url: `/workcase/meeting/${meetingId}/end`,
method: 'post'
})
}
// 生成会议分享URL
export function generateMeetingShareUrl(meetingId: string, baseUrl?: string) {
return request({
url: `/workcase/meeting/${meetingId}/share-url`,
method: 'get',
params: { baseUrl }
})
}
```
#### 2.3 路由配置
```typescript
// src/router/workcase.ts
{
path: '/meeting/:meetingId',
name: 'JitsiMeeting',
component: () => import('@/views/public/Meeting/JitsiMeetingView.vue'),
meta: {
layout: 'BlankLayout',
requiresAuth: false, // 允许通过URL token认证
title: '视频会议'
}
}
```
#### 2.4 修改 ChatRoom 组件
在 ChatRoom 组件中,点击"进入会议"时:
```typescript
// 进入会议
const joinMeeting = async (meetingId: string) => {
// 生成会议入口URL
const response = await generateMeetingShareUrl(meetingId)
if (response.success) {
// 在新窗口打开会议页面
window.open(response.data, '_blank', 'width=1200,height=800')
}
}
```
#### 2.5 Web 初始化逻辑修改
在 workcase 前端的初始化逻辑中,添加从 URL token 恢复登录状态的功能:
```typescript
// src/utils/auth.ts
export async function initAuth() {
// 1. 检查 localStorage 中是否有登录信息
const storedToken = localStorage.getItem('token')
if (storedToken) {
// 验证token是否有效
const isValid = await validateToken(storedToken)
if (isValid) {
return true
}
}
// 2. 检查 URL 参数中是否有 token
const urlParams = new URLSearchParams(window.location.search)
const urlToken = urlParams.get('token')
if (urlToken) {
try {
// 调用 refresh 接口验证 token 并获取用户信息
const response = await refreshToken(urlToken)
if (response.success) {
// 保存登录信息到 localStorage
localStorage.setItem('token', response.data.token)
localStorage.setItem('loginDomain', JSON.stringify(response.data))
return true
}
} catch (e) {
console.error('URL token 验证失败:', e)
}
}
return false
}
```
### 3. 小程序端实现
小程序端直接给出会议链接,用户在手机浏览器中打开:
```javascript
// 小程序中获取会议链接
const getMeetingUrl = async (meetingId) => {
const token = wx.getStorageSync('token')
const baseUrl = 'https://your-domain.com/workcase'
// 构建会议入口URL
const meetingUrl = `${baseUrl}/meeting/${meetingId}?token=${token}`
// 复制到剪贴板或显示给用户
wx.setClipboardData({
data: meetingUrl,
success: () => {
wx.showToast({
title: '会议链接已复制,请在浏览器中打开',
icon: 'none'
})
}
})
}
```
### 4. 关键流程
#### 4.1 Web 端进入会议流程
```
用户点击"进入会议"
调用 generateMeetingShareUrl 获取带token的URL
window.open 打开 JitsiMeetingView.vue
JitsiMeetingView 从URL获取token
调用 /meeting/{id}/entry?token=xxx 获取会议信息
初始化 Jitsi External API
监听 readyToClose/hangup 事件
主持人结束会议时调用 endMeeting API
```
#### 4.2 小程序端进入会议流程
```
用户点击"进入会议"
获取当前用户token
构建会议URL: /meeting/{id}?token=xxx
复制URL到剪贴板
用户在手机浏览器打开URL
JitsiMeetingView 从URL获取token
调用 /meeting/{id}/entry?token=xxx 获取会议信息
初始化 Jitsi External API
```
### 5. 注意事项
1. **主持人权限**:只有会议创建人(`meeting.creator`才是主持人JWT token 中的 `moderator` 字段会正确设置
2. **结束会议**:只有主持人点击"结束会议"或挂断时才会调用 `endMeeting` API
3. **Token 安全**URL 中的 token 应该有时效性,建议使用短期 token 或一次性 token
4. **跨域问题**:确保 Jitsi 服务器配置了正确的 CORS 策略
5. **HTTPS**:生产环境必须使用 HTTPS否则浏览器可能阻止摄像头/麦克风访问