fix(phase2e-2): 全局 H5 容器铺满视口 + 静默自动清理 stale 会议
问题
1. 桌面浏览器大窗口下所有页面内容偏左上、右下大片空白:
uni-app H5 的 html/body/#app/uni-page-* 默认不显式 100% 高度,
且 .room 仅靠 position:fixed inset:0 在某些模式下可能被祖先
容器尺寸限制,导致 toolbar / tabBar 不贴底、内容挤在左上角
2. 刷新会议后再次加入会触发 toast "你不在当前会议中":
cleanupStaleMeetings 对列表里已过期的会议调 leave 必然返回 400,
被全局 request 拦截器误报给用户
3. 会议界面部分按钮点击无反应:
根因同 #1,布局错位后按钮视觉位置与 hit box 不一致
修复
- App.vue: H5 条件编译注入全局基础样式,强制 html/body/#app/uni-app/
uni-page/uni-page-wrapper/uni-page-body 宽高铺满,min-height 100%
- room.vue: .room 追加 width/height/min-width/min-height: 100vw/100vh 冗余
+ z-index:10,彻底兜底 position:fixed 失效场景
- request.js: 新增 options.silent 选项,业务错误 / 401 / 403 / 500 /
网络异常均根据 silent 决定是否弹 toast;401 仍会跳登录
- api/meeting.js: createRoom / joinRoom / leaveRoom 均接受 options 参数
- store/meeting.js:
· cleanupStaleMeetings 内 leave 传 silent:true 忽略幂等错误
· joinAndEnter / createAndEnter 首次尝试(未重试时)传 silent:true,
若命中 staleMeetingHint 自动清理重试,对用户完全无感;重试仍失败
的真实错误走正常 toast
验证
- Playwright 1024x576:room 页 / profile 页 / chat 列表 / 刷新后重新加入
流程截图均显示全屏铺满、tabBar/toolbar 贴底、无误报 toast
- 刷新 → join → 预览 → 加入 日志:首次 400 静默 → 自动清理 → 重试加入
成功,MediaEngine 正常建立
Made-with: Cursor
This commit is contained in:
@@ -52,4 +52,23 @@ export default {
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* 每个页面公共 css */
|
/* 每个页面公共 css */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 基础全屏容器:防止 uni-app H5 在大窗口下祖先容器尺寸小于视口
|
||||||
|
* 导致 position: fixed 元素(如会议房间 .room)"偏左上"。
|
||||||
|
* 同时让普通页面在桌面宽屏下仍能把内容区(.page / uni-page-body)
|
||||||
|
* 拉伸到视口高度,避免下方出现大片浏览器默认底色。
|
||||||
|
*/
|
||||||
|
/* #ifdef H5 */
|
||||||
|
html, body, #app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
uni-app, uni-app > uni-page, uni-page-wrapper, uni-page-body {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
/* #endif */
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ const unwrap = (promise) => promise.then((resp) => (resp && 'data' in resp ? (re
|
|||||||
* @param {Object} payload - { title, password?, max_members? }
|
* @param {Object} payload - { title, password?, max_members? }
|
||||||
* @returns {Promise<{ room: MeetingRoomDTO }>}
|
* @returns {Promise<{ room: MeetingRoomDTO }>}
|
||||||
*/
|
*/
|
||||||
const createRoom = (payload) => {
|
const createRoom = (payload, options = {}) => {
|
||||||
return unwrap(post('/api/v1/meeting/rooms', payload))
|
return unwrap(post('/api/v1/meeting/rooms', payload, options))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,17 +44,18 @@ const getRoom = (roomCode) => {
|
|||||||
* @param {Object} [payload] - { password? }
|
* @param {Object} [payload] - { password? }
|
||||||
* @returns {Promise<{ room, participant, router_id }>}
|
* @returns {Promise<{ room, participant, router_id }>}
|
||||||
*/
|
*/
|
||||||
const joinRoom = (roomCode, payload = {}) => {
|
const joinRoom = (roomCode, payload = {}, options = {}) => {
|
||||||
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/join`, payload))
|
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/join`, payload, options))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 离开会议
|
* 离开会议
|
||||||
* @param {string} roomCode
|
* @param {string} roomCode
|
||||||
|
* @param {Object} [options] - 请求层选项,如 { silent: true } 抑制失败 toast
|
||||||
* @returns {Promise<{ duration }>}
|
* @returns {Promise<{ duration }>}
|
||||||
*/
|
*/
|
||||||
const leaveRoom = (roomCode) => {
|
const leaveRoom = (roomCode, options = {}) => {
|
||||||
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/leave`))
|
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/leave`, undefined, options))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -530,12 +530,20 @@ onUnload(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.room {
|
.room {
|
||||||
|
/* 使用 fixed + 100vw/100vh 双重保险:
|
||||||
|
某些祖先(uni-page-wrapper)带 transform 时 fixed 会相对该祖先而非视口;
|
||||||
|
显式 100vw/100vh 可保证始终铺满整个浏览器窗口。 */
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
min-width: 100vw;
|
||||||
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #0F172A;
|
background: #0F172A;
|
||||||
color: #F3F4F6;
|
color: #F3F4F6;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-bar {
|
.top-bar {
|
||||||
|
|||||||
@@ -534,7 +534,8 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
}
|
}
|
||||||
localState.value = MEETING_LOCAL_STATE_JOINING
|
localState.value = MEETING_LOCAL_STATE_JOINING
|
||||||
try {
|
try {
|
||||||
const resp = await meetingApi.createRoom(payload)
|
// 首次调用时抑制失败 toast:失败后若是 stale 残留会自动清理 + 重试,对用户无感
|
||||||
|
const resp = await meetingApi.createRoom(payload, _retried ? {} : { silent: true })
|
||||||
currentRoom.value = resp.room
|
currentRoom.value = resp.room
|
||||||
routerID.value = resp.router_id || ''
|
routerID.value = resp.router_id || ''
|
||||||
// 创建者 host participant 尚未通过 REST 返回,用本地 userStore 占位
|
// 创建者 host participant 尚未通过 REST 返回,用本地 userStore 占位
|
||||||
@@ -573,7 +574,8 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
}
|
}
|
||||||
localState.value = MEETING_LOCAL_STATE_JOINING
|
localState.value = MEETING_LOCAL_STATE_JOINING
|
||||||
try {
|
try {
|
||||||
const resp = await meetingApi.joinRoom(roomCode, { password })
|
// 首次调用时抑制失败 toast:失败后若是 stale 残留会自动清理 + 重试,对用户无感
|
||||||
|
const resp = await meetingApi.joinRoom(roomCode, { password }, _retried ? {} : { silent: true })
|
||||||
currentRoom.value = resp.room
|
currentRoom.value = resp.room
|
||||||
currentParticipant.value = resp.participant
|
currentParticipant.value = resp.participant
|
||||||
routerID.value = resp.router_id || ''
|
routerID.value = resp.router_id || ''
|
||||||
@@ -769,7 +771,9 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
const cleanedCodes = []
|
const cleanedCodes = []
|
||||||
for (const room of list) {
|
for (const room of list) {
|
||||||
try {
|
try {
|
||||||
await meetingApi.leaveRoom(room.room_code)
|
// silent: true —— 清理流程中 leave 失败(如 "你不在此会议中")
|
||||||
|
// 属于幂等预期错误,不应弹 toast 打扰用户
|
||||||
|
await meetingApi.leaveRoom(room.room_code, { silent: true })
|
||||||
cleanedCodes.push(room.room_code)
|
cleanedCodes.push(room.room_code)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
_log('warn', '[Meeting] 清理遗留会议失败(忽略继续)', room.room_code, err)
|
_log('warn', '[Meeting] 清理遗留会议失败(忽略继续)', room.room_code, err)
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ const TIMEOUT = 15000
|
|||||||
* @param {Object} [options.params] - URL Query 参数
|
* @param {Object} [options.params] - URL Query 参数
|
||||||
* @param {Object} [options.header] - 自定义请求头
|
* @param {Object} [options.header] - 自定义请求头
|
||||||
* @param {boolean} [options.needAuth=true] - 是否需要自动附加 Token
|
* @param {boolean} [options.needAuth=true] - 是否需要自动附加 Token
|
||||||
|
* @param {boolean} [options.silent=false] - 是否抑制失败 toast(用于内部重试 / 清理等静默流程)
|
||||||
* @returns {Promise<Object>} 响应中的 data 字段(已解包)
|
* @returns {Promise<Object>} 响应中的 data 字段(已解包)
|
||||||
*/
|
*/
|
||||||
const request = (options) => {
|
const request = (options) => {
|
||||||
|
const silent = options.silent === true
|
||||||
|
const toastIfNotSilent = (toast) => {
|
||||||
|
if (!silent) uni.showToast(toast)
|
||||||
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 构建请求头
|
// 构建请求头
|
||||||
const header = {
|
const header = {
|
||||||
@@ -66,7 +71,7 @@ const request = (options) => {
|
|||||||
resolve(data)
|
resolve(data)
|
||||||
} else {
|
} else {
|
||||||
// code 非 0 表示业务逻辑错误
|
// code 非 0 表示业务逻辑错误
|
||||||
uni.showToast({
|
toastIfNotSilent({
|
||||||
title: data.message || '请求失败',
|
title: data.message || '请求失败',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
duration: 2000
|
duration: 2000
|
||||||
@@ -76,7 +81,7 @@ const request = (options) => {
|
|||||||
} else if (statusCode === 401) {
|
} else if (statusCode === 401) {
|
||||||
const isAuthEndpoint = /\/auth\/(login|register)$/.test(options.url)
|
const isAuthEndpoint = /\/auth\/(login|register)$/.test(options.url)
|
||||||
const message = data?.message || '登录已过期,请重新登录'
|
const message = data?.message || '登录已过期,请重新登录'
|
||||||
uni.showToast({ title: message, icon: 'none', duration: 2000 })
|
toastIfNotSilent({ title: message, icon: 'none', duration: 2000 })
|
||||||
if (!isAuthEndpoint) {
|
if (!isAuthEndpoint) {
|
||||||
removeToken()
|
removeToken()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -85,7 +90,7 @@ const request = (options) => {
|
|||||||
}
|
}
|
||||||
reject({ code: 401, message })
|
reject({ code: 401, message })
|
||||||
} else if (statusCode === 403) {
|
} else if (statusCode === 403) {
|
||||||
uni.showToast({
|
toastIfNotSilent({
|
||||||
title: data.message || '没有访问权限',
|
title: data.message || '没有访问权限',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
duration: 2000
|
duration: 2000
|
||||||
@@ -93,7 +98,7 @@ const request = (options) => {
|
|||||||
reject(data)
|
reject(data)
|
||||||
} else {
|
} else {
|
||||||
// 其他 HTTP 错误(400/404/500 等)
|
// 其他 HTTP 错误(400/404/500 等)
|
||||||
uni.showToast({
|
toastIfNotSilent({
|
||||||
title: data.message || `请求错误 (${statusCode})`,
|
title: data.message || `请求错误 (${statusCode})`,
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
duration: 2000
|
duration: 2000
|
||||||
@@ -103,7 +108,7 @@ const request = (options) => {
|
|||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
// 网络错误或请求超时
|
// 网络错误或请求超时
|
||||||
uni.showToast({
|
toastIfNotSilent({
|
||||||
title: '网络异常,请检查网络连接',
|
title: '网络异常,请检查网络连接',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
duration: 2000
|
duration: 2000
|
||||||
|
|||||||
Reference in New Issue
Block a user