diff --git a/.gitea/workflows/android-build.yml b/.gitea/workflows/android-build.yml index 4340858..9e33ea2 100644 --- a/.gitea/workflows/android-build.yml +++ b/.gitea/workflows/android-build.yml @@ -123,6 +123,10 @@ jobs: cd src-tauri npx tauri android init --ci + # 软键盘避让: 必须紧跟 init (gen/android 每次全新生成), 在 build 之前完成 + - name: Inject keyboard insets fix + run: bash scripts/ci/android-softinput.sh + - name: Inject release signing config run: bash scripts/ci/android-signing.sh env: diff --git a/package-lock.json b/package-lock.json index 1123107..b0a9e23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.26.0", "recharts": "^3.8.1", + "scroll-into-view-if-needed": "^3.1.0", "swiper": "^14.4.0", "tauri-plugin-hotswap-api": "^0.0.4", "ws": "^8.21.2", @@ -3639,6 +3640,12 @@ "node": ">=18" } }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, "node_modules/concurrently": { "version": "9.2.4", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", @@ -6394,6 +6401,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", diff --git a/package.json b/package.json index ecb339b..9c6ed60 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "react-dom": "^18.2.0", "react-router-dom": "^6.26.0", "recharts": "^3.8.1", + "scroll-into-view-if-needed": "^3.1.0", "swiper": "^14.4.0", "tauri-plugin-hotswap-api": "^0.0.4", "ws": "^8.21.2", diff --git a/scripts/ci/android-softinput.sh b/scripts/ci/android-softinput.sh new file mode 100644 index 0000000..2df4bb2 --- /dev/null +++ b/scripts/ci/android-softinput.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# CI / 本地共用: 修复 Android 软键盘遮挡输入框 +# 在 `tauri android init` 之后、`tauri android build` 之前运行。 +# +# 背景: +# Tauri 2 生成的 MainActivity 调用 enableEdgeToEdge(), 且不处理 IME Insets。 +# Android 15+ (targetSdk 35+) 强制 edge-to-edge 后, 系统不再因软键盘缩小窗口, +# 清单里的 adjustResize 也随之失效 → 键盘直接盖住 WebView, 底部弹层输入框被遮挡。 +# +# 做法: +# 1. AndroidManifest.xml 的 MainActivity 补 windowSoftInputMode="adjustResize" +# (兼容 API < 35 的旧行为) +# 2. 重写 MainActivity.kt: 监听 WindowInsetsCompat.Type.ime(), 键盘可见时 +# 把 IME 高度作为底部内边距加给内容容器 (等价恢复旧版 adjustResize), +# 这样 WebView 真正变矮, 所有 position:fixed / bottom:0 的弹层自动上移。 +# +# 幂等: 重复执行不会重复注入。 +# 注意: gen/android 被 gitignore, CI 每次 init 都重新生成, 所以每次构建都要跑本脚本。 +set -euo pipefail + +MANIFEST="src-tauri/gen/android/app/src/main/AndroidManifest.xml" +MAIN_ACTIVITY_DIR="src-tauri/gen/android/app/src/main/java" + +if [ ! -f "$MANIFEST" ]; then + echo "[softinput] ERROR: 找不到 $MANIFEST (请先运行 tauri android init)" >&2 + exit 1 +fi + +MAIN_ACTIVITY=$(find "$MAIN_ACTIVITY_DIR" -name MainActivity.kt -print -quit 2>/dev/null || true) +if [ -z "$MAIN_ACTIVITY" ]; then + echo "[softinput] ERROR: 找不到 MainActivity.kt (请先运行 tauri android init)" >&2 + exit 1 +fi + +echo "[softinput] 注入 Android 软键盘避让配置..." + +python3 - "$MANIFEST" "$MAIN_ACTIVITY" <<'PYEOF' +import re +import sys + +manifest_path, activity_path = sys.argv[1], sys.argv[2] + +# ---------- 1. AndroidManifest.xml: 补 windowSoftInputMode ---------- +src = open(manifest_path, encoding='utf-8').read() +if 'windowSoftInputMode' not in src: + m = re.search(r'', file=sys.stderr) + sys.exit(1) + src = src[:m.end()] + '\n android:windowSoftInputMode="adjustResize"' + src[m.end():] + open(manifest_path, 'w', encoding='utf-8').write(src) + print('[softinput] AndroidManifest.xml: 已注入 windowSoftInputMode="adjustResize"') +else: + print('[softinput] AndroidManifest.xml: 已有 windowSoftInputMode, 跳过') + +# ---------- 2. MainActivity.kt: IME Insets 处理 ---------- +src = open(activity_path, encoding='utf-8').read() +if '[iboard] keyboard-insets' in src: + print('[softinput] MainActivity.kt: 已注入键盘避让, 跳过') + sys.exit(0) + +m = re.search(r'^\s*package\s+([\w.]+)\s*$', src, flags=re.M) +if not m: + print('[softinput] ERROR: MainActivity.kt 中找不到 package 声明', file=sys.stderr) + sys.exit(1) +package = m.group(1) + +kotlin = f'''package {package} + +import android.os.Build +import android.os.Bundle +import android.view.View +import androidx.activity.enableEdgeToEdge +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat + +class MainActivity : TauriActivity() {{ + override fun onCreate(savedInstanceState: Bundle?) {{ + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + // [iboard] keyboard-insets + // Android 15+ 强制 edge-to-edge 后, 系统不再因软键盘缩小窗口, adjustResize 失效, + // 键盘会直接盖住 WebView 底部。这里监听 IME Insets, 键盘可见时把键盘高度 + // 作为内容容器的底部内边距, 等价恢复旧版 adjustResize 行为。 + // API < 30 由清单里的 adjustResize 处理, 避免系统已缩小窗口时重复留白。 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {{ + val content = findViewById(android.R.id.content) + ViewCompat.setOnApplyWindowInsetsListener(content) {{ view, insets -> + // 键盘隐藏时 getInsets(ime).bottom 为 0 (AndroidX 在 API < 30 也做了兼容) + val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + view.setPadding(0, 0, 0, imeBottom) + insets + }} + ViewCompat.requestApplyInsets(content) + }} + }} +}} +''' +open(activity_path, 'w', encoding='utf-8').write(kotlin) +print(f'[softinput] MainActivity.kt: 已注入 IME Insets 处理 ({activity_path})') +PYEOF + +# 断言: 防止注入被模板覆盖或路径写错 +if ! grep -q 'windowSoftInputMode' "$MANIFEST"; then + echo "[softinput] ERROR: AndroidManifest.xml 注入未生效" >&2 + exit 1 +fi +if ! grep -q '\[iboard\] keyboard-insets' "$MAIN_ACTIVITY"; then + echo "[softinput] ERROR: MainActivity.kt 注入未生效" >&2 + exit 1 +fi +echo "[softinput] 完成" diff --git a/scripts/wsl/build-apk.sh b/scripts/wsl/build-apk.sh index 12cc790..33a41c2 100644 --- a/scripts/wsl/build-apk.sh +++ b/scripts/wsl/build-apk.sh @@ -31,6 +31,9 @@ if [ ! -d "gen/android" ]; then fi cd "$WIN_PROJECT" +# 0.5 软键盘避让: 注入 MainActivity 的 IME Insets 处理 (幂等, 每次都要跑) +bash scripts/ci/android-softinput.sh + # Android 后端地址, 写到前端 bundle 里 (覆盖默认的 /api 相对路径) export VITE_API_BASE="${VITE_API_BASE:-https://iboard.honor3.com}" echo "VITE_API_BASE=$VITE_API_BASE" diff --git a/src/App.jsx b/src/App.jsx index 58f2604..779a8bb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,6 +9,7 @@ import CheckinPage from './pages/checkin/CheckinPage.jsx' import TaskPage from './pages/task/TaskPage.jsx' import { useAuth } from './auth/AuthContext.jsx' import { PowerSyncProvider } from './lib/powersync/react.jsx' +import useKeepInputVisible from './hooks/useKeepInputVisible.js' // 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中) const HOME_PATH = '/checkin' @@ -31,6 +32,9 @@ function RequireAuth({ children }) { } export default function App() { + // 全局: 输入框聚焦时滚进可视区 (登录页与内页都覆盖) + useKeepInputVisible() + return ( diff --git a/src/hooks/useKeepInputVisible.js b/src/hooks/useKeepInputVisible.js new file mode 100644 index 0000000..fd2bc18 --- /dev/null +++ b/src/hooks/useKeepInputVisible.js @@ -0,0 +1,59 @@ +import { useEffect } from 'react' +import scrollIntoView from 'scroll-into-view-if-needed' + +// 全局兜底: 输入框获得焦点后保证它滚进可视区。 +// +// 原生层 (scripts/ci/android-softinput.sh) 已经把软键盘高度从 WebView 里扣掉, +// 弹层 (position: fixed; bottom: 0) 会自动上移; 这里只负责把被聚焦的控件 +// 滚到可视区中间, 覆盖页面滚动、Popup 内部滚动等所有场景。 +// +// 用 scroll-into-view-if-needed (成熟库) 而不是手写滚动计算: +// 它会自动处理嵌套滚动容器, 并在目标已可见时不做多余滚动。 +const FIELD_SELECTOR = 'input, textarea, [contenteditable="true"]' + +function isField(el) { + return !!el && typeof el.matches === 'function' && el.matches(FIELD_SELECTOR) +} + +export default function useKeepInputVisible() { + useEffect(() => { + let timer = 0 + + const reveal = (el, behavior) => { + if (!isField(el)) return + scrollIntoView(el, { + behavior, + block: 'center', + inline: 'nearest', + scrollMode: 'if-needed', + }) + } + + const onFocusIn = (e) => { + const el = e.target + if (!isField(el)) return + // 先滚一次 (部分机型聚焦后立刻可用), 等键盘动画结束后再校正一次 + reveal(el, 'smooth') + clearTimeout(timer) + timer = setTimeout(() => reveal(el, 'smooth'), 260) + } + + // 键盘弹出/收起会改变可视区高度 (原生层已让 WebView 变矮) + const onViewportResize = () => { + const el = document.activeElement + if (!isField(el)) return + reveal(el, 'auto') + } + + document.addEventListener('focusin', onFocusIn) + window.addEventListener('resize', onViewportResize) + window.visualViewport?.addEventListener('resize', onViewportResize) + + return () => { + clearTimeout(timer) + document.removeEventListener('focusin', onFocusIn) + window.removeEventListener('resize', onViewportResize) + window.visualViewport?.removeEventListener('resize', onViewportResize) + } + }, []) +} diff --git a/src/main.jsx b/src/main.jsx index e2f053f..4e0b139 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -54,6 +54,9 @@ function calibrateSafeTop() { // 只在"纵向且高度差合理"时采用, 避免横屏误判 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 保底 diff --git a/src/styles/globals.css b/src/styles/globals.css index 9ccba8b..4118737 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -706,6 +706,21 @@ body.me-safe-top .app-safe-top { height: 0; } flex: 1; border: 1px solid var(--color-border); border-radius: 6px; padding: 6px 8px; } +/* ----- 软键盘避让 (Android) ----- + 原生层把键盘高度从 WebView 里扣掉后, 可视高度会变小; + 弹层内容超过剩余高度时改为内部滚动, 避免顶部/输入框被顶出屏幕。 */ +.adm-popup-body { + max-height: 100vh; + max-height: 100dvh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} +.adm-dialog-body { + max-height: calc(100vh - 48px); + max-height: calc(100dvh - 48px); + overflow-y: auto; +} + /* ----- 表单弹窗 (打卡项 / 清单) ----- */ .form-popup { padding: 16px 16px 8px; } .form-title { font-size: 16px; font-weight: 700; color: var(--color-text); margin-bottom: 12px; }