创建会议修正

This commit is contained in:
2025-12-27 19:23:33 +08:00
parent 50df8495c7
commit 750c112eac
12 changed files with 448 additions and 343 deletions

View File

@@ -0,0 +1,122 @@
package org.xyzh.workcase.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* @description Jitsi Meet 配置属性类
* @filename JitsiProperties.java
* @author claude
* @copyright xyzh
* @since 2025-12-27
*/
@Configuration
@ConfigurationProperties(prefix = "jitsi")
public class JitsiProperties {
/**
* 应用配置
*/
private App app = new App();
/**
* 服务器配置
*/
private Server server = new Server();
/**
* Token配置
*/
private Token token = new Token();
public App getApp() {
return app;
}
public void setApp(App app) {
this.app = app;
}
public Server getServer() {
return server;
}
public void setServer(Server server) {
this.server = server;
}
public Token getToken() {
return token;
}
public void setToken(Token token) {
this.token = token;
}
/**
* 应用配置
*/
public static class App {
/**
* 应用ID必须与Docker配置中的JWT_APP_ID一致
*/
private String id = "urbanLifeline";
/**
* JWT密钥必须与Docker配置中的JWT_APP_SECRET一致
*/
private String secret = "your-secret-key-change-in-production";
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
}
/**
* 服务器配置
*/
public static class Server {
/**
* Jitsi Meet服务器地址
*/
private String url = "https://meet.jit.si";
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
}
/**
* Token配置
*/
public static class Token {
/**
* JWT Token有效期毫秒- 默认2小时
*/
private Long expiration = 7200000L;
public Long getExpiration() {
return expiration;
}
public void setExpiration(Long expiration) {
this.expiration = expiration;
}
}
}

View File

@@ -7,8 +7,9 @@ import io.jsonwebtoken.SignatureAlgorithm;
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.factory.annotation.Value; import org.springframework.beans.factory.annotation.Autowired;
import org.xyzh.api.workcase.service.JitsiTokenService; import org.xyzh.api.workcase.service.JitsiTokenService;
import org.xyzh.workcase.config.JitsiProperties;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
@@ -25,17 +26,8 @@ import java.util.Map;
public class JitsiTokenServiceImpl implements JitsiTokenService { public class JitsiTokenServiceImpl implements JitsiTokenService {
private static final Logger logger = LoggerFactory.getLogger(JitsiTokenServiceImpl.class); private static final Logger logger = LoggerFactory.getLogger(JitsiTokenServiceImpl.class);
@Value("${jitsi.app.id:urbanLifeline}") @Autowired
private String jitsiAppId; private JitsiProperties jitsiProperties;
@Value("${jitsi.app.secret:your-secret-key-change-in-production}")
private String jitsiAppSecret;
@Value("${jitsi.server.url:https://meet.jit.si}")
private String jitsiServerUrl;
@Value("${jitsi.token.expiration:7200000}")
private Long tokenExpiration; // 默认2小时
@Override @Override
public String generateJwtToken(String roomName, String userId, String userName, boolean isModerator) { public String generateJwtToken(String roomName, String userId, String userName, boolean isModerator) {
@@ -44,7 +36,7 @@ public class JitsiTokenServiceImpl implements JitsiTokenService {
try { try {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
long exp = now + tokenExpiration; long exp = now + jitsiProperties.getToken().getExpiration();
// 构建用户上下文 // 构建用户上下文
Map<String, Object> userContext = new HashMap<>(); Map<String, Object> userContext = new HashMap<>();
@@ -56,9 +48,9 @@ public class JitsiTokenServiceImpl implements JitsiTokenService {
Map<String, Object> claims = new HashMap<>(); Map<String, Object> claims = new HashMap<>();
claims.put("context", Map.of("user", userContext)); claims.put("context", Map.of("user", userContext));
claims.put("room", roomName); claims.put("room", roomName);
claims.put("iss", jitsiAppId); claims.put("iss", jitsiProperties.getApp().getId());
claims.put("aud", "jitsi"); claims.put("aud", "jitsi");
claims.put("sub", jitsiServerUrl); claims.put("sub", jitsiProperties.getServer().getUrl());
claims.put("exp", exp / 1000); // 秒级时间戳 claims.put("exp", exp / 1000); // 秒级时间戳
claims.put("nbf", now / 1000); claims.put("nbf", now / 1000);
@@ -73,7 +65,7 @@ public class JitsiTokenServiceImpl implements JitsiTokenService {
.setClaims(claims) .setClaims(claims)
.setIssuedAt(new Date(now)) .setIssuedAt(new Date(now))
.setExpiration(new Date(exp)) .setExpiration(new Date(exp))
.signWith(SignatureAlgorithm.HS256, jitsiAppSecret.getBytes()) .signWith(SignatureAlgorithm.HS256, jitsiProperties.getApp().getSecret().getBytes())
.compact(); .compact();
logger.info("JWT Token生成成功: roomName={}", roomName); logger.info("JWT Token生成成功: roomName={}", roomName);
@@ -88,7 +80,7 @@ public class JitsiTokenServiceImpl implements JitsiTokenService {
public boolean validateJwtToken(String token) { public boolean validateJwtToken(String token) {
try { try {
Claims claims = Jwts.parser() Claims claims = Jwts.parser()
.setSigningKey(jitsiAppSecret.getBytes()) .setSigningKey(jitsiProperties.getApp().getSecret().getBytes())
.parseClaimsJws(token) .parseClaimsJws(token)
.getBody(); .getBody();
@@ -106,7 +98,7 @@ public class JitsiTokenServiceImpl implements JitsiTokenService {
logger.info("构建Jitsi iframe URL: roomName={}", roomName); logger.info("构建Jitsi iframe URL: roomName={}", roomName);
StringBuilder url = new StringBuilder(); StringBuilder url = new StringBuilder();
url.append(jitsiServerUrl).append("/").append(roomName); url.append(jitsiProperties.getServer().getUrl()).append("/").append(roomName);
// 添加JWT Token // 添加JWT Token
url.append("?jwt=").append(jwtToken); url.append("?jwt=").append(jwtToken);

View File

@@ -21,6 +21,7 @@ import org.xyzh.common.auth.utils.LoginUtil;
import org.xyzh.common.core.domain.LoginDomain; import org.xyzh.common.core.domain.LoginDomain;
import org.xyzh.common.core.domain.ResultDomain; import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.utils.id.IdUtil; import org.xyzh.common.utils.id.IdUtil;
import org.xyzh.workcase.config.JitsiProperties;
import org.xyzh.workcase.mapper.TbChatRoomMemberMapper; import org.xyzh.workcase.mapper.TbChatRoomMemberMapper;
import org.xyzh.workcase.mapper.TbVideoMeetingMapper; import org.xyzh.workcase.mapper.TbVideoMeetingMapper;
@@ -53,6 +54,9 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
@Autowired @Autowired
private ChatRoomService chatRoomService; private ChatRoomService chatRoomService;
@Autowired
private JitsiProperties jitsiProperties;
@DubboReference(version = "1.0.0", group = "auth", timeout = 30000, retries = 0) @DubboReference(version = "1.0.0", group = "auth", timeout = 30000, retries = 0)
private AuthService authService; private AuthService authService;
@@ -132,7 +136,7 @@ public class VideoMeetingServiceImpl implements VideoMeetingService {
if (meetingDTO.getAdvance() == null) { if (meetingDTO.getAdvance() == null) {
meetingDTO.setAdvance(5); // 默认提前5分钟可入会 meetingDTO.setAdvance(5); // 默认提前5分钟可入会
} }
meetingDTO.setJitsiServerUrl(jitsiProperties.getServer().getUrl());
// 6. 插入数据库 // 6. 插入数据库
int rows = videoMeetingMapper.insertVideoMeeting(meetingDTO); int rows = videoMeetingMapper.insertVideoMeeting(meetingDTO);
if (rows > 0) { if (rows > 0) {

View File

@@ -683,8 +683,9 @@ const subscribeToRoom = (roomId: string) => {
roomSubscription = stompClient.subscribe(`/topic/chat/${roomId}`, (message: any) => { roomSubscription = stompClient.subscribe(`/topic/chat/${roomId}`, (message: any) => {
const chatMessage = JSON.parse(message.body) as ChatRoomMessageVO const chatMessage = JSON.parse(message.body) as ChatRoomMessageVO
// 避免重复添加自己发送的消息 // 避免重复添加自己发送的普通消息
if (chatMessage.senderId !== loginDomain.user.userId) { // 但会议消息meet类型始终添加因为它是系统生成的通知
if (chatMessage.messageType === 'meet' || chatMessage.senderId !== loginDomain.user.userId) {
messages.value.push(chatMessage) messages.value.push(chatMessage)
scrollToBottom() scrollToBottom()
} }

View File

@@ -1,6 +1,9 @@
<template> <template>
<!-- 消息会议卡片 --> <!-- 消息会议卡片 -->
<div class="meeting-card" :class="`meeting-card--${meeting.status}`"> <div v-if="loading" class="meeting-card meeting-card--loading">
<div class="meeting-card-loading">加载中...</div>
</div>
<div v-else-if="meeting" class="meeting-card" :class="`meeting-card--${meeting.status}`">
<div class="meeting-card-header"> <div class="meeting-card-header">
<div class="meeting-card-title">{{ meeting.meetingName }}</div> <div class="meeting-card-title">{{ meeting.meetingName }}</div>
<div class="meeting-card-status"> <div class="meeting-card-status">
@@ -28,15 +31,19 @@
</ElButton> </ElButton>
</div> </div>
</div> </div>
<div v-else class="meeting-card meeting-card--error">
<div class="meeting-card-error">会议信息加载失败</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ElButton, ElMessage } from 'element-plus' import { ElButton, ElMessage } from 'element-plus'
import type { VideoMeetingVO } from '@/types' import type { VideoMeetingVO } from '@/types'
import { computed, ref, onMounted, onUnmounted } from 'vue' import { computed, ref, onMounted, onUnmounted } from 'vue'
import { workcaseChatAPI } from '@/api/workcase'
interface Props { interface Props {
meeting: VideoMeetingVO meetingId: string
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -44,20 +51,56 @@ const emit = defineEmits<{
join: [meetingId: string] join: [meetingId: string]
}>() }>()
// 会议详情从API获取的实时数据
const meeting = ref<VideoMeetingVO | null>(null)
const loading = ref(false)
// 当前时间,每秒更新 // 当前时间,每秒更新
const currentTime = ref(Date.now()) const currentTime = ref(Date.now())
let timer: number | null = null let timeTimer: number | null = null
let refreshTimer: number | null = null
// 获取会议详情
async function fetchMeetingInfo() {
if (!props.meetingId) return
try {
loading.value = true
const result = await workcaseChatAPI.getVideoMeetingInfo(props.meetingId)
if (result.success && result.data) {
meeting.value = result.data
console.log('[MeetingCard] 获取会议信息成功:', result.data)
} else {
console.error('[MeetingCard] 获取会议信息失败:', result.message)
}
} catch (error) {
console.error('[MeetingCard] 获取会议信息异常:', error)
} finally {
loading.value = false
}
}
onMounted(() => { onMounted(() => {
// 初始加载会议信息
fetchMeetingInfo()
// 每秒更新当前时间 // 每秒更新当前时间
timer = window.setInterval(() => { timeTimer = window.setInterval(() => {
currentTime.value = Date.now() currentTime.value = Date.now()
}, 1000) }, 1000)
// 每30秒刷新会议状态
refreshTimer = window.setInterval(() => {
fetchMeetingInfo()
}, 30000)
}) })
onUnmounted(() => { onUnmounted(() => {
if (timer) { if (timeTimer) {
clearInterval(timer) clearInterval(timeTimer)
}
if (refreshTimer) {
clearInterval(refreshTimer)
} }
}) })
@@ -78,24 +121,23 @@ function formatDateTime(dateStr?: string): string {
* 计算倒计时文本 * 计算倒计时文本
*/ */
const countdownText = computed(() => { const countdownText = computed(() => {
const { meeting } = props if (!meeting.value || !meeting.value.startTime || !meeting.value.endTime) {
if (!meeting || !meeting.startTime || !meeting.endTime) {
return '' return ''
} }
const advanceMinutes = meeting.advance || 0 const advanceMinutes = meeting.value.advance || 0
const now = currentTime.value const now = currentTime.value
const startTime = new Date(meeting.startTime).getTime() const startTime = new Date(meeting.value.startTime).getTime()
const endTime = new Date(meeting.endTime).getTime() const endTime = new Date(meeting.value.endTime).getTime()
// 检查时间解析是否有效 // 检查时间解析是否有效
if (isNaN(startTime) || isNaN(endTime)) { if (isNaN(startTime) || isNaN(endTime)) {
return '' return ''
} }
const advanceTime = startTime - advanceMinutes * 60 * 1000 const advanceTime = startTime - advanceMinutes * 60 * 1000
if (meeting.status === 'ended') { if (meeting.value.status === 'ended') {
return '会议已结束' return '会议已结束'
} }
@@ -119,7 +161,7 @@ const countdownText = computed(() => {
return '可以入会' return '可以入会'
} else if (now < endTime) { } else if (now < endTime) {
// 会议进行中 // 会议进行中
if (meeting.status === 'ongoing') { if (meeting.value.status === 'ongoing') {
return '会议进行中' return '会议进行中'
} else { } else {
return '可以入会' return '可以入会'
@@ -134,25 +176,24 @@ const countdownText = computed(() => {
* 是否可以加入会议 * 是否可以加入会议
*/ */
const canJoinMeeting = computed(() => { const canJoinMeeting = computed(() => {
const { meeting } = props if (!meeting.value || !meeting.value.startTime || !meeting.value.endTime) {
if (!meeting || !meeting.startTime || !meeting.endTime) {
return false return false
} }
if (meeting.status === 'ended') { if (meeting.value.status === 'ended') {
return false return false
} }
const advanceMinutes = meeting.advance || 0 const advanceMinutes = meeting.value.advance || 0
const now = currentTime.value const now = currentTime.value
const startTime = new Date(meeting.startTime).getTime() const startTime = new Date(meeting.value.startTime).getTime()
const endTime = new Date(meeting.endTime).getTime() const endTime = new Date(meeting.value.endTime).getTime()
// 检查时间解析是否有效 // 检查时间解析是否有效
if (isNaN(startTime) || isNaN(endTime)) { if (isNaN(startTime) || isNaN(endTime)) {
return false return false
} }
const advanceTime = startTime - advanceMinutes * 60 * 1000 const advanceTime = startTime - advanceMinutes * 60 * 1000
// 在允许入会的时间窗口内(提前入会时间 ~ 结束时间) // 在允许入会的时间窗口内(提前入会时间 ~ 结束时间)
@@ -163,8 +204,10 @@ const canJoinMeeting = computed(() => {
* 按钮文本 * 按钮文本
*/ */
const buttonText = computed(() => { const buttonText = computed(() => {
const { meeting } = props if (!meeting.value) {
if (meeting.status === 'ended') { return '加载中'
}
if (meeting.value.status === 'ended') {
return '会议已结束' return '会议已结束'
} }
if (!canJoinMeeting.value) { if (!canJoinMeeting.value) {
@@ -177,7 +220,7 @@ const buttonText = computed(() => {
* 加入会议 * 加入会议
*/ */
async function handleJoinMeeting() { async function handleJoinMeeting() {
if (!props.meeting.meetingId) { if (!props.meetingId) {
ElMessage.error('会议ID不存在') ElMessage.error('会议ID不存在')
return return
} }
@@ -187,9 +230,9 @@ async function handleJoinMeeting() {
} }
// 发出事件让父组件处理 // 发出事件让父组件处理
emit('join', props.meeting.meetingId) emit('join', props.meetingId)
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@import './MeetingCard.scss'; @import url('./MeetingCard.scss');
</style> </style>

View File

@@ -36,7 +36,7 @@
<div class="message-content-wrapper"> <div class="message-content-wrapper">
<!-- 会议消息卡片 --> <!-- 会议消息卡片 -->
<template v-if="message.messageType === 'meet'"> <template v-if="message.messageType === 'meet'">
<MeetingCard :meeting="getMeetingData(message.contentExtra)" @join="handleJoinMeeting" /> <MeetingCard :meetingId="getMeetingId(message.contentExtra)" @join="handleJoinMeeting" />
<div class="message-time">{{ formatTime(message.sendTime) }}</div> <div class="message-time">{{ formatTime(message.sendTime) }}</div>
</template> </template>
@@ -186,7 +186,9 @@ const showMeetingCreate = ref(false)
// 打开创建会议对话框或直接emit事件给父组件处理 // 打开创建会议对话框或直接emit事件给父组件处理
const handleStartMeeting = () => { const handleStartMeeting = () => {
// emit事件给父组件让父组件处理会议逻辑 // emit事件给父组件让父组件处理会议逻辑
emit('start-meeting') // emit('start-meeting')
showMeetingCreate.value = true
} }
// 会议创建成功回调 // 会议创建成功回调
@@ -322,11 +324,13 @@ const handleJoinMeeting = async (meetingId: string) => {
} }
// 获取会议数据将contentExtra转换为VideoMeetingVO // 获取会议数据将contentExtra转换为VideoMeetingVO
function getMeetingData(contentExtra: Record<string, any> | undefined): VideoMeetingVO { // 从消息extra中提取meetingId
if (!contentExtra) { function getMeetingId(contentExtra: Record<string, any> | undefined): string {
return {} as VideoMeetingVO if (!contentExtra || !contentExtra.meetingId) {
console.warn('[ChatRoom] contentExtra中没有meetingId:', contentExtra)
return ''
} }
return contentExtra as VideoMeetingVO return contentExtra.meetingId as string
} }
// Markdown渲染函数 // Markdown渲染函数

View File

@@ -46,7 +46,7 @@
// 显示模式选择器 // 显示模式选择器
showModeSelector() { showModeSelector() {
uni.showActionSheet({ uni.showActionSheet({
itemList: ['员工模式 (17857100375)', '访客模式 (17857100376)'], itemList: ['员工模式 (17857100375)', '访客模式 (17857100377)'],
success: (res) => { success: (res) => {
let wechatId = '' let wechatId = ''
let userMode = '' let userMode = ''
@@ -56,8 +56,8 @@
phone = '17857100375' phone = '17857100375'
userMode = 'staff' userMode = 'staff'
} else { } else {
wechatId = '17857100376' wechatId = '17857100377'
phone = '17857100376' phone = '17857100377'
userMode = 'guest' userMode = 'guest'
} }
// 存储选择 // 存储选择
@@ -73,7 +73,7 @@
fail: () => { fail: () => {
// 用户取消,默认使用访客模式 // 用户取消,默认使用访客模式
uni.setStorageSync('userMode', 'guest') uni.setStorageSync('userMode', 'guest')
uni.setStorageSync('wechatId', '17857100376') uni.setStorageSync('wechatId', '17857100377')
console.log('默认使用访客模式') console.log('默认使用访客模式')
} }
}) })

View File

@@ -41,7 +41,7 @@
} }
}, },
{ {
"path": "pages/meeting/MeetingCreate", "path": "pages/meeting/meetingCreate/MeetingCreate",
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "创建会议" "navigationBarTitleText": "创建会议"

View File

@@ -66,7 +66,7 @@
</view> </view>
<!-- 会议消息卡片 --> <!-- 会议消息卡片 -->
<view class="message-content meeting-card-wrapper" v-if="msg.messageType === 'meet' && msg.contentExtra"> <view class="message-content meeting-card-wrapper" v-if="msg.messageType === 'meet' && msg.contentExtra">
<MeetingCard :meeting="getMeetingData(msg.contentExtra)" @join="handleJoinMeeting" /> <MeetingCard :meetingId="msg.content" @join="handleJoinMeeting" />
<text class="message-time">{{ formatTime(msg.sendTime) }}</text> <text class="message-time">{{ formatTime(msg.sendTime) }}</text>
</view> </view>
<!-- 普通消息 --> <!-- 普通消息 -->
@@ -81,7 +81,7 @@
<view class="message-row self-row" v-else> <view class="message-row self-row" v-else>
<!-- 会议消息卡片 --> <!-- 会议消息卡片 -->
<view class="message-content meeting-card-wrapper" v-if="msg.messageType === 'meet' && msg.contentExtra"> <view class="message-content meeting-card-wrapper" v-if="msg.messageType === 'meet' && msg.contentExtra">
<MeetingCard :meeting="getMeetingData(msg.contentExtra)" @join="handleJoinMeeting" /> <MeetingCard :meetingId="msg.content" @join="handleJoinMeeting" />
<text class="message-time">{{ formatTime(msg.sendTime) }}</text> <text class="message-time">{{ formatTime(msg.sendTime) }}</text>
</view> </view>
<!-- 普通消息 --> <!-- 普通消息 -->
@@ -410,11 +410,13 @@ function formatTime(time?: string): string {
} }
// 获取会议数据将contentExtra转换为VideoMeetingVO // 获取会议数据将contentExtra转换为VideoMeetingVO
function getMeetingData(contentExtra: Record<string, any> | undefined): VideoMeetingVO { // 从消息extra中提取meetingId
if (!contentExtra) { function getMeetingId(contentExtra: Record<string, any> | undefined): string {
return {} as VideoMeetingVO if (!contentExtra || !contentExtra.meetingId) {
console.warn('[chatRoom] contentExtra中没有meetingId:', contentExtra)
return ''
} }
return contentExtra as VideoMeetingVO return contentExtra.meetingId as string
} }
// Markdown渲染函数返回富文本HTML // Markdown渲染函数返回富文本HTML
@@ -553,119 +555,20 @@ function handleWorkcaseAction() {
} }
// 发起会议 - 跳转到会议创建页面 // 发起会议 - 跳转到会议创建页面
async function startMeeting() { function startMeeting() {
// 先检查是否有活跃会议 // 跳转到会议创建页面
try { const url = `/pages/meeting/meetingCreate/MeetingCreate?roomId=${roomId.value}${workcaseId.value ? '&workcaseId=' + workcaseId.value : ''}`
const res = await workcaseChatAPI.getActiveMeeting(roomId.value) console.log('[chatRoom] 跳转会议创建页面:', url)
if (res.success && res.data) { uni.navigateTo({
// 已有活跃会议,直接加入 url: url,
const meetingPageUrl = res.data.iframeUrl fail: (err) => {
const meetingId = res.data.meetingId console.error('[chatRoom] 跳转会议创建页面失败:', err)
const meetingName = res.data.meetingName || '视频会议'
// 构建完整的会议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
})
}
})
}
}
})
return
}
} catch (e) {
console.log('[chatRoom] 无活跃会议')
}
// 没有活跃会议,创建新会议
try {
const createRes = await workcaseChatAPI.createMeeting({
roomId: roomId.value,
meetingName: roomName.value || '视频会议'
})
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({ uni.showToast({
title: createRes.message || '创建会议失败', title: '跳转失败',
icon: 'none' icon: 'none'
}) })
} }
} catch (error) { })
console.error('[chatRoom] 创建会议失败:', error)
uni.showToast({
title: '创建会议失败',
icon: 'none'
})
}
} }
// 加入会议从MeetingCard点击加入 // 加入会议从MeetingCard点击加入
@@ -793,24 +696,25 @@ function disconnectWebSocket() {
// 处理接收到的新消息 // 处理接收到的新消息
function handleNewMessage(message: ChatRoomMessageVO) { function handleNewMessage(message: ChatRoomMessageVO) {
console.log('[chatRoom] 收到新消息:', message) console.log('[chatRoom] 收到新消息:', message)
// 避免重复添加自己发送的消息自己发送的消息已经通过sendMessage添加到列表 // 避免重复添加自己发送的普通消息自己发送的消息已经通过sendMessage添加到列表
if (message.senderId === currentUserId.value) { // 但会议消息meet类型始终添加因为它是系统生成的通知
console.log('[chatRoom] 跳过自己发送的消息') if (message.messageType !== 'meet' && message.senderId === currentUserId.value) {
console.log('[chatRoom] 跳过自己发送的普通消息')
return return
} }
// 检查消息是否已存在(避免重复) // 检查消息是否已存在(避免重复)
const exists = messages.some(m => m.messageId === message.messageId) const exists = messages.some(m => m.messageId === message.messageId)
if (exists) { if (exists) {
console.log('[chatRoom] 消息已存在,跳过') console.log('[chatRoom] 消息已存在,跳过')
return return
} }
// 添加新消息到列表 // 添加新消息到列表
messages.push(message) messages.push(message)
nextTick(() => scrollToBottom()) nextTick(() => scrollToBottom())
// 可以添加消息提示音或震动 // 可以添加消息提示音或震动
// uni.vibrateShort() // uni.vibrateShort()
} }

View File

@@ -1,6 +1,9 @@
<template> <template>
<!-- 消息会议卡片 --> <!-- 消息会议卡片 -->
<view :class="['meeting-card', meeting.status ? `meeting-card--${meeting.status}` : '']"> <view v-if="loading" class="meeting-card meeting-card--loading">
<text class="meeting-card-loading">加载中...</text>
</view>
<view v-else-if="meeting" :class="['meeting-card', meeting.status ? `meeting-card--${meeting.status}` : '']">
<view class="meeting-card-header"> <view class="meeting-card-header">
<view class="meeting-card-title">{{ meeting.meetingName || '未命名会议' }}</view> <view class="meeting-card-title">{{ meeting.meetingName || '未命名会议' }}</view>
<view class="meeting-card-status"> <view class="meeting-card-status">
@@ -29,36 +32,76 @@
</button> </button>
</view> </view>
</view> </view>
<view v-else class="meeting-card meeting-card--error">
<text class="meeting-card-error">会议信息加载失败</text>
</view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { VideoMeetingVO } from '../../../types/workcase/chatRoom' import type { VideoMeetingVO } from '../../../types/workcase/chatRoom'
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { workcaseChatAPI } from '../../../api/workcase'
interface Props { interface Props {
meeting: VideoMeetingVO meetingId: string
} }
const props = defineProps<Props>() const props = defineProps<Props>()
console.log("meeting", JSON.stringify(props.meeting))
const emit = defineEmits<{ const emit = defineEmits<{
join: [meetingId: string] join: [meetingId: string]
}>() }>()
// 会议详情从API获取的实时数据
const meeting = ref<VideoMeetingVO | null>(null)
const loading = ref(false)
// 当前时间,每秒更新 // 当前时间,每秒更新
const currentTime = ref(Date.now()) const currentTime = ref(Date.now())
let timer: ReturnType<typeof setInterval> | null = null let timeTimer: ReturnType<typeof setInterval> | null = null
let refreshTimer: ReturnType<typeof setInterval> | null = null
// 获取会议详情
async function fetchMeetingInfo() {
if (!props.meetingId) return
try {
loading.value = true
const result = await workcaseChatAPI.getMeetingInfo(props.meetingId)
if (result.success && result.data) {
meeting.value = result.data
console.log('[MeetingCard] 获取会议信息成功:', result.data)
} else {
console.error('[MeetingCard] 获取会议信息失败:', result.message)
}
} catch (error) {
console.error('[MeetingCard] 获取会议信息异常:', error)
} finally {
loading.value = false
}
}
onMounted(() => { onMounted(() => {
// 初始加载会议信息
fetchMeetingInfo()
// 每秒更新当前时间 // 每秒更新当前时间
timer = setInterval(() => { timeTimer = setInterval(() => {
currentTime.value = Date.now() currentTime.value = Date.now()
}, 1000) }, 1000)
// 每30秒刷新会议状态
refreshTimer = setInterval(() => {
fetchMeetingInfo()
}, 30000)
}) })
onUnmounted(() => { onUnmounted(() => {
if (timer !== null) { if (timeTimer !== null) {
clearInterval(timer) clearInterval(timeTimer)
timer = null timeTimer = null
}
if (refreshTimer !== null) {
clearInterval(refreshTimer)
refreshTimer = null
} }
}) })
@@ -82,32 +125,26 @@ function formatDateTime(dateStr?: string): string {
* 计算倒计时文本 * 计算倒计时文本
*/ */
const countdownText = computed((): string => { const countdownText = computed((): string => {
const meeting = props.meeting if (!meeting.value || !meeting.value.startTime || !meeting.value.endTime) {
if (!meeting) {
return '' return ''
} }
// 检查必要字段
if (!meeting.startTime || !meeting.endTime) {
return ''
}
// advance 默认为 0 // advance 默认为 0
const advanceMinutes = meeting.advance || 0 const advanceMinutes = meeting.value.advance || 0
const now = currentTime.value const now = currentTime.value
// iOS和小程序兼容性处理 // iOS和小程序兼容性处理
const startTime = new Date(meeting.startTime.replace(' ', 'T')).getTime() const startTime = new Date(meeting.value.startTime.replace(' ', 'T')).getTime()
const endTime = new Date(meeting.endTime.replace(' ', 'T')).getTime() const endTime = new Date(meeting.value.endTime.replace(' ', 'T')).getTime()
// 检查时间解析是否有效 // 检查时间解析是否有效
if (isNaN(startTime) || isNaN(endTime)) { if (isNaN(startTime) || isNaN(endTime)) {
return '' return ''
} }
const advanceTime = startTime - advanceMinutes * 60 * 1000 const advanceTime = startTime - advanceMinutes * 60 * 1000
if (meeting.status === 'ended') { if (meeting.value.status === 'ended') {
return '会议已结束' return '会议已结束'
} }
@@ -131,7 +168,7 @@ const countdownText = computed((): string => {
return '可以入会' return '可以入会'
} else if (now < endTime) { } else if (now < endTime) {
// 会议进行中 // 会议进行中
if (meeting.status === 'ongoing') { if (meeting.value.status === 'ongoing') {
return '会议进行中' return '会议进行中'
} else { } else {
return '可以入会' return '可以入会'
@@ -146,30 +183,25 @@ const countdownText = computed((): string => {
* 是否可以加入会议 * 是否可以加入会议
*/ */
const canJoinMeeting = computed((): boolean => { const canJoinMeeting = computed((): boolean => {
const meeting = props.meeting if (!meeting.value || !meeting.value.startTime || !meeting.value.endTime) {
if (!meeting) {
return false
}
if (!meeting.startTime || !meeting.endTime) {
return false return false
} }
if (meeting.status === 'ended') { if (meeting.value.status === 'ended') {
return false return false
} }
const advanceMinutes = meeting.advance || 0 const advanceMinutes = meeting.value.advance || 0
const now = currentTime.value const now = currentTime.value
// iOS和小程序兼容性处理 // iOS和小程序兼容性处理
const startTime = new Date(meeting.startTime.replace(' ', 'T')).getTime() const startTime = new Date(meeting.value.startTime.replace(' ', 'T')).getTime()
const endTime = new Date(meeting.endTime.replace(' ', 'T')).getTime() const endTime = new Date(meeting.value.endTime.replace(' ', 'T')).getTime()
// 检查时间解析是否有效 // 检查时间解析是否有效
if (isNaN(startTime) || isNaN(endTime)) { if (isNaN(startTime) || isNaN(endTime)) {
return false return false
} }
const advanceTime = startTime - advanceMinutes * 60 * 1000 const advanceTime = startTime - advanceMinutes * 60 * 1000
// 在允许入会的时间窗口内(提前入会时间 ~ 结束时间) // 在允许入会的时间窗口内(提前入会时间 ~ 结束时间)
@@ -180,8 +212,10 @@ const canJoinMeeting = computed((): boolean => {
* 按钮文本 * 按钮文本
*/ */
const buttonText = computed((): string => { const buttonText = computed((): string => {
const meeting = props.meeting if (!meeting.value) {
if (meeting.status === 'ended') { return '加载中'
}
if (meeting.value.status === 'ended') {
return '会议已结束' return '会议已结束'
} }
if (!canJoinMeeting.value) { if (!canJoinMeeting.value) {
@@ -195,12 +229,12 @@ const buttonText = computed((): string => {
*/ */
function handleJoinMeeting() { function handleJoinMeeting() {
console.log('[MeetingCard] handleJoinMeeting 被点击', { console.log('[MeetingCard] handleJoinMeeting 被点击', {
meetingId: props.meeting.meetingId, meetingId: props.meetingId,
canJoin: canJoinMeeting.value, canJoin: canJoinMeeting.value,
meeting: props.meeting meeting: meeting.value
}) })
if (!props.meeting.meetingId) { if (!props.meetingId) {
console.error('[MeetingCard] 会议ID不存在') console.error('[MeetingCard] 会议ID不存在')
uni.showToast({ uni.showToast({
title: '会议ID不存在', title: '会议ID不存在',
@@ -211,14 +245,14 @@ function handleJoinMeeting() {
if (!canJoinMeeting.value) { if (!canJoinMeeting.value) {
console.warn('[MeetingCard] 不允许加入会议', { console.warn('[MeetingCard] 不允许加入会议', {
status: props.meeting.status, status: meeting.value?.status,
canJoin: canJoinMeeting.value canJoin: canJoinMeeting.value
}) })
return return
} }
console.log('[MeetingCard] 触发join事件, meetingId:', props.meeting.meetingId) console.log('[MeetingCard] 触发join事件, meetingId:', props.meetingId)
emit('join', props.meeting.meetingId) emit('join', props.meetingId)
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -0,0 +1,116 @@
.meeting-create-page {
min-height: 100vh;
background-color: #f5f7fa;
padding-bottom: 120rpx;
}
.page-header {
background-color: #fff;
padding: 32rpx;
border-bottom: 1px solid #ebeef5;
}
.page-title {
font-size: 36rpx;
font-weight: 600;
color: #303133;
}
.form-container {
background-color: #fff;
margin-top: 16rpx;
}
.form-item {
padding: 24rpx 32rpx;
border-bottom: 1px solid #ebeef5;
}
.form-item.required .label-text::after {
content: ' *';
color: #f56c6c;
}
.form-label {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.label-text {
font-size: 28rpx;
color: #606266;
font-weight: 500;
}
.required-star {
color: #f56c6c;
margin-left: 8rpx;
}
.form-input {
width: 100%;
padding: 16rpx 24rpx;
border: 1px solid #dcdfe6;
border-radius: 8rpx;
font-size: 28rpx;
color: #303133;
}
.picker-display {
padding: 16rpx 24rpx;
border: 1px solid #dcdfe6;
border-radius: 8rpx;
font-size: 28rpx;
color: #303133;
}
.picker-display .placeholder {
color: #c0c4cc;
}
.form-tip {
margin-top: 8rpx;
}
.form-tip text {
font-size: 24rpx;
color: #909399;
}
.form-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
padding: 24rpx 32rpx;
background-color: #fff;
border-top: 1px solid #ebeef5;
box-shadow: 0 -2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.btn {
flex: 1;
padding: 24rpx 0;
border-radius: 8rpx;
font-size: 32rpx;
text-align: center;
border: none;
}
.btn-cancel {
background-color: #f5f7fa;
color: #606266;
margin-right: 16rpx;
}
.btn-submit {
background-color: #409eff;
color: #fff;
}
.btn-submit[loading] {
opacity: 0.7;
}

View File

@@ -114,8 +114,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { workcaseChatAPI } from '../../api/workcase/workcaseChat' import { workcaseChatAPI } from '../../../api/workcase/workcaseChat'
import type { CreateMeetingParam } from '../../types/workcase/chatRoom' import type { CreateMeetingParam } from '../../../types/workcase/chatRoom'
// 路由参数 // 路由参数
const roomId = ref('') const roomId = ref('')
@@ -328,120 +328,5 @@ function handleCancel() {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.meeting-create-page { @import url('./MeetingCreate.scss')
min-height: 100vh;
background-color: #f5f7fa;
padding-bottom: 120rpx;
}
.page-header {
background-color: #fff;
padding: 32rpx;
border-bottom: 1px solid #ebeef5;
}
.page-title {
font-size: 36rpx;
font-weight: 600;
color: #303133;
}
.form-container {
background-color: #fff;
margin-top: 16rpx;
}
.form-item {
padding: 24rpx 32rpx;
border-bottom: 1px solid #ebeef5;
}
.form-item.required .label-text::after {
content: ' *';
color: #f56c6c;
}
.form-label {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.label-text {
font-size: 28rpx;
color: #606266;
font-weight: 500;
}
.required-star {
color: #f56c6c;
margin-left: 8rpx;
}
.form-input {
width: 100%;
padding: 16rpx 24rpx;
border: 1px solid #dcdfe6;
border-radius: 8rpx;
font-size: 28rpx;
color: #303133;
}
.picker-display {
padding: 16rpx 24rpx;
border: 1px solid #dcdfe6;
border-radius: 8rpx;
font-size: 28rpx;
color: #303133;
}
.picker-display .placeholder {
color: #c0c4cc;
}
.form-tip {
margin-top: 8rpx;
}
.form-tip text {
font-size: 24rpx;
color: #909399;
}
.form-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
padding: 24rpx 32rpx;
background-color: #fff;
border-top: 1px solid #ebeef5;
box-shadow: 0 -2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.btn {
flex: 1;
padding: 24rpx 0;
border-radius: 8rpx;
font-size: 32rpx;
text-align: center;
border: none;
}
.btn-cancel {
background-color: #f5f7fa;
color: #606266;
margin-right: 16rpx;
}
.btn-submit {
background-color: #409eff;
color: #fff;
}
.btn-submit[loading] {
opacity: 0.7;
}
</style> </style>