/** * 屏幕尺寸检测工具 * 用于识别小屏幕设备并提供响应式布局参数 * * V2.0: 完全计算式布局系统 * - 从可用高度出发,逆向计算所有元素位置 * - 确保无重叠、无溢出 * - 自动降级(滚动) */ export interface ScreenSize { width: number; height: number; isSmallScreen: boolean; isVerySmallScreen: boolean; isTooSmall: boolean; // 极小屏幕,需要滚动条 availableHeight: number; } /** * 动态检测浏览器底部安全区域 * 用于适配移动端浏览器的系统 UI(工具栏、Home Indicator 等) * * @returns 底部安全区域的像素值(px) */ export function detectBottomSafeArea(): number { // 方法 1: 尝试读取 CSS env(safe-area-inset-bottom) // 这是 iOS Safari 和部分 Android 浏览器支持的标准方法 const testDiv = document.createElement('div'); testDiv.style.cssText = ` position: fixed; bottom: 0; left: 0; width: 1px; height: 1px; padding-bottom: env(safe-area-inset-bottom); pointer-events: none; visibility: hidden; `; document.body.appendChild(testDiv); const computedStyle = window.getComputedStyle(testDiv); const paddingBottom = parseFloat(computedStyle.paddingBottom); document.body.removeChild(testDiv); // 如果成功读取到安全区域值(> 0) if (paddingBottom > 0) { return paddingBottom; } // 方法 2: 使用 visualViewport API 检测实际可视区域 // 某些浏览器会因为底部工具栏而缩小可视区域 if (window.visualViewport) { const viewportHeight = window.visualViewport.height; const windowHeight = window.innerHeight; const difference = windowHeight - viewportHeight; // 如果差异 > 20px,认为有底部 UI 遮挡 if (difference > 20) { return difference; } } // 方法 3: 根据 User Agent 提供默认值 const ua = navigator.userAgent.toLowerCase(); // iOS 设备(有 Home Indicator 或 Home 按钮) if (/iphone|ipad|ipod/.test(ua)) { // iPhone X 及以后的全面屏机型 if (window.screen.height >= 812) { return 34; // Home Indicator 高度 } // 老款 iPhone(有 Home 按钮) return 0; } // Android 设备 if (/android/.test(ua)) { return 48; // Android 底部导航栏典型高度 } // 桌面浏览器 return 0; } /** * 获取当前屏幕尺寸信息 */ export const getScreenSize = (): ScreenSize => { const width = window.innerWidth; const height = window.innerHeight; // 小屏幕:高度 < 700px (iPhone SE, iPhone 8 等) const isSmallScreen = height < 700; // 超小屏幕:高度 < 650px (iPhone SE 第一代等) const isVerySmallScreen = height < 650; // 极小屏幕:宽度 < 280px 或 高度 < 500px // 此时即使最小缩放也无法完整显示,需要启用滚动条 const isTooSmall = width < 280 || height < 500; // 可用高度(扣除浏览器UI) const availableHeight = height; return { width, height, isSmallScreen, isVerySmallScreen, isTooSmall, availableHeight, }; }; /** * 根据屏幕高度计算缩放比例 * @param baseHeight 基准高度(设计稿高度,如 852px) * @returns 缩放比例 (0.7 - 1.0) */ export const getScaleFactor = (baseHeight: number = 852): number => { const { height } = getScreenSize(); // 计算缩放比例,最小 0.7,最大 1.0 const scale = Math.max(0.7, Math.min(1.0, height / baseHeight)); return scale; }; /** * 根据屏幕高度获取布局参数 */ export interface LayoutParams { // 头像区域 avatarScale: number; avatarTopOffset: number; // 情感标签 emotionTopOffset: number; // 录音按钮 recordButtonTopOffset: number; recordButtonSize: number; // 声纹波形 waveformHeight: number; // 间距 verticalGap: number; buttonGap: number; // 取消和发送按钮之间的水平间距 // 进度环边距(进度环比录音按钮大的尺寸) progressRingPadding: number; } export const getLayoutParams = (): LayoutParams => { const { isSmallScreen, isVerySmallScreen, isTooSmall } = getScreenSize(); if (isTooSmall) { // 极小屏幕 (< 280px 宽或 < 500px 高):最小可用尺寸 // 此时会启用滚动条,但保证元素可点击 return { avatarScale: 0.6, avatarTopOffset: 60, emotionTopOffset: 320, recordButtonTopOffset: 380, recordButtonSize: 72, // 最小可点击尺寸 waveformHeight: 44, verticalGap: 10, buttonGap: 35, // 固定值,不缩放 progressRingPadding: 14, // 72 * 0.75 * 0.26 ≈ 14 (按比例缩放) }; } else if (isVerySmallScreen) { // 超小屏幕 (< 650px):大幅缩小 return { avatarScale: 0.7, avatarTopOffset: 80, emotionTopOffset: 380, recordButtonTopOffset: 450, recordButtonSize: 80, waveformHeight: 48, verticalGap: 12, buttonGap: 35, // 固定值,不缩放 progressRingPadding: 15, // 80 * 0.83 * 0.23 ≈ 15 (按比例缩放) }; } else if (isSmallScreen) { // 小屏幕 (< 700px):适度缩小 return { avatarScale: 0.8, avatarTopOffset: 100, emotionTopOffset: 420, recordButtonTopOffset: 500, recordButtonSize: 88, waveformHeight: 52, verticalGap: 16, buttonGap: 35, progressRingPadding: 17, // 88 * 0.92 * 0.21 ≈ 17 (按比例缩放) }; } else { // 正常屏幕:使用设计稿尺寸(393×852) return { avatarScale: 1.0, avatarTopOffset: 160, emotionTopOffset: 640, recordButtonTopOffset: 693, recordButtonSize: 101, waveformHeight: 59, verticalGap: 21, buttonGap: 37, progressRingPadding: 19, }; } }; /** * 监听屏幕尺寸变化 */ export const onScreenResize = (callback: (size: ScreenSize) => void): (() => void) => { const handler = () => { callback(getScreenSize()); }; window.addEventListener('resize', handler); window.addEventListener('orientationchange', handler); // 返回清理函数 return () => { window.removeEventListener('resize', handler); window.removeEventListener('orientationchange', handler); }; }; /** * 设置极小屏幕标记(用于 CSS 选择器) * 当屏幕太小时,自动启用滚动条降级方案 */ export const updateTooSmallFlag = (): void => { const { isTooSmall } = getScreenSize(); // 设置 HTML 元素标记 document.documentElement.setAttribute('data-too-small', String(isTooSmall)); // 设置 body 元素标记 document.body.setAttribute('data-too-small', String(isTooSmall)); // 设置 root 元素标记 const root = document.getElementById('root'); if (root) { root.setAttribute('data-too-small', String(isTooSmall)); } // 设置所有 content-card 元素标记 const cards = document.querySelectorAll('.content-card'); cards.forEach(card => { card.setAttribute('data-too-small', String(isTooSmall)); }); // 极小屏幕已启用滚动条降级方案 }; // ============================================================================ // V2.0: 完全计算式布局系统 // ============================================================================ /** * 基准布局参数(设计稿:393×852,由原 375×812 等比换算) */ const BASE_LAYOUT = { // 视口 baseWidth: 393, baseHeight: 852, // 安全区域 topSafeArea: 46, // 顶部安全区(状态栏) bottomSafeArea: 36, // ⚠️ 已废弃:底部安全区现在使用 detectBottomSafeArea() 动态检测 // 进度环容器距离底部(设计稿基准) progressContainerBottom: 88, // 进度环容器距离视口底部的距离 // AnimaZone 头像区域(新设计稿) avatarWidth: 360, // 设计稿宽度 avatarHeight: 357, // 设计稿高度 avatarTop: 260, // 距离顶部位置 avatarContainerHeight: 502, // 装饰元素 treesWidth: 414, treesLeft: -27, // 设计稿中树木 left(向左偏移,部分超出屏幕) avatarCSize: 42, // 情感标签 emotionTop: 53, // 距离顶部位置 emotionHeight: 38, emotionGap: 21, // 情感标签与头像的间距 // 录音按钮 recordButtonSize: 101, recordButtonMinSize: 46, // 进度环(基于设计稿 393×852) progressRingPadding: 19, progressRingToAvatarGap: 27, // 进度环顶部距离头像底部的间距 progressRingSize: 120, // 进度环容器尺寸 // 波形图 waveformHeight: 59, // 间距 verticalGap: 21, // 波形与按钮的垂直间距 buttonGap: 37, // 取消和发送按钮之间的水平间距(仅用于旧布局,V3 等比例版本中会根据按钮尺寸重新计算) minGapBetweenElements: 17, // 元素之间最小间距 } as const; /** * 完全计算式布局结果 */ export interface CalculatedLayout { // 视口信息 viewportWidth: number; viewportHeight: number; // 缩放比例 avatarScale: number; // 各元素位置(绝对定位) avatarCTop: number; avatarCRight: number; treesTop: number; treesLeft: number; // 树木装饰 left 位置(自适应) animaZoneTop: number; mainAvatarTop: number; emotionTop: number; progressRingTop: number; // 进度环距离顶部 progressRingBottom: number; recordButtonBottom: number; waveformBottom: number; // 各元素尺寸 avatarCSize: number; treesWidth: number; mainAvatarWidth: number; mainAvatarHeight: number; emotionHeight: number; recordButtonSize: number; progressRingSize: number; waveformHeight: number; // 行为按钮(取消/发送)尺寸 actionButtonSize: number; // 间距 verticalGap: number; buttonGap: number; // 取消和发送按钮之间的水平间距(随按钮尺寸弹性变化) progressRingPadding: number; recordButtonOffset: number; // 录音按钮相对于进度环的 top 偏移 // Tooltips 位置和尺寸(等比例缩放) tooltips: { progressBar: { width: number; height: number; top: number; left: number }; tapToChat: { width: number; height: number; top: number; left: number }; cancel: { width: number; height: number; bottom: string; left: string }; send: { width: number; height: number; bottom: string; right: string }; }; // 状态标记 needsScroll: boolean; // 是否需要滚动条 hasOverlap: boolean; // 是否存在重叠 availableSpace: number; // 剩余可用空间 contentHeight: number; // 内容总高度 } /** * 完全计算式布局系统 - V3.0 等比例缩放版本 * * 基于 393×852 基准设计稿,进行等比例缩放适配 * * @param viewportWidth 视口宽度 * @param viewportHeight 视口高度 * @returns 完整的布局计算结果 */ export function calculateLayout( viewportWidth: number = window.innerWidth, viewportHeight: number = window.innerHeight ): CalculatedLayout { const BASE = BASE_LAYOUT; // ============================================================================ // V3.0: 等比例缩放策略 // ============================================================================ // 1. 计算缩放比例(以高度为主,宽度为辅) const scaleByHeight = viewportHeight / BASE.baseHeight; const scaleByWidth = viewportWidth / BASE.baseWidth; // 取较小的缩放比例,确保内容不会溢出 let scale = Math.min(scaleByHeight, scaleByWidth); // 限制缩放范围:仅限制最小值,移除最大值限制 // - 最小 0.6:避免元素过小不可点击 // - 无最大值限制:完全自适应所有大屏幕 scale = Math.max(0.6, scale); // 2. 基于缩放比例计算所有元素尺寸 const waveformHeight = Math.round(BASE.waveformHeight * scale); const verticalGap = Math.round(BASE.verticalGap * scale); const recordButtonSize = Math.max(44, Math.round(BASE.recordButtonSize * scale)); // 最小 44px 可点击 const progressRingPadding = Math.round(BASE.progressRingPadding * scale); const progressRingSize = recordButtonSize + progressRingPadding; // 行为按钮(取消/发送)尺寸与录音按钮保持 0.58 比例 const actionButtonSize = Math.round(recordButtonSize * 0.58); // 为了让两按钮的中心距离约等于两个按钮宽度之和 D ≈ 2 * actionButtonSize: // 当前几何关系:中心距 D = actionButtonSize + 2 * buttonGap // 令 D = 2 * actionButtonSize,可得 buttonGap = actionButtonSize / 2 const buttonGap = Math.round(actionButtonSize / 2); // 3. 动态检测底部安全区域(移动端适配) // 声波纹直接贴底,不需要安全区域 const bottomSafeArea = 0; // detectBottomSafeArea(); // 4. 计算顶部安全区域 const topSafeArea = Math.round(BASE.topSafeArea * scale); // 5. 计算各元素尺寸(等比例缩放) const avatarCSize = Math.round(BASE.avatarCSize * scale); const treesWidth = Math.round(BASE.treesWidth * scale); const mainAvatarWidth = Math.round(BASE.avatarWidth * scale); const mainAvatarHeight = Math.round(BASE.avatarHeight * scale); const emotionHeight = Math.round(BASE.emotionHeight * scale); // 6. 计算各元素位置(等比例缩放,基于设计稿位置) // 所有位置按相同缩放比例计算,保持设计稿布局 const avatarCTop = Math.round(66 * scale); // 设计稿中 Avatar-C 距顶部(393×852) const avatarCRight = Math.round(35 * scale); // 设计稿中 Avatar-C 距右侧 const treesTop = Math.round(184 * scale); // 设计稿中树木距顶部 const treesLeft = Math.round(BASE.treesLeft * scale); // 设计稿中树木 left: -26px(自适应) const animaZoneTop = Math.round(BASE.avatarTop * scale); // 设计稿中头像距顶部 248px const mainAvatarTop = topSafeArea; // 头像区域从安全区域开始 const emotionTop = Math.round(BASE.emotionTop * scale); // 设计稿中情感标签距顶部 50px // 进度环位置 = 头像底部 + 间距(26px) const avatarBottom = animaZoneTop + mainAvatarHeight; const progressRingTop = avatarBottom + Math.round(BASE.progressRingToAvatarGap * scale); // 7. 计算底部元素位置(从下往上) // 音频波形底部保持安全区域,避免被移动端系统 UI 遮挡 const waveformBottom = bottomSafeArea; // 🔧 FIX: 进度环 bottom 值 = 视口高度 - 进度环顶部 - 进度环尺寸 // 这样确保进度环始终在头像下方固定距离,不受视口高度变化影响 const progressRingBottom = viewportHeight - progressRingTop - progressRingSize; // 录音按钮底部位置(在进度环内居中,略微偏下) const recordButtonBottom = progressRingBottom + progressRingPadding / 2; // 8. 检查是否需要滚动 // 🔧 FIX: 计算进度环实际需要的空间 // 进度环底部应该距离视口底部有 bottomSafeArea 的空间 const progressRingActualBottom = progressRingTop + progressRingSize; const contentHeight = progressRingActualBottom + bottomSafeArea; // 当内容高度超过视口高度,或进度环底部超出视口时,启用滚动 const needsScroll = contentHeight > viewportHeight || progressRingActualBottom > viewportHeight || viewportHeight < 500; const hasOverlap = false; // 等比例缩放不会产生重叠 const availableSpace = viewportHeight - contentHeight; // 9. 录音按钮相对于进度环的偏移 const baseOffset = Math.round(5 * scale); // 基准偏移也按比例缩放 const recordButtonOffset = (progressRingSize - recordButtonSize) / 2 + baseOffset; // 10. Tooltips 位置和尺寸(等比例缩放) // 设计稿基准值(393×852) // 计算 tapToChat 相对于录音按钮的偏移量 const tapToChatWidth = Math.round(134 * scale); const tapToChatLeftOffset = Math.round(87 * scale); // 从录音按钮左边缘到 TapToChat 左边缘的距离 const tooltips = { // 进度条提示:相对于录音按钮左上顶点定位(top: 图片高度的一半的负值,left: 图片宽度的一半的负值) progressBar: { width: Math.round(175 * scale), height: Math.round(137 * scale), top: -Math.round(137 * scale) / 2, // 图片高度的一半的负值 left: -Math.round(175 * scale) / 2, // 图片宽度的一半的负值 }, // Tap to Chat:相对于录音按钮定位 tapToChat: { width: tapToChatWidth, height: 0, // auto top: Math.round(-78 * scale), // 相对于录音按钮顶部向上偏移 left: tapToChatLeftOffset, // 相对于录音按钮左边缘向右偏移 }, // 取消按钮提示:使用百分比定位(tooltip 放在按钮容器内) cancel: { width: Math.round(115 * scale), height: 0, // auto // bottom: 110% - tooltip 底部在按钮底部下方 10% 的位置 bottom: '110%', // left: -100% - tooltip 左边缘对齐到按钮的右边缘(tooltip 在按钮左侧) left: '-100%', }, // 发送按钮提示:使用百分比定位(tooltip 放在按钮容器内) send: { width: Math.round(116 * scale), height: 0, // auto // bottom: 110% - tooltip 底部在按钮底部下方 10% 的位置 bottom: '110%', // right: -100% - tooltip 右边缘对齐到按钮的左边缘(tooltip 在按钮左侧) right: '-100%', }, }; // 11. 布局计算完成 // 12. 返回完整布局 return { viewportWidth, viewportHeight, avatarScale: scale, // V3.0: 统一使用缩放比例 avatarCTop, avatarCRight, treesTop, treesLeft, animaZoneTop, mainAvatarTop, emotionTop, progressRingTop, progressRingBottom, recordButtonBottom, waveformBottom, avatarCSize, treesWidth, mainAvatarWidth, mainAvatarHeight, emotionHeight, recordButtonSize, progressRingSize, waveformHeight, actionButtonSize, verticalGap, buttonGap, progressRingPadding, recordButtonOffset, tooltips, needsScroll, hasOverlap, availableSpace, contentHeight, }; }