# 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，否则浏览器可能阻止摄像头/麦克风访问
