// 移动端 UI 框架: 全局样式 + 主题 (antd-mobile 5) import 'antd-mobile/es/global' import React from 'react' import ReactDOM from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' import { AuthProvider } from './auth/AuthContext.jsx' import { initOta } from './api/ota.js' import { startIfinanceAutoRefresh } from './lib/ifinance.js' import './styles/globals.css' // OTA 启动: 仅 Tauri 内生效 // - notifyReady: 确认当前资源可用, 否则下次启动自动回滚 (hotswap 插件行为) // - 异步执行, 不阻塞 React 挂载 initOta() // ifinance 索引每日自动更新 (启动 4s 后检查一次, 之后每 30 分钟看是否已过 24h) startIfinanceAutoRefresh() // ==================== 顶部状态栏安全区校准 ==================== // 背景: Android WebView (Tauri) 下 env(safe-area-inset-top) 常返回 0, // 而 antd-mobile 的 只是个依赖该变量的空 div, 于是内容会顶到状态栏下. // 这里用实测值覆盖 --safe-top, 保证不同机型/不同原生配置下都能正确让位. function calibrateSafeTop() { const root = document.documentElement // 校准只对 Android 有意义 (状态栏/刘海让位)。桌面 (Windows WebView2 / 桌面浏览器) // 没有状态栏: env() 本来就是 0; 而「screen.height - visualViewport.height」在桌面 // 竖屏窗口下会把标题栏+边框高度 (~99px) 误判成状态栏, 让标题栏下多出一条大空隙 // (2026-10-26 Windows 外壳实测)。非 Android 一律 0。 const isAndroid = /android/i.test(navigator.userAgent) if (!isAndroid) { root.style.setProperty('--safe-top', '0px') return } // env() 的真实值必须用一个临时元素测量, 不能直接读 --safe-top —— // 该变量是 max(env(...), 0px) 这样的表达式, parseFloat 会得到 NaN. const probe = document.createElement('div') probe.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:0;' + 'padding-top:env(safe-area-inset-top, 0px);visibility:hidden;pointer-events:none;' document.body.appendChild(probe) const envTop = parseFloat(getComputedStyle(probe).paddingTop) || 0 document.body.removeChild(probe) // 屏幕顶部与 WebView 可视区顶部的差值 = 状态栏(及刘海)高度. // 优先用 visualViewport (能反映真实可视区域), 回退 innerHeight. const screenH = window.screen?.height || 0 const viewH = window.visualViewport?.height || window.innerHeight || 0 const screenW = window.screen?.width || 0 const viewW = window.visualViewport?.width || window.innerWidth || 0 // 只在"纵向且高度差合理"时采用, 避免横屏误判 const isPortrait = viewW <= viewH const diff = screenH - viewH // 软键盘弹出会让 visualViewport 高度骤减 (diff 远大于状态栏高度)。 // 此时必须保留上一次算出的值, 否则会被当成"没有状态栏"而改成 24px, 整页跳动。 if (isPortrait && diff >= 120 && root.style.getPropertyValue('--safe-top')) return const measured = isPortrait && diff > 0 && diff < 120 ? diff : 0 // 取 env() 与实测值的较大者; 两者都失效时给 24px 保底 // (多数 Android 状态栏在 24~28px, 宁可多一点也不能压字) let top = Math.max(envTop, measured) if (top < 4) top = 24 root.style.setProperty('--safe-top', top + 'px') } // DOM 就绪后再校准 (需要往 body 插探测元素); 同步先跑一次保证首屏不闪 if (document.body) { calibrateSafeTop() } else { document.addEventListener('DOMContentLoaded', calibrateSafeTop, { once: true }) } // 旋转屏幕 / 视口变化时重算 window.addEventListener('orientationchange', () => setTimeout(calibrateSafeTop, 120)) window.visualViewport?.addEventListener('resize', calibrateSafeTop) ReactDOM.createRoot(document.getElementById('root')).render( , )