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

@@ -0,0 +1,187 @@
<!--
创建会议表单页Task 10
数据流
用户在本页填写 title/password/开关 点击"下一步"
保存表单到 meetingStore.draftCreatePayload
uni.navigateTo('/pages/meeting/preview?mode=create')
在预览页选定设备后真正调用 createAndEnter
设计依据
- Phase 2e-2 设计 §8.1 路由表 + D04 决策入会前强制经过设备预览页
- 本页不调用任何会议 API无副作用取消即返回
-->
<template>
<view class="page">
<view class="container">
<text class="title">创建会议</text>
<text class="subtitle">填写会议基本信息下一步设置摄像头与麦克风</text>
<view class="card">
<view class="field">
<text class="label">会议标题 <text class="required">*</text></text>
<input
v-model="form.title"
class="input"
placeholder="例如:周会同步(限 40 字)"
maxlength="40"
/>
<text class="hint" v-if="titleError">{{ titleError }}</text>
</view>
<view class="field">
<text class="label">会议密码可选</text>
<input
v-model="form.password"
class="input"
placeholder="留空表示无密码4-16 位数字/字母"
maxlength="16"
/>
<text class="hint" v-if="passwordError">{{ passwordError }}</text>
</view>
<view class="field switch-row">
<view class="switch-label">
<text class="label">入会默认静音</text>
<text class="desc">新成员加入时麦克风默认关闭</text>
</view>
<switch :checked="form.enter_muted" @change="onToggleMuted" color="#3B82F6" />
</view>
<view class="field switch-row">
<view class="switch-label">
<text class="label">允许会议内聊天</text>
<text class="desc">关闭后成员不能发送聊天消息</text>
</view>
<switch :checked="form.allow_chat" @change="onToggleChat" color="#3B82F6" />
</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 { reactive, computed } from 'vue'
import { useMeetingStore } from '@/store/meeting'
import { useUserStore } from '@/store/user'
const meetingStore = useMeetingStore()
const userStore = useUserStore()
const defaultTitle = () => {
const name = userStore.userInfo?.nickname || userStore.userInfo?.username || '我'
return `${name}的会议`
}
const form = reactive({
title: defaultTitle(),
password: '',
enter_muted: false,
allow_chat: true
})
const titleError = computed(() => {
const t = form.title.trim()
if (!t) return '会议标题不能为空'
if (t.length > 40) return '会议标题不能超过 40 字'
return ''
})
const passwordError = computed(() => {
const p = form.password
if (!p) return ''
if (p.length < 4 || p.length > 16) return '密码长度需为 4-16 位'
if (!/^[A-Za-z0-9]+$/.test(p)) return '密码仅支持数字和英文字母'
return ''
})
const isFormValid = computed(() => !titleError.value && !passwordError.value)
const onToggleMuted = (e) => { form.enter_muted = e.detail.value }
const onToggleChat = (e) => { form.allow_chat = e.detail.value }
const onNext = () => {
if (!isFormValid.value) return
meetingStore.draftCreatePayload = {
title: form.title.trim(),
password: form.password || undefined,
enter_muted: form.enter_muted,
allow_chat: form.allow_chat
}
uni.navigateTo({ url: '/pages/meeting/preview?mode=create' })
}
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; }
.desc { font-size: 24rpx; color: #94A3B8; display: block; margin-top: 4rpx; }
.hint { font-size: 22rpx; color: #EF4444; display: block; margin-top: 8rpx; }
.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; }
.switch-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.switch-label { flex: 1; }
.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>