Files
iboard/src/main.jsx
T
jif2.zhang 7b52220aad
Docker Build and Push / build-image (push) Successful in 3m52s
fix(键盘): 统一修复 Android 软键盘遮挡输入框
根因: Tauri targetSdk 37 强制 edge-to-edge, MainActivity 不处理 IME Insets,
系统不再因键盘缩小窗口, 清单 adjustResize 失效, 键盘直接盖住 WebView。

- 新增 scripts/ci/android-softinput.sh: init 后注入 windowSoftInputMode=adjustResize,
  并重写 MainActivity.kt 监听 IME Insets, 键盘可见时把高度加到内容容器底部 (API 30+),
  让 WebView 真正变矮, 所有 bottom 弹层自动上移
- CI (android-build.yml) 与本地 build-apk.sh 在 init 之后、build 之前调用该脚本 (幂等)
- 新增全局 useKeepInputVisible: 输入框聚焦后用 scroll-into-view-if-needed 滚入可视区,
  覆盖页面滚动与 Popup 内部滚动
- 弹层补 max-height + 内部滚动, 防止键盘弹出后顶部被顶出屏幕
- calibrateSafeTop 在键盘弹出时保留旧值, 避免误判导致整页跳动
2026-11-09 16:25:11 +08:00

88 lines
4.0 KiB
React

// 移动端 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 的 <SafeArea> 只是个依赖该变量的空 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(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
)