视频会议保存

This commit is contained in:
duoaohui
2026-05-18 19:42:55 +08:00
parent 315884685f
commit ac8ec4c903
41 changed files with 5069 additions and 39 deletions

View 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写入 storagesaveToken + 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>

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

View File

@@ -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)
/** 录制按钮 loadingREST 调用期间或后端异步上传期间均禁用 */
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;