页面
- 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
169 lines
5.1 KiB
Vue
169 lines
5.1 KiB
Vue
<!--
|
||
加入会议表单页(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>
|