temp jitsi

This commit is contained in:
2025-12-26 10:37:52 +08:00
parent e39dc03f92
commit c2b37503fc
22 changed files with 1710 additions and 416 deletions

View File

@@ -0,0 +1,79 @@
import { http } from '@/utils/http'
/**
* 创建视频会议参数
*/
export interface CreateMeetingParams {
roomId: string
workcaseId?: string
meetingName: string
maxParticipants?: number
}
/**
* 视频会议VO
*/
export interface VideoMeetingVO {
meetingId: string
roomId: string
workcaseId?: string
meetingName: string
meetingPassword?: string
jwtToken: string
jitsiRoomName: string
jitsiServerUrl: string
status: string
creatorId: string
creatorType: string
creatorName: string
participantCount: number
maxParticipants: number
actualStartTime?: string
actualEndTime?: string
durationSeconds?: number
durationFormatted?: string
iframeUrl: string
config?: any
}
/**
* 创建视频会议
*/
export const createVideoMeeting = (params: CreateMeetingParams) => {
return http.post<VideoMeetingVO>('/workcase/chat/meeting/create', params)
}
/**
* 获取会议信息
*/
export const getMeetingInfo = (meetingId: string) => {
return http.get<VideoMeetingVO>(`/workcase/chat/meeting/${meetingId}`)
}
/**
* 获取聊天室活跃会议
*/
export const getActiveMeeting = (roomId: string) => {
return http.get<VideoMeetingVO>(`/workcase/chat/meeting/room/${roomId}/active`)
}
/**
* 加入会议生成用户专属JWT
*/
export const joinMeeting = (meetingId: string) => {
return http.post<VideoMeetingVO>(`/workcase/chat/meeting/${meetingId}/join`)
}
/**
* 开始会议
*/
export const startVideoMeeting = (meetingId: string) => {
return http.post(`/workcase/chat/meeting/${meetingId}/start`)
}
/**
* 结束会议
*/
export const endVideoMeeting = (meetingId: string) => {
return http.post<VideoMeetingVO>(`/workcase/chat/meeting/${meetingId}/end`)
}

View File

@@ -47,18 +47,59 @@ $brand-color-hover: #004488;
// ==================== Jitsi Meet会议容器 ====================
.meeting-container {
position: sticky;
top: 0;
z-index: 10;
height: 400px;
background: #000;
border-bottom: 2px solid $brand-color;
width: 100%;
height: 500px;
margin-bottom: 16px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e5e7eb;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
iframe {
width: 100%;
height: 100%;
.meeting-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 500;
font-size: 14px;
}
.close-meeting-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
}
.meeting-iframe {
width: 100%;
height: calc(100% - 48px);
border: none;
}
}
// 按钮禁用状态
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
&:hover {
border-color: #e2e8f0;
color: #64748b;
background: transparent;
}
}

View File

@@ -17,7 +17,14 @@
<!-- Jitsi Meet会议iframe -->
<div v-if="showMeeting && meetingUrl" class="meeting-container">
<IframeView :src="meetingUrl" />
<div class="meeting-header">
<span>视频会议进行中</span>
<button class="close-meeting-btn" @click="handleEndMeeting">
<X :size="20" />
结束会议
</button>
</div>
<IframeView :src="meetingUrl" class="meeting-iframe" />
</div>
<!-- 聊天消息列表 -->
@@ -72,12 +79,16 @@
<footer class="input-area">
<!-- 操作按钮区域 -->
<div class="action-buttons">
<!-- 发起会议按钮始终显示 -->
<button class="action-btn" @click="$emit('start-meeting')">
<!-- 发起会议按钮 -->
<button
class="action-btn"
:disabled="meetingLoading || showMeeting"
@click="handleStartMeeting"
>
<Video :size="18" />
发起会议
{{ showMeeting ? '会议进行中' : '发起会议' }}
</button>
<!-- 额外的操作按钮插槽 -->
<slot name="action-area"></slot>
</div>
@@ -125,17 +136,18 @@
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { FileText, Video, Paperclip, Send } from 'lucide-vue-next'
import { ref, nextTick, onMounted } from 'vue'
import { FileText, Video, Paperclip, Send, X } from 'lucide-vue-next'
import IframeView from 'shared/components/iframe/IframeView.vue'
import type { ChatRoomMessageVO } from '@/types/workcase'
import { createVideoMeeting, getActiveMeeting, endVideoMeeting } from '@/api/workcase/meeting'
interface Props {
messages: ChatRoomMessageVO[]
currentUserId: string
roomId: string
roomName?: string
meetingUrl?: string
showMeeting?: boolean
workcaseId?: string
fileDownloadUrl?: string
hasMore?: boolean
loadingMore?: boolean
@@ -143,7 +155,6 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
roomName: '聊天室',
showMeeting: false,
fileDownloadUrl: '',
hasMore: true,
loadingMore: false
@@ -153,11 +164,76 @@ const FILE_DOWNLOAD_URL = props.fileDownloadUrl
const emit = defineEmits<{
'send-message': [content: string, files: File[]]
'start-meeting': []
'download-file': [fileId: string]
'load-more': []
}>()
// 会议相关状态
const showMeeting = ref(false)
const meetingUrl = ref('')
const currentMeetingId = ref('')
const meetingLoading = ref(false)
// 创建并加入会议
const handleStartMeeting = async () => {
try {
meetingLoading.value = true
// 创建会议
const createRes = await createVideoMeeting({
roomId: props.roomId,
workcaseId: props.workcaseId,
meetingName: `工单 ${props.workcaseId || props.roomId} 技术支持`,
maxParticipants: 10
})
if (createRes.code === 0 && createRes.data) {
currentMeetingId.value = createRes.data.meetingId
meetingUrl.value = createRes.data.iframeUrl
showMeeting.value = true
} else {
console.error('创建会议失败:', createRes.message)
}
} catch (error) {
console.error('创建会议异常:', error)
} finally {
meetingLoading.value = false
}
}
// 结束会议
const handleEndMeeting = async () => {
if (!currentMeetingId.value) return
try {
await endVideoMeeting(currentMeetingId.value)
showMeeting.value = false
meetingUrl.value = ''
currentMeetingId.value = ''
} catch (error) {
console.error('结束会议失败:', error)
}
}
// 检查是否有活跃会议
const checkActiveMeeting = async () => {
try {
const res = await 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 target = e.target as HTMLElement
@@ -293,7 +369,9 @@ const renderMarkdown = (text: string): string => {
// 暴露方法给父组件
defineExpose({
scrollToBottom
scrollToBottom,
handleStartMeeting,
handleEndMeeting
})
</script>

View File

@@ -7,7 +7,7 @@ export interface TbWorkcaseDTO extends BaseDTO {
/** 工单ID */
workcaseId?: string
/** 聊天室ID */
roomId: string
roomId?: string
/** 来客ID */
userId?: string
/** 来客姓名 */
@@ -21,7 +21,7 @@ export interface TbWorkcaseDTO extends BaseDTO {
/** 设备代码 */
deviceCode?: string
deviceNamePlate?: string
deviceNamePlateImg: string
deviceNamePlateImg?: string
/** 地址 */
address?: string
/** 故障描述 */

View File

@@ -148,6 +148,16 @@
<el-button type="primary" @click="createTicket">创建</el-button>
</template>
</el-dialog>
<!-- 工单详情弹窗 -->
<el-dialog v-model="showDetailDialog" title="工单详情" width="900px" destroy-on-close>
<WorkcaseDetail
v-if="showDetailDialog"
:workcase="currentWorkcase"
mode="view"
@cancel="showDetailDialog = false"
/>
</el-dialog>
</AdminLayout>
</template>
@@ -157,8 +167,9 @@ import AdminLayout from '@/views/admin/AdminLayout.vue'
import { Plus, Search } from 'lucide-vue-next'
import { ElMessage, ElMessageBox } from 'element-plus'
import { workcaseAPI } from '@/api/workcase'
import WorkcaseDetail from '@/views/public/workcase/WorkcaseDetail/WorkcaseDetail.vue'
import type { TbWorkcaseDTO, TbWorkcaseProcessDTO } from '@/types/workcase'
import type { PageRequest, PageParam, ResultDomain } from 'shared/types'
import type { PageRequest, PageParam } from 'shared/types'
const statusFilter = ref('all')
const typeFilter = ref('')
@@ -168,11 +179,11 @@ const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const showCreateDialog = ref(false)
const showDetailDialog = ref(false)
const currentWorkcase = ref<TbWorkcaseDTO>({})
const loading = ref(false)
const formData = ref<TbWorkcaseDTO>({
roomId: '',
deviceNamePlateImg: '',
username: '',
phone: '',
device: '',
@@ -215,7 +226,7 @@ const loadWorkcases = async () => {
const res = await workcaseAPI.getWorkcasePage(pageRequest)
if (res.success) {
workcaseList.value = res.dataList || res.pageDomain?.dataList || []
total.value = res.pageParam?.totalElements || 0
total.value = res.pageDomain?.pageParam?.total || 0
} else {
ElMessage.error(res.message || '加载失败')
}
@@ -365,8 +376,8 @@ const handlePageChange = (page: number) => {
}
const viewDetail = (row: TbWorkcaseDTO) => {
ElMessage.info(`查看工单详情: ${row.workcaseId}`)
// TODO: 跳转到工单详情页面
currentWorkcase.value = { ...row }
showDetailDialog.value = true
}
const assignTicket = (row: TbWorkcaseDTO) => {

View File

@@ -95,7 +95,7 @@
<div class="table-label">铭牌照片</div>
<div class="table-value table-value-full">
<div class="nameplate-photo" @click="previewNameplateImage">
<img :src="formData.deviceNamePlateImg" alt="设备铭牌" />
<img :src="getImageUrl(formData.deviceNamePlateImg)" alt="设备铭牌" />
</div>
</div>
</div>
@@ -132,7 +132,7 @@
</div>
<div class="photos-grid">
<div v-for="(img, index) in formData.imgs" :key="index" class="photo-item">
<img :src="img" alt="故障照片" />
<img :src="getImageUrl(img)" alt="故障照片" />
</div>
<div v-if="mode !== 'view'" class="photo-upload">
<Plus :size="32" />
@@ -185,6 +185,7 @@ import { ChatMessage } from '@/views/public/ChatRoom/'
import { ElButton, ElInput, ElSelect, ElOption, ElDialog, ElMessage } from 'element-plus'
import { MessageSquare, ImageIcon as ImageIcon, Plus } from 'lucide-vue-next'
import type { TbWorkcaseDTO } from '@/types/workcase/workcase'
import { FILE_DOWNLOAD_URL } from '@/config'
interface TimelineItem {
status: 'system' | 'manager' | 'engineer'
@@ -225,6 +226,14 @@ const timeline = ref<TimelineItem[]>([
}
])
function getImageUrl(fileId: string): string {
if (!fileId) return ''
if (fileId.startsWith('http://') || fileId.startsWith('https://')) {
return fileId
}
return `${FILE_DOWNLOAD_URL}${fileId}`
}
// 根据 workcaseId 获取聊天室ID
const loadChatRoom = async () => {
if (!formData.value.workcaseId) return