Files
EchoChat/frontend/src/pages/meeting/create.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

188 lines
5.4 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
数据流
用户在本页填写 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>