视频会议保存
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
* - 响应中的 DTO 字段命名与后端 DTO 完全一致(下划线),前端按需再转换
|
||||
*/
|
||||
|
||||
import { get, post } from '@/utils/request'
|
||||
import { get, post, del } from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 统一拆包:utils/request.js 返回完整 envelope { code, message, data, trace_id, time },
|
||||
@@ -145,6 +145,36 @@ const listChats = (roomCode, params = {}) => {
|
||||
return unwrap(get(`/api/v1/meeting/rooms/${roomCode}/chats`, params))
|
||||
}
|
||||
|
||||
// ==================== 会议录制(Phase B 新增,3 接口) ====================
|
||||
|
||||
/**
|
||||
* 启动录制(仅 host)
|
||||
* @param {string} roomCode
|
||||
* @returns {Promise<MeetingRecordingDTO>}
|
||||
*/
|
||||
const startRecording = (roomCode) => {
|
||||
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/recordings`))
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录制(仅 host)
|
||||
* @param {string} roomCode
|
||||
* @param {number|string} recordingID
|
||||
* @returns {Promise<MeetingRecordingDTO>}
|
||||
*/
|
||||
const stopRecording = (roomCode, recordingID) => {
|
||||
return unwrap(del(`/api/v1/meeting/rooms/${roomCode}/recordings/${recordingID}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询录制列表(host 或参与者均可)
|
||||
* @param {string} roomCode
|
||||
* @returns {Promise<{ list: MeetingRecordingDTO[] }>}
|
||||
*/
|
||||
const listRecordings = (roomCode) => {
|
||||
return unwrap(get(`/api/v1/meeting/rooms/${roomCode}/recordings`))
|
||||
}
|
||||
|
||||
export default {
|
||||
createRoom,
|
||||
getRoom,
|
||||
@@ -157,5 +187,8 @@ export default {
|
||||
inviteUsers,
|
||||
redeemInvite,
|
||||
sendChat,
|
||||
listChats
|
||||
listChats,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
listRecordings
|
||||
}
|
||||
|
||||
@@ -45,6 +45,54 @@
|
||||
<text class="label">{{ videoEnabled ? '停止视频' : '开启视频' }}</text>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn"
|
||||
:class="{ active: screenSharing, loading: screenLoading }"
|
||||
:disabled="screenLoading || screenDisabled"
|
||||
:title="screenDisabledReason || ''"
|
||||
@click="emitIf('screen-toggle')"
|
||||
>
|
||||
<view class="icon">
|
||||
<svg v-if="screenSharing" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- 共享中:实心显示器 + 停止图示 -->
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<line x1="8" y1="21" x2="16" y2="21"></line>
|
||||
<line x1="12" y1="17" x2="12" y2="21"></line>
|
||||
<line x1="6" y1="7" x2="18" y2="13"></line>
|
||||
<line x1="18" y1="7" x2="6" y2="13"></line>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- 未共享:空显示器 + 上箭头 -->
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<line x1="8" y1="21" x2="16" y2="21"></line>
|
||||
<line x1="12" y1="17" x2="12" y2="21"></line>
|
||||
<polyline points="9 10 12 7 15 10"></polyline>
|
||||
<line x1="12" y1="7" x2="12" y2="14"></line>
|
||||
</svg>
|
||||
</view>
|
||||
<text class="label">{{ screenSharing ? '停止共享' : '共享屏幕' }}</text>
|
||||
</button>
|
||||
|
||||
<!-- 录制按钮(Phase B):仅主持人可见;recording=true 时红色脉动;上传中禁用 -->
|
||||
<button
|
||||
v-if="showRecording"
|
||||
class="btn btn-recording"
|
||||
:class="{ active: recording, 'is-recording': recording, loading: recordingLoading }"
|
||||
:disabled="recordingLoading"
|
||||
@click="emitIf('recording-toggle')"
|
||||
>
|
||||
<view class="icon">
|
||||
<svg v-if="recording" viewBox="0 0 24 24" width="22" height="22" fill="currentColor" stroke="none">
|
||||
<circle cx="12" cy="12" r="6"></circle>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="7"></circle>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
</view>
|
||||
<text class="label">{{ recording ? '停止录制' : '开始录制' }}</text>
|
||||
</button>
|
||||
|
||||
<button class="btn" @click="emitIf('invite')">
|
||||
<view class="icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -102,15 +150,31 @@ const props = defineProps({
|
||||
unreadChatCount: { type: Number, default: 0 },
|
||||
allowChat: { type: Boolean, default: true },
|
||||
/** Task 15 原创特色 4:全员静音氛围色 */
|
||||
allMuted: { type: Boolean, default: false }
|
||||
allMuted: { type: Boolean, default: false },
|
||||
/** Phase 3 屏幕共享:当前用户是否在共享屏幕(决定按钮文案/高亮) */
|
||||
screenSharing: { type: Boolean, default: false },
|
||||
/** 屏幕共享操作中(getDisplayMedia / produce / closeProducer 同步等待) */
|
||||
screenLoading: { type: Boolean, default: false },
|
||||
/** 是否禁用屏幕共享按钮(有他人在共享 / 平台不支持等场景) */
|
||||
screenDisabled: { type: Boolean, default: false },
|
||||
/** 屏幕共享按钮禁用原因(hover 提示文案) */
|
||||
screenDisabledReason: { type: String, default: '' },
|
||||
/** Phase B 录制:是否显示录制按钮(仅 host 显示) */
|
||||
showRecording: { type: Boolean, default: false },
|
||||
/** Phase B 录制:当前是否在录制中 */
|
||||
recording: { type: Boolean, default: false },
|
||||
/** Phase B 录制:REST 调用 / 上传中 loading 态,禁用按钮 */
|
||||
recordingLoading: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['mic-toggle', 'cam-toggle', 'invite', 'members', 'chat', 'leave'])
|
||||
const emit = defineEmits(['mic-toggle', 'cam-toggle', 'screen-toggle', 'recording-toggle', 'invite', 'members', 'chat', 'leave'])
|
||||
|
||||
/** 统一过滤 loading 态,避免重复点击 */
|
||||
const emitIf = (name) => {
|
||||
if (name === 'mic-toggle' && props.audioLoading) return
|
||||
if (name === 'cam-toggle' && props.videoLoading) return
|
||||
if (name === 'screen-toggle' && (props.screenLoading || props.screenDisabled)) return
|
||||
if (name === 'recording-toggle' && props.recordingLoading) return
|
||||
emit(name)
|
||||
}
|
||||
</script>
|
||||
@@ -191,6 +255,16 @@ const emitIf = (name) => {
|
||||
.btn-leave .icon { background: #EF4444; }
|
||||
.btn-leave:hover .icon { background: #DC2626; }
|
||||
|
||||
/* Phase B 录制按钮:录制中红色脉动 */
|
||||
.btn-recording.is-recording .icon {
|
||||
background: #EF4444;
|
||||
animation: recording-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes recording-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.55); }
|
||||
50% { box-shadow: 0 0 0 12rpx rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -6rpx;
|
||||
|
||||
@@ -121,6 +121,27 @@ export const MEETING_WS_PRODUCER_CLOSE = 'meeting.producer.close' // C→S
|
||||
// 聊天(1 个)
|
||||
export const MEETING_WS_CHAT_MESSAGE = 'meeting.chat.message' // S→C
|
||||
|
||||
// 屏幕共享(Phase 3 新增,2 个,仅 S→C;启动/停止由 produce.start/producer.close 触发,无独立 C→S 事件)
|
||||
export const MEETING_WS_SCREEN_STARTED = 'meeting.screen.started' // S→C
|
||||
export const MEETING_WS_SCREEN_STOPPED = 'meeting.screen.stopped' // S→C
|
||||
|
||||
// 录制(Phase B 新增,2 个,仅 S→C;启动/停止由 REST 触发,无独立 C→S 事件)
|
||||
export const MEETING_WS_RECORDING_STARTED = 'meeting.recording.started' // S→C
|
||||
export const MEETING_WS_RECORDING_STOPPED = 'meeting.recording.stopped' // S→C
|
||||
|
||||
// 录制状态(与后端 model.MeetingRecordingStatus* 对应)
|
||||
export const MEETING_RECORDING_STATUS_RECORDING = 'recording'
|
||||
export const MEETING_RECORDING_STATUS_UPLOADING = 'uploading'
|
||||
export const MEETING_RECORDING_STATUS_READY = 'ready'
|
||||
export const MEETING_RECORDING_STATUS_FAILED = 'failed'
|
||||
|
||||
export const MEETING_RECORDING_STATUS_LABEL = {
|
||||
[MEETING_RECORDING_STATUS_RECORDING]: '录制中',
|
||||
[MEETING_RECORDING_STATUS_UPLOADING]: '上传中',
|
||||
[MEETING_RECORDING_STATUS_READY]: '已就绪',
|
||||
[MEETING_RECORDING_STATUS_FAILED]: '失败'
|
||||
}
|
||||
|
||||
/** C→S 客户端可主动发起的事件白名单 */
|
||||
export const MEETING_WS_CLIENT_EVENTS = [
|
||||
MEETING_WS_ROOM_JOIN,
|
||||
@@ -143,7 +164,11 @@ export const MEETING_WS_SERVER_EVENTS = [
|
||||
MEETING_WS_MEMBER_KICKED,
|
||||
MEETING_WS_MEMBER_PRODUCER_NEW,
|
||||
MEETING_WS_HOST_CHANGED,
|
||||
MEETING_WS_CHAT_MESSAGE
|
||||
MEETING_WS_CHAT_MESSAGE,
|
||||
MEETING_WS_SCREEN_STARTED,
|
||||
MEETING_WS_SCREEN_STOPPED,
|
||||
MEETING_WS_RECORDING_STARTED,
|
||||
MEETING_WS_RECORDING_STOPPED
|
||||
]
|
||||
|
||||
// ==================== 本地会议状态机(前端专用,与后端 meeting_rooms.status 无关) ====================
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
"navigationBarTitleText": "注册"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/auth/sso",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录中"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/index",
|
||||
"style": {
|
||||
@@ -173,6 +179,12 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/recordings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会议录制"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/notify/index",
|
||||
"style": {
|
||||
|
||||
71
frontend/src/pages/auth/sso.vue
Normal file
71
frontend/src/pages/auth/sso.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<!--
|
||||
外部系统 SSO 桥接页(cloud_law -> EchoChat)
|
||||
|
||||
入参(query):
|
||||
- payload: URI 编码的 JSON 字符串 { token, refresh_token, expires_in, user }
|
||||
- redirect: URI 编码的目标 hash 路径,例如 /pages/meeting/join?code=XXX-XXX-XXX
|
||||
|
||||
逻辑:
|
||||
1. 读取 payload,写入 storage(saveToken + saveUserInfo)
|
||||
2. 同步 user store
|
||||
3. 跳转 redirect(默认 /pages/meeting/index)
|
||||
-->
|
||||
<template>
|
||||
<view class="sso">
|
||||
<text class="sso-title">{{ statusText }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { saveToken, saveUserInfo } from '@/utils/storage'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const statusText = ref('正在登录…')
|
||||
|
||||
onLoad((query) => {
|
||||
try {
|
||||
if (!query?.payload) {
|
||||
statusText.value = '缺少 payload 参数'
|
||||
return
|
||||
}
|
||||
const payload = JSON.parse(decodeURIComponent(query.payload))
|
||||
if (!payload.token || !payload.user) {
|
||||
statusText.value = 'payload 字段不完整'
|
||||
return
|
||||
}
|
||||
// 写入持久化存储
|
||||
saveToken(payload.token, payload.refresh_token || '', payload.expires_in || 0)
|
||||
saveUserInfo(payload.user)
|
||||
// 同步 pinia store
|
||||
const userStore = useUserStore()
|
||||
userStore.token = payload.token
|
||||
userStore.userInfo = payload.user
|
||||
|
||||
const redirect = query.redirect ? decodeURIComponent(query.redirect) : '/pages/meeting/index'
|
||||
statusText.value = '登录成功,跳转中…'
|
||||
// redirectTo 不保留历史,避免返回到 sso 页造成循环
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: redirect })
|
||||
}, 200)
|
||||
} catch (e) {
|
||||
console.error('[SSO] 解析失败', e)
|
||||
statusText.value = `SSO 失败:${e.message || e}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sso {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #F8FAFC;
|
||||
}
|
||||
.sso-title {
|
||||
font-size: 30rpx;
|
||||
color: #475569;
|
||||
}
|
||||
</style>
|
||||
271
frontend/src/pages/meeting/recordings.vue
Normal file
271
frontend/src/pages/meeting/recordings.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<!--
|
||||
会议录制列表 + 回放页(Phase B)
|
||||
|
||||
路由:/pages/meeting/recordings?code=<room_code>
|
||||
- 通过 query.code 拉取该房间的录制历史
|
||||
- 仅会议参与者(包括 host)可访问;后端 listRecordings 会做鉴权
|
||||
- 列表按开始时间倒序;每条展示 状态徽章 / 时长 / 大小 / 操作按钮
|
||||
- status=ready 时可在线播放(H5 用 <video>)或下载;其他状态禁用回放
|
||||
-->
|
||||
<template>
|
||||
<view class="recordings">
|
||||
<view class="header">
|
||||
<text class="room-code">会议号 {{ roomCode || '—' }}</text>
|
||||
<button class="refresh-btn" :disabled="loading" @click="loadList">刷新</button>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && list.length === 0" class="empty-state">
|
||||
<text>加载中…</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="list.length === 0" class="empty-state">
|
||||
<text>{{ errorMsg || '该会议暂无录制' }}</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="rec-list">
|
||||
<view
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="rec-card"
|
||||
:class="{ active: playingId === item.id }"
|
||||
>
|
||||
<view class="rec-meta">
|
||||
<view class="rec-row">
|
||||
<view class="status-pill" :class="`status-${item.status}`">
|
||||
{{ statusLabel(item.status) }}
|
||||
</view>
|
||||
<text class="rec-time">{{ formatStartTime(item.started_at) }}</text>
|
||||
</view>
|
||||
<view class="rec-row">
|
||||
<text class="rec-info">时长 {{ formatDuration(item.duration_sec) }}</text>
|
||||
<text class="rec-info">大小 {{ formatBytes(item.size_bytes) }}</text>
|
||||
</view>
|
||||
<view v-if="item.status === 'failed' && item.failure_reason" class="rec-failure">
|
||||
失败原因:{{ item.failure_reason }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="rec-actions">
|
||||
<button
|
||||
v-if="item.status === 'ready' && item.file_url"
|
||||
class="action-btn primary"
|
||||
@click="togglePlay(item)"
|
||||
>
|
||||
{{ playingId === item.id ? '收起' : '播放' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status === 'ready' && item.file_url"
|
||||
class="action-btn"
|
||||
@click="copyLink(item)"
|
||||
>
|
||||
复制链接
|
||||
</button>
|
||||
</view>
|
||||
<!-- 内联播放器:仅 H5 用 <video>;其他平台跳系统播放器 -->
|
||||
<!-- #ifdef H5 -->
|
||||
<view v-if="playingId === item.id && item.file_url" class="rec-player">
|
||||
<video :src="item.file_url" controls autoplay class="player-el"></video>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import meetingApi from '@/api/meeting'
|
||||
import {
|
||||
MEETING_RECORDING_STATUS_LABEL
|
||||
} from '@/constants/meeting'
|
||||
|
||||
const roomCode = ref('')
|
||||
const list = ref([])
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const playingId = ref(null)
|
||||
|
||||
const statusLabel = (s) => MEETING_RECORDING_STATUS_LABEL[s] || s || '—'
|
||||
|
||||
const formatStartTime = (unix) => {
|
||||
if (!unix) return '—'
|
||||
const d = new Date(unix * 1000)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
const formatDuration = (sec) => {
|
||||
if (!sec || sec <= 0) return '—'
|
||||
const h = Math.floor(sec / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
const s = sec % 60
|
||||
if (h > 0) return `${h}h ${m}m ${s}s`
|
||||
if (m > 0) return `${m}m ${s}s`
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
const formatBytes = (b) => {
|
||||
if (!b || b <= 0) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let n = b
|
||||
let i = 0
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024
|
||||
i++
|
||||
}
|
||||
return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
|
||||
const loadList = async () => {
|
||||
if (!roomCode.value) {
|
||||
errorMsg.value = '缺少会议号参数'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const resp = await meetingApi.listRecordings(roomCode.value)
|
||||
// 后端返回 { list: [...] }
|
||||
const arr = Array.isArray(resp?.list) ? resp.list : []
|
||||
// 按 started_at 倒序,最新优先
|
||||
arr.sort((a, b) => (b.started_at || 0) - (a.started_at || 0))
|
||||
list.value = arr
|
||||
} catch (err) {
|
||||
errorMsg.value = err?.message || '加载失败'
|
||||
uni.showToast({ title: errorMsg.value, icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlay = (item) => {
|
||||
if (playingId.value === item.id) {
|
||||
playingId.value = null
|
||||
return
|
||||
}
|
||||
playingId.value = item.id
|
||||
// #ifndef H5
|
||||
// 非 H5 平台直接跳系统播放器
|
||||
uni.navigateTo({
|
||||
url: `/pages/common/video-player?src=${encodeURIComponent(item.file_url)}`
|
||||
}).catch(() => {
|
||||
// 兜底:未注册 video-player 页,用系统打开
|
||||
plus?.runtime?.openURL?.(item.file_url)
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
const copyLink = (item) => {
|
||||
uni.setClipboardData({
|
||||
data: item.file_url,
|
||||
success: () => uni.showToast({ title: '链接已复制', icon: 'success' })
|
||||
})
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
roomCode.value = (query?.code || '').toString()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (roomCode.value) {
|
||||
loadList()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.recordings {
|
||||
min-height: 100vh;
|
||||
background: #F9FAFB;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.room-code {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
.refresh-btn {
|
||||
font-size: 26rpx;
|
||||
padding: 8rpx 24rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #3B82F6;
|
||||
color: #FFFFFF;
|
||||
border: none;
|
||||
}
|
||||
.refresh-btn[disabled] { opacity: 0.5; }
|
||||
|
||||
.empty-state {
|
||||
padding: 120rpx 0;
|
||||
text-align: center;
|
||||
color: #6B7280;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.rec-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.rec-card {
|
||||
background: #FFFFFF;
|
||||
border: 1rpx solid #E5E7EB;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.04);
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
.rec-card.active { border-color: #3B82F6; }
|
||||
|
||||
.rec-meta { display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.rec-row { display: flex; align-items: center; gap: 16rpx; flex-wrap: wrap; }
|
||||
.rec-time { font-size: 26rpx; color: #374151; font-weight: 500; }
|
||||
.rec-info { font-size: 24rpx; color: #6B7280; }
|
||||
.rec-failure {
|
||||
font-size: 22rpx;
|
||||
color: #DC2626;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 22rpx;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-recording { background: #FEE2E2; color: #DC2626; }
|
||||
.status-uploading { background: #FEF3C7; color: #B45309; }
|
||||
.status-ready { background: #D1FAE5; color: #065F46; }
|
||||
.status-failed { background: #E5E7EB; color: #6B7280; }
|
||||
|
||||
.rec-actions {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.action-btn {
|
||||
font-size: 24rpx;
|
||||
padding: 8rpx 24rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #F3F4F6;
|
||||
color: #374151;
|
||||
border: 1rpx solid #E5E7EB;
|
||||
}
|
||||
.action-btn.primary {
|
||||
background: #3B82F6;
|
||||
color: #FFFFFF;
|
||||
border-color: #3B82F6;
|
||||
}
|
||||
|
||||
.rec-player { margin-top: 16rpx; }
|
||||
.player-el { width: 100%; max-height: 480rpx; border-radius: 12rpx; background: #000; }
|
||||
</style>
|
||||
@@ -29,6 +29,16 @@
|
||||
<view class="state-dot"></view>
|
||||
<text>{{ stateText }}</text>
|
||||
</view>
|
||||
<!-- Phase B 录制指示器:所有人可见,点击对 host 触发停止;非 host 仅展示 -->
|
||||
<view
|
||||
v-if="isRecording || recordingLoading"
|
||||
class="recording-indicator"
|
||||
:class="{ uploading: recordingLoading && !isRecording }"
|
||||
:title="recordingHintText"
|
||||
>
|
||||
<view class="rec-dot"></view>
|
||||
<text class="rec-text">{{ isRecording ? 'REC' : '上传中' }}</text>
|
||||
</view>
|
||||
<NetworkBadge :level="networkLevel" />
|
||||
<text class="timer">{{ durationText }}</text>
|
||||
</view>
|
||||
@@ -36,8 +46,41 @@
|
||||
|
||||
<!-- 主区:左视频 + 右侧聊天面板(仅 chatVisible 时侧栏展开) -->
|
||||
<view class="body">
|
||||
<view ref="videoCol" class="video-col">
|
||||
<VideoGrid :tiles="gridTiles">
|
||||
<view ref="videoCol" class="video-col" :class="{ 'has-screen': !!screenTile }">
|
||||
<!-- Phase 3:有屏幕共享时,屏幕画面独占大画面区,参与者网格降级为底部横向条带 -->
|
||||
<view v-if="screenTile" class="screen-stage">
|
||||
<view class="screen-stage-tile">
|
||||
<VideoTile
|
||||
:user-id="screenTile.userId"
|
||||
:name="screenTile.name + '(屏幕共享)'"
|
||||
:is-local="screenTile.isLocal"
|
||||
:is-host="screenTile.isHost"
|
||||
:audio-enabled="false"
|
||||
:video-enabled="true"
|
||||
:audio-track="null"
|
||||
:video-track="screenTile.videoTrack"
|
||||
:is-speaking="false"
|
||||
/>
|
||||
</view>
|
||||
<view class="screen-strip">
|
||||
<view v-for="t in stripTiles" :key="t.key" class="strip-cell">
|
||||
<VideoTile
|
||||
:user-id="t.userId"
|
||||
:name="t.name"
|
||||
:is-local="t.isLocal"
|
||||
:is-host="t.isHost"
|
||||
:audio-enabled="t.audioEnabled"
|
||||
:video-enabled="t.videoEnabled"
|
||||
:audio-track="t.audioTrack"
|
||||
:video-track="t.videoTrack"
|
||||
:is-speaking="!!speakingMap[t.userId]"
|
||||
:compact="true"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<VideoGrid v-else :tiles="gridTiles">
|
||||
<template #tile="{ tile }">
|
||||
<VideoTile
|
||||
:user-id="tile.userId"
|
||||
@@ -94,8 +137,17 @@
|
||||
:unread-chat-count="unreadChatCount"
|
||||
:allow-chat="allowChat"
|
||||
:all-muted="allMuted"
|
||||
:screen-sharing="screenSharingByMe"
|
||||
:screen-loading="screenLoading"
|
||||
:screen-disabled="screenDisabled"
|
||||
:screen-disabled-reason="screenDisabledReason"
|
||||
:show-recording="isHost"
|
||||
:recording="isRecording"
|
||||
:recording-loading="recordingLoading"
|
||||
@mic-toggle="onMicToggle"
|
||||
@cam-toggle="onCamToggle"
|
||||
@screen-toggle="onScreenToggle"
|
||||
@recording-toggle="onRecordingToggle"
|
||||
@invite="openInvite"
|
||||
@members="openMembers"
|
||||
@chat="openChat"
|
||||
@@ -157,7 +209,12 @@ import {
|
||||
MEETING_LOCAL_STATE_RECONNECTING,
|
||||
MEETING_LOCAL_STATE_LEAVING,
|
||||
MEETING_LOCAL_STATE_ENDED,
|
||||
MEETING_ENDED_REASON_LABEL
|
||||
MEETING_ENDED_REASON_LABEL,
|
||||
MEETING_RECORDING_STATUS_RECORDING,
|
||||
MEETING_RECORDING_STATUS_UPLOADING,
|
||||
MEETING_RECORDING_STATUS_READY,
|
||||
MEETING_RECORDING_STATUS_FAILED,
|
||||
MEETING_RECORDING_STATUS_LABEL
|
||||
} from '@/constants/meeting'
|
||||
|
||||
import VideoGrid from '@/components/meeting/VideoGrid.vue'
|
||||
@@ -178,6 +235,7 @@ const chatVisible = ref(false)
|
||||
const leaveDialogVisible = ref(false)
|
||||
const audioLoading = ref(false)
|
||||
const videoLoading = ref(false)
|
||||
const screenLoading = ref(false)
|
||||
const chatLoadingMore = ref(false)
|
||||
const networkLevel = ref(4) // 持续接入 getStats 后动态更新(Task 16 进一步收敛)
|
||||
const startedAt = ref(0)
|
||||
@@ -289,18 +347,94 @@ const durationText = computed(() => {
|
||||
const audioEnabled = computed(() => meetingStore.localAudioEnabled)
|
||||
const videoEnabled = computed(() => meetingStore.localVideoEnabled)
|
||||
|
||||
/** 监听 store.getLocalTrack 的变化:localProducers 变化即 bump 版本 */
|
||||
const localTrackVersion = computed(() => {
|
||||
return `${meetingStore.localProducers.audio || ''}-${meetingStore.localProducers.video || ''}`
|
||||
// ============ Phase 3 屏幕共享 ============
|
||||
|
||||
/** 当前会议是否已有屏幕共享(任意成员) */
|
||||
const screenSharingActive = computed(() => !!meetingStore.screenShare?.ownerUserId)
|
||||
/** 屏幕共享者是否就是自己(决定工具栏按钮高亮 + 文案) */
|
||||
const screenSharingByMe = computed(() => {
|
||||
const owner = meetingStore.screenShare?.ownerUserId
|
||||
return !!owner && owner === myUserId.value
|
||||
})
|
||||
/** H5 桌面浏览器才有 getDisplayMedia;非 H5 / 移动端 / 老内核都禁用按钮 */
|
||||
const screenSupported = computed(() => {
|
||||
// #ifdef H5
|
||||
return typeof navigator !== 'undefined' &&
|
||||
!!navigator.mediaDevices &&
|
||||
typeof navigator.mediaDevices.getDisplayMedia === 'function'
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return false
|
||||
// #endif
|
||||
})
|
||||
/** 按钮禁用条件:平台不支持 / 已有他人在共享 */
|
||||
const screenDisabled = computed(() => {
|
||||
if (!screenSupported.value) return true
|
||||
if (screenSharingActive.value && !screenSharingByMe.value) return true
|
||||
return false
|
||||
})
|
||||
const screenDisabledReason = computed(() => {
|
||||
if (!screenSupported.value) return '当前浏览器不支持屏幕共享'
|
||||
if (screenSharingActive.value && !screenSharingByMe.value) return '已有成员正在共享屏幕'
|
||||
return ''
|
||||
})
|
||||
|
||||
/** 远端 track 版本:让 tiles 在 slot.audio/slot.video 变化时重新计算 */
|
||||
/**
|
||||
* 屏幕共享大画面 tile:当前会议有屏幕共享时返回 tile 描述对象,否则 null
|
||||
*
|
||||
* track 来源分两路:
|
||||
* - 自己共享:从 store.getLocalTrack('screen') 取本地 track
|
||||
* - 他人共享:从 store.getRemoteTrack(ownerUid, 'screen') 取远端 Consumer track
|
||||
*
|
||||
* 触发重算:依赖 screenShare reactive + localTrackVersion + remoteTrackVersion
|
||||
*/
|
||||
const screenTile = computed(() => {
|
||||
const owner = meetingStore.screenShare?.ownerUserId
|
||||
if (!owner) return null
|
||||
// 触发依赖收集,让 store 内 producer/consumer 变化也能让本 computed 重算
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
localTrackVersion.value
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
remoteTrackVersion.value
|
||||
|
||||
const isLocal = owner === myUserId.value
|
||||
const track = isLocal
|
||||
? meetingStore.getLocalTrack('screen')
|
||||
: meetingStore.getRemoteTrack(owner, 'screen')
|
||||
// track 还没就绪时(screen.started 已到但 producer.new 未到 / consumer 未建好)暂不显示大画面,
|
||||
// 等下次重算(_onProducerNew 完成后 remoteTrackVersion 变化触发)
|
||||
if (!track) return null
|
||||
|
||||
return {
|
||||
key: `screen_${owner}`,
|
||||
userId: owner,
|
||||
name: isLocal ? '你' : nameOf(owner),
|
||||
isLocal,
|
||||
isHost: owner === hostId.value,
|
||||
videoTrack: track
|
||||
}
|
||||
})
|
||||
|
||||
/** 屏幕共享期间,底部条带显示参会者(含本地 camera tile) */
|
||||
const stripTiles = computed(() => {
|
||||
if (!screenTile.value) return []
|
||||
// 直接复用 tiles 列表(含本地);后续可按需限制数量
|
||||
return tiles.value
|
||||
})
|
||||
|
||||
/** 监听 store.getLocalTrack 的变化:localProducers 变化即 bump 版本(含屏幕共享 producer) */
|
||||
const localTrackVersion = computed(() => {
|
||||
const lp = meetingStore.localProducers
|
||||
return `${lp.audio || ''}-${lp.video || ''}-${lp.screen || ''}`
|
||||
})
|
||||
|
||||
/** 远端 track 版本:让 tiles 在 slot.audio/slot.video/slot.screen 变化时重新计算 */
|
||||
const remoteTrackVersion = computed(() => {
|
||||
const parts = []
|
||||
const map = meetingStore.remoteConsumers
|
||||
Object.keys(map).forEach((uid) => {
|
||||
const slot = map[uid]
|
||||
parts.push(`${uid}:${slot?.audio?.id || ''}:${slot?.video?.id || ''}`)
|
||||
parts.push(`${uid}:${slot?.audio?.id || ''}:${slot?.video?.id || ''}:${slot?.screen?.id || ''}`)
|
||||
})
|
||||
return parts.join('|')
|
||||
})
|
||||
@@ -439,6 +573,76 @@ const onCamToggle = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具栏共享屏幕切换
|
||||
*
|
||||
* 状态机:
|
||||
* - 未在共享 + 平台支持 + 无他人共享 → 调 startScreenShare
|
||||
* - 自己已在共享 → 调 stopScreenShare
|
||||
* - 其他场景由 MeetingToolbar.emitIf 拦截,不会进入本函数
|
||||
*
|
||||
* 容错:getDisplayMedia 用户取消会抛 NotAllowedError,对用户体验友好的提示是"已取消"
|
||||
*/
|
||||
const onScreenToggle = async () => {
|
||||
if (screenLoading.value) return
|
||||
screenLoading.value = true
|
||||
try {
|
||||
if (screenSharingByMe.value) {
|
||||
await meetingStore.stopScreenShare()
|
||||
} else {
|
||||
await meetingStore.startScreenShare()
|
||||
}
|
||||
} catch (err) {
|
||||
// NotAllowedError = 用户在系统选择器中点了取消,静默处理;其他错误才提示
|
||||
if (err?.name !== 'NotAllowedError') {
|
||||
uni.showToast({ title: err?.message || '屏幕共享失败', icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
screenLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 录制(Phase B) ============
|
||||
|
||||
/** 当前是否处于"录制中"(按钮高亮 + 顶部红点) */
|
||||
const isRecording = computed(() => meetingStore.recording.status === MEETING_RECORDING_STATUS_RECORDING)
|
||||
/** 录制按钮 loading:REST 调用期间或后端异步上传期间均禁用 */
|
||||
const recordingLoading = computed(() => meetingStore.recording.pending || meetingStore.recording.status === MEETING_RECORDING_STATUS_UPLOADING)
|
||||
/** 录制中提示文案(VideoGrid 上方红点 hover/aria) */
|
||||
const recordingHintText = computed(() => {
|
||||
const r = meetingStore.recording
|
||||
if (!r.status) return ''
|
||||
return MEETING_RECORDING_STATUS_LABEL[r.status] || r.status
|
||||
})
|
||||
|
||||
/** 切换录制:仅 host;按钮 emitIf 已防抖,这里再做 try/catch + toast */
|
||||
const onRecordingToggle = async () => {
|
||||
if (!isHost.value) return
|
||||
if (recordingLoading.value) return
|
||||
try {
|
||||
if (isRecording.value) {
|
||||
await meetingStore.stopRecording()
|
||||
uni.showToast({ title: '录制已停止,正在上传…', icon: 'none' })
|
||||
} else {
|
||||
await meetingStore.startRecording()
|
||||
uni.showToast({ title: '已开始录制', icon: 'success' })
|
||||
}
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err?.message || '录制操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听录制终态:ready → 提示已就绪;failed → 错误提示 */
|
||||
watch(() => meetingStore.recording.status, (newStatus, oldStatus) => {
|
||||
if (newStatus === oldStatus) return
|
||||
if (newStatus === MEETING_RECORDING_STATUS_READY) {
|
||||
uni.showToast({ title: '录制已就绪', icon: 'success' })
|
||||
} else if (newStatus === MEETING_RECORDING_STATUS_FAILED) {
|
||||
const reason = meetingStore.recording.failureReason || '未知原因'
|
||||
uni.showToast({ title: `录制失败:${reason}`, icon: 'none', duration: 3000 })
|
||||
}
|
||||
})
|
||||
|
||||
const openInvite = () => { inviteVisible.value = !inviteVisible.value }
|
||||
const openMembers = () => { memberVisible.value = !memberVisible.value }
|
||||
const openChat = () => {
|
||||
@@ -733,6 +937,40 @@ onUnload(() => {
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
/* Phase B 录制状态指示器(顶栏) */
|
||||
.recording-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
border: 1rpx solid rgba(239, 68, 68, 0.55);
|
||||
color: #FCA5A5;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.recording-indicator.uploading {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
border-color: rgba(245, 158, 11, 0.55);
|
||||
color: #FCD34D;
|
||||
}
|
||||
.rec-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: #EF4444;
|
||||
animation: rec-blink 1s infinite ease-in-out;
|
||||
}
|
||||
.recording-indicator.uploading .rec-dot {
|
||||
background: #F59E0B;
|
||||
}
|
||||
@keyframes rec-blink {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.7); }
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -748,6 +986,46 @@ onUnload(() => {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Phase 3 屏幕共享专属布局:大画面 + 底部横向条带 */
|
||||
.video-col.has-screen { flex-direction: column; }
|
||||
.screen-stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
padding: 12rpx;
|
||||
}
|
||||
.screen-stage-tile {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #000;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
/* 屏幕画面用 contain 而非 cover,避免桌面边缘内容被裁切 */
|
||||
}
|
||||
.screen-stage-tile :deep(video) {
|
||||
object-fit: contain !important;
|
||||
background: #000;
|
||||
}
|
||||
.screen-strip {
|
||||
flex-shrink: 0;
|
||||
height: 160rpx;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12rpx;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.screen-strip .strip-cell {
|
||||
flex: 0 0 220rpx;
|
||||
height: 100%;
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.chat-col {
|
||||
width: 0;
|
||||
min-height: 0;
|
||||
|
||||
@@ -46,6 +46,14 @@ import {
|
||||
MEETING_WS_MEMBER_PRODUCER_NEW,
|
||||
MEETING_WS_HOST_CHANGED,
|
||||
MEETING_WS_CHAT_MESSAGE,
|
||||
MEETING_WS_SCREEN_STARTED,
|
||||
MEETING_WS_SCREEN_STOPPED,
|
||||
MEETING_WS_RECORDING_STARTED,
|
||||
MEETING_WS_RECORDING_STOPPED,
|
||||
MEETING_RECORDING_STATUS_RECORDING,
|
||||
MEETING_RECORDING_STATUS_UPLOADING,
|
||||
MEETING_RECORDING_STATUS_READY,
|
||||
MEETING_RECORDING_STATUS_FAILED,
|
||||
MEETING_ROLE_HOST,
|
||||
MEETING_ENDED_REASON_LABEL,
|
||||
MEETING_ENDED_REASON_KICKED,
|
||||
@@ -103,10 +111,57 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
const localVideoEnabled = ref(false)
|
||||
|
||||
/**
|
||||
* 当前用户的本地 Producer 索引:{ audio: producerId?, video: producerId? }
|
||||
* 用于启停麦克风/摄像头
|
||||
* 当前用户的本地 Producer 索引:{ audio: producerId?, video: producerId?, screen: producerId? }
|
||||
* 用于启停麦克风/摄像头/屏幕共享
|
||||
*
|
||||
* Phase 3 屏幕共享:screen 与 video 同为 video kind,但走独立 Producer
|
||||
* (appData.screen=true 区分),便于单独开关,互不影响摄像头
|
||||
*/
|
||||
const localProducers = reactive({ audio: null, video: null })
|
||||
const localProducers = reactive({ audio: null, video: null, screen: null })
|
||||
|
||||
/**
|
||||
* 当前会议屏幕共享状态(Phase 3)
|
||||
* - ownerUserId: 谁在共享(含自己);null 表示无人共享
|
||||
* - producerId: 屏幕流 Producer ID;远端用于匹配 consumer slot,本地用于回查
|
||||
*
|
||||
* 数据来源:
|
||||
* 1) 自己开启:startScreenShare 成功后由 OnProduceStart 触发 meeting.screen.started 广播写入
|
||||
* 2) 他人开启:收到 meeting.screen.started 后写入
|
||||
* 3) 后入者:OnRoomJoin 后端会定向补推 meeting.screen.started(existing=true)
|
||||
*/
|
||||
const screenShare = reactive({ ownerUserId: null, producerId: null })
|
||||
|
||||
/**
|
||||
* 会议录制状态(Phase B)
|
||||
*
|
||||
* 字段:
|
||||
* - id 后端 meeting_recordings.id;null 表示当前无录制
|
||||
* - status recording / uploading / ready / failed
|
||||
* - startedBy 发起录制的 host userId(便于 UI 提示"由 XXX 录制")
|
||||
* - startedAt unix 秒
|
||||
* - stoppedAt unix 秒
|
||||
* - fileUrl 最终回放 URL(仅 ready)
|
||||
* - sizeBytes 文件大小(仅 ready)
|
||||
* - durationSec 录制时长(仅 ready)
|
||||
* - failureReason 失败原因(仅 failed)
|
||||
* - pending 本地"启停按钮防抖"标记:调 REST 期间禁用按钮,避免重复请求
|
||||
*
|
||||
* 数据流:
|
||||
* - 进入会议时不主动拉历史录制;只通过 WS meeting.recording.started/stopped 维护
|
||||
* - 同时只允许 1 条活跃录制 → 单对象足够,不必维护数组
|
||||
*/
|
||||
const recording = reactive({
|
||||
id: null,
|
||||
status: null,
|
||||
startedBy: null,
|
||||
startedAt: null,
|
||||
stoppedAt: null,
|
||||
fileUrl: '',
|
||||
sizeBytes: 0,
|
||||
durationSec: 0,
|
||||
failureReason: '',
|
||||
pending: false
|
||||
})
|
||||
|
||||
/**
|
||||
* 远端 Consumer 索引:{ [userId]: { audio?: Consumer, video?: Consumer, producerIds: Set } }
|
||||
@@ -254,6 +309,10 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
localVideoEnabled.value = false
|
||||
localProducers.audio = null
|
||||
localProducers.video = null
|
||||
localProducers.screen = null
|
||||
screenShare.ownerUserId = null
|
||||
screenShare.producerId = null
|
||||
_resetRecordingState()
|
||||
Object.keys(remoteConsumers).forEach(k => delete remoteConsumers[k])
|
||||
_engine = null
|
||||
}
|
||||
@@ -269,6 +328,10 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
[MEETING_WS_MEMBER_PRODUCER_NEW]: _onProducerNew,
|
||||
[MEETING_WS_HOST_CHANGED]: _onHostChanged,
|
||||
[MEETING_WS_CHAT_MESSAGE]: _onChatMessage,
|
||||
[MEETING_WS_SCREEN_STARTED]: _onScreenStarted,
|
||||
[MEETING_WS_SCREEN_STOPPED]: _onScreenStopped,
|
||||
[MEETING_WS_RECORDING_STARTED]: _onRecordingStarted,
|
||||
[MEETING_WS_RECORDING_STOPPED]: _onRecordingStopped,
|
||||
// WS 连接生命周期:用于断线/重连期间维持会议会话,避免"连接断开 → 后端失去 roomCode 绑定 → 广播丢失"
|
||||
_connected: _onWsReconnected,
|
||||
_disconnected: _onWsDisconnected
|
||||
@@ -299,6 +362,10 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
// 表现为"重新发起会议后看不到自己的画面"
|
||||
localProducers.audio = null
|
||||
localProducers.video = null
|
||||
localProducers.screen = null
|
||||
screenShare.ownerUserId = null
|
||||
screenShare.producerId = null
|
||||
_resetRecordingState()
|
||||
localAudioEnabled.value = false
|
||||
localVideoEnabled.value = false
|
||||
Object.keys(remoteConsumers).forEach(k => delete remoteConsumers[k])
|
||||
@@ -446,16 +513,20 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
try {
|
||||
const consumer = await _engine.consume({ producerId: data.producer_id })
|
||||
if (!remoteConsumers[data.user_id]) {
|
||||
// 外层 slot 保持 reactive 以便 slot.audio/slot.video 赋值能驱动 VideoTile 重渲染;
|
||||
// 外层 slot 保持 reactive 以便 slot.audio/slot.video/slot.screen 赋值能驱动 VideoTile 重渲染;
|
||||
// 仅 Consumer 实例和 Set 本身用 markRaw 隔离 Vue 代理
|
||||
remoteConsumers[data.user_id] = {
|
||||
audio: null,
|
||||
video: null,
|
||||
screen: null,
|
||||
producerIds: markRaw(new Set())
|
||||
}
|
||||
}
|
||||
const slot = remoteConsumers[data.user_id]
|
||||
slot[consumer.kind] = markRaw(consumer)
|
||||
// Phase 3 屏幕共享:后端 producer.new 携带 screen=true 时挂到独立 screen 槽位,
|
||||
// 避免覆盖摄像头 video 槽。consumer.kind 仍是 'video',不能用 kind 区分
|
||||
const slotKey = data.screen ? 'screen' : consumer.kind
|
||||
slot[slotKey] = markRaw(consumer)
|
||||
slot.producerIds.add(data.producer_id)
|
||||
|
||||
// track 已在 consumer.track 中,调用方(页面)可从 store 拿 consumer.track 挂到 DOM
|
||||
@@ -466,6 +537,116 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到 meeting.screen.started 广播 → 更新 screenShare 状态
|
||||
*
|
||||
* 数据驱动而非直接操作 consumer:consumer 已经由 _onProducerNew 自动建好(带 screen=true 路由到 slot.screen),
|
||||
* 本 handler 只负责标记"当前谁在共享 + 哪个 producer",UI 层据此把屏幕 tile 提升为大画面
|
||||
*
|
||||
* 兼容路径:existing=true 表示后端 OnRoomJoin 内部补推(后入者首次连进来时拉取当前状态)
|
||||
*/
|
||||
const _onScreenStarted = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
screenShare.ownerUserId = data.user_id
|
||||
screenShare.producerId = data.producer_id
|
||||
_log('info', '[Meeting] 屏幕共享已开始', data.user_id, data.producer_id, data.existing ? '(existing)' : '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到 meeting.screen.stopped 广播 → 清空 screenShare 状态
|
||||
*
|
||||
* 幂等保护:若当前 owner 不是 data.user_id(说明已被新的 started 覆盖),不做处理
|
||||
* 远端屏幕 Consumer 的清理由 meeting.member.producer.new(closed=true) 复用 _cleanupRemoteProducer 完成,
|
||||
* 本 handler 不重复关闭,避免 race
|
||||
*/
|
||||
const _onScreenStopped = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
if (screenShare.ownerUserId && screenShare.ownerUserId !== data.user_id) {
|
||||
_log('debug', '[Meeting] 忽略过期 screen.stopped', data.user_id)
|
||||
return
|
||||
}
|
||||
screenShare.ownerUserId = null
|
||||
screenShare.producerId = null
|
||||
_log('info', '[Meeting] 屏幕共享已停止', data.user_id)
|
||||
}
|
||||
|
||||
// ==================== 录制相关辅助 / 回调(Phase B) ====================
|
||||
|
||||
/** 重置 recording 状态(_reset / _cleanupMedia / room.ended 复用) */
|
||||
const _resetRecordingState = () => {
|
||||
recording.id = null
|
||||
recording.status = null
|
||||
recording.startedBy = null
|
||||
recording.startedAt = null
|
||||
recording.stoppedAt = null
|
||||
recording.fileUrl = ''
|
||||
recording.sizeBytes = 0
|
||||
recording.durationSec = 0
|
||||
recording.failureReason = ''
|
||||
recording.pending = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 server 返回的 recording DTO 合并到本地 recording reactive
|
||||
* 注意:服务端字段是 snake_case,前端 store 字段是 camelCase
|
||||
*/
|
||||
const _applyRecordingDTO = (dto) => {
|
||||
if (!dto || typeof dto !== 'object') return
|
||||
if (dto.id != null) recording.id = dto.id
|
||||
if (dto.status) recording.status = dto.status
|
||||
if (dto.started_by != null) recording.startedBy = dto.started_by
|
||||
if (dto.started_at != null) recording.startedAt = dto.started_at
|
||||
if (dto.stopped_at != null) recording.stoppedAt = dto.stopped_at
|
||||
if (typeof dto.file_url === 'string') recording.fileUrl = dto.file_url
|
||||
if (typeof dto.size_bytes === 'number') recording.sizeBytes = dto.size_bytes
|
||||
if (typeof dto.duration_sec === 'number') recording.durationSec = dto.duration_sec
|
||||
if (typeof dto.failure_reason === 'string') recording.failureReason = dto.failure_reason
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到 meeting.recording.started 广播
|
||||
* 后端 payload: { room_code, recording_id, started_by, started_at, status='recording' }
|
||||
*/
|
||||
const _onRecordingStarted = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
// 与本地状态合并:可能本地刚 startRecording 拿到 DTO 后又收到广播(id 必须一致才合并)
|
||||
if (recording.id && recording.id !== data.recording_id) {
|
||||
_log('warn', '[Meeting] 忽略不一致的 recording.started', data.recording_id, '当前', recording.id)
|
||||
return
|
||||
}
|
||||
recording.id = data.recording_id
|
||||
recording.status = data.status || MEETING_RECORDING_STATUS_RECORDING
|
||||
recording.startedBy = data.started_by
|
||||
recording.startedAt = data.started_at
|
||||
recording.pending = false
|
||||
_log('info', '[Meeting] 录制已开始', data.recording_id, data.started_by)
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到 meeting.recording.stopped 广播
|
||||
* payload: { room_code, recording_id, status: ready|failed, file_url?, size_bytes?, duration_sec?, failure_reason?, stopped_at }
|
||||
* status=uploading 阶段后端不广播;前端只需在 ready/failed 时清理 pending、保留终态展示
|
||||
*/
|
||||
const _onRecordingStopped = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
if (recording.id && recording.id !== data.recording_id) {
|
||||
_log('warn', '[Meeting] 忽略不一致的 recording.stopped', data.recording_id, '当前', recording.id)
|
||||
return
|
||||
}
|
||||
recording.status = data.status || MEETING_RECORDING_STATUS_READY
|
||||
recording.stoppedAt = data.stopped_at || Math.floor(Date.now() / 1000)
|
||||
if (typeof data.file_url === 'string') recording.fileUrl = data.file_url
|
||||
if (typeof data.size_bytes === 'number') recording.sizeBytes = data.size_bytes
|
||||
if (typeof data.duration_sec === 'number') recording.durationSec = data.duration_sec
|
||||
if (typeof data.failure_reason === 'string') recording.failureReason = data.failure_reason
|
||||
recording.pending = false
|
||||
_log('info', '[Meeting] 录制已停止', data.recording_id, data.status, data.file_url)
|
||||
}
|
||||
|
||||
const _onHostChanged = (msg) => {
|
||||
const data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
@@ -563,7 +744,8 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
const slot = remoteConsumers[userId]
|
||||
if (!slot) return
|
||||
slot.producerIds.delete(producerId)
|
||||
;['audio', 'video'].forEach(kind => {
|
||||
// Phase 3:新增 screen 槽位一并扫描,关闭归属 producerId 的那一个
|
||||
;['audio', 'video', 'screen'].forEach(kind => {
|
||||
const consumer = slot[kind]
|
||||
if (!consumer) return
|
||||
if (consumer.producerId && consumer.producerId !== producerId) return
|
||||
@@ -572,7 +754,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
}
|
||||
slot[kind] = null
|
||||
})
|
||||
if (!slot.audio && !slot.video && slot.producerIds.size === 0) {
|
||||
if (!slot.audio && !slot.video && !slot.screen && slot.producerIds.size === 0) {
|
||||
delete remoteConsumers[userId]
|
||||
}
|
||||
}
|
||||
@@ -580,12 +762,18 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
const _cleanupRemoteUser = (userId) => {
|
||||
const slot = remoteConsumers[userId]
|
||||
if (!slot) return
|
||||
;['audio', 'video'].forEach(kind => {
|
||||
;['audio', 'video', 'screen'].forEach(kind => {
|
||||
if (slot[kind] && !slot[kind].closed) {
|
||||
try { slot[kind].close() } catch {}
|
||||
}
|
||||
})
|
||||
delete remoteConsumers[userId]
|
||||
// Phase 3:若离开者恰好是当前屏幕共享者,本地兜底清空 screenShare
|
||||
// (正常路径下后端的 cleanupUserResources 会广播 screen.stopped,此处只是 race 防御)
|
||||
if (screenShare.ownerUserId === userId) {
|
||||
screenShare.ownerUserId = null
|
||||
screenShare.producerId = null
|
||||
}
|
||||
}
|
||||
|
||||
const _cleanupMedia = () => {
|
||||
@@ -1039,6 +1227,153 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
_broadcastSelfState({ video_enabled: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启屏幕共享(Phase 3)
|
||||
*
|
||||
* 流程:
|
||||
* 1) 前置校验:会议是否已有他人在共享(前端拦截,后端 OnProduceStart 也会兜底拒绝)
|
||||
* 2) navigator.mediaDevices.getDisplayMedia 弹出系统选择器(仅 H5/桌面浏览器支持)
|
||||
* 3) MediaEngine.produce 携带 appData={screen:true},让后端打 owner key + 广播 screen.started
|
||||
* 4) 监听 track.onended:用户点浏览器原生"停止共享"按钮时自动调 stopScreenShare 清理
|
||||
*
|
||||
* 不与摄像头互斥:摄像头开启时仍可叠加屏幕共享,两者各自占独立 Producer
|
||||
*
|
||||
* 失败处理:getDisplayMedia 用户取消、设备不支持、后端拒绝等均抛错给调用方提示 toast;
|
||||
* produce 失败时主动 stop() track 释放屏幕采集,避免幽灵采集
|
||||
*/
|
||||
const startScreenShare = async () => {
|
||||
if (!_engine) throw new Error('未连接')
|
||||
if (localProducers.screen) return
|
||||
if (typeof navigator === 'undefined' ||
|
||||
!navigator.mediaDevices ||
|
||||
typeof navigator.mediaDevices.getDisplayMedia !== 'function') {
|
||||
throw new Error('当前浏览器不支持屏幕共享')
|
||||
}
|
||||
const myUid = useUserStore().userInfo?.id
|
||||
if (screenShare.ownerUserId && screenShare.ownerUserId !== myUid) {
|
||||
throw new Error('已有成员正在共享屏幕')
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
// 屏幕共享对帧率要求低、清晰度要求高,控制带宽优先保证清晰度
|
||||
video: { frameRate: { ideal: 15, max: 30 } },
|
||||
audio: false
|
||||
})
|
||||
const track = stream.getVideoTracks()[0]
|
||||
if (!track) {
|
||||
// 极端情况:用户授权了但 track 为空,主动 stop 整个 stream
|
||||
try { stream.getTracks().forEach(t => t.stop()) } catch {}
|
||||
throw new Error('未获取到屏幕共享视频流')
|
||||
}
|
||||
|
||||
let producer
|
||||
try {
|
||||
producer = await _engine.produce({
|
||||
kind: 'video',
|
||||
track,
|
||||
appData: { screen: true }
|
||||
})
|
||||
} catch (err) {
|
||||
// produce 失败必须主动 stop() track,否则浏览器顶部"正在共享屏幕"提示条会一直显示
|
||||
try { track.stop() } catch {}
|
||||
throw err
|
||||
}
|
||||
localProducers.screen = producer.id
|
||||
|
||||
// 浏览器原生"停止共享"按钮:用户从系统 UI 取消时 track 进入 ended,
|
||||
// 此时本地 producer.on('trackended') 也会触发关闭,这里加一层保险
|
||||
// 注意监听 track 而不是 producer:producer 的 trackended handler 已经做了 closeProducer
|
||||
// 本 listener 主要负责前端 store 状态收尾(localProducers.screen 清空、广播由后端发)
|
||||
track.addEventListener('ended', () => {
|
||||
_log('info', '[Meeting] 屏幕共享 track 已结束(用户从浏览器原生 UI 取消)')
|
||||
// 双保险:调一次 stopScreenShare,幂等
|
||||
stopScreenShare().catch((err) => _log('warn', '[Meeting] 自动停止屏幕共享失败', err))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止屏幕共享(Phase 3)
|
||||
* - 关闭本地 screen Producer(MediaEngine 内部触发 producer.close WS 通知后端)
|
||||
* - 后端 OnProducerClose 检测到这是 screenOwnerKey 对应的 producer,会广播 screen.stopped
|
||||
* - 本地 screenShare 状态由 _onScreenStopped 广播 handler 兜底清空(保证幂等)
|
||||
* 幂等:未在共享时静默返回
|
||||
*/
|
||||
const stopScreenShare = async () => {
|
||||
if (!_engine || !localProducers.screen) return
|
||||
const pid = localProducers.screen
|
||||
// 先清本地引用,防止 producer.close 走完前 UI 重复触发
|
||||
localProducers.screen = null
|
||||
try {
|
||||
await _engine.closeProducer(pid)
|
||||
} catch (err) {
|
||||
_log('warn', '[Meeting] 关闭屏幕共享 Producer 失败(忽略,后端会兜底清理)', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 录制 actions(Phase B) ====================
|
||||
|
||||
/**
|
||||
* 启动录制(仅 host 可调用)
|
||||
* - REST 调用 startRecording → 后端 200 后会广播 meeting.recording.started
|
||||
* - 本 action 在 REST 成功时同步本地 recording 状态(pending=true 期间禁用按钮)
|
||||
* - 失败时 recording.pending 置回 false,错误向上抛由 UI toast
|
||||
*/
|
||||
const startRecording = async () => {
|
||||
if (!currentRoom.value) {
|
||||
throw new Error('当前未在会议中')
|
||||
}
|
||||
if (recording.pending) {
|
||||
_log('debug', '[Meeting] startRecording 防抖:已有 pending 请求')
|
||||
return
|
||||
}
|
||||
if (recording.id && recording.status === MEETING_RECORDING_STATUS_RECORDING) {
|
||||
_log('debug', '[Meeting] startRecording:当前已在录制,跳过')
|
||||
return
|
||||
}
|
||||
recording.pending = true
|
||||
try {
|
||||
const dto = await meetingApi.startRecording(currentRoom.value.room_code)
|
||||
_applyRecordingDTO(dto)
|
||||
_log('info', '[Meeting] 录制启动成功', dto?.id)
|
||||
return dto
|
||||
} catch (err) {
|
||||
recording.pending = false
|
||||
_log('error', '[Meeting] 录制启动失败', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录制(仅 host 可调用)
|
||||
* - REST 返回时录制已经处于 uploading(异步上传 MinIO);广播 stopped 在 ready/failed 终态触发
|
||||
* - 本 action 仅负责发起 REST + pending 标记,UI 等待广播终态
|
||||
*/
|
||||
const stopRecording = async () => {
|
||||
if (!currentRoom.value) {
|
||||
throw new Error('当前未在会议中')
|
||||
}
|
||||
if (!recording.id) {
|
||||
_log('debug', '[Meeting] stopRecording:当前无活跃录制')
|
||||
return
|
||||
}
|
||||
if (recording.pending) {
|
||||
_log('debug', '[Meeting] stopRecording 防抖:已有 pending 请求')
|
||||
return
|
||||
}
|
||||
recording.pending = true
|
||||
try {
|
||||
const dto = await meetingApi.stopRecording(currentRoom.value.room_code, recording.id)
|
||||
_applyRecordingDTO(dto)
|
||||
// pending 保持 true,由 _onRecordingStopped 广播置回 false(终态)
|
||||
_log('info', '[Meeting] 录制停止已发起,等待终态广播', dto?.id, dto?.status)
|
||||
return dto
|
||||
} catch (err) {
|
||||
recording.pending = false
|
||||
_log('error', '[Meeting] 录制停止失败', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Task 15:向房间其他成员广播自身音视频状态
|
||||
* - 后端 meeting.member.state.changed 语义:操作自己无需 target_user_id
|
||||
@@ -1099,7 +1434,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
/**
|
||||
* 取得某个远端用户的 track(供页面挂到 <video>/<audio>)
|
||||
* @param {number} userId
|
||||
* @param {'audio'|'video'} kind
|
||||
* @param {'audio'|'video'|'screen'} kind - Phase 3 起新增 'screen' 槽位
|
||||
* @returns {MediaStreamTrack|null}
|
||||
*/
|
||||
const getRemoteTrack = (userId, kind) => {
|
||||
@@ -1243,6 +1578,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
localAudioEnabled,
|
||||
localVideoEnabled,
|
||||
localProducers,
|
||||
screenShare,
|
||||
remoteConsumers,
|
||||
draftCreatePayload,
|
||||
draftJoinPayload,
|
||||
@@ -1269,6 +1605,11 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
startLocalVideo,
|
||||
stopLocalAudio,
|
||||
stopLocalVideo,
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
recording,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
getRemoteTrack,
|
||||
getLocalTrack,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user