feat(phase2e-2): 前端创建/加入/设备预览 3 页落地(Task 10)

页面
- pages/meeting/create.vue:创建表单,标题(默认"{昵称}的会议"/40 字限制) + 密码(4-16 位)
  + 入会静音开关 + 允许聊天开关。提交时表单暂存到 store.draftCreatePayload,跳 preview 页
- pages/meeting/join.vue:会议号输入(9 位纯数字/带连字符均可,自动格式化 XXX-XXX-XXX)
  + 可选密码。支持 URL query code/password 回填(邀请链接一键入会入口)
- pages/meeting/preview.vue:设备预览页
  - navigator.mediaDevices.enumerateDevices() 列出摄像头/麦克风/扬声器
  - getUserMedia({video,audio}) 本地画面预览(用原生 <video> 避开 uni-input 对 srcObject 的限制)
  - AudioContext.AnalyserNode 以 rAF 采样 RMS,渲染渐变音量条
  - 显示名输入 + 入会默认开麦/开摄像头开关
  - 权限被拒/浏览器不支持 WebRTC 时给清晰错误提示
  - 离开页时释放 preview stream 与 AudioContext,避免占用摄像头

Store 扩展(meeting.js)
- 新增 state:draftCreatePayload(create→preview 暂存) + devicePreview(设备选择结果)
- createAndEnter(payload, mediaPrefs?) / joinAndEnter(code, password, mediaPrefs?) 新增
  可选 mediaPrefs 参数:{ startAudio, startVideo, audioDeviceId, videoDeviceId }
- _afterJoined 内按 mediaPrefs 入会后自动 startLocalAudio/Video(失败仅告警不阻断)
- startLocalAudio/Video 支持可选 deviceId,映射到 getUserMedia 的 deviceId.exact 约束

路由
- pages.json 注册 create/join/preview 3 条路由(均非 TabBar 页,从 URL 或后续入口跳入)

临时占位
- preview 页加入成功后暂时 redirectTo 到 debug.vue(Task 11 room 页待落地时改为 /pages/meeting/room)

Playwright 验证(真实 macOS 设备)
- create → preview:表单 "新昵称的会议" 正确携带到 preview 页
- preview 页:FaceTime 摄像头 + MacBook Pro 麦克风枚举成功,video readyState=4,音量条工作正常
- 点"加入会议":createAndEnter 成功,debug 页显示 localState=connected,主持人=是
  mediaPrefs 生效——麦克风/摄像头按钮显示"关闭麦克风/关闭摄像头"说明自动推流成功
- join 页:URL ?code=123-456-789&password=secret 正确预填,"下一步" 按钮启用

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-04-22 14:20:57 +08:00
parent 467135e0d5
commit eb3857a37a
5 changed files with 935 additions and 12 deletions

View File

@@ -104,6 +104,27 @@ export const useMeetingStore = defineStore('meeting', () => {
*/
const remoteConsumers = reactive({})
/**
* 创建会议表单草稿Task 10
* create.vue 填完表单后暂存于此preview.vue 点"加入会议"时才真正调用 createAndEnter。
* 字段:{ title, password?, enter_muted, allow_chat }
*/
const draftCreatePayload = ref(null)
/**
* 设备预览页的设备选择与显示名Task 10
* preview.vue 选完设备后写入,入会时作为 mediaPrefs 传给 createAndEnter / joinAndEnter
* 字段:{ audioDeviceId, videoDeviceId, speakerDeviceId, displayName, startAudio, startVideo }
*/
const devicePreview = ref({
audioDeviceId: '',
videoDeviceId: '',
speakerDeviceId: '',
displayName: '',
startAudio: true,
startVideo: true
})
/**
* 标记:当前在 Store 内的事件监听器集合(进房注册,离房注销)
* Key 是事件名Value 是监听函数引用
@@ -411,8 +432,14 @@ export const useMeetingStore = defineStore('meeting', () => {
/**
* 入会后的共同后置流程:加载 Device、创建两条 Transport、发 room.join 信令、订阅在线 Producer
* @param {Object} opts
* @param {Object} opts.rtpCapabilities - Router RTP capabilities
* @param {Object} [opts.mediaPrefs] - 设备预览页传入的媒体偏好,入会成功后自动推流
* - startAudio: boolean 是否默认开麦
* - startVideo: boolean 是否默认开摄像头
* - audioDeviceId / videoDeviceId: 指定采集设备(可空→走系统默认)
*/
const _afterJoined = async ({ rtpCapabilities }) => {
const _afterJoined = async ({ rtpCapabilities, mediaPrefs }) => {
if (!isMediaSupported()) {
throw new Error('当前平台不支持会议(仅支持 H5 浏览器)')
}
@@ -451,15 +478,28 @@ export const useMeetingStore = defineStore('meeting', () => {
}
localState.value = MEETING_LOCAL_STATE_CONNECTED
// mediaPrefs 自动推流:失败仅告警,不阻断入会流程,允许用户在会议室内手动重试开麦/开摄像头
if (mediaPrefs) {
if (mediaPrefs.startAudio) {
try { await startLocalAudio(mediaPrefs.audioDeviceId) }
catch (e) { _log('warn', '[Meeting] 自动开麦失败', e) }
}
if (mediaPrefs.startVideo) {
try { await startLocalVideo(mediaPrefs.videoDeviceId) }
catch (e) { _log('warn', '[Meeting] 自动开摄像头失败', e) }
}
}
}
// ==================== Actions会议生命周期 ====================
/**
* 创建即时会议并立即入会
* @param {Object} payload - { title, password?, max_members? }
* @param {Object} payload - { title, password?, max_members?, enter_muted?, allow_chat? }
* @param {Object} [mediaPrefs] - 设备预览页的媒体偏好,入会成功后自动推流(可空)
*/
const createAndEnter = async (payload) => {
const createAndEnter = async (payload, mediaPrefs) => {
if (isInMeeting.value) {
throw new Error('你当前已在其他会议中')
}
@@ -476,7 +516,7 @@ export const useMeetingStore = defineStore('meeting', () => {
is_active: true,
joined_at: new Date().toISOString()
}
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities })
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities, mediaPrefs })
return currentRoom.value
} catch (err) {
_log('error', '[Meeting] 创建并入会失败', err)
@@ -489,8 +529,9 @@ export const useMeetingStore = defineStore('meeting', () => {
* 加入已有会议
* @param {string} roomCode
* @param {string} [password]
* @param {Object} [mediaPrefs] - 设备预览页的媒体偏好(可空)
*/
const joinAndEnter = async (roomCode, password = '') => {
const joinAndEnter = async (roomCode, password = '', mediaPrefs) => {
if (isInMeeting.value) {
throw new Error('你当前已在其他会议中')
}
@@ -500,7 +541,7 @@ export const useMeetingStore = defineStore('meeting', () => {
currentRoom.value = resp.room
currentParticipant.value = resp.participant
routerID.value = resp.router_id || ''
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities })
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities, mediaPrefs })
return currentRoom.value
} catch (err) {
_log('error', '[Meeting] 加入会议失败', err)
@@ -540,22 +581,34 @@ export const useMeetingStore = defineStore('meeting', () => {
// ==================== Actions本地推流 ====================
/** 开启本地音频 */
const startLocalAudio = async () => {
/**
* 开启本地音频
* @param {string} [deviceId] - 指定麦克风 deviceId从 enumerateDevices 获取),为空则走系统默认
*/
const startLocalAudio = async (deviceId) => {
if (!_engine) throw new Error('未连接')
if (localProducers.audio) return
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const audioConstraints = deviceId ? { deviceId: { exact: deviceId } } : true
const stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints })
const track = stream.getAudioTracks()[0]
const producer = await _engine.produce({ kind: 'audio', track })
localProducers.audio = producer.id
localAudioEnabled.value = true
}
/** 开启本地视频 */
const startLocalVideo = async () => {
/**
* 开启本地视频
* @param {string} [deviceId] - 指定摄像头 deviceId为空则走系统默认
*/
const startLocalVideo = async (deviceId) => {
if (!_engine) throw new Error('未连接')
if (localProducers.video) return
const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } })
const videoConstraints = {
width: 640,
height: 480,
...(deviceId ? { deviceId: { exact: deviceId } } : {})
}
const stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints })
const track = stream.getVideoTracks()[0]
const producer = await _engine.produce({ kind: 'video', track })
localProducers.video = producer.id
@@ -671,6 +724,8 @@ export const useMeetingStore = defineStore('meeting', () => {
localVideoEnabled,
localProducers,
remoteConsumers,
draftCreatePayload,
devicePreview,
// getters
isInMeeting,