暂存
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,7 +19,7 @@
|
|||||||
*.zip
|
*.zip
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.rar
|
*.rar
|
||||||
|
.tmp
|
||||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||||
hs_err_pid*
|
hs_err_pid*
|
||||||
replay_pid*
|
replay_pid*
|
||||||
|
|||||||
@@ -285,7 +285,11 @@ INSERT INTO sys.tb_sys_view (
|
|||||||
'route', NULL, 'workcase', NULL, 162, '工单操作日志', 'system', now(), false),
|
'route', NULL, 'workcase', NULL, 162, '工单操作日志', 'system', now(), false),
|
||||||
|
|
||||||
('VIEW-W109', 'view_workcase_admin_log_system', '系统日志', 'view_workcase_admin_log', '/admin/log/system', 'admin/log/systemLog/SystemLogView.vue', 'Settings', 1,
|
('VIEW-W109', 'view_workcase_admin_log_system', '系统日志', 'view_workcase_admin_log', '/admin/log/system', 'admin/log/systemLog/SystemLogView.vue', 'Settings', 1,
|
||||||
'route', NULL, 'workcase', NULL, 163, '系统运行日志', 'system', now(), false);
|
'route', NULL, 'workcase', NULL, 163, '系统运行日志', 'system', now(), false),
|
||||||
|
|
||||||
|
-- Jitsi视频会议独立页面(支持URL参数token认证,用于小程序和外部链接访问)
|
||||||
|
('VIEW-W003', 'view_workcase_jitsi_meeting', 'Jitsi视频会议', NULL, '/meeting', 'public/JitsiMeeting/JitsiMeetingView.vue', 'Video', 1,
|
||||||
|
'route', NULL, 'workcase', 'BlankLayout', 25, 'Jitsi视频会议独立页面,支持URL参数token认证', 'system', now(), false);
|
||||||
|
|
||||||
-- =============================
|
-- =============================
|
||||||
-- 6. 角色权限关联(超级管理员拥有所有权限)
|
-- 6. 角色权限关联(超级管理员拥有所有权限)
|
||||||
@@ -426,7 +430,10 @@ INSERT INTO sys.tb_sys_view_permission (
|
|||||||
('VP-W106', 'view_workcase_admin_log', 'perm_workcase_log', 'system', NULL, now(), false),
|
('VP-W106', 'view_workcase_admin_log', 'perm_workcase_log', 'system', NULL, now(), false),
|
||||||
('VP-W107', 'view_workcase_admin_log_knowledge', 'perm_workcase_log', 'system', NULL, now(), false),
|
('VP-W107', 'view_workcase_admin_log_knowledge', 'perm_workcase_log', 'system', NULL, now(), false),
|
||||||
('VP-W108', 'view_workcase_admin_log_workcase', 'perm_workcase_log', 'system', NULL, now(), false),
|
('VP-W108', 'view_workcase_admin_log_workcase', 'perm_workcase_log', 'system', NULL, now(), false),
|
||||||
('VP-W109', 'view_workcase_admin_log_system', 'perm_workcase_log', 'system', NULL, now(), false);
|
('VP-W109', 'view_workcase_admin_log_system', 'perm_workcase_log', 'system', NULL, now(), false),
|
||||||
|
|
||||||
|
-- Jitsi视频会议页面关联会议权限
|
||||||
|
('VP-W003', 'view_workcase_jitsi_meeting', 'perm_meeting_join', 'system', NULL, now(), false);
|
||||||
|
|
||||||
-- -- 用户管理视图关联用户权限(已注释,因为view_user被注释掉了)
|
-- -- 用户管理视图关联用户权限(已注释,因为view_user被注释掉了)
|
||||||
-- -- ('VP-0001', 'view_user', 'perm_user_view', 'system', NULL, now(), false),
|
-- -- ('VP-0001', 'view_user', 'perm_user_view', 'system', NULL, now(), false),
|
||||||
|
|||||||
@@ -89,4 +89,25 @@ public interface VideoMeetingService {
|
|||||||
* @since 2025-12-25
|
* @since 2025-12-25
|
||||||
*/
|
*/
|
||||||
boolean isMemberOfRoom(String roomId, String userId);
|
boolean isMemberOfRoom(String roomId, String userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 通过token获取会议入口信息(用于小程序和外部链接访问)
|
||||||
|
* 此接口在gateway白名单中,通过URL参数token进行认证
|
||||||
|
* @param meetingId 会议ID
|
||||||
|
* @param token 用户认证token
|
||||||
|
* @return ResultDomain<VideoMeetingVO> 包含会议信息和用户专属的iframe URL
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-12-27
|
||||||
|
*/
|
||||||
|
ResultDomain<VideoMeetingVO> getMeetingEntryByToken(String meetingId, String token);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 生成会议入口URL(用于分享给小程序用户)
|
||||||
|
* @param meetingId 会议ID
|
||||||
|
* @param baseUrl 基础URL(如 https://example.com/workcase)
|
||||||
|
* @return ResultDomain<String> 完整的会议入口URL(包含token参数)
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-12-27
|
||||||
|
*/
|
||||||
|
ResultDomain<String> generateMeetingEntryUrl(String meetingId, String baseUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,9 +86,12 @@ public class VideoMeetingVO extends BaseVO {
|
|||||||
@Schema(description = "会议时长(格式化,如:1小时30分)")
|
@Schema(description = "会议时长(格式化,如:1小时30分)")
|
||||||
private String durationFormatted;
|
private String durationFormatted;
|
||||||
|
|
||||||
@Schema(description = "iframe嵌入URL")
|
@Schema(description = "会议页面URL(用于路由跳转)")
|
||||||
private String iframeUrl;
|
private String iframeUrl;
|
||||||
|
|
||||||
|
@Schema(description = "Jitsi真正的iframe URL(用于嵌入播放)")
|
||||||
|
private String jitsiIframeUrl;
|
||||||
|
|
||||||
@Schema(description = "Jitsi配置项")
|
@Schema(description = "Jitsi配置项")
|
||||||
private JSONObject config;
|
private JSONObject config;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ auth:
|
|||||||
# ai 服务白名单
|
# ai 服务白名单
|
||||||
- /urban-lifeline/ai/chat/**
|
- /urban-lifeline/ai/chat/**
|
||||||
- /urban-lifeline/system/guest/identify
|
- /urban-lifeline/system/guest/identify
|
||||||
|
# workcase 会议入口白名单(支持URL参数token认证)
|
||||||
|
- /urban-lifeline/workcase/meeting/*/entry
|
||||||
security:
|
security:
|
||||||
aes:
|
aes:
|
||||||
secret-key: MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI= # Base64 编码,32字节(256位)
|
secret-key: MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI= # Base64 编码,32字节(256位)
|
||||||
|
|||||||
530
urbanLifelineServ/workcase/Jitsi会议独立页面实现方案.md
Normal file
530
urbanLifelineServ/workcase/Jitsi会议独立页面实现方案.md
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
# 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,否则浏览器可能阻止摄像头/麦克风访问
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.xyzh.workcase.controller;
|
package org.xyzh.workcase.controller;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
@@ -393,6 +394,53 @@ public class WorkcaseChatContorller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "会议入口(支持URL参数token认证,用于小程序和外部链接)")
|
||||||
|
@GetMapping("/meeting/{meetingId}/entry")
|
||||||
|
public ResultDomain<VideoMeetingVO> getMeetingEntry(
|
||||||
|
@PathVariable(value = "meetingId") String meetingId,
|
||||||
|
@RequestParam(value = "token", required = false) String token,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
// 优先从URL参数获取token,其次从Header获取
|
||||||
|
if (token == null || token.trim().isEmpty()) {
|
||||||
|
token = request.getHeader("Authorization");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return videoMeetingService.getMeetingEntryByToken(meetingId, token);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResultDomain.failure(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "生成会议入口URL(用于分享给小程序用户)")
|
||||||
|
@PreAuthorize("hasAuthority('meeting:url:any')")
|
||||||
|
@GetMapping("/meeting/{meetingId}/share-url")
|
||||||
|
public ResultDomain<String> generateMeetingShareUrl(
|
||||||
|
@PathVariable(value = "meetingId") String meetingId,
|
||||||
|
@RequestParam(value = "baseUrl", defaultValue = "") String baseUrl,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
// 如果没有提供baseUrl,则从请求中构建
|
||||||
|
if (baseUrl == null || baseUrl.trim().isEmpty()) {
|
||||||
|
String scheme = request.getScheme();
|
||||||
|
String serverName = request.getServerName();
|
||||||
|
int serverPort = request.getServerPort();
|
||||||
|
String contextPath = request.getContextPath();
|
||||||
|
|
||||||
|
if ((scheme.equals("http") && serverPort == 80) ||
|
||||||
|
(scheme.equals("https") && serverPort == 443)) {
|
||||||
|
baseUrl = scheme + "://" + serverName + contextPath + "/workcase";
|
||||||
|
} else {
|
||||||
|
baseUrl = scheme + "://" + serverName + ":" + serverPort + contextPath + "/workcase";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return videoMeetingService.generateMeetingEntryUrl(meetingId, baseUrl);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResultDomain.failure(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========================= 微信客服消息回调 =========================
|
// ========================= 微信客服消息回调 =========================
|
||||||
|
|
||||||
// @Operation(summary = "微信客服消息回调验证(GET)")
|
// @Operation(summary = "微信客服消息回调验证(GET)")
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package org.xyzh.workcase.service;
|
package org.xyzh.workcase.service;
|
||||||
|
|
||||||
import com.alibaba.fastjson2.JSONObject;
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
|
import org.apache.dubbo.config.annotation.DubboReference;
|
||||||
import org.apache.dubbo.config.annotation.DubboService;
|
import org.apache.dubbo.config.annotation.DubboService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.xyzh.api.auth.service.AuthService;
|
||||||
import org.xyzh.api.workcase.dto.TbChatRoomMemberDTO;
|
import org.xyzh.api.workcase.dto.TbChatRoomMemberDTO;
|
||||||
import org.xyzh.api.workcase.dto.TbChatRoomMessageDTO;
|
import org.xyzh.api.workcase.dto.TbChatRoomMessageDTO;
|
||||||
import org.xyzh.api.workcase.dto.TbVideoMeetingDTO;
|
import org.xyzh.api.workcase.dto.TbVideoMeetingDTO;
|
||||||
@@ -51,6 +53,9 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ChatRoomService chatRoomService;
|
private ChatRoomService chatRoomService;
|
||||||
|
|
||||||
|
@DubboReference(version = "1.0.0", group = "auth", timeout = 30000, retries = 0)
|
||||||
|
private AuthService authService;
|
||||||
|
|
||||||
// 会议创建锁映射表:每个meetingId对应一个ReentrantLock
|
// 会议创建锁映射表:每个meetingId对应一个ReentrantLock
|
||||||
private final ConcurrentHashMap<String, ReentrantLock> meetingLocks = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, ReentrantLock> meetingLocks = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
@@ -326,7 +331,7 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
|||||||
userName = member.getUserName();
|
userName = member.getUserName();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 生成用户专属JWT Token
|
// 6. 生成用户专属JWT Token(用于Jitsi内部认证)
|
||||||
String userJwtToken = jitsiTokenService.generateJwtToken(
|
String userJwtToken = jitsiTokenService.generateJwtToken(
|
||||||
meeting.getJitsiRoomName(),
|
meeting.getJitsiRoomName(),
|
||||||
userId,
|
userId,
|
||||||
@@ -334,16 +339,24 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
|||||||
isModerator
|
isModerator
|
||||||
);
|
);
|
||||||
|
|
||||||
// 7. 构建用户专属iframe URL
|
// 7. 构建真正的Jitsi iframe URL
|
||||||
String userIframeUrl = jitsiTokenService.buildIframeUrl(
|
String jitsiIframeUrl = jitsiTokenService.buildIframeUrl(
|
||||||
meeting.getJitsiRoomName(),
|
meeting.getJitsiRoomName(),
|
||||||
userJwtToken,
|
userJwtToken,
|
||||||
meeting.getConfig()
|
meeting.getConfig()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 8. 更新VO
|
// 8. 构建会议页面URL(用于Web端路由跳转和小程序外部访问)
|
||||||
|
// 获取当前用户的登录token(用于页面token认证)
|
||||||
|
String userToken = LoginUtil.getToken();
|
||||||
|
// 注意:URL不包含/workcase前缀,因为workcase应用的路由base已经是/workcase
|
||||||
|
String meetingPageUrl = "/meeting?meetingId=" + meetingId +
|
||||||
|
"&token=" + (userToken != null ? userToken : "");
|
||||||
|
|
||||||
|
// 9. 更新VO
|
||||||
meeting.setJwtToken(userJwtToken);
|
meeting.setJwtToken(userJwtToken);
|
||||||
meeting.setIframeUrl(userIframeUrl);
|
meeting.setJitsiIframeUrl(jitsiIframeUrl); // 真正的Jitsi URL
|
||||||
|
meeting.setIframeUrl(meetingPageUrl); // 会议页面URL(用于router跳转)
|
||||||
|
|
||||||
logger.info("生成用户专属会议URL成功: meetingId={}, userId={}, status={}",
|
logger.info("生成用户专属会议URL成功: meetingId={}, userId={}, status={}",
|
||||||
meetingId, userId, meeting.getStatus());
|
meetingId, userId, meeting.getStatus());
|
||||||
@@ -534,4 +547,177 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
|
|||||||
// 时间段1的结束时间 > 时间段2的开始时间 AND 时间段1的开始时间 < 时间段2的结束时间
|
// 时间段1的结束时间 > 时间段2的开始时间 AND 时间段1的开始时间 < 时间段2的结束时间
|
||||||
return end1.after(start2) && start1.before(end2);
|
return end1.after(start2) && start1.before(end2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ResultDomain<VideoMeetingVO> getMeetingEntryByToken(String meetingId, String token) {
|
||||||
|
logger.info("通过token获取会议入口: meetingId={}", meetingId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 验证token并获取用户信息
|
||||||
|
if (token == null || token.trim().isEmpty()) {
|
||||||
|
logger.warn("token为空: meetingId={}", meetingId);
|
||||||
|
return ResultDomain.failure("认证token不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去除Bearer前缀(如果有)
|
||||||
|
if (token.startsWith("Bearer ")) {
|
||||||
|
token = token.substring(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
ResultDomain<LoginDomain> loginResult = authService.getLoginByToken(token);
|
||||||
|
if (!loginResult.getSuccess() || loginResult.getData() == null) {
|
||||||
|
logger.warn("token验证失败: meetingId={}, error={}", meetingId, loginResult.getMessage());
|
||||||
|
return ResultDomain.failure("认证失败: " + loginResult.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginDomain loginDomain = loginResult.getData();
|
||||||
|
String userId = loginDomain.getUser().getUserId();
|
||||||
|
logger.info("token验证成功: meetingId={}, userId={}", meetingId, userId);
|
||||||
|
|
||||||
|
// 2. 调用现有的generateUserMeetingUrl方法生成会议URL
|
||||||
|
// 但需要先设置当前登录上下文(因为generateUserMeetingUrl可能依赖LoginUtil)
|
||||||
|
// 这里直接复用generateUserMeetingUrl的核心逻辑
|
||||||
|
|
||||||
|
// 获取会议信息
|
||||||
|
TbVideoMeetingDTO filter = new TbVideoMeetingDTO();
|
||||||
|
filter.setMeetingId(meetingId);
|
||||||
|
List<VideoMeetingVO> meetings = videoMeetingMapper.selectVideoMeetingList(filter);
|
||||||
|
|
||||||
|
if (meetings == null || meetings.isEmpty()) {
|
||||||
|
logger.warn("会议不存在: meetingId={}", meetingId);
|
||||||
|
return ResultDomain.failure("会议不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoMeetingVO meeting = meetings.get(0);
|
||||||
|
|
||||||
|
// 3. 验证访问权限(用户必须是聊天室成员)
|
||||||
|
if (!isMemberOfRoom(meeting.getRoomId(), userId)) {
|
||||||
|
logger.warn("用户无权访问会议: meetingId={}, userId={}", meetingId, userId);
|
||||||
|
return ResultDomain.failure("您无权访问此会议");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 检查会议状态和时间窗口
|
||||||
|
if ("ended".equals(meeting.getStatus())) {
|
||||||
|
logger.warn("会议已结束: meetingId={}", meetingId);
|
||||||
|
return ResultDomain.failure("会议已结束");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("scheduled".equals(meeting.getStatus())) {
|
||||||
|
Date now = new Date();
|
||||||
|
|
||||||
|
// 计算提前入会时间点
|
||||||
|
Calendar calendar = Calendar.getInstance();
|
||||||
|
calendar.setTime(meeting.getStartTime());
|
||||||
|
calendar.add(Calendar.MINUTE, -meeting.getAdvance());
|
||||||
|
Date advanceTime = calendar.getTime();
|
||||||
|
|
||||||
|
if (now.before(advanceTime)) {
|
||||||
|
logger.warn("会议未到入会时间: meetingId={}", meetingId);
|
||||||
|
return ResultDomain.failure("会议未到入会时间,请在 " + advanceTime + " 之后加入");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now.after(meeting.getEndTime())) {
|
||||||
|
logger.warn("会议已过期: meetingId={}", meetingId);
|
||||||
|
return ResultDomain.failure("会议已结束");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次入会时更新会议状态
|
||||||
|
ReentrantLock lock = meetingLocks.computeIfAbsent(meetingId, k -> new ReentrantLock());
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
List<VideoMeetingVO> recheck = videoMeetingMapper.selectVideoMeetingList(filter);
|
||||||
|
if (recheck != null && !recheck.isEmpty() && "scheduled".equals(recheck.get(0).getStatus())) {
|
||||||
|
TbVideoMeetingDTO updateDTO = new TbVideoMeetingDTO();
|
||||||
|
updateDTO.setMeetingId(meetingId);
|
||||||
|
updateDTO.setStatus("ongoing");
|
||||||
|
updateDTO.setActualStartTime(new Date());
|
||||||
|
videoMeetingMapper.updateVideoMeeting(updateDTO);
|
||||||
|
meeting.setStatus("ongoing");
|
||||||
|
meeting.setActualStartTime(new Date());
|
||||||
|
logger.info("会议状态已更新为进行中: meetingId={}", meetingId);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
if (!lock.hasQueuedThreads()) {
|
||||||
|
meetingLocks.remove(meetingId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 获取用户信息
|
||||||
|
TbChatRoomMemberDTO memberFilter = new TbChatRoomMemberDTO();
|
||||||
|
memberFilter.setRoomId(meeting.getRoomId());
|
||||||
|
memberFilter.setUserId(userId);
|
||||||
|
List<ChatMemberVO> members = chatRoomMemberMapper.selectChatRoomMemberList(memberFilter);
|
||||||
|
|
||||||
|
String userName = loginDomain.getUserInfo().getUsername();
|
||||||
|
boolean isModerator = userId.equals(meeting.getCreator());
|
||||||
|
|
||||||
|
if (members != null && !members.isEmpty()) {
|
||||||
|
userName = members.get(0).getUserName();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 生成用户专属JWT Token
|
||||||
|
String userJwtToken = jitsiTokenService.generateJwtToken(
|
||||||
|
meeting.getJitsiRoomName(),
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
isModerator
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. 构建真正的Jitsi iframe URL
|
||||||
|
String jitsiIframeUrl = jitsiTokenService.buildIframeUrl(
|
||||||
|
meeting.getJitsiRoomName(),
|
||||||
|
userJwtToken,
|
||||||
|
meeting.getConfig()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 8. 构建会议页面URL(用于Web端路由跳转和小程序外部访问)
|
||||||
|
// 注意:使用提供的token参数而非LoginUtil.getToken()
|
||||||
|
String meetingPageUrl = "/meeting?meetingId=" + meetingId +
|
||||||
|
"&token=" + token;
|
||||||
|
|
||||||
|
// 9. 更新VO并返回
|
||||||
|
meeting.setJwtToken(userJwtToken);
|
||||||
|
meeting.setJitsiIframeUrl(jitsiIframeUrl); // 真正的Jitsi URL
|
||||||
|
meeting.setIframeUrl(meetingPageUrl); // 会议页面URL(用于router跳转)
|
||||||
|
|
||||||
|
logger.info("通过token获取会议入口成功: meetingId={}, userId={}, isModerator={}",
|
||||||
|
meetingId, userId, isModerator);
|
||||||
|
return ResultDomain.success("获取会议入口成功", meeting);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("通过token获取会议入口异常: meetingId={}, error={}", meetingId, e.getMessage(), e);
|
||||||
|
return ResultDomain.failure("获取会议入口失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResultDomain<String> generateMeetingEntryUrl(String meetingId, String baseUrl) {
|
||||||
|
logger.info("生成会议入口URL: meetingId={}, baseUrl={}", meetingId, baseUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取当前用户token
|
||||||
|
LoginDomain loginDomain = LoginUtil.getCurrentLogin();
|
||||||
|
if (loginDomain == null || loginDomain.getToken() == null) {
|
||||||
|
logger.warn("无法获取当前用户token: meetingId={}", meetingId);
|
||||||
|
return ResultDomain.failure("无法获取当前用户认证信息");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建完整URL: {baseUrl}/meeting/{meetingId}?token={token}
|
||||||
|
String entryUrl = String.format("%s/meeting/%s?token=%s",
|
||||||
|
baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl,
|
||||||
|
meetingId,
|
||||||
|
loginDomain.getToken()
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("会议入口URL生成成功: meetingId={}", meetingId);
|
||||||
|
return ResultDomain.success("生成会议入口URL成功", entryUrl);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("生成会议入口URL异常: meetingId={}, error={}", meetingId, e.getMessage(), e);
|
||||||
|
return ResultDomain.failure("生成会议入口URL失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
import {loadShare} from "@module-federation/runtime";
|
import {loadShare} from "@module-federation/runtime";
|
||||||
const importMap = {
|
const importMap = {
|
||||||
|
|
||||||
|
"axios": async () => {
|
||||||
|
let pkg = await import("__mf__virtual/workcase__prebuild__axios__prebuild__.js");
|
||||||
|
return pkg;
|
||||||
|
}
|
||||||
|
,
|
||||||
"element-plus": async () => {
|
"element-plus": async () => {
|
||||||
let pkg = await import("__mf__virtual/workcase__prebuild__element_mf_2_plus__prebuild__.js");
|
let pkg = await import("__mf__virtual/workcase__prebuild__element_mf_2_plus__prebuild__.js");
|
||||||
return pkg;
|
return pkg;
|
||||||
@@ -22,6 +27,36 @@
|
|||||||
}
|
}
|
||||||
const usedShared = {
|
const usedShared = {
|
||||||
|
|
||||||
|
"axios": {
|
||||||
|
name: "axios",
|
||||||
|
version: "1.13.2",
|
||||||
|
scope: ["default"],
|
||||||
|
loaded: false,
|
||||||
|
from: "workcase",
|
||||||
|
async get () {
|
||||||
|
if (false) {
|
||||||
|
throw new Error(`Shared module '${"axios"}' must be provided by host`);
|
||||||
|
}
|
||||||
|
usedShared["axios"].loaded = true
|
||||||
|
const {"axios": pkgDynamicImport} = importMap
|
||||||
|
const res = await pkgDynamicImport()
|
||||||
|
const exportModule = {...res}
|
||||||
|
// All npm packages pre-built by vite will be converted to esm
|
||||||
|
Object.defineProperty(exportModule, "__esModule", {
|
||||||
|
value: true,
|
||||||
|
enumerable: false
|
||||||
|
})
|
||||||
|
return function () {
|
||||||
|
return exportModule
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shareConfig: {
|
||||||
|
singleton: false,
|
||||||
|
requiredVersion: "^1.13.2",
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
,
|
||||||
"element-plus": {
|
"element-plus": {
|
||||||
name: "element-plus",
|
name: "element-plus",
|
||||||
version: "2.12.0",
|
version: "2.12.0",
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ const router = createRouter({
|
|||||||
let dynamicRoutesLoaded = false
|
let dynamicRoutesLoaded = false
|
||||||
|
|
||||||
// 路由守卫
|
// 路由守卫
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
console.log('[Workcase Router] 路由守卫触发:', {
|
console.log('[Workcase Router] 路由守卫触发:', {
|
||||||
to: to.path,
|
to: to.path,
|
||||||
from: from.path,
|
from: from.path,
|
||||||
meta: to.meta
|
meta: to.meta,
|
||||||
|
query: to.query
|
||||||
})
|
})
|
||||||
|
|
||||||
// 设置页面标题
|
// 设置页面标题
|
||||||
@@ -30,6 +31,44 @@ router.beforeEach((to, from, next) => {
|
|||||||
document.title = `${to.meta.title} - 工单管理系统`
|
document.title = `${to.meta.title} - 工单管理系统`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查URL参数中是否有token(用于外部链接和小程序访问)
|
||||||
|
const tokenParam = to.query.token as string | undefined
|
||||||
|
|
||||||
|
// 如果URL中有token,但localStorage中没有loginDomain,使用refresh接口验证
|
||||||
|
if (tokenParam && !localStorage.getItem('loginDomain')) {
|
||||||
|
console.log('[Workcase Router] 检测到token参数,尝试验证登录状态...')
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/urban-lifeline/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${tokenParam}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const loginDomain = result.data
|
||||||
|
const newToken = loginDomain.token
|
||||||
|
|
||||||
|
// 保存到localStorage
|
||||||
|
localStorage.setItem('token', newToken)
|
||||||
|
localStorage.setItem('loginDomain', JSON.stringify(loginDomain))
|
||||||
|
TokenManager.setToken(newToken)
|
||||||
|
|
||||||
|
console.log('[Workcase Router] Token验证成功,登录状态已保存')
|
||||||
|
} else {
|
||||||
|
console.warn('[Workcase Router] Token验证失败:', result.message)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[Workcase Router] Token验证请求失败:', response.status)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Workcase Router] Token验证异常:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否需要登录
|
// 检查是否需要登录
|
||||||
const requiresAuth = to.meta.requiresAuth !== false
|
const requiresAuth = to.meta.requiresAuth !== false
|
||||||
const hasToken = TokenManager.hasToken()
|
const hasToken = TokenManager.hasToken()
|
||||||
|
|||||||
@@ -249,7 +249,10 @@ export interface VideoMeetingVO extends BaseVO {
|
|||||||
actualEndTime?: string
|
actualEndTime?: string
|
||||||
durationSeconds?: number
|
durationSeconds?: number
|
||||||
durationFormatted?: string
|
durationFormatted?: string
|
||||||
|
/** 会议页面URL(用于路由跳转) */
|
||||||
iframeUrl?: string
|
iframeUrl?: string
|
||||||
|
/** Jitsi真正的iframe URL(用于嵌入播放) */
|
||||||
|
jitsiIframeUrl?: string
|
||||||
config?: Record<string, any>
|
config?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,8 +92,6 @@
|
|||||||
:room-id="currentRoomId"
|
:room-id="currentRoomId"
|
||||||
:workcase-id="currentWorkcaseId"
|
:workcase-id="currentWorkcaseId"
|
||||||
:room-name="currentRoom?.roomName"
|
:room-name="currentRoom?.roomName"
|
||||||
:meeting-url="currentMeetingUrl"
|
|
||||||
:show-meeting="showMeetingIframe"
|
|
||||||
:file-download-url="FILE_DOWNLOAD_URL"
|
:file-download-url="FILE_DOWNLOAD_URL"
|
||||||
:has-more="hasMore"
|
:has-more="hasMore"
|
||||||
:loading-more="loadingMore"
|
:loading-more="loadingMore"
|
||||||
@@ -145,8 +143,13 @@
|
|||||||
title="工单详情"
|
title="工单详情"
|
||||||
width="800px"
|
width="800px"
|
||||||
class="workcase-dialog"
|
class="workcase-dialog"
|
||||||
|
destroy-on-close
|
||||||
>
|
>
|
||||||
<WorkcaseDetail :workcase-id="currentWorkcaseId" />
|
<WorkcaseDetail
|
||||||
|
mode="view"
|
||||||
|
:workcase-id="currentWorkcaseId"
|
||||||
|
@cancel="showWorkcaseDetail = false"
|
||||||
|
/>
|
||||||
</ElDialog>
|
</ElDialog>
|
||||||
|
|
||||||
<!-- 工单创建对话框 -->
|
<!-- 工单创建对话框 -->
|
||||||
@@ -155,9 +158,14 @@
|
|||||||
title="创建工单"
|
title="创建工单"
|
||||||
width="800px"
|
width="800px"
|
||||||
class="workcase-dialog"
|
class="workcase-dialog"
|
||||||
|
destroy-on-close
|
||||||
>
|
>
|
||||||
<!-- TODO: 添加工单创建组件 -->
|
<WorkcaseDetail
|
||||||
<div>工单创建表单</div>
|
mode="create"
|
||||||
|
:room-id="currentRoomId!"
|
||||||
|
@cancel="showWorkcaseCreator = false"
|
||||||
|
@created="onWorkcaseCreated"
|
||||||
|
/>
|
||||||
</ElDialog>
|
</ElDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -247,11 +255,6 @@ const showWorkcaseDetail = ref(false)
|
|||||||
// 工单创建对话框
|
// 工单创建对话框
|
||||||
const showWorkcaseCreator = ref(false)
|
const showWorkcaseCreator = ref(false)
|
||||||
|
|
||||||
// Jitsi Meet会议相关
|
|
||||||
const currentMeetingUrl = ref('')
|
|
||||||
const showMeetingIframe = ref(false)
|
|
||||||
const currentMeetingId = ref<string | null>(null)
|
|
||||||
|
|
||||||
// ChatRoom组件引用
|
// ChatRoom组件引用
|
||||||
const chatRoomRef = ref<InstanceType<typeof ChatRoom> | null>(null)
|
const chatRoomRef = ref<InstanceType<typeof ChatRoom> | null>(null)
|
||||||
|
|
||||||
@@ -508,6 +511,8 @@ const onWorkcaseCreated = (workcaseId: string) => {
|
|||||||
if (currentRoom.value) {
|
if (currentRoom.value) {
|
||||||
currentRoom.value.workcaseId = workcaseId
|
currentRoom.value.workcaseId = workcaseId
|
||||||
}
|
}
|
||||||
|
// 刷新聊天室列表
|
||||||
|
fetchChatRooms()
|
||||||
ElMessage.success('工单创建成功')
|
ElMessage.success('工单创建成功')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,8 +528,9 @@ const startMeeting = async () => {
|
|||||||
currentMeetingId.value = activeResult.data.meetingId!
|
currentMeetingId.value = activeResult.data.meetingId!
|
||||||
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!)
|
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!)
|
||||||
if (joinResult.success && joinResult.data?.iframeUrl) {
|
if (joinResult.success && joinResult.data?.iframeUrl) {
|
||||||
currentMeetingUrl.value = joinResult.data.iframeUrl
|
// 使用router跳转到JitsiMeetingView页面,附加roomId参数用于返回
|
||||||
showMeetingIframe.value = true
|
const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId.value}`
|
||||||
|
router.push(meetingUrl)
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(joinResult.message || '加入会议失败')
|
ElMessage.error(joinResult.message || '加入会议失败')
|
||||||
}
|
}
|
||||||
@@ -543,11 +549,12 @@ const startMeeting = async () => {
|
|||||||
// 开始会议
|
// 开始会议
|
||||||
await workcaseChatAPI.startVideoMeeting(currentMeetingId.value!)
|
await workcaseChatAPI.startVideoMeeting(currentMeetingId.value!)
|
||||||
|
|
||||||
// 加入会议获取iframe URL
|
// 加入会议获取会议页面URL
|
||||||
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!)
|
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!)
|
||||||
if (joinResult.success && joinResult.data?.iframeUrl) {
|
if (joinResult.success && joinResult.data?.iframeUrl) {
|
||||||
currentMeetingUrl.value = joinResult.data.iframeUrl
|
// 使用router跳转到JitsiMeetingView页面,附加roomId参数用于返回
|
||||||
showMeetingIframe.value = true
|
const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId.value}`
|
||||||
|
router.push(meetingUrl)
|
||||||
ElMessage.success('会议已创建')
|
ElMessage.success('会议已创建')
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(joinResult.message || '获取会议链接失败')
|
ElMessage.error(joinResult.message || '获取会议链接失败')
|
||||||
|
|||||||
@@ -79,11 +79,11 @@
|
|||||||
<!-- 发起会议按钮 -->
|
<!-- 发起会议按钮 -->
|
||||||
<button
|
<button
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
:disabled="meetingLoading || showMeeting"
|
:disabled="meetingLoading"
|
||||||
@click="handleStartMeeting"
|
@click="handleStartMeeting"
|
||||||
>
|
>
|
||||||
<Video :size="18" />
|
<Video :size="18" />
|
||||||
{{ showMeeting ? '会议进行中' : '发起会议' }}
|
发起会议
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 额外的操作按钮插槽 -->
|
<!-- 额外的操作按钮插槽 -->
|
||||||
@@ -137,48 +137,20 @@
|
|||||||
:workcase-id="workcaseId || ''"
|
:workcase-id="workcaseId || ''"
|
||||||
@success="handleMeetingCreated"
|
@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 :url="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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, nextTick, onMounted } from 'vue'
|
import { ref, nextTick } from 'vue'
|
||||||
import { FileText, Video, Paperclip, Send, X, Minus } from 'lucide-vue-next'
|
import { useRouter } from 'vue-router'
|
||||||
import IframeView from 'shared/components/iframe/IframeView.vue'
|
import { FileText, Video, Paperclip, Send } from 'lucide-vue-next'
|
||||||
import MeetingCreate from '../MeetingCreate/MeetingCreate.vue'
|
import MeetingCreate from '../MeetingCreate/MeetingCreate.vue'
|
||||||
import MeetingCard from '../MeetingCard/MeetingCard.vue'
|
import MeetingCard from '../MeetingCard/MeetingCard.vue'
|
||||||
import type { ChatRoomMessageVO, VideoMeetingVO } from '@/types/workcase'
|
import type { ChatRoomMessageVO, VideoMeetingVO } from '@/types/workcase'
|
||||||
import { workcaseChatAPI } from '@/api/workcase'
|
import { workcaseChatAPI } from '@/api/workcase'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
messages: ChatRoomMessageVO[]
|
messages: ChatRoomMessageVO[]
|
||||||
@@ -204,37 +176,17 @@ const emit = defineEmits<{
|
|||||||
'send-message': [content: string, files: File[]]
|
'send-message': [content: string, files: File[]]
|
||||||
'download-file': [fileId: string]
|
'download-file': [fileId: string]
|
||||||
'load-more': []
|
'load-more': []
|
||||||
|
'start-meeting': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 会议相关状态
|
// 会议相关状态
|
||||||
const showMeeting = ref(false)
|
|
||||||
const meetingUrl = ref('')
|
|
||||||
const currentMeetingId = ref('')
|
|
||||||
const meetingLoading = ref(false)
|
const meetingLoading = ref(false)
|
||||||
const showMeetingCreate = ref(false)
|
const showMeetingCreate = ref(false)
|
||||||
const meetingMinimized = ref(false)
|
|
||||||
|
|
||||||
// 最小化会议
|
// 打开创建会议对话框或直接emit事件给父组件处理
|
||||||
const minimizeMeeting = () => {
|
|
||||||
meetingMinimized.value = true
|
|
||||||
showMeeting.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 恢复会议窗口
|
|
||||||
const restoreMeeting = () => {
|
|
||||||
meetingMinimized.value = false
|
|
||||||
showMeeting.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 打开创建会议对话框
|
|
||||||
const handleStartMeeting = () => {
|
const handleStartMeeting = () => {
|
||||||
// 先检查是否有活跃会议
|
// emit事件给父组件,让父组件处理会议逻辑
|
||||||
checkActiveMeeting().then(() => {
|
emit('start-meeting')
|
||||||
if (!showMeeting.value) {
|
|
||||||
// 没有活跃会议,打开创建对话框
|
|
||||||
showMeetingCreate.value = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 会议创建成功回调
|
// 会议创建成功回调
|
||||||
@@ -245,40 +197,6 @@ const handleMeetingCreated = async (meetingId: string) => {
|
|||||||
// 会议消息会通过后端发送到聊天室,用户可以点击消息卡片加入
|
// 会议消息会通过后端发送到聊天室,用户可以点击消息卡片加入
|
||||||
}
|
}
|
||||||
|
|
||||||
// 结束会议
|
|
||||||
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 handleScroll = (e: Event) => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
@@ -370,34 +288,36 @@ const formatTime = (time?: string) => {
|
|||||||
// 处理从MeetingCard发出的加入会议事件
|
// 处理从MeetingCard发出的加入会议事件
|
||||||
const handleJoinMeeting = async (meetingId: string) => {
|
const handleJoinMeeting = async (meetingId: string) => {
|
||||||
try {
|
try {
|
||||||
// 调用加入会议接口获取iframe URL
|
meetingLoading.value = true
|
||||||
|
// 调用加入会议接口获取会议页面URL
|
||||||
const joinRes = await workcaseChatAPI.joinVideoMeeting(meetingId)
|
const joinRes = await workcaseChatAPI.joinVideoMeeting(meetingId)
|
||||||
if (joinRes.success && joinRes.data) {
|
if (joinRes.success && joinRes.data) {
|
||||||
// 检查会议状态
|
// 检查会议状态
|
||||||
const meetingData = joinRes.data
|
const meetingData = joinRes.data
|
||||||
if (meetingData.status === 'ended') {
|
if (meetingData.status === 'ended') {
|
||||||
// 会议已结束,提示用户
|
// 会议已结束,提示用户
|
||||||
alert('该会议已结束')
|
ElMessage.warning('该会议已结束')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!meetingData.iframeUrl) {
|
if (!meetingData.iframeUrl) {
|
||||||
console.error('加入会议失败: 未获取到会议地址')
|
console.error('加入会议失败: 未获取到会议地址')
|
||||||
alert('加入会议失败:未获取到会议地址')
|
ElMessage.error('加入会议失败:未获取到会议地址')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
currentMeetingId.value = meetingId
|
// 使用router跳转到JitsiMeetingView页面,附加roomId参数用于返回
|
||||||
meetingUrl.value = meetingData.iframeUrl
|
const meetingUrl = meetingData.iframeUrl + `&roomId=${props.roomId}`
|
||||||
showMeeting.value = true
|
router.push(meetingUrl)
|
||||||
meetingMinimized.value = false
|
|
||||||
} else {
|
} else {
|
||||||
console.error('加入会议失败:', joinRes.message)
|
console.error('加入会议失败:', joinRes.message)
|
||||||
alert(joinRes.message || '加入会议失败')
|
ElMessage.error(joinRes.message || '加入会议失败')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加入会议失败:', error)
|
console.error('加入会议失败:', error)
|
||||||
alert('加入会议失败,请稍后重试')
|
ElMessage.error('加入会议失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
meetingLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,8 +377,7 @@ const renderMarkdown = (text: string): string => {
|
|||||||
// 暴露方法给父组件
|
// 暴露方法给父组件
|
||||||
defineExpose({
|
defineExpose({
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
handleStartMeeting,
|
handleStartMeeting
|
||||||
handleEndMeeting
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
.jitsi-meeting-view {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background: #000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container,
|
||||||
|
.error-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-btn {
|
||||||
|
padding: 10px 30px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #fff;
|
||||||
|
background: #409eff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #66b1ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
// Jitsi External API 会在这个容器内创建 iframe
|
||||||
|
#jitsi-meet-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
<template>
|
||||||
|
<div class="jitsi-meeting-view">
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div v-if="loading" class="loading-container">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<div class="loading-text">正在加载会议...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误提示 -->
|
||||||
|
<div v-else-if="error" class="error-container">
|
||||||
|
<div class="error-icon">⚠️</div>
|
||||||
|
<div class="error-title">无法加入会议</div>
|
||||||
|
<div class="error-message">{{ error }}</div>
|
||||||
|
<button class="error-btn" @click="retryJoin">重新尝试</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jitsi 会议容器 -->
|
||||||
|
<div v-else ref="jitsiContainer" id="jitsi-meet-container" class="meeting-container"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { workcaseChatAPI } from '@/api/workcase'
|
||||||
|
// @ts-ignore
|
||||||
|
import { TokenManager } from 'shared/api'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const meetingId = ref('')
|
||||||
|
const roomId = ref('')
|
||||||
|
const jitsiContainer = ref<HTMLDivElement | null>(null)
|
||||||
|
let jitsiApi: any = null
|
||||||
|
|
||||||
|
// 从URL获取参数
|
||||||
|
const getMeetingParams = () => {
|
||||||
|
// 优先从query参数获取
|
||||||
|
meetingId.value = route.query.meetingId as string || ''
|
||||||
|
roomId.value = route.query.roomId as string || ''
|
||||||
|
const tokenParam = route.query.token as string || ''
|
||||||
|
|
||||||
|
console.log('[JitsiMeetingView] URL参数:', {
|
||||||
|
meetingId: meetingId.value,
|
||||||
|
roomId: roomId.value,
|
||||||
|
hasToken: !!tokenParam
|
||||||
|
})
|
||||||
|
|
||||||
|
return { meetingId: meetingId.value, roomId: roomId.value, token: tokenParam }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用token刷新登录状态
|
||||||
|
const refreshLoginWithToken = async (token: string) => {
|
||||||
|
try {
|
||||||
|
console.log('[JitsiMeetingView] 使用token刷新登录状态...')
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
'/api/urban-lifeline/auth/refresh',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.data?.success && response.data?.data) {
|
||||||
|
const loginDomain = response.data.data
|
||||||
|
const newToken = loginDomain.token
|
||||||
|
|
||||||
|
// 保存到localStorage
|
||||||
|
localStorage.setItem('token', newToken)
|
||||||
|
localStorage.setItem('loginDomain', JSON.stringify(loginDomain))
|
||||||
|
|
||||||
|
// 使用TokenManager设置token
|
||||||
|
TokenManager.setToken(newToken)
|
||||||
|
|
||||||
|
console.log('[JitsiMeetingView] 登录状态刷新成功')
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
console.error('[JitsiMeetingView] 刷新登录失败:', response.data?.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[JitsiMeetingView] 刷新登录异常:', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载 Jitsi External API 脚本
|
||||||
|
const loadJitsiScript = (): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 检查是否已经加载
|
||||||
|
if ((window as any).JitsiMeetExternalAPI) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = 'http://localhost:8280/external_api.js'
|
||||||
|
script.async = true
|
||||||
|
script.onload = () => {
|
||||||
|
console.log('[JitsiMeetingView] Jitsi External API 脚本加载成功')
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
script.onerror = () => {
|
||||||
|
reject(new Error('加载 Jitsi External API 失败'))
|
||||||
|
}
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化 Jitsi Meet
|
||||||
|
const initJitsiMeet = async (jitsiServerUrl: string, roomName: string, jwt: string, displayName: string) => {
|
||||||
|
try {
|
||||||
|
console.log('[JitsiMeetingView] 初始化 Jitsi Meet:', {
|
||||||
|
server: jitsiServerUrl,
|
||||||
|
room: roomName,
|
||||||
|
name: displayName
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载 External API 脚本
|
||||||
|
await loadJitsiScript()
|
||||||
|
|
||||||
|
const JitsiMeetExternalAPI = (window as any).JitsiMeetExternalAPI
|
||||||
|
|
||||||
|
// 解析 URL 获取协议和域名
|
||||||
|
const urlObj = new URL(jitsiServerUrl)
|
||||||
|
const domain = urlObj.host // 获取 host (localhost:8280)
|
||||||
|
const useHttps = urlObj.protocol === 'https:'
|
||||||
|
|
||||||
|
console.log('[JitsiMeetingView] 解析服务器配置:', {
|
||||||
|
domain,
|
||||||
|
protocol: urlObj.protocol,
|
||||||
|
useHttps
|
||||||
|
})
|
||||||
|
|
||||||
|
// 配置选项 - 关键!指定是否使用 HTTPS
|
||||||
|
// 👇 替换你原有的 options 全部代码,保留原有逻辑,仅修改配置项
|
||||||
|
const options: any = {
|
||||||
|
roomName: roomName,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
parentNode: jitsiContainer.value,
|
||||||
|
jwt: jwt,
|
||||||
|
// ✅ 修复1:核心!正确的顶层配置项是 useHTTPS(不是 https),强制关闭HTTPS
|
||||||
|
useHTTPS: false,
|
||||||
|
// ✅ 修复2:禁用WebSocket的HTTPS,彻底阻止wss://请求(局域网必加)
|
||||||
|
useWebSocket: false,
|
||||||
|
configOverwrite: {
|
||||||
|
startWithAudioMuted: false,
|
||||||
|
startWithVideoMuted: false,
|
||||||
|
enableWelcomePage: false,
|
||||||
|
prejoinPageEnabled: false,
|
||||||
|
disableDeepLinking: true,
|
||||||
|
enableChat: true,
|
||||||
|
enableScreenSharing: true,
|
||||||
|
// ✅ 修复3:叠加禁用,彻底阻断API内部的HTTPS强制逻辑(局域网核心)
|
||||||
|
useHTTPS: false,
|
||||||
|
// ✅ 修复4:禁用第三方HTTPS资源请求,避免混合内容报错
|
||||||
|
disableThirdPartyRequests: false,
|
||||||
|
// ✅ 修复5:关闭服务端的HTTPS重定向检测
|
||||||
|
disableHttpsRedirect: true
|
||||||
|
},
|
||||||
|
interfaceConfigOverwrite: {
|
||||||
|
SHOW_JITSI_WATERMARK: false,
|
||||||
|
SHOW_WATERMARK_FOR_GUESTS: false,
|
||||||
|
DISABLE_JOIN_LEAVE_NOTIFICATIONS: false
|
||||||
|
},
|
||||||
|
userInfo: {
|
||||||
|
displayName: displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[JitsiMeetingView] 创建 JitsiMeetExternalAPI 实例,https=' + useHttps)
|
||||||
|
jitsiApi = new JitsiMeetExternalAPI(domain, options)
|
||||||
|
|
||||||
|
// 监听会议准备就绪事件
|
||||||
|
jitsiApi.addEventListener('videoConferenceJoined', (event: any) => {
|
||||||
|
console.log('[JitsiMeetingView] 用户已加入会议:', event)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听用户离开会议事件
|
||||||
|
jitsiApi.addEventListener('videoConferenceLeft', (event: any) => {
|
||||||
|
console.log('[JitsiMeetingView] 用户离开会议:', event)
|
||||||
|
handleLeaveMeeting()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听准备关闭事件
|
||||||
|
jitsiApi.addEventListener('readyToClose', () => {
|
||||||
|
console.log('[JitsiMeetingView] Jitsi 准备关闭')
|
||||||
|
handleLeaveMeeting()
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[JitsiMeetingView] Jitsi Meet 初始化完成')
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[JitsiMeetingView] 初始化 Jitsi Meet 失败:', err)
|
||||||
|
error.value = err.message || '初始化会议失败'
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加入会议
|
||||||
|
const joinMeeting = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
const { meetingId: mid, roomId: rid, token } = getMeetingParams()
|
||||||
|
|
||||||
|
if (!mid) {
|
||||||
|
error.value = '缺少会议ID参数'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有loginDomain
|
||||||
|
const hasLoginDomain = !!localStorage.getItem('loginDomain')
|
||||||
|
const hasToken = !!localStorage.getItem('token') || !!token
|
||||||
|
|
||||||
|
console.log('[JitsiMeetingView] 登录状态检查:', {
|
||||||
|
hasLoginDomain,
|
||||||
|
hasToken
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果没有loginDomain但有token(小程序或外部链接访问),先刷新登录
|
||||||
|
if (!hasLoginDomain && token) {
|
||||||
|
const refreshed = await refreshLoginWithToken(token)
|
||||||
|
if (!refreshed) {
|
||||||
|
error.value = '登录验证失败,请重新获取会议链接'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查登录状态
|
||||||
|
if (!TokenManager.hasToken()) {
|
||||||
|
error.value = '未登录,请先登录'
|
||||||
|
loading.value = false
|
||||||
|
// 重定向到登录页
|
||||||
|
const currentUrl = window.location.href
|
||||||
|
window.location.href = `/login?redirect=${encodeURIComponent(currentUrl)}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用后端接口加入会议
|
||||||
|
console.log('[JitsiMeetingView] 正在加入会议:', mid)
|
||||||
|
const result = await workcaseChatAPI.joinVideoMeeting(mid)
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const meetingData = result.data
|
||||||
|
|
||||||
|
// 检查会议数据
|
||||||
|
if (!meetingData.jitsiServerUrl || !meetingData.jitsiRoomName || !meetingData.jwtToken) {
|
||||||
|
error.value = '会议数据不完整,无法加入'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户名
|
||||||
|
const loginDomain = JSON.parse(localStorage.getItem('loginDomain') || '{}')
|
||||||
|
const displayName = loginDomain.user?.userName || '访客'
|
||||||
|
|
||||||
|
// 先设置 loading 为 false,让容器渲染出来
|
||||||
|
loading.value = false
|
||||||
|
|
||||||
|
// 等待下一个 tick,确保 DOM 已渲染
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// 检查容器是否已经渲染
|
||||||
|
if (!jitsiContainer.value) {
|
||||||
|
error.value = '会议容器未准备好'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 Jitsi External API 初始化会议
|
||||||
|
await initJitsiMeet(
|
||||||
|
meetingData.jitsiServerUrl,
|
||||||
|
meetingData.jitsiRoomName,
|
||||||
|
meetingData.jwtToken,
|
||||||
|
displayName
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
error.value = result.message || '加入会议失败'
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[JitsiMeetingView] 加入会议异常:', err)
|
||||||
|
error.value = err.message || '加入会议失败'
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理离开会议(不结束)
|
||||||
|
const handleLeaveMeeting = () => {
|
||||||
|
console.log('[JitsiMeetingView] 处理离开会议,返回聊天室:', roomId.value)
|
||||||
|
|
||||||
|
// 清理 Jitsi API
|
||||||
|
if (jitsiApi) {
|
||||||
|
jitsiApi.dispose()
|
||||||
|
jitsiApi = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有roomId,返回到对应的聊天室
|
||||||
|
if (roomId.value) {
|
||||||
|
router.push(`/chatRoom?roomId=${roomId.value}`)
|
||||||
|
} else {
|
||||||
|
// 没有roomId,尝试返回上一页或关闭窗口
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
router.back()
|
||||||
|
} else {
|
||||||
|
window.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理结束会议
|
||||||
|
const handleEndMeeting = async () => {
|
||||||
|
try {
|
||||||
|
console.log('[JitsiMeetingView] 处理结束会议:', meetingId.value)
|
||||||
|
|
||||||
|
if (meetingId.value) {
|
||||||
|
// 调用后端接口结束会议
|
||||||
|
const result = await workcaseChatAPI.endVideoMeeting(meetingId.value)
|
||||||
|
console.log('[JitsiMeetingView] 结束会议结果:', result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回上一页或关闭窗口
|
||||||
|
handleLeaveMeeting()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[JitsiMeetingView] 结束会议失败:', err)
|
||||||
|
// 即使失败也返回上一页
|
||||||
|
handleLeaveMeeting()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新尝试加入
|
||||||
|
const retryJoin = () => {
|
||||||
|
joinMeeting()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('[JitsiMeetingView] 组件挂载')
|
||||||
|
joinMeeting()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
console.log('[JitsiMeetingView] 组件卸载')
|
||||||
|
// 清理 Jitsi API
|
||||||
|
if (jitsiApi) {
|
||||||
|
jitsiApi.dispose()
|
||||||
|
jitsiApi = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import url('./JitsiMeetingView.scss')
|
||||||
|
</style>
|
||||||
@@ -51,7 +51,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="table-label">故障类型</div>
|
<div class="table-label">故障类型</div>
|
||||||
<div class="table-value">
|
<div class="table-value">
|
||||||
<ElInput v-if="mode !== 'view'" v-model="formData.type" placeholder="请输入故障类型" size="small" />
|
<ElSelect v-if="mode !== 'view'" v-model="formData.type" placeholder="请选择故障类型" size="small">
|
||||||
|
<ElOption v-for="item in faultTypes" :key="item" :label="item" :value="item" />
|
||||||
|
</ElSelect>
|
||||||
<span v-else>{{ formData.type || '-' }}</span>
|
<span v-else>{{ formData.type || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,22 +143,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 处理记录 (仅查看模式) -->
|
<!-- 处理记录 (仅查看模式) -->
|
||||||
<div class="timeline-section" v-if="mode === 'view' && timeline.length">
|
<div class="timeline-section" v-if="mode === 'view' && processList.length > 0">
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<div class="title-bar"></div>
|
<div class="title-bar"></div>
|
||||||
处理记录
|
处理记录
|
||||||
</div>
|
</div>
|
||||||
<div class="timeline-content">
|
<div class="timeline-content">
|
||||||
<div class="timeline-line"></div>
|
<div v-for="(item, index) in processList" :key="index" class="timeline-item">
|
||||||
<div v-for="(item, index) in timeline" :key="index" class="timeline-item">
|
<div class="timeline-dot" :class="getTimelineDotClass(item.action)"></div>
|
||||||
<div class="timeline-dot" :class="`timeline-dot-${item.status}`"></div>
|
<div class="timeline-line" v-if="index < processList.length - 1"></div>
|
||||||
<div class="timeline-body">
|
<div class="timeline-body">
|
||||||
<div class="timeline-header">
|
<div class="timeline-header">
|
||||||
<span class="timeline-actor">{{ item.title.split(' ')[0] }}</span>
|
<span class="timeline-time">{{ getTime(item.createTime) }}</span>
|
||||||
<span class="timeline-action">{{ item.title.split(' ').slice(1).join(' ') }}</span>
|
<span class="timeline-date">{{ getDate(item.createTime) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-info">
|
||||||
|
<span class="timeline-action">{{ getActionText(item.action) }}:</span>
|
||||||
|
<span class="timeline-desc">{{ item.message }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="timeline-desc">{{ item.desc }}</div>
|
|
||||||
<div class="timeline-time">{{ item.time }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,27 +184,25 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, watch, onMounted } from 'vue'
|
||||||
import { ChatMessage } from '@/views/public/ChatRoom/'
|
import { ChatMessage } from '@/views/public/ChatRoom/'
|
||||||
import { ElButton, ElInput, ElSelect, ElOption, ElDialog, ElMessage } from 'element-plus'
|
import { ElButton, ElInput, ElSelect, ElOption, ElDialog, ElMessage, ElLoading } from 'element-plus'
|
||||||
import { MessageSquare, ImageIcon as ImageIcon, Plus } from 'lucide-vue-next'
|
import { MessageSquare, ImageIcon as ImageIcon, Plus } from 'lucide-vue-next'
|
||||||
import type { TbWorkcaseDTO } from '@/types/workcase/workcase'
|
import type { TbWorkcaseDTO, TbWorkcaseProcessDTO } from '@/types/workcase/workcase'
|
||||||
|
import { workcaseAPI } from '@/api/workcase'
|
||||||
import { FILE_DOWNLOAD_URL } from '@/config'
|
import { FILE_DOWNLOAD_URL } from '@/config'
|
||||||
|
|
||||||
interface TimelineItem {
|
|
||||||
status: 'system' | 'manager' | 'engineer'
|
|
||||||
title: string
|
|
||||||
desc: string
|
|
||||||
time: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode?: 'view' | 'edit' | 'create'
|
mode?: 'view' | 'edit' | 'create'
|
||||||
workcase?: TbWorkcaseDTO
|
workcaseId?: string // 查看/编辑模式传入 workcaseId,组件内部加载数据
|
||||||
|
roomId?: string // 创建模式传入 roomId
|
||||||
|
workcase?: TbWorkcaseDTO // 兼容旧用法,直接传入数据
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
mode: 'view',
|
mode: 'view',
|
||||||
|
workcaseId: '',
|
||||||
|
roomId: '',
|
||||||
workcase: () => ({} as TbWorkcaseDTO)
|
workcase: () => ({} as TbWorkcaseDTO)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -209,22 +211,98 @@ const emit = defineEmits<{
|
|||||||
submit: [data: TbWorkcaseDTO]
|
submit: [data: TbWorkcaseDTO]
|
||||||
assign: [workcaseId: string]
|
assign: [workcaseId: string]
|
||||||
complete: [workcaseId: string]
|
complete: [workcaseId: string]
|
||||||
|
created: [workcaseId: string] // 创建成功事件
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
const formData = ref<TbWorkcaseDTO>({
|
const formData = ref<TbWorkcaseDTO>({
|
||||||
...props.workcase
|
...props.workcase
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 故障类型选项(与微信端保持一致)
|
||||||
|
const faultTypes = ['电气系统故障', '机械故障', '控制系统故障', '油路系统故障', '其他故障']
|
||||||
|
|
||||||
const showChatMessage = ref(false)
|
const showChatMessage = ref(false)
|
||||||
const currentRoomId = ref<string>('')
|
const currentRoomId = ref<string>('')
|
||||||
const timeline = ref<TimelineItem[]>([
|
const processList = ref<TbWorkcaseProcessDTO[]>([])
|
||||||
{
|
|
||||||
status: 'system',
|
// 加载工单详情
|
||||||
title: '系统 工单创建',
|
const loadWorkcaseDetail = async (workcaseId: string) => {
|
||||||
desc: '客户通过小电对话提交',
|
if (!workcaseId) return
|
||||||
time: ''
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await workcaseAPI.getWorkcaseById(workcaseId)
|
||||||
|
if (res.success && res.data) {
|
||||||
|
formData.value = res.data
|
||||||
|
currentRoomId.value = res.data.roomId || ''
|
||||||
|
// 加载处理记录
|
||||||
|
await loadProcessList(workcaseId)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '加载工单详情失败')
|
||||||
}
|
}
|
||||||
])
|
} catch (error) {
|
||||||
|
console.error('加载工单详情失败:', error)
|
||||||
|
ElMessage.error('加载工单详情失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载处理记录
|
||||||
|
const loadProcessList = async (workcaseId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await workcaseAPI.getWorkcaseProcessList({ workcaseId })
|
||||||
|
if (res.success && res.dataList) {
|
||||||
|
processList.value = res.dataList
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载处理记录失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取时间线圆点样式类
|
||||||
|
const getTimelineDotClass = (action?: string): string => {
|
||||||
|
switch (action) {
|
||||||
|
case 'create':
|
||||||
|
case 'info':
|
||||||
|
return 'dot-system'
|
||||||
|
case 'assign':
|
||||||
|
case 'redeploy':
|
||||||
|
return 'dot-manager'
|
||||||
|
case 'finish':
|
||||||
|
return 'dot-engineer'
|
||||||
|
case 'repeal':
|
||||||
|
return 'dot-cancel'
|
||||||
|
default:
|
||||||
|
return 'dot-system'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取动作文本
|
||||||
|
const getActionText = (action?: string): string => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
create: '工单创建',
|
||||||
|
info: '记录信息',
|
||||||
|
assign: '指派工程师',
|
||||||
|
redeploy: '转派工程师',
|
||||||
|
repeal: '撤销工单',
|
||||||
|
finish: '完成工单'
|
||||||
|
}
|
||||||
|
return map[action || ''] || '未知操作'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取时间部分
|
||||||
|
const getTime = (datetime?: string): string => {
|
||||||
|
if (!datetime) return ''
|
||||||
|
return datetime.split(' ')[1] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取日期部分
|
||||||
|
const getDate = (datetime?: string): string => {
|
||||||
|
if (!datetime) return ''
|
||||||
|
return datetime.split(' ')[0] || ''
|
||||||
|
}
|
||||||
|
|
||||||
function getImageUrl(fileId: string): string {
|
function getImageUrl(fileId: string): string {
|
||||||
if (!fileId) return ''
|
if (!fileId) return ''
|
||||||
@@ -234,26 +312,46 @@ function getImageUrl(fileId: string): string {
|
|||||||
return `${FILE_DOWNLOAD_URL}${fileId}`
|
return `${FILE_DOWNLOAD_URL}${fileId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据 workcaseId 获取聊天室ID
|
// 初始化
|
||||||
const loadChatRoom = async () => {
|
onMounted(() => {
|
||||||
if (!formData.value.workcaseId) return
|
if (props.mode === 'view' || props.mode === 'edit') {
|
||||||
|
// 查看/编辑模式:通过 workcaseId 加载数据
|
||||||
try {
|
if (props.workcaseId) {
|
||||||
// TODO: 调用 API 根据 workcaseId 查询聊天室
|
loadWorkcaseDetail(props.workcaseId)
|
||||||
// const res = await workcaseChatAPI.getChatRoomByWorkcaseId(formData.value.workcaseId)
|
} else if (props.workcase?.workcaseId) {
|
||||||
// if (res.success && res.data) {
|
// 兼容旧用法
|
||||||
// currentRoomId.value = res.data.roomId || ''
|
formData.value = { ...props.workcase }
|
||||||
// }
|
currentRoomId.value = props.workcase.roomId || ''
|
||||||
|
if (props.workcase.workcaseId) {
|
||||||
// 临时:假设 roomId 和 workcaseId 相关联
|
loadProcessList(props.workcase.workcaseId)
|
||||||
console.log('需要根据 workcaseId 查询聊天室:', formData.value.workcaseId)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载聊天室失败:', error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (props.mode === 'create') {
|
||||||
|
// 创建模式:初始化空表单,设置 roomId
|
||||||
|
const loginDomain = JSON.parse(localStorage.getItem('loginDomain') || '{}')
|
||||||
|
formData.value = {
|
||||||
|
roomId: props.roomId,
|
||||||
|
username: loginDomain?.userInfo?.username || '',
|
||||||
|
phone: loginDomain?.user?.phone || '',
|
||||||
|
userId: loginDomain?.user?.userId || '',
|
||||||
|
emergency: 'normal',
|
||||||
|
imgs: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 workcaseId 变化
|
||||||
|
watch(() => props.workcaseId, (newVal) => {
|
||||||
|
if (newVal && (props.mode === 'view' || props.mode === 'edit')) {
|
||||||
|
loadWorkcaseDetail(newVal)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 兼容旧用法:监听 workcase prop 变化
|
||||||
watch(() => props.workcase, (newVal) => {
|
watch(() => props.workcase, (newVal) => {
|
||||||
|
if (newVal && !props.workcaseId) {
|
||||||
formData.value = { ...newVal }
|
formData.value = { ...newVal }
|
||||||
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
const statusLabel = (status: string) => {
|
const statusLabel = (status: string) => {
|
||||||
@@ -304,14 +402,56 @@ const handleCancel = () => {
|
|||||||
emit('cancel')
|
emit('cancel')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = async () => {
|
||||||
if (props.mode === 'create' || props.mode === 'edit') {
|
if (props.mode === 'create' || props.mode === 'edit') {
|
||||||
if (!formData.value.username || !formData.value.phone) {
|
// 校验必填项
|
||||||
ElMessage.warning('请填写必填项')
|
if (!formData.value.username) {
|
||||||
|
ElMessage.warning('请输入客户姓名')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!formData.value.phone) {
|
||||||
|
ElMessage.warning('请输入联系电话')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!formData.value.device) {
|
||||||
|
ElMessage.warning('请输入设备名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!formData.value.type) {
|
||||||
|
ElMessage.warning('请选择故障类型')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!formData.value.address) {
|
||||||
|
ElMessage.warning('请输入现场地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!formData.value.description) {
|
||||||
|
ElMessage.warning('请输入故障描述')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.mode === 'create') {
|
||||||
|
// 创建工单
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await workcaseAPI.createWorkcase(formData.value)
|
||||||
|
if (res.success && res.data) {
|
||||||
|
ElMessage.success('工单创建成功')
|
||||||
|
emit('created', res.data.workcaseId!)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.message || '创建失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建工单失败:', error)
|
||||||
|
ElMessage.error('创建工单失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 编辑模式,触发 submit 事件
|
||||||
emit('submit', formData.value)
|
emit('submit', formData.value)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAssign = () => {
|
const handleAssign = () => {
|
||||||
|
|||||||
@@ -559,37 +559,120 @@ async function startMeeting() {
|
|||||||
const res = await workcaseChatAPI.getActiveMeeting(roomId.value)
|
const res = await workcaseChatAPI.getActiveMeeting(roomId.value)
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
// 已有活跃会议,直接加入
|
// 已有活跃会议,直接加入
|
||||||
const meetingUrl = res.data.iframeUrl
|
const meetingPageUrl = res.data.iframeUrl
|
||||||
const meetingId = res.data.meetingId
|
const meetingId = res.data.meetingId
|
||||||
const meetingName = res.data.meetingName || '视频会议'
|
const meetingName = res.data.meetingName || '视频会议'
|
||||||
uni.navigateTo({
|
|
||||||
url: `/pages/meeting/Meeting?meetingUrl=${encodeURIComponent(meetingUrl)}&meetingId=${meetingId}&meetingName=${encodeURIComponent(meetingName)}`,
|
// 构建完整的会议URL(包含域名和workcase路径)
|
||||||
fail: (err) => {
|
const protocol = window.location.protocol
|
||||||
console.error('[chatRoom] 跳转会议页面失败:', err)
|
const host = window.location.host
|
||||||
uni.showToast({ title: '打开会议失败', icon: 'none' })
|
const fullPath = meetingPageUrl.startsWith('/workcase')
|
||||||
|
? meetingPageUrl
|
||||||
|
: '/workcase' + meetingPageUrl
|
||||||
|
// 附加roomId参数,用于离开会议后返回聊天室
|
||||||
|
const fullMeetingUrl = `${protocol}//${host}${fullPath}&roomId=${roomId.value}`
|
||||||
|
|
||||||
|
// 小程序环境:显示提示,引导用户复制链接在浏览器打开
|
||||||
|
uni.showModal({
|
||||||
|
title: '视频会议',
|
||||||
|
content: '微信小程序暂不支持视频会议,请复制链接在浏览器中打开',
|
||||||
|
confirmText: '复制链接',
|
||||||
|
cancelText: '取消',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: fullMeetingUrl,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({
|
||||||
|
title: '链接已复制,请在浏览器中打开',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 3000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('[chatRoom] 无活跃会议,跳转创建页面')
|
console.log('[chatRoom] 无活跃会议')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 没有活跃会议,跳转到创建会议页面
|
// 没有活跃会议,创建新会议
|
||||||
uni.navigateTo({
|
try {
|
||||||
url: `/pages/meeting/MeetingCreate?roomId=${roomId.value}&workcaseId=${workcaseId.value || ''}`,
|
const createRes = await workcaseChatAPI.createMeeting({
|
||||||
fail: (err) => {
|
roomId: roomId.value,
|
||||||
console.error('[chatRoom] 跳转创建会议页面失败:', err)
|
meetingName: roomName.value || '视频会议'
|
||||||
uni.showToast({ title: '打开创建会议页面失败', icon: 'none' })
|
})
|
||||||
|
|
||||||
|
if (createRes.success && createRes.data) {
|
||||||
|
const meetingId = createRes.data.meetingId
|
||||||
|
|
||||||
|
// 开始会议
|
||||||
|
await workcaseChatAPI.startMeeting(meetingId)
|
||||||
|
|
||||||
|
// 加入会议获取会议页面URL
|
||||||
|
const joinRes = await workcaseChatAPI.joinMeeting(meetingId)
|
||||||
|
if (joinRes.success && joinRes.data && joinRes.data.iframeUrl) {
|
||||||
|
const meetingPageUrl = joinRes.data.iframeUrl
|
||||||
|
|
||||||
|
// 构建完整的会议URL(包含域名和workcase路径)
|
||||||
|
const protocol = window.location.protocol
|
||||||
|
const host = window.location.host
|
||||||
|
const fullPath = meetingPageUrl.startsWith('/workcase')
|
||||||
|
? meetingPageUrl
|
||||||
|
: '/workcase' + meetingPageUrl
|
||||||
|
// 附加roomId参数,用于离开会议后返回聊天室
|
||||||
|
const fullMeetingUrl = `${protocol}//${host}${fullPath}&roomId=${roomId.value}`
|
||||||
|
|
||||||
|
// 小程序环境:显示提示,引导用户复制链接在浏览器打开
|
||||||
|
uni.showModal({
|
||||||
|
title: '会议已创建',
|
||||||
|
content: '微信小程序暂不支持视频会议,请复制链接在浏览器中打开',
|
||||||
|
confirmText: '复制链接',
|
||||||
|
cancelText: '取消',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: fullMeetingUrl,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({
|
||||||
|
title: '链接已复制,请在浏览器中打开',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 3000
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
uni.showToast({
|
||||||
|
title: '获取会议链接失败',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
uni.showToast({
|
||||||
|
title: createRes.message || '创建会议失败',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[chatRoom] 创建会议失败:', error)
|
||||||
|
uni.showToast({
|
||||||
|
title: '创建会议失败',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加入会议(从MeetingCard点击加入)
|
// 加入会议(从MeetingCard点击加入)
|
||||||
async function handleJoinMeeting(meetingId: string) {
|
async function handleJoinMeeting(meetingId: string) {
|
||||||
console.log('[handleJoinMeeting] 开始加入会议, meetingId:', meetingId)
|
console.log('[handleJoinMeeting] 开始加入会议, meetingId:', meetingId)
|
||||||
try {
|
try {
|
||||||
// 调用加入会议接口获取iframe URL
|
// 调用加入会议接口获取会议页面URL
|
||||||
const joinRes = await workcaseChatAPI.joinMeeting(meetingId)
|
const joinRes = await workcaseChatAPI.joinMeeting(meetingId)
|
||||||
console.log('[handleJoinMeeting] API响应:', JSON.stringify(joinRes))
|
console.log('[handleJoinMeeting] API响应:', JSON.stringify(joinRes))
|
||||||
|
|
||||||
@@ -598,18 +681,48 @@ async function handleJoinMeeting(meetingId: string) {
|
|||||||
const meetingData = joinRes.data
|
const meetingData = joinRes.data
|
||||||
|
|
||||||
if (isSuccess && meetingData && meetingData.iframeUrl) {
|
if (isSuccess && meetingData && meetingData.iframeUrl) {
|
||||||
const meetingUrl = meetingData.iframeUrl
|
const meetingPageUrl = meetingData.iframeUrl
|
||||||
const meetingName = meetingData.meetingName || '视频会议'
|
const meetingName = meetingData.meetingName || '视频会议'
|
||||||
console.log('[handleJoinMeeting] 获取到会议URL:', meetingUrl, '会议名称:', meetingName)
|
console.log('[handleJoinMeeting] 获取到会议页面URL:', meetingPageUrl, '会议名称:', meetingName)
|
||||||
// 跳转到会议页面
|
|
||||||
uni.navigateTo({
|
// 构建完整的会议URL(包含域名和workcase路径)
|
||||||
url: `/pages/meeting/Meeting?meetingUrl=${encodeURIComponent(meetingUrl)}&meetingId=${meetingId}&meetingName=${encodeURIComponent(meetingName)}`,
|
const protocol = window.location.protocol
|
||||||
|
const host = window.location.host
|
||||||
|
// 如果meetingPageUrl不包含/workcase,需要加上
|
||||||
|
const fullPath = meetingPageUrl.startsWith('/workcase')
|
||||||
|
? meetingPageUrl
|
||||||
|
: '/workcase' + meetingPageUrl
|
||||||
|
// 附加roomId参数,用于离开会议后返回聊天室
|
||||||
|
const fullMeetingUrl = `${protocol}//${host}${fullPath}&roomId=${roomId.value}`
|
||||||
|
|
||||||
|
console.log('[handleJoinMeeting] 完整会议URL:', fullMeetingUrl)
|
||||||
|
|
||||||
|
// 小程序环境:显示提示,引导用户复制链接在浏览器打开
|
||||||
|
uni.showModal({
|
||||||
|
title: '视频会议',
|
||||||
|
content: '微信小程序暂不支持视频会议,请复制链接在浏览器中打开',
|
||||||
|
confirmText: '复制链接',
|
||||||
|
cancelText: '取消',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
// 复制链接到剪贴板
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: fullMeetingUrl,
|
||||||
success: () => {
|
success: () => {
|
||||||
console.log('[handleJoinMeeting] 跳转成功')
|
uni.showToast({
|
||||||
|
title: '链接已复制,请在浏览器中打开',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 3000
|
||||||
|
})
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: () => {
|
||||||
console.error('[handleJoinMeeting] 跳转会议页面失败:', err)
|
uni.showToast({
|
||||||
uni.showToast({ title: '打开会议失败', icon: 'none' })
|
title: '复制失败,请手动复制',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user