Files
EchoChat/frontend/src/pages/meeting/join.vue
bujinyuan eb3857a37a 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
2026-04-22 14:20:57 +08:00

169 lines
5.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
加入会议表单页Task 10
数据流
用户输入会议号可粘贴带 / 不带连字符的 9 位数字+ 可选密码 点击"下一步"
uni.navigateTo('/pages/meeting/preview?mode=join&code=XXX-XXX-XXX&password=xxx')
预览页选定设备后真正调用 joinAndEnter
邀请链接支持
- URL 参数 code=XXX-XXX-XXX必传时直接回填
- URL 参数 password=xxx可选从邀请短链来
- 参考设计 §2.2.1邀请链接形如 /#/pages/meeting/join?code=XXX-XXX-XXX
-->
<template>
<view class="page">
<view class="container">
<text class="title">加入会议</text>
<text class="subtitle">输入主持人分享的 9 位会议号下一步选择设备</text>
<view class="card">
<view class="field">
<text class="label">会议号 <text class="required">*</text></text>
<input
v-model="rawCodeInput"
class="input code-input"
placeholder="XXX-XXX-XXX"
maxlength="11"
@input="onCodeInput"
/>
<text class="hint" v-if="codeError">{{ codeError }}</text>
<text class="hint neutral" v-else-if="formattedCode">已识别{{ formattedCode }}</text>
</view>
<view class="field">
<text class="label">会议密码如有</text>
<input
v-model="form.password"
class="input"
placeholder="若会议设置了密码请填写"
maxlength="16"
/>
</view>
</view>
<view class="actions">
<button class="btn btn-secondary" @click="onCancel">取消</button>
<button class="btn btn-primary" :disabled="!isFormValid" @click="onNext">下一步设备预览</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
const rawCodeInput = ref('')
const form = reactive({ password: '' })
/** 去掉所有非数字字符,截至 9 位 */
const digitsOf = (s) => (s || '').replace(/\D/g, '').slice(0, 9)
/** 把 9 位数字格式化为 XXX-XXX-XXX不足 9 位时按实际位数插连字符) */
const formatCode = (digits) => {
if (digits.length <= 3) return digits
if (digits.length <= 6) return `${digits.slice(0, 3)}-${digits.slice(3)}`
return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`
}
const formattedCode = computed(() => {
const d = digitsOf(rawCodeInput.value)
return d.length === 9 ? formatCode(d) : ''
})
const codeError = computed(() => {
const d = digitsOf(rawCodeInput.value)
if (d.length === 0) return ''
if (d.length < 9) return `还差 ${9 - d.length}`
return ''
})
const isFormValid = computed(() => digitsOf(rawCodeInput.value).length === 9)
const onCodeInput = (e) => {
// 同时兼容 uni-app 事件e.detail.value与原生 input 事件e.target.value自动化测试友好
const raw = e?.detail?.value ?? e?.target?.value ?? rawCodeInput.value
const d = digitsOf(raw)
rawCodeInput.value = formatCode(d)
}
onLoad((query) => {
if (query?.code) {
const d = digitsOf(query.code)
if (d.length === 9) rawCodeInput.value = formatCode(d)
}
if (query?.password) {
form.password = String(query.password)
}
})
const onNext = () => {
if (!isFormValid.value) return
const code = formattedCode.value
const passwordQuery = form.password ? `&password=${encodeURIComponent(form.password)}` : ''
uni.navigateTo({
url: `/pages/meeting/preview?mode=join&code=${code}${passwordQuery}`
})
}
const onCancel = () => {
uni.navigateBack()
}
</script>
<style scoped>
.page { min-height: 100vh; background: #F8FAFC; padding: 24rpx; }
.container { max-width: 680rpx; margin: 0 auto; }
.title { font-size: 40rpx; font-weight: 600; color: #0F172A; display: block; margin: 24rpx 0 8rpx; }
.subtitle { font-size: 26rpx; color: #64748B; display: block; margin-bottom: 32rpx; }
.card {
background: #FFFFFF;
border-radius: 16rpx;
padding: 32rpx;
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.04);
}
.field { margin-bottom: 28rpx; }
.field:last-child { margin-bottom: 0; }
.label { font-size: 28rpx; color: #1E293B; font-weight: 500; display: block; margin-bottom: 12rpx; }
.required { color: #EF4444; font-weight: 600; }
.hint { font-size: 22rpx; color: #EF4444; display: block; margin-top: 8rpx; }
.hint.neutral { color: #10B981; }
.input {
width: 100%;
padding: 20rpx 24rpx;
background: #F1F5F9;
border-radius: 12rpx;
font-size: 28rpx;
color: #0F172A;
border: 1px solid transparent;
}
.input:focus { border-color: #3B82F6; background: #FFFFFF; }
.code-input {
font-size: 36rpx;
letter-spacing: 4rpx;
text-align: center;
font-variant-numeric: tabular-nums;
}
.actions {
display: flex;
gap: 24rpx;
margin-top: 40rpx;
}
.btn {
flex: 1;
padding: 24rpx 0;
border-radius: 12rpx;
font-size: 30rpx;
font-weight: 500;
border: none;
}
.btn-secondary { background: #F1F5F9; color: #475569; }
.btn-primary { background: #3B82F6; color: #FFFFFF; }
.btn-primary[disabled] { background: #BFDBFE; color: #FFFFFF; }
</style>