/blog/post-1785745636285/博客
READY
技术笔记

一次代码,两套 UI:我把考研英语翻译工具搬上了手机

2026-08-03约 7 分钟

今年年初,我给一个考研英语翻译练习工具(EnglishTranslationTool)加上了移动端支持,并把它打包成了 Android APK。整个过程最核心的决策就是:同一个 Vue 项目,两套完全独立的 UI 外壳,共享全部状态和业务逻辑。 不做响应式勉强适配,而是让手机端拥有真正为触摸和窄屏设计的界面。

这篇文章就复盘一下这次移植的经验,尤其是那些 “不踩不知道” 的坑。


为什么不能“缩小”桌面端?

很多项目试图用 CSS 媒体查询把三栏布局挤成手机宽度——但这根本行不通。手机端和桌面端的交互范式完全不同:

  • 手指 vs 鼠标:hover 不存在,点击目标至少 7mm,拖拽和滚动容易冲突

  • 全屏 vs 弹窗:375dp 宽度的设备上,弹出 el-dialog 再被键盘一挤,体验是灾难

  • 滑动 vs 点击:手机上天然习惯左右滑动、底部导航

  • 信息密度:三栏变一栏不是简单的 flex-wrap,而是交互的彻底重构

所以我直接为移动端写了一整套新的 template:MobileApp.vue(约 1070 行)。App.vue 根据 window.innerWidth < 768 条件,桌面端渲染三栏布局,移动端渲染全新的手机 UI。两套 UI 通过 Vue 的 provide/inject 共享同一个大脑。

text

App.vue(4500行 — 所有状态、API、业务逻辑)
├── provide('ett', 全量状态和方法)
├── <template v-if="!isMobile">   桌面端 UI </template>
└── <MobileApp v-if="isMobile" />  移动端 UI
     inject('ett') 拿到一切

这样做的好处是:移动端 UI 绝不直接操作数据层,不自己调 API,不写 localStorage。 它只是数据的另一种“皮肤”。虽然耦合度高,但对于只有一个消费者的情况,用 provide/inject 比引入 Pinia 更合适。


手机端的 UI 新设计

弹窗适配:键盘感知的二分法

桌面端的 el-dialog 在手机上体验很差,于是我制定了一条规则:

  • 只要弹窗里有 textarea 输入区域(会调起键盘)→ 使用全屏遮罩,给键盘留足空间

  • 如果是纯浏览、搜索、选择类弹窗 → 使用底部抽屉,顺手且不中断任务流

比如“生词池”是纯浏览加搜索,用底部抽屉;“短语默写”需要大量输入,直接全屏。这样的分类让手机端的信息层级清晰很多。

三 Tab 导航与吸顶

底部固定三 Tab(范文库 / 练习 / 我的),加 safe-area-inset-bottom 适配刘海屏。练习页做了类似 Instagram Stories 的原文横向滑动,每段占满全宽,配合导航点和 snap 吸附。页面头部使用 position: sticky 吸顶,保证滚动时始终能看见操作栏。

字体缩放和暗色模式

考虑到长时间阅读,默认暗色模式。同时在整个应用中引入了字体缩放系统,通过 CSS 变量 --ett-fs 统一控制所有文字大小,用户可以在“我的”页面拖动滑块从 0.8x 到 2.0x 自由调整,并自动保存偏好。


最难啃的骨头:批注系统

桌面端的批注是在 canvas 上用鼠标涂鸦,换成手机端问题一下子复杂了好几倍。

1. 鼠标事件 → 触摸事件

mousedown / mousemove / mouseup 全部改为 touchstart / touchmove / touchend。而且因为 canvas 的物理像素受 devicePixelRatio 影响,触摸坐标必须经过转换:

js

const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const x = (touch.clientX - rect.left) * scaleX

如果不做这个变换,画出来的线条位置完全错乱。而且桌面端和移动端的批注坐标空间不同,所以 localStorage 的存储 key 也得分开(ett_annotations vs ett_annotations_mob)。

2. 指针模式:让涂鸦和操作共存

canvas 如果完全覆盖屏幕,所有触摸都会被拦截,导致无法操作下面的页面。我借鉴了希沃白板的设计,加入了三模式切换:笔 / 橡皮 / 指针。在“指针模式”下,canvas 设为 pointer-events: none,触摸穿透到下层,涂鸦保留可见。用户点箭头后就能正常滚动页面,同时看到之前的批注。

3. 悬浮入口球:触屏上的“鼠标光标”

为了随时呼出批注工具栏,我做了一个类似 iOS AssistiveTouch 的悬浮球。它可拖拽、松手后半收起、位置持久化。这里踩的坑很典型:

  • 如果直接 @touchstart.prevent,后续的 click 合成事件就没了,点击无效。必须等到移动超过阈值才调用 preventDefault

  • 拖拽时 CSS transition 会产生延迟,需要临临时设为 transition: none

  • 收起动画如果直接修改 left,球的“固定位置”会改变;改用 transform: translateX() 做视觉位移


打包 APK:Capacitor 与 versionCode 奇遇记

我选用了 Capacitor 而非纯 WebView,因为它能自动生成 Android 壳,并提供原生文件系统等 API。构建流程很简单:Vite 打包 → cap sync → Gradle 编译。真正的坑出现在 versionCode 上。

Android 靠 versionCode(一个整数)判断新版本。我的 build.gradle 里原本写死了 versionCode 1,每次打包都覆盖不了旧版本。于是我把构建脚本改成自动用日期生成数字,比如 107081230(代表 2026 年 7 月 8 日 12:30)。但是万万没想到,Groovy 把以 0 开头的数字解析为八进制,而 8 和 9 不是合法的八进制数字,直接编译报错“Invalid octal number”。后来用 1$(date +%m%d%H%M) 以 1 开头才彻底避开这个问题。

整个工具链要求也很苛刻:AGP 8.x 强制 JDK 17,不能高不能低;Gradle 缓存必须放 D 盘因为 C 盘只剩 3GB;Android SDK 还要通过 cmdline-tools 手动安装 platform 34。


三条最重要的教训

  1. 不要用 CSS 把桌面端“挤”成移动端。 直接写两套 template,这是成本最低、体验最好的方式。

  2. 触摸事件的坐标系必须经过 devicePixelRatio 转换。 桌面端和手机端的批注数据不互通。

  3. Groovy 的数字字面量不是 JSON。 0 开头是八进制,超过 int 上限会变 Long,写构建脚本时这些小细节能卡你一整天。

为了让这些经验可复用,我还整理了一份移植 checklist,从代码适配、Capacitor 初始化到构建脚本一应俱全。此外,为了在写代码之前就确定方案,我专门建了一个“组件测试”目录,为每个子功能都独立创建了 2-4 个方案对比的 Vue 文件,用 240px 手机框 mock 展示,让使用者先确认再动工——这也帮我节省了大量返工的时间。


如果你也在做 Vue 项目的移动端移植,希望这篇经验能让你少踩一些坑。完整的架构文档和 APK 封装流程在我的项目仓库里,欢迎参考。

关联项目

EnglishTranslationTool-mobile

基于Vue3+Capacitor的考研英语翻译练习Android应用,支持AI评分、多种训练模式。