This commit is contained in:
2025-12-27 15:36:40 +08:00
parent 7c6fbc5ebe
commit 55801fa0ec
17 changed files with 1728 additions and 229 deletions

View File

@@ -4,6 +4,11 @@
import {loadShare} from "@module-federation/runtime";
const importMap = {
"axios": async () => {
let pkg = await import("__mf__virtual/workcase__prebuild__axios__prebuild__.js");
return pkg;
}
,
"element-plus": async () => {
let pkg = await import("__mf__virtual/workcase__prebuild__element_mf_2_plus__prebuild__.js");
return pkg;
@@ -22,6 +27,36 @@
}
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": {
name: "element-plus",
version: "2.12.0",

View File

@@ -18,45 +18,84 @@ const router = createRouter({
let dynamicRoutesLoaded = false
// 路由守卫
router.beforeEach((to, from, next) => {
router.beforeEach(async (to, from, next) => {
console.log('[Workcase Router] 路由守卫触发:', {
to: to.path,
from: from.path,
meta: to.meta
meta: to.meta,
query: to.query
})
// 设置页面标题
if (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 hasToken = TokenManager.hasToken()
console.log('[Workcase Router] 认证检查:', {
requiresAuth,
hasToken,
tokenValue: localStorage.getItem('token')
})
// 其他页面:检查是否需要登录
if (requiresAuth && !hasToken) {
// 需要登录但未登录,重定向到 platform 的登录页
// 重要必须使用完整URL包含origin避免被workcase的路由拦截造成循环
const currentUrl = window.location.href
const origin = window.location.origin
// 构建platform登录页的完整URL
const loginUrl = `${origin}/login?redirect=${encodeURIComponent(currentUrl)}`
console.log('[Workcase Router] 未登录重定向到Platform登录页:', loginUrl)
// 使用完整URL跳转跳出workcase的路由系统
window.location.href = loginUrl
return
}
// 如果已登录且动态路由未加载,先加载动态路由
if (hasToken && !dynamicRoutesLoaded) {
console.log('[Workcase Router] 开始加载动态路由...')
@@ -64,14 +103,14 @@ router.beforeEach((to, from, next) => {
loginDomain: localStorage.getItem('loginDomain'),
token: localStorage.getItem('token')
})
dynamicRoutesLoaded = true
const loaded = loadRoutesFromStorage?.()
console.log('[Workcase Router] 动态路由加载结果:', loaded)
console.log('[Workcase Router] 当前路径:', to.path)
console.log('[Workcase Router] 所有路由:', router.getRoutes().map(r => r.path))
if (loaded) {
if (to.path === '/') {
// 访问根路径,重定向到第一个可用路由
@@ -103,7 +142,7 @@ router.beforeEach((to, from, next) => {
console.warn('[Workcase Router] 动态路由加载失败')
}
}
// 如果已登录且访问根路径,但动态路由已加载,重定向到第一个可用路由
if (hasToken && to.path === '/' && dynamicRoutesLoaded) {
const firstRoute = getFirstAvailableRoute()
@@ -114,7 +153,7 @@ router.beforeEach((to, from, next) => {
return
}
}
// 如果访问 /admin重定向到第一个 admin 路由
if (hasToken && to.path === '/admin' && dynamicRoutesLoaded) {
const firstAdminRoute = getFirstAdminRoute()
@@ -124,7 +163,7 @@ router.beforeEach((to, from, next) => {
return
}
}
console.log('[Workcase Router] 继续正常导航')
next()
})

View File

@@ -240,7 +240,7 @@ export interface VideoMeetingVO extends BaseVO {
participantCount?: number
maxParticipants?: number
// 预定开始时间
startTime?: string
startTime?: string
// 预定结束时间
endTime?: string
// 提前入会时间(分钟)
@@ -249,7 +249,10 @@ export interface VideoMeetingVO extends BaseVO {
actualEndTime?: string
durationSeconds?: number
durationFormatted?: string
/** 会议页面URL用于路由跳转 */
iframeUrl?: string
/** Jitsi真正的iframe URL用于嵌入播放 */
jitsiIframeUrl?: string
config?: Record<string, any>
}

View File

@@ -92,8 +92,6 @@
:room-id="currentRoomId"
:workcase-id="currentWorkcaseId"
:room-name="currentRoom?.roomName"
:meeting-url="currentMeetingUrl"
:show-meeting="showMeetingIframe"
:file-download-url="FILE_DOWNLOAD_URL"
:has-more="hasMore"
:loading-more="loadingMore"
@@ -145,8 +143,13 @@
title="工单详情"
width="800px"
class="workcase-dialog"
destroy-on-close
>
<WorkcaseDetail :workcase-id="currentWorkcaseId" />
<WorkcaseDetail
mode="view"
:workcase-id="currentWorkcaseId"
@cancel="showWorkcaseDetail = false"
/>
</ElDialog>
<!-- 工单创建对话框 -->
@@ -155,9 +158,14 @@
title="创建工单"
width="800px"
class="workcase-dialog"
destroy-on-close
>
<!-- TODO: 添加工单创建组件 -->
<div>工单创建表单</div>
<WorkcaseDetail
mode="create"
:room-id="currentRoomId!"
@cancel="showWorkcaseCreator = false"
@created="onWorkcaseCreated"
/>
</ElDialog>
</div>
</template>
@@ -247,11 +255,6 @@ const showWorkcaseDetail = ref(false)
// 工单创建对话框
const showWorkcaseCreator = ref(false)
// Jitsi Meet会议相关
const currentMeetingUrl = ref('')
const showMeetingIframe = ref(false)
const currentMeetingId = ref<string | null>(null)
// ChatRoom组件引用
const chatRoomRef = ref<InstanceType<typeof ChatRoom> | null>(null)
@@ -508,13 +511,15 @@ const onWorkcaseCreated = (workcaseId: string) => {
if (currentRoom.value) {
currentRoom.value.workcaseId = workcaseId
}
// 刷新聊天室列表
fetchChatRooms()
ElMessage.success('工单创建成功')
}
// 发起会议
const startMeeting = async () => {
if (!currentRoomId.value) return
try {
// 先检查是否有活跃会议
const activeResult = await workcaseChatAPI.getActiveMeeting(currentRoomId.value)
@@ -523,31 +528,33 @@ const startMeeting = async () => {
currentMeetingId.value = activeResult.data.meetingId!
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!)
if (joinResult.success && joinResult.data?.iframeUrl) {
currentMeetingUrl.value = joinResult.data.iframeUrl
showMeetingIframe.value = true
// 使用router跳转到JitsiMeetingView页面附加roomId参数用于返回
const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId.value}`
router.push(meetingUrl)
} else {
ElMessage.error(joinResult.message || '加入会议失败')
}
return
}
// 没有活跃会议,创建新会议
const createResult = await workcaseChatAPI.createVideoMeeting({
roomId: currentRoomId.value,
meetingName: currentRoom.value?.roomName || '视频会议'
})
if (createResult.success && createResult.data) {
currentMeetingId.value = createResult.data.meetingId!
// 开始会议
await workcaseChatAPI.startVideoMeeting(currentMeetingId.value!)
// 加入会议获取iframe URL
// 加入会议获取会议页面URL
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!)
if (joinResult.success && joinResult.data?.iframeUrl) {
currentMeetingUrl.value = joinResult.data.iframeUrl
showMeetingIframe.value = true
// 使用router跳转到JitsiMeetingView页面附加roomId参数用于返回
const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId.value}`
router.push(meetingUrl)
ElMessage.success('会议已创建')
} else {
ElMessage.error(joinResult.message || '获取会议链接失败')

View File

@@ -79,11 +79,11 @@
<!-- 发起会议按钮 -->
<button
class="action-btn"
:disabled="meetingLoading || showMeeting"
:disabled="meetingLoading"
@click="handleStartMeeting"
>
<Video :size="18" />
{{ showMeeting ? '会议进行中' : '发起会议' }}
发起会议
</button>
<!-- 额外的操作按钮插槽 -->
@@ -137,48 +137,20 @@
:workcase-id="workcaseId || ''"
@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>
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted } from 'vue'
import { FileText, Video, Paperclip, Send, X, Minus } from 'lucide-vue-next'
import IframeView from 'shared/components/iframe/IframeView.vue'
import { ref, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { FileText, Video, Paperclip, Send } from 'lucide-vue-next'
import MeetingCreate from '../MeetingCreate/MeetingCreate.vue'
import MeetingCard from '../MeetingCard/MeetingCard.vue'
import type { ChatRoomMessageVO, VideoMeetingVO } from '@/types/workcase'
import { workcaseChatAPI } from '@/api/workcase'
import { ElMessage } from 'element-plus'
const router = useRouter()
interface Props {
messages: ChatRoomMessageVO[]
@@ -204,37 +176,17 @@ const emit = defineEmits<{
'send-message': [content: string, files: File[]]
'download-file': [fileId: string]
'load-more': []
'start-meeting': []
}>()
// 会议相关状态
const showMeeting = ref(false)
const meetingUrl = ref('')
const currentMeetingId = ref('')
const meetingLoading = ref(false)
const showMeetingCreate = ref(false)
const meetingMinimized = ref(false)
// 最小化会议
const minimizeMeeting = () => {
meetingMinimized.value = true
showMeeting.value = false
}
// 恢复会议窗口
const restoreMeeting = () => {
meetingMinimized.value = false
showMeeting.value = true
}
// 打开创建会议对话框
// 打开创建会议对话框或直接emit事件给父组件处理
const handleStartMeeting = () => {
// 先检查是否有活跃会议
checkActiveMeeting().then(() => {
if (!showMeeting.value) {
// 没有活跃会议,打开创建对话框
showMeetingCreate.value = true
}
})
// emit事件给父组件让父组件处理会议逻辑
emit('start-meeting')
}
// 会议创建成功回调
@@ -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 target = e.target as HTMLElement
@@ -370,34 +288,36 @@ const formatTime = (time?: string) => {
// 处理从MeetingCard发出的加入会议事件
const handleJoinMeeting = async (meetingId: string) => {
try {
// 调用加入会议接口获取iframe URL
meetingLoading.value = true
// 调用加入会议接口获取会议页面URL
const joinRes = await workcaseChatAPI.joinVideoMeeting(meetingId)
if (joinRes.success && joinRes.data) {
// 检查会议状态
const meetingData = joinRes.data
if (meetingData.status === 'ended') {
// 会议已结束,提示用户
alert('该会议已结束')
ElMessage.warning('该会议已结束')
return
}
if (!meetingData.iframeUrl) {
console.error('加入会议失败: 未获取到会议地址')
alert('加入会议失败:未获取到会议地址')
ElMessage.error('加入会议失败:未获取到会议地址')
return
}
currentMeetingId.value = meetingId
meetingUrl.value = meetingData.iframeUrl
showMeeting.value = true
meetingMinimized.value = false
// 使用router跳转到JitsiMeetingView页面附加roomId参数用于返回
const meetingUrl = meetingData.iframeUrl + `&roomId=${props.roomId}`
router.push(meetingUrl)
} else {
console.error('加入会议失败:', joinRes.message)
alert(joinRes.message || '加入会议失败')
ElMessage.error(joinRes.message || '加入会议失败')
}
} catch (error) {
console.error('加入会议失败:', error)
alert('加入会议失败,请稍后重试')
ElMessage.error('加入会议失败,请稍后重试')
} finally {
meetingLoading.value = false
}
}
@@ -457,8 +377,7 @@ const renderMarkdown = (text: string): string => {
// 暴露方法给父组件
defineExpose({
scrollToBottom,
handleStartMeeting,
handleEndMeeting
handleStartMeeting
})
</script>

View File

@@ -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%;
}
}

View File

@@ -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>

View File

@@ -51,7 +51,9 @@
</div>
<div class="table-label">故障类型</div>
<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>
</div>
</div>
@@ -141,22 +143,24 @@
</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="title-bar"></div>
处理记录
</div>
<div class="timeline-content">
<div class="timeline-line"></div>
<div v-for="(item, index) in timeline" :key="index" class="timeline-item">
<div class="timeline-dot" :class="`timeline-dot-${item.status}`"></div>
<div v-for="(item, index) in processList" :key="index" class="timeline-item">
<div class="timeline-dot" :class="getTimelineDotClass(item.action)"></div>
<div class="timeline-line" v-if="index < processList.length - 1"></div>
<div class="timeline-body">
<div class="timeline-header">
<span class="timeline-actor">{{ item.title.split(' ')[0] }}</span>
<span class="timeline-action">{{ item.title.split(' ').slice(1).join(' ') }}</span>
<span class="timeline-time">{{ getTime(item.createTime) }}</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 class="timeline-desc">{{ item.desc }}</div>
<div class="timeline-time">{{ item.time }}</div>
</div>
</div>
</div>
@@ -180,27 +184,25 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, watch, onMounted } from 'vue'
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 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'
interface TimelineItem {
status: 'system' | 'manager' | 'engineer'
title: string
desc: string
time: string
}
interface Props {
mode?: 'view' | 'edit' | 'create'
workcase?: TbWorkcaseDTO
workcaseId?: string // 查看/编辑模式传入 workcaseId组件内部加载数据
roomId?: string // 创建模式传入 roomId
workcase?: TbWorkcaseDTO // 兼容旧用法,直接传入数据
}
const props = withDefaults(defineProps<Props>(), {
mode: 'view',
workcaseId: '',
roomId: '',
workcase: () => ({} as TbWorkcaseDTO)
})
@@ -209,22 +211,98 @@ const emit = defineEmits<{
submit: [data: TbWorkcaseDTO]
assign: [workcaseId: string]
complete: [workcaseId: string]
created: [workcaseId: string] // 创建成功事件
}>()
const loading = ref(false)
const formData = ref<TbWorkcaseDTO>({
...props.workcase
})
// 故障类型选项(与微信端保持一致)
const faultTypes = ['电气系统故障', '机械故障', '控制系统故障', '油路系统故障', '其他故障']
const showChatMessage = ref(false)
const currentRoomId = ref<string>('')
const timeline = ref<TimelineItem[]>([
{
status: 'system',
title: '系统 工单创建',
desc: '客户通过小电对话提交',
time: ''
const processList = ref<TbWorkcaseProcessDTO[]>([])
// 加载工单详情
const loadWorkcaseDetail = async (workcaseId: string) => {
if (!workcaseId) return
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 {
if (!fileId) return ''
@@ -234,26 +312,46 @@ function getImageUrl(fileId: string): string {
return `${FILE_DOWNLOAD_URL}${fileId}`
}
// 根据 workcaseId 获取聊天室ID
const loadChatRoom = async () => {
if (!formData.value.workcaseId) return
try {
// TODO: 调用 API 根据 workcaseId 查询聊天室
// const res = await workcaseChatAPI.getChatRoomByWorkcaseId(formData.value.workcaseId)
// if (res.success && res.data) {
// currentRoomId.value = res.data.roomId || ''
// }
// 临时:假设 roomId 和 workcaseId 相关联
console.log('需要根据 workcaseId 查询聊天室:', formData.value.workcaseId)
} catch (error) {
console.error('加载聊天室失败:', error)
// 初始化
onMounted(() => {
if (props.mode === 'view' || props.mode === 'edit') {
// 查看/编辑模式:通过 workcaseId 加载数据
if (props.workcaseId) {
loadWorkcaseDetail(props.workcaseId)
} else if (props.workcase?.workcaseId) {
// 兼容旧用法
formData.value = { ...props.workcase }
currentRoomId.value = props.workcase.roomId || ''
if (props.workcase.workcaseId) {
loadProcessList(props.workcase.workcaseId)
}
}
} 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) => {
formData.value = { ...newVal }
if (newVal && !props.workcaseId) {
formData.value = { ...newVal }
}
}, { deep: true })
const statusLabel = (status: string) => {
@@ -304,13 +402,55 @@ const handleCancel = () => {
emit('cancel')
}
const handleSubmit = () => {
const handleSubmit = async () => {
if (props.mode === 'create' || props.mode === 'edit') {
if (!formData.value.username || !formData.value.phone) {
ElMessage.warning('请填写必填项')
// 校验必填项
if (!formData.value.username) {
ElMessage.warning('请输入客户姓名')
return
}
emit('submit', formData.value)
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)
}
}
}

View File

@@ -559,37 +559,120 @@ async function startMeeting() {
const res = await workcaseChatAPI.getActiveMeeting(roomId.value)
if (res.success && res.data) {
// 已有活跃会议,直接加入
const meetingUrl = res.data.iframeUrl
const meetingPageUrl = res.data.iframeUrl
const meetingId = res.data.meetingId
const meetingName = res.data.meetingName || '视频会议'
uni.navigateTo({
url: `/pages/meeting/Meeting?meetingUrl=${encodeURIComponent(meetingUrl)}&meetingId=${meetingId}&meetingName=${encodeURIComponent(meetingName)}`,
fail: (err) => {
console.error('[chatRoom] 跳转会议页面失败:', err)
uni.showToast({ title: '打开会议失败', icon: 'none' })
// 构建完整的会议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] 无活跃会议,跳转创建页面')
console.log('[chatRoom] 无活跃会议')
}
// 没有活跃会议,跳转到创建会议页面
uni.navigateTo({
url: `/pages/meeting/MeetingCreate?roomId=${roomId.value}&workcaseId=${workcaseId.value || ''}`,
fail: (err) => {
console.error('[chatRoom] 跳转创建会议页面失败:', err)
uni.showToast({ title: '打开创建会议页面失败', icon: 'none' })
// 没有活跃会议,创建会议
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({
title: createRes.message || '创建会议失败',
icon: 'none'
})
}
})
} catch (error) {
console.error('[chatRoom] 创建会议失败:', error)
uni.showToast({
title: '创建会议失败',
icon: 'none'
})
}
}
// 加入会议从MeetingCard点击加入
async function handleJoinMeeting(meetingId: string) {
console.log('[handleJoinMeeting] 开始加入会议, meetingId:', meetingId)
try {
// 调用加入会议接口获取iframe URL
// 调用加入会议接口获取会议页面URL
const joinRes = await workcaseChatAPI.joinMeeting(meetingId)
console.log('[handleJoinMeeting] API响应:', JSON.stringify(joinRes))
@@ -598,18 +681,48 @@ async function handleJoinMeeting(meetingId: string) {
const meetingData = joinRes.data
if (isSuccess && meetingData && meetingData.iframeUrl) {
const meetingUrl = meetingData.iframeUrl
const meetingPageUrl = meetingData.iframeUrl
const meetingName = meetingData.meetingName || '视频会议'
console.log('[handleJoinMeeting] 获取到会议URL:', meetingUrl, '会议名称:', meetingName)
// 跳转到会议页面
uni.navigateTo({
url: `/pages/meeting/Meeting?meetingUrl=${encodeURIComponent(meetingUrl)}&meetingId=${meetingId}&meetingName=${encodeURIComponent(meetingName)}`,
success: () => {
console.log('[handleJoinMeeting] 跳转成功')
},
fail: (err) => {
console.error('[handleJoinMeeting] 跳转会议页面失败:', err)
uni.showToast({ title: '打开会议失败', icon: 'none' })
console.log('[handleJoinMeeting] 获取到会议页面URL:', meetingPageUrl, '会议名称:', meetingName)
// 构建完整的会议URL包含域名和workcase路径
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: () => {
uni.showToast({
title: '链接已复制,请在浏览器中打开',
icon: 'none',
duration: 3000
})
},
fail: () => {
uni.showToast({
title: '复制失败,请手动复制',
icon: 'none'
})
}
})
}
}
})
} else {