feat: 完成国际出港移库/出库交接页面5.5寸手机端适配

- 落地手机端双形态适配框架:同名布局+资源限定符(layout/手机、
  layout-sw600dp/平板)自动切换,DeviceUtil 设备形态判定,7个
  手机版公共组件(PhoneSearchBar/StatusTab/DataLayout/FilterPanel/
  BottomBar/StatBox/KvItem)
- 完成「国际出港移库」「国际出港出库交接」两页手机端适配,新增
  各自详情页(IntExpMoveDetailActivity/IntExpOutHandoverDetailActivity)
- 手机端首页菜单接入"出港移库""出库交接"入口
- 修复手机端进入页面先横屏后转竖屏的闪屏问题:Manifest 全项目
  192 处改为 screenOrientation="unspecified",由 BaseActivity
  按设备形态运行时锁定方向
- 修复该方案的中间版本在平板端引入的回归(先竖后横 + UI放大1.6倍)
- AutoSize 补充手机竖屏 390×844 设计基准
- 修复 CHANGELOG.md 因脚本异常导致的内容重复损坏(膨胀至12万行),
  恢复正常结构并补充本次变更记录

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 14:18:29 +08:00
parent 9c4588e47e
commit db062c8ee3
134 changed files with 19602 additions and 2965 deletions

View File

@@ -1,14 +1,10 @@
{
"mcpServers": {
"空港集团 - API 文档": {
"command": "/Users/kid/.version-fox/sdks/nodejs/bin/npx",
"args": [
"-y",
"apifox-mcp-server@latest",
"--project-id=7382863"
],
"env": {
"APIFOX_ACCESS_TOKEN": "APS-S2aVVwqasbdByzPLgSqryRC8BB0ZFqhQ"
"api-doc": {
"type": "http",
"url": "https://www.agentfoxapp.com/mcp/976caff7-5f98-4487-bccf-3aa20c92cf1f",
"headers": {
"Authorization": "Bearer afk_G_VKmHTdXUi3GjQdg5AeHeH-0BSYQEPP"
}
}
}

View File

@@ -0,0 +1,277 @@
---
name: phone-adapt
description: 参照 documents/5.5寸手持端设计文件/ 的 HTML 设计稿,把 AirLogistics 指定页面适配成 5.5 寸手机竖屏版本——浏览器渲染设计稿逐态截图分析、复用 Phone* 公共组件写手机布局、复用现有 ViewModel 与接口、保证 Pad 端零改动,最后走完整登录流程实机联调并与设计稿逐项核对 UI。当用户说"适配手机端/做手机版/手持端适配"、点名某个设计稿入口(如"02 出库交接"、"做一下出港计重")、要求"按设计稿还原页面"、或提到 5.5 寸/手机版/双形态时,都要使用本 skill即使没明说"适配"二字。
---
# 5.5 寸手持端页面适配
把一个已有的平板页面,按设计稿适配出手机竖屏版本。核心约束是**只重写布局,业务逻辑与接口全部复用**
Pad 端行为必须零变化。
先读 `CLAUDE.md` 的「5.5 寸手持端(手机版)双形态适配规范」章节——那里是权威规范,本 skill 是执行流程。
## 前置确认
需要用户给出**目标页面**设计稿目录名或业务名如「02 出库交接」/「出港计重」)。
其余自己查:`documents/5.5寸手持端设计文件/00_全部入口总览.html` 列出全部 11 个入口,
每个子目录下可能有多个 HTML主页 + 详情 + 子页面),都要处理。
对应的平板页面靠业务名在 `module_gjc/gjj/gnc/gnj` 里搜 Activity。**注意认准首页菜单实际跳转的那个
Activity**,同名旧版文件在这个仓库里很常见。
## 阶段 1渲染设计稿看图不看代码
设计稿是带 Tailwind + JS 的交互原型,一个 HTML 里常塞了列表页、Tab、详情页、筛选弹层靠 JS 切换。
**只读 HTML 源码会出错**,必须渲染成图:
```bash
S=.claude/skills/phone-adapt/scripts
# 1) 先看有哪些状态可切
python3 $S/design_shots.py --html "documents/5.5寸手持端设计文件/02_出库交接/出库交接.html" --list
# 2) 按 --list 结果逐态渲染(照抄 onclick 里的真实参数最省事)
python3 $S/design_shots.py --html "…/出库交接.html" --out /tmp/design \
--state "01_列表:" \
--state "02_已交接Tab:switchTab('shipped');" \
--state "03_详情:showDetail({id:'PMC90901CZ',status:'已交接',rackNo:'076'});" \
--state "04_筛选弹层:toggleFilterDrawer(true);"
```
然后用 Read 工具**逐张看图**,抽取:配色、字号、圆角、间距、图标、控件顺序、空态、
各状态下哪些元素显示/隐藏例如「已交接」Tab 下底部操作条整条消失)。
`--list` 输出里如果出现 `window.location.href='xxx.html'`,说明是多文件原型,那些文件同样要渲染。
**设计稿的 JS 可能有 bug别照抄字段映射。** 出库交接的详情页就把「航班日期」绑成了重量、
「备注」绑成了航班号HTML 里 id 重复)。判断依据是**静态 label 文案**label 写「航班日期」
就绑 `fdate`,写「板型」就绑 `boardType`。label 是设计意图JS 只是 demo 接线。
标签文案同理卡片上写死的「IMP代码」是占位符实际要绑 `bean.dgrCode` 的值。
## 阶段 2对齐接口能力不要臆想
把设计稿的每个筛选项/Tab/字段,对照平板 ViewModel 已有的字段:
- **已有字段** → 直接复用(如出港移库的 Tab 直接绑既有 `moveState`
- **平板没有的新维度**(新 Tab 状态、新下拉、新排序)→ **停下来问用户**要用什么接口参数名,
连带问数据源(如「交接人」用 `DictUtils.getWHSUserList()`
这一步不能猜。出库交接就是实测发现后端 `pageQuery` 根本不认 `hoState`,两个 Tab 返回同一批数据——
参数名对不对只有后端知道。用 `AskUserQuestion` 一次问清,并在阶段 5 用真实请求体验证是否生效;
**没生效就在交付说明里如实标注**,不要让它看起来是通的。
### 开工前对齐方案
分析完先把结论摊给用户,避免闷头做错方向。一次说清,别挤牙膏:
```
📱 页面适配方案:<页面名>
设计稿documents/5.5寸手持端设计文件/<目录>/N 个 HTML主页 / 详情 / …)
平板页面XxxActivity + XxxViewModelmodule_xxx
手机版结构:
搜索行:<字段>(含扫码/筛选按钮)
状态 Tab<Tab1> / <Tab2> → 绑定 <字段名>
列表卡片:<字段…>,两种形态(<形态A> / <形态B>
底部操作条:全选 + 已选统计 + [<按钮>]
筛选弹层:<字段1> / <字段2> / <字段3>
二级页:<详情页名>(新增 Activity / 复用已有)
复用组件PhoneSearchBar、PhoneStatusTab、PhoneStatBox…无需新增 / 需新增 XXX
接口:列表 <接口> 复用;新增参数 <名>(待你确认);新增字典 <DictUtils 方法>
Pad 端影响:无(新字段默认空、额外请求由手机开关控制)
设计稿与平板的差异:<如手机版无「配运」按钮>
确认后开始编码。
```
## 阶段 3写布局复用公共组件
`module_base/.../ui/weight/phone/` 已有 7 个组件,**优先复用,不要重写 XML**。
组件清单、属性表与用法见 `references/components.md`(写布局前读一遍)。
改造步骤:
1. 平板布局 `git mv``res/layout-sw600dp/`item 布局同理)
2.`res/layout/` 下建**同名**文件写手机布局
3. Kotlin 侧 `layoutId()` / `itemLayoutId` **一律不动**,不做设备分支
三条硬约束(违反会编译失败或运行时崩溃,细节见 `references/pad-safety.md`
| 约束 | 说明 |
|------|------|
| 两变体 `<variable>` 完全一致 | 手机变体新增 `activity` 变量时,平板变体也要补上(写注释说明平板不用它) |
| 共用 id 双方都要有 | ViewHolder/Activity 引用的 id`srl`/`rv`/`iv_icon` 等)两边都保留 |
| 单侧独有 id 自动 `@Nullable` | 如手机独有的 `ll_detail`ViewHolder 里必须判空调用 |
**一套 item 布局覆盖多种卡片形态**,不要为「待交接/已交接」建两个布局。按 bean 状态切
visibility 即可Bean 上加个只读计算属性如 `isHandovered` 让 XML 更干净;计算属性不参与 Gson
序列化,安全)。
## 阶段 4接业务且不碰 Pad
手机端新增的查询维度用这个模式,能做到平板端请求次数与行为零变化:
```kotlin
// ViewModel新字段默认空值getData() 里仅非空才进请求
val handoverState = MutableLiveData("") // 平板布局无对应控件 → 恒为空 → 不进请求
val filterParams = mapOf(
/* 原有字段… */
"hoState" to handoverState.value?.ifEmpty { null },
)
// 手机专属的额外请求Tab 角标计数、新下拉字典)用内部开关控制
private var phoneExtrasEnabled = false
fun initPhoneExtras() { phoneExtrasEnabled = true; handoverState.value = "0"; /* 加载字典 */ }
```
```kotlin
// Activity只有手机形态才开启平板不调用
if (DeviceUtil.isPhone()) viewModel.initPhoneExtras()
```
设备判断只用于**数据默认值与额外请求****绝不用于选布局**(选布局靠资源限定符)。
纯 UI 交互弹层显隐、Tab 切换)写在 Activity`ObservableBoolean` 暴露给 XML不下沉到 ViewModel。
设计稿若少了平板上的某个按钮(如出库交接手机版只有「交接」没有「配运」),按设计稿做,
但要在交付说明里点出来这个功能在手机端不可达,让用户决定。
新增手机专属页面(详情页等)记得:`app/src/main/AndroidManifest.xml` 注册 +
`app/src/debug/AndroidManifest.xml` 追加 `exported="true"` 便于直启调试。
注册时 `screenOrientation` **必须写 `unspecified`**
```xml
<activity android:name="…"
android:configChanges="orientation|keyboardHidden"
android:exported="false"
android:screenOrientation="unspecified" />
```
- 写死 `userLandscape` → 手机端「先横屏、约 1 秒后转竖屏」闪一下(方向在建窗口时就定了,代码再纠正已晚)
-`@integer/xxx` 资源引用 → **更糟**:系统解析 Manifest 不套用设备限定符,平板会取到默认(竖屏)值,
先竖后横,且 AutoSize 随之选错基准导致 UI 放大约 1.6 倍
`unspecified` 让系统按设备当前物理方向建窗口,两端起始方向本就正确,
`BaseActivity` 的形态锁定不会产生二次纠正。
### ⚠️ 别忘了加手机端首页入口
手机端首页是 `module_p``PDAEnterActivity`「国际」Tab 的菜单在
`module_p/.../ui/enter/gj/GjViewModel.kt`(国内在 `gn/GnViewModel.kt`)。
**菜单是手写清单,不是接口下发**;权限串只做过滤(`authList.contains(bean.key)`)。
适配完页面不回来加一行,手机上就**根本没有入口**——出库交接第一版就漏了这步,
只能靠 adb 直启才能进去。加法:
```kotlin
ActionBean(
"出港移库",
R.drawable.gjc_yi_ku_icon, // 图标复用 Pad 同款,复制 PNG 到 module_p/res/drawable-xxhdpi/
Constant.AuthName.GjcYiKuListActivity, // 权限串与 Pad 端菜单一致
ARouterConstants.ACTIVITY_URL_INT_EXP_MOVE // 填了 route 就走通用跳转,无需 when 分支
),
```
权限串和路由**都以 Pad 端 `app/.../HomeFragment.kt` 里该权限对应的 `ARouter.build(...)` 为准**
(同一业务常有新旧两个 ActivityPad 菜单实际跳的才是要适配的那个)。
`module_p` 只依赖 `module_base`,看不到业务模块,所以跨模块必须走 ARouter、图标必须复制一份。
菜单没出现时先分清是「没权限」还是「没加清单」:
```bash
adb -s <serial> shell "run-as com.lukouguoji.aerologic \
cat /data/data/com.lukouguoji.aerologic/shared_prefs/data.xml" | tr ',' '\n' | grep -oE "App[A-Za-z]+" | sort -u
```
## 阶段 5端到端实机验证
先构建,再双端跑。命令与排障细节见 `references/verification.md`
```bash
./gradlew assembleDebug 2>&1 | grep -E "^(BUILD|FAILURE)|error:"
```
构建过后核对 DataBinding 变体是否如预期(单侧 id 应为 `@Nullable`
```bash
grep -B3 "llDetail" module_gjc/build/generated/data_binding_base_class_source_out/debug/out/\
com/lukouguoji/gjc/databinding/ItemXxxBinding.java
```
**手机端**(真实数据链路,从登录开始):
```bash
P=.claude/skills/phone-adapt/scripts/ui_probe.sh
adb -s <phone> install -r -d app/build/outputs/apk/debug/app-debug.apk
# 走登录 → 底部「国际」Tab → 点新加的菜单项进入(顺便验证入口加对了)
# 同名文字多处时 tap-text 会提示命中数,用第 4 个参数指定序号(如底部 Tab 的「国际」)
# 若账号确实没有该权限,改用冷启动直启(见 verification.md
$P <phone> form # 应打印 PHONE(sw=411dp…)
$P <phone> wait-text "待交接" # 点击前先等页面就绪:加载弹窗会吞掉点击
$P <phone> tap-text "已交接" # 按文字点击,比手算坐标可靠
$P <phone> texts # 用文字变化断言点击确实生效(如底部条应消失)
$P <phone> shot /tmp/p1.png
$P <phone> crash # 必须为 0
$P <phone> req pageQuery # 核对请求体参数确实带上了新字段
```
逐项走查:列表/卡片两种形态、详情页、筛选弹层(重置+确认、Tab 切换、单选与全选联动、
下拉真实取数、扫码入口。每步 `crash` 都要为 0。
点完**一定要断言效果**`texts` 或截图),不要假设点中了——加载弹窗盖屏时 `input tap` 会被静默吞掉。
手机端还要确认方向没闪屏(冷启动采样 `mRotation` 应恒为 0命令见 `references/verification.md`
**Pad 端回归**(必做):同样安装到平板模拟器打开同一页面截图,与改造前逐项对比——
搜索条数量、默认值、按钮、底部统计都应一致。改过 `module_base` 公共组件时这步尤其不能省。
## 阶段 6与设计稿二次核对
把阶段 1 的设计稿截图和阶段 5 的实机截图放在一起,用 Read 工具对照看,逐项核对:
配色 / 字号 / 字重 / 圆角 / 内外间距 / 图标形状与大小 / 控件顺序 / 选中态 / 状态标签配色 /
**空值兜底**(真实数据空字段很常见——出库交接就出现过「库位」为空时橙色高亮标签只剩一个孤零零的
小色块,需要空值时不套标签底)。
服务端当天没数据时(列表空)先放宽条件重查;仍为空就临时在 Activity 里注入样例 Bean 截图比对:
```kotlin
// import com.lukouguoji.module_base.ktx.commonAdapter
binding.rv.postDelayed({
binding.rv.commonAdapter()?.refresh(listOf(/* 覆盖每种卡片形态各一条 */))
}, 2500) // 等首次请求结束,否则会被真实结果覆盖
```
**核对完必须删掉**,删完重新构建并 grep 确认无残留。
## 收尾:文档与交付说明
代码之外还要落三处文档,否则下一个人会重复踩坑:
- 新增了公共组件/资源 → 更新 `CLAUDE.md`「手机版公共组件库」表 + `references/components.md`
- 踩到新坑(崩溃、静默失效、环境问题)→ 写进 `CLAUDE.md`「常见编译错误速查」和本 skill 的
对应 reference把**定位手法**也写上,不只写结论
- `CHANGELOG.md` 追加条目(新增 / 改进 / 修复 / 验证 四段),记忆文件
`phone-5.5inch-adaptation.md` 同步
向用户汇报时明确写出这四块:
1. 新增/修改的文件清单布局、组件、Activity/ViewModel/Bean
2. Pad 端零破坏的具体做法 + 回归结论
3. 复用与新增的公共组件
4. **待确认事项**:后端未生效的参数、自己推断的业务规则(如 IMP 代码红/绿的判定)、
设计稿与平板功能的差异。如实说,别含糊。
## 参考文件
- `references/components.md` — 7 个手机版公共组件的属性表、用法与封装新组件的规矩(写布局前读)
- `references/pad-safety.md` — 布局变体约束、Pad 零破坏模式、易崩点(写代码前读)
- `references/verification.md` — 双端 adb 命令、登录与直启、已知环境坑(验证前读)
- `scripts/design_shots.py` — 设计稿逐态渲染截图
- `scripts/ui_probe.sh` — 实机取文字/按文字点击/等待/截图/抓请求体/查崩溃

View File

@@ -0,0 +1,120 @@
# 手机版公共组件(`module_base/.../ui/weight/phone/`
7 个组件覆盖设计稿反复出现的部件。**先复用,实在没有再新增**——每个新组件都要维护、都要踩一遍
状态恢复的坑,能少一个是一个。
所有属性都是**无命名空间写法**(与平板端 `PadSearchLayout` 一致DataBinding 适配器集中在
`PhoneWidgetKtx.kt`。同名属性(`hint`/`value`/`title`/`list`)按控件类型解析,与 Pad 系组件不冲突。
## 组件与属性
| 组件 | 用途 | 属性 |
|------|------|------|
| `PhoneSearchBar` | 顶部搜索行:搜索框 + 扫码 + 圆形筛选按钮 | `hint``value`(双向)、`showScan``showFilter``upperCase``setOnScanClickListener``setOnFilterClickListener``setRefreshCallBack` |
| `PhoneStatusTab` | 状态 Tab 条(文字 + 角标 + 选中下划线) | `tabs`(List\<KeyValue\>)、`counts`(List\<String\>)、`value`(双向)、`setOnTabChanged` |
| `PhoneDataLayout` | 表单/筛选项,三形态 | `title``hint``type`(`PhoneDataLayoutType.INPUT`/`DATE`/`SPINNER`)、`value`(双向)、`list``required``enable``setRefreshCallBack` |
| `PhoneFilterPanel` | 底部筛选弹层(遮罩 + 面板 + 重置/确认) | `title``panelVisible``setOnResetClick``setOnConfirmClick``setOnDismissClick` |
| `PhoneBottomBar` | 底部操作条(全选 + 已选统计 + 主按钮) | `allChecked``countText``actionText``setOnAllCheckClick``setOnActionClick` |
| `PhoneStatBox` | 卡片内 2~4 列统计框,未传 label 的列自动隐藏 | `label1..4``value1..4` |
| `PhoneKvItem` | 详情页字段项(灰标签 + 数值) | `title``value``tag`(true → 橙色高亮标签) |
`KeyValue``dev.utils.app.info.KeyValue`,字段是 **`key`(显示文案)+ `value`(业务值)**
不是 `name``PhoneStatusTab``tabs` 就按 `KeyValue("待交接","0")` 传。
## PhoneFilterPanel 的内容插槽
筛选项直接写在标签内,组件 `onFinishInflate` 会自动把它们移进面板中部并施加 20dp 项间距,
使用方不用关心内部层级、也不用逐个写 `marginTop`
```xml
<com.lukouguoji.module_base.ui.weight.phone.PhoneFilterPanel
android:layout_width="match_parent"
android:layout_height="match_parent"
title='@{"筛选条件"}'
panelVisible="@{activity.filterPanelVisible}"
setOnResetClick="@{(v)-> activity.resetFilter()}"
setOnConfirmClick="@{(v)-> activity.confirmFilter()}"
setOnDismissClick="@{(v)-> activity.toggleFilterPanel()}">
<com.lukouguoji.module_base.ui.weight.phone.PhoneDataLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
title='@{"航班日期"}' hint='@{"请选择航班日期"}'
type="@{PhoneDataLayoutType.DATE}" value="@={viewModel.flightDate}" />
</com.lukouguoji.module_base.ui.weight.phone.PhoneFilterPanel>
```
记得在 `<data>``<import type="com.lukouguoji.module_base.ui.weight.phone.PhoneDataLayoutType" />`
## ⚠️ 新增复合组件必做:屏蔽子控件状态恢复
同一页面放多个同类组件时(比如筛选弹层里 3 个 `PhoneDataLayout`),它们的内部子控件
`et`/`tv`/`spinner`…)**共用同一套 id**。系统按 id 保存/恢复视图状态会跨实例串档。
实际后果出库交接踩过DATE 项的默认日期被邻近输入框恢复的空文本覆盖,
`EditText.setText("")` 触发 `doOnTextChanged`,再经双向绑定把 ViewModel 的航班日期也写成空——
**筛选条件静默丢失,界面上只表现为"日期没了",极难联想到状态恢复**
所以每个含内部 id 的组件都要写:
```kotlin
override fun dispatchSaveInstanceState(container: SparseArray<Parcelable>) =
dispatchFreezeSelfOnly(container)
override fun dispatchRestoreInstanceState(container: SparseArray<Parcelable>) =
dispatchThawSelfOnly(container)
```
取值本来就由 ViewModel + DataBinding 持有,不需要视图级恢复,屏蔽掉没有副作用。
配套的第二道防线:多形态组件里,文本回写要限定形态,别让隐藏控件影响取值:
```kotlin
et.doOnTextChanged { text, _, _, _ ->
if (type == PhoneDataLayoutType.INPUT) value = text.toString()
}
```
### 遇到"值被神秘清空"怎么定位
在 setter 里打一条带堆栈的日志,一次就能看到真凶:
```kotlin
android.util.Log.d("DBG", "value '$field' -> '$v'", Throwable("trace"))
```
出库交接那次的栈是 `TextView.onRestoreInstanceState → EditText.setText → doOnTextChanged`
指向非常明确。**排完记得删日志。**
## 新增组件的写法约定
照现有 7 个组件的模式来,保持一致:
1. 继承 `LinearLayout`/`FrameLayout``init``inflate(context, R.layout.layout_phone_xxx, this)`
2. 内部布局用 `<merge tools:parentTag="android.widget.LinearLayout">`,避免多一层嵌套
3. 属性做成 Kotlin `var` + setter 里立即生效;双向绑定值用
`onChangeListener: InverseBindingListener?`
4. BindingAdapter 统一加到 `PhoneWidgetKtx.kt`,函数名带组件名前缀避免顶层函数重名
5. 有互斥形态时,**`type` 要先于 `value` 生效**(把 type 放进同一个多属性 adapter 的第一个
`?.let`),否则 value 会写进错误的子控件
6. 加上上面那两个 `dispatch*SelfOnly` 覆写
7. 完成后同步更新 `CLAUDE.md` 的组件表和记忆文件 `phone-5.5inch-adaptation.md`
## 资源
配色一律用 `phone_*`,背景用 `bg_phone_*`,图标用 `ic_phone_*`,禁止写死色值。
已有的(不够再加,加完更新 CLAUDE.md
- 色:`phone_primary` `phone_page_bg` `phone_bg` `phone_stat_bg` `phone_text_title/body/hint/label/strong/weak_btn`
`phone_divider` `phone_badge_bg` `phone_green` `phone_check_border` `phone_tag_gray_*` `phone_tag_green_*`
`phone_tag_red_*` `phone_orange_bg/text`
- 背景:`bg_phone_card` `bg_phone_search` `bg_phone_input` `bg_phone_stat_box` `bg_phone_panel_top`
`bg_phone_icon_round` `bg_phone_btn_primary` `bg_phone_btn_outline` `bg_phone_badge`
`bg_phone_tag_green/gray/red/orange` `bg_phone_card_header_blue/green`
- 图标:`ic_phone_airplane` `ic_phone_location` `ic_phone_person` `ic_phone_check_circle`
`ic_phone_chevron_right/down` `ic_phone_calendar` `ic_phone_close`
`ic_phone_check_round_checked/unchecked`(圆形复选框)
- 复用 Pad 的矢量图:`img_search` `img_scan` `img_filter`(可 `app:tint` 染色)
设计稿里的 MDI 图标若缺失,直接写 24×24 的 vector用 MDI 官方 path比找位图靠谱且能染色。

View File

@@ -0,0 +1,167 @@
# Pad 端零破坏 & 易崩点
平板端是在跑的生产功能,适配手机不能改变它的任何行为。以下是必须遵守的约束和踩过的坑。
## 布局变体的三条硬约束
同名布局 + 资源限定符(`layout/` 手机 / `layout-sw600dp/` 平板)会让 DataBinding 生成
**一个 Binding 基类 + 两个变体实现**,因此 Kotlin 侧零改动。代价是两个变体必须兼容:
### 1. `<variable>` 必须完全一致
手机变体需要 `activity` 变量(暴露纯 UI 方法给 XML**平板变体也要补上**,否则编译报
`variable not found`。平板不用它,加个注释说明即可:
```xml
<!-- 平板端不使用,仅为与手机变体保持变量一致 -->
<variable name="activity" type="com.lukouguoji.gjc.activity.XxxActivity" />
```
### 2. 共用 id 双方都要保留
Activity / ViewHolder 引用到的 id`srl``rv``iv_icon``checkIcon`…)两个变体都要有。
可以换位置换样式,但 id 得在。
### 3. 单侧独有的 id 会变 `@Nullable`
只在手机变体存在的 id如「查看详情」的 `ll_detail`Binding 基类里是可空字段,
Kotlin 侧必须判空:
```kotlin
// 平板变体没有这个控件,为 null 时直接跳过
binding.llDetail?.setOnClickListener { XxxDetailActivity.start(itemView.context, bean) }
```
编译器会强制你处理,不用担心漏掉。改完可以核对生成代码确认:
```bash
grep -B3 "llDetail" module_gjc/build/generated/data_binding_base_class_source_out/debug/out/\
com/lukouguoji/gjc/databinding/ItemXxxBinding.java
```
### ⛔ 绝对不要用「不同文件名 + 代码判断」切布局
`activity_xxx_phone.xml` 会生成 `ActivityXxxPhoneBinding`,而字段声明的类型是
`ActivityXxxBinding`。泛型擦除让**编译期查不出来**,一进页面就 `ClassCastException`
## 业务层零破坏的三个模式
### 新增查询维度:默认空值 + 仅非空进请求
平板布局没有对应控件 → 字段恒为空 → 不进请求体 → 平板查询与改造前逐字节一致。
```kotlin
val handoverState = MutableLiveData("") // 手机 Tab 绑这个,平板无控件
val hoId = MutableLiveData("")
val filterParams = mapOf(
"fdate" to flightDate.value?.ifEmpty { null },
/* 原有字段… */
"hoState" to handoverState.value?.ifEmpty { null }, // 空 → null → 不影响平板
"hoId" to hoId.value?.ifEmpty { null },
)
```
### 手机专属的额外请求ViewModel 内部开关
Tab 角标计数、新增下拉字典这类请求,平板不需要,多发就是改变了平板行为(请求次数)。
用一个内部布尔控制,由 Activity 在手机形态下开启:
```kotlin
// ViewModel
private var phoneExtrasEnabled = false
fun initPhoneExtras() {
phoneExtrasEnabled = true
handoverState.value = "0" // 手机默认停在第一个 Tab
DictUtils.getWHSUserList(addAll = false) { hoUserList.postValue(it) }
}
override fun getData() {
/* 原有列表 + 统计请求… */
if (phoneExtrasEnabled) loadTabCounts(filterParams) // 平板不触发
}
```
```kotlin
// Activity
if (DeviceUtil.isPhone()) viewModel.initPhoneExtras()
```
`DeviceUtil.isPhone()` 只用来决定**数据默认值和额外请求****永远不用来选布局**。
### 纯 UI 状态放 Activity
弹层显隐、Tab 高亮这类与业务无关的状态,用 `ObservableBoolean` 放在 Activity 上暴露给 XML
不要污染 ViewModel
```kotlin
val filterPanelVisible = ObservableBoolean(false)
fun toggleFilterPanel() = filterPanelVisible.set(!filterPanelVisible.get())
```
### 同名 id 在两个变体里必须是同一种控件
平板变体的 `btnMove``TextView`,手机版底部换成了 `PhoneBottomBar`——**不要为了复用
Activity 里的 `binding.btnMove` 而把这个 id 安到 PhoneBottomBar 上**。同名不同类型会让
DataBinding 把字段类型退化成公共父类,编译期未必报错,运行时行为难料。
正确做法:手机端的按钮点击走组件自身的 `setOnActionClick``btnMove` 就只留在平板变体里,
Activity 侧判空调用:
```kotlin
binding.btnMove?.setOnClickListener { showMoveConfirmDialog() }
```
被 XML 调用的 Activity 方法记得改成 public。
### 调字典要对齐既有页面的参数口径
`DictUtils` 的方法参数常有"看起来合理但后端不认"的取值。出港移库的特码下拉一开始传
`ieFlag="E"`(出港),接口返回空数组;而同模块既有页面(`GjcWeighingStartViewModel` 等)
一律传 `ieFlag=""`,能正常返回。
所以调字典前先 grep 一下同模块其他页面怎么调的,照抄它们的参数;改完必须在实机上
**把下拉点开确认真的有数据**,空列表在 UI 上看不出报错。
### 共享方法别为手机改语义
`checkAllClick()` 这类平板也在用的方法,不要为了手机端的语义去改它。
手机「待交接」Tab 下理论上只有未交接记录(服务端按状态过滤),所以全选照原样全量勾选就是对的;
若为此加上「跳过已交接」的过滤,反而改变了平板端(平板列表混排两种状态)的行为。
## 屏幕方向Manifest 必须写资源引用
新增 Activity 注册时:
```xml
<activity android:name="…"
android:configChanges="orientation|keyboardHidden"
android:exported="false"
android:screenOrientation="@integer/screen_orientation" />
```
**不要写死 `userLandscape`。** `screenOrientation` 由系统在创建 Activity 窗口时生效,
那时业务代码还没跑;写死横屏后即使 `BaseActivity``super.onCreate` 之前改成竖屏,
用户也会看到「先横屏、约 1 秒后转竖屏」的闪屏和重建。
资源取值:`module_base/res/values/integers.xml` = 1(portrait)
`values-sw600dp/integers.xml` = 11(userLandscape)。全项目已统一替换。
## 改 module_base 公共代码时
`module_base` 被所有业务模块依赖,改动会影响全部页面。原则:
- **只做增量**:加新组件、新颜色、新 drawable别改既有组件的行为
- 确实要改公共基类(`BaseActivity` / `BaseBindingActivity` / Pad 系组件)时,
**必须在平板上回归**至少 2~3 个不同类型的既有页面
- Bean 上加只读计算属性是安全的Gson 只序列化字段,不动 getter
加**字段**要谨慎,会进请求体
## 已知的既有问题(不是你改坏的)
`am start` 直启页面时,若**先经过首页**再启目标页,会崩在
`LoadingModel.showLoading` → XPopup `popupInfo is null`。在**未改动**的页面上同样复现,
属于既有缺陷。**冷启动直启不受影响**`force-stop` 后直接 `am start`)。
遇到崩溃先做这个对照实验再下结论:拿一个你没碰过的页面用同样方式打开,看是否同样崩。

View File

@@ -0,0 +1,166 @@
# 端到端验证手册
目标:在**真实接口**下把手机端走通,并确认平板端没退化。只看编译通过是不够的——
本次适配里"筛选条件被静默清空"和"后端不认新参数"两个问题,都只有跑真机才暴露。
## 环境
```bash
~/Library/Android/sdk/emulator/emulator -list-avds # Medium_Phone_API_36.1(手机) / Aerologic_Tablet(平板)
adb devices -l
adb -s <serial> shell wm size && adb -s <serial> shell wm density
```
判定形态:手机 1080×2400@420dpi`PHONE(sw=411dp)`;平板 1280×800@160dpi`TABLET(sw=800dp)`
`ui_probe.sh` 封装了常用操作,比手写 adb 省事且更可靠:
```bash
P=.claude/skills/phone-adapt/scripts/ui_probe.sh
$P <serial> form # 打印形态识别结果
$P <serial> texts # 当前界面所有可见文字(做断言用)
$P <serial> bounds "已交接" # 元素 bounds + 中心点
$P <serial> tap-text "确认" # 按文字点击(自动取中心点)
$P <serial> wait-text "待交接" # 等页面就绪
$P <serial> wait-gone "请稍候……" # 等加载弹窗消失
$P <serial> shot /tmp/a.png # 截图
$P <serial> req pageQuery # 抓请求/响应体
$P <serial> crash # 崩溃次数 + 栈
```
## 构建安装
```bash
./gradlew assembleDebug 2>&1 | grep -E "^(BUILD|FAILURE)|error:"
adb -s <serial> install -r -d app/build/outputs/apk/debug/app-debug.apk
```
注意 gradle 输出里大量 `e: 注: ARouter::Compiler …` 是注解处理器的**提示**被误标成 `e:`
不是错误;只看 `BUILD SUCCESSFUL` / `error:` 就行。
## 进入目标页面的两条路
### 正规路径(优先):登录 → 首页菜单
```bash
adb -s <serial> shell monkey -p com.lukouguoji.aerologic -c android.intent.category.LAUNCHER 1
```
登录页账号密码需要用户提供。填表用 `input text`,注意 `#` 等字符要转义(`Aa123456\#`)。
底部有「首页 / 国内 / 国际 / 我的」Tab国际业务页找对应入口。
**首页菜单是按权限过滤的**:当前账号角色没有该权限时,入口根本不显示(本次 ADMIN 登录后
国际业务只有「板箱过磅」「进港理货」,没有「出库交接」)。这时走下面的直启。
### 直启(权限缺失时的替代):冷启动 + am start
`app/src/debug/AndroidManifest.xml` 里把调试页临时 `exported="true"`,就能直接拉起。
**必须冷启动**(先 force-stop否则会撞上既有的 LoadingModel 崩溃:
```bash
adb -s <serial> shell am force-stop com.lukouguoji.aerologic
sleep 2
adb -s <serial> shell am start -n com.lukouguoji.aerologic/<完整类名>
```
登录态存在 SharedPreference 里,冷启动直启同样带 token接口能正常返回真实数据。
> 先经首页再 `am start` 会崩在 `LoadingModel.showLoading` → XPopup `popupInfo is null`
> 这是既有缺陷(未改动的页面也复现),不是本次改动引入的。
## 点击前先等就绪
加载弹窗("请稍候……")会盖住整屏并**吞掉 `input tap`**,表现为"点了没反应"。
本次验证就因此白跑了几轮。所以:
```bash
$P <serial> wait-text "待交接" # 等目标元素出现
$P <serial> tap-text "已交接"
$P <serial> texts | grep -c "全选" # 用文字变化确认点击确实生效
```
点完**一定要断言效果**(用 `texts` 或截图),别假设点中了。
## 逐项走查清单
每一步做完都跑一次 `crash`(必须为 0
- [ ] 列表加载:卡片字段、各种状态形态(如待处理 / 已完成两种卡片)
- [ ] Tab 切换:指示条位置、角标数字、底部条按设计显隐
- [ ] 筛选弹层:打开、各控件取值、重置、确认后重新查询
- [ ] 下拉字典:真实取到数据(不是空列表)
- [ ] 多选:单选、全选联动、"已选 N 项"计数
- [ ] 详情页/二级页:字段绑定、条件显示的区块
- [ ] 扫码入口能拉起
## 核对请求参数是否真的生效
新增的查询字段要确认两件事:客户端**发出去了**,服务端**认**。
```bash
$P <serial> req pageQuery
```
OkHttp 日志是多行 pretty JSON。看到请求体里有你的字段`"hoState": "0"`)只说明客户端对了。
**服务端是否生效要用行为验证**:切到另一个 Tab看返回数据是否真的变了。
本次实测:请求正确携带 `hoState:"0"/"1"`,但两个 Tab 返回同一批数据 → 后端没做过滤。
这种情况**必须在交付说明里如实标注**,不能让它看起来是通的。
## 无数据时怎么核对 UI
服务端当天可能没数据(列表空)。放宽条件(清空日期)再查;仍为空时,临时注入样例 Bean
```kotlin
// 临时:覆盖各种形态各一条,验证后删除
binding.rv.postDelayed({
binding.rv.commonAdapter()?.refresh(listOf(/* 待处理 / 危险品 / 已完成 各一条 */))
}, 2500)
```
截图核对完**必须删除**,然后重新构建 + grep 确认无残留:
```bash
grep -rn "injectMockForVisualCheck\|TODO 临时" module_base/src module_gjc/src | wc -l # 应为 0
```
## 屏幕方向验证
新增/修改 Activity 后,确认手机端不会「先横屏再转竖屏」:
```bash
# 冷启动时连续采样:应全程 mRotation=0
adb -s <phone> shell am force-stop com.lukouguoji.aerologic; sleep 2
adb -s <phone> shell monkey -p com.lukouguoji.aerologic -c android.intent.category.LAUNCHER 1
for i in $(seq 1 10); do adb -s <phone> shell dumpsys window | grep -m1 -oE "mRotation=[0-9]+"; sleep 0.4; done
```
平板则应首屏就是横屏(截图宽 > 高)。若手机出现横屏帧,检查该 Activity 的 Manifest
是否漏写 `android:screenOrientation="@integer/screen_orientation"`
## Pad 端回归(必做)
```bash
adb -s <tablet> install -r -d app/build/outputs/apk/debug/app-debug.apk
adb -s <tablet> shell am start -n com.lukouguoji.aerologic/<同一个类名>
$P <tablet> shot /tmp/tablet.png
$P <tablet> form # 应为 TABLET(sw=800dp…)
```
对比改造前后:搜索条数量与顺序、日期默认值、按钮(平板可能比手机多按钮)、底部统计文案。
改过 `module_base` 里的公共基类或 Pad 系组件时,这一步不能省,且要多看几个页面。
## 定位"值被神秘修改"类问题
在 setter 里打带堆栈的日志,一次就能看到调用来源:
```kotlin
android.util.Log.d("DBG", "value '$field' -> '$v'", Throwable("trace"))
```
```bash
adb -s <serial> logcat -d -s DBG | grep -A14 "'旧值' -> ''" | grep "at com.lukouguoji\|at android"
```
本次靠这招定位到 `TextView.onRestoreInstanceState → EditText.setText → doOnTextChanged`
即复合自定义 View 的 id 串档(见 `components.md`)。**排完删日志。**

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
把 5.5 寸手持端设计稿HTML 原型)渲染成截图,供逐像素比对。
设计稿是带 Tailwind + JS 的交互原型列表页、Tab、详情页、筛选弹层往往挤在同一个
HTML 里,靠 JS 函数切换。只读源码容易漏掉视觉细节(间距、圆角、真实配色、
以及 JS 里 id 映射写错导致的字段错位),所以务必渲染成图来看。
用法:
# 1) 先列出 HTML 里可用的状态切换函数与元素 id据此决定要截哪些状态
python3 design_shots.py --html 出库交接.html --list
# 2) 渲染:默认状态 + 注入 JS 触发的各个状态
python3 design_shots.py --html 出库交接.html --out /tmp/shots \
--state "01_列表:" \
--state "02_已交接Tab:switchTab('shipped');" \
--state "03_详情:showDetail({id:'PMC1',status:'已交接'});" \
--state "04_筛选弹层:toggleFilterDrawer(true);"
每个 --state 形如 "名称:JS"JS 可为空表示页面初始态。
输出 <out>/<名称>.png同时保留注入后的 <名称>.html 便于复查。
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import urllib.parse
CHROME_CANDIDATES = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
shutil.which("google-chrome") or "",
shutil.which("chromium") or "",
shutil.which("chromium-browser") or "",
]
def find_chrome() -> str:
for path in CHROME_CANDIDATES:
if path and os.path.exists(path):
return path
sys.exit(
"找不到 Chrome/Chromium。请安装或用 --chrome 指定可执行文件路径。"
)
def list_hooks(html: str) -> None:
"""列出可用于构造状态的 JS 函数和元素 id。"""
funcs = sorted(set(re.findall(r"function\s+([A-Za-z_$][\w$]*)\s*\(", html)))
onclicks = sorted(set(re.findall(r'onclick="([^"]+)"', html)))
ids = sorted(set(re.findall(r'id="([^"]+)"', html)))
print("== JS 函数(可直接注入调用)==")
for f in funcs:
print(f" {f}()")
print("\n== onclick 表达式(含真实参数,照抄最省事)==")
for o in onclicks[:40]:
print(f" {o}")
if len(onclicks) > 40:
print(f" ... 其余 {len(onclicks) - 40} 条略")
print("\n== 元素 id判断有哪些页面/弹层)==")
print(" " + ", ".join(ids))
def render(chrome: str, html: str, name: str, js: str, out_dir: str,
width: int, height: int, scale: float, budget: int) -> str:
# 等 load 之后再触发,确保 CDN 的 Tailwind/Iconify 已生效
inject = (
"<script>window.addEventListener('load',function(){"
f"setTimeout(function(){{{js}}},300);}});</script>"
)
page_path = os.path.join(out_dir, f"{name}.html")
with open(page_path, "w", encoding="utf-8") as fp:
fp.write(html.replace("</body>", inject + "</body>"))
png_path = os.path.join(out_dir, f"{name}.png")
subprocess.run(
[
chrome,
"--headless=new",
"--disable-gpu",
"--hide-scrollbars",
f"--force-device-scale-factor={scale}",
f"--window-size={width},{height}",
f"--virtual-time-budget={budget}",
f"--screenshot={png_path}",
"file://" + urllib.parse.quote(os.path.abspath(page_path)),
],
capture_output=True,
)
return png_path
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--html", required=True, help="设计稿 HTML 路径")
ap.add_argument("--out", default="/tmp/design_shots", help="输出目录")
ap.add_argument("--state", action="append", default=[],
help='形如 "名称:JS"可重复JS 为空表示初始态')
ap.add_argument("--list", action="store_true",
help="只列出可用的 JS 函数 / onclick / id不截图")
ap.add_argument("--chrome", default="", help="Chrome 可执行文件路径")
# 原型容器一般是 375×812 的手机壳,留出边距后 460×900 刚好完整收进去
ap.add_argument("--width", type=int, default=460)
ap.add_argument("--height", type=int, default=900)
ap.add_argument("--scale", type=float, default=2.0, help="缩放倍数2 便于看清细节")
ap.add_argument("--budget", type=int, default=9000,
help="虚拟时间预算(ms),需覆盖 CDN 脚本加载")
args = ap.parse_args()
with open(args.html, encoding="utf-8") as fp:
html = fp.read()
if args.list:
list_hooks(html)
return
chrome = args.chrome or find_chrome()
os.makedirs(args.out, exist_ok=True)
states = args.state or ["01_default:"]
for spec in states:
name, _, js = spec.partition(":")
png = render(chrome, html, name.strip(), js, args.out,
args.width, args.height, args.scale, args.budget)
ok = os.path.exists(png) and os.path.getsize(png) > 0
size = os.path.getsize(png) if ok else 0
print(f"{'' if ok else ''} {name.strip():<24} {png} ({size} bytes)")
print(f"\n用 Read 工具逐张查看 {args.out}/*.png再开始写布局。")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# 实机验证辅助:读取界面文字 / 按文字点击 / 截图 / 抓请求体。
#
# 为什么需要它:手机端验证要反复点 Tab、弹层按钮、卡片链接。靠肉眼估坐标点击
# 极易点空(本次适配就因此白跑了几轮),而 uiautomator 能拿到元素真实 bounds。
# tap-text 直接按文字定位并点中心,比手算坐标可靠得多。
#
# 用法:
# ui_probe.sh <serial> texts 列出当前界面所有可见文字
# ui_probe.sh <serial> bounds "已交接" 打印该文字元素的 bounds 与中心点
# ui_probe.sh <serial> tap-text "确认" [序号] 按文字点击(取中心点);同名多处时默认第 1 处
# ui_probe.sh <serial> wait-text "待交接" [秒] 等文字出现(默认 20 秒),点击前先等页面就绪
# ui_probe.sh <serial> wait-gone "请稍候……" [秒] 等文字消失(如等加载弹窗关闭)
# ui_probe.sh <serial> shot /tmp/a.png 截图到本地
# ui_probe.sh <serial> req <关键字> 从 logcat 抓含关键字的请求/响应体
# ui_probe.sh <serial> crash 统计并打印最近一次崩溃栈
# ui_probe.sh <serial> form 打印形态识别结果 PHONE/TABLET
set -uo pipefail
SERIAL="${1:?用法: ui_probe.sh <serial> <命令> [参数]}"
CMD="${2:?缺少命令}"
ARG="${3:-}"
PKG="com.lukouguoji.aerologic"
A="adb -s $SERIAL"
dump() {
$A shell uiautomator dump /sdcard/_probe.xml >/dev/null 2>&1
# 注意dump 出来的 XML 是一整行,必须按 '<' 拆成一节点一行才能按节点过滤。
# `tr '>' '>\n'` 是字符映射不是字符串替换,等于空操作,别踩这个坑。)
$A shell cat /sdcard/_probe.xml | tr '<' '\n'
}
# 打印所有 text 完全等于 $1 的节点的 bounds 与中心点
centers_of() {
dump | grep -F "text=\"$1\"" \
| grep -oE 'bounds="\[[0-9]+,[0-9]+\]\[[0-9]+,[0-9]+\]"' \
| sed -E 's/bounds="\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\]"/\1 \2 \3 \4/' \
| awk '{printf "bounds=[%d,%d][%d,%d] center=%d,%d\n", $1,$2,$3,$4, ($1+$3)/2, ($2+$4)/2}'
}
case "$CMD" in
texts)
dump | tr '<' '\n' | grep -oE 'text="[^"]+"' | sed 's/text=//;s/"//g' | awk '!seen[$0]++'
;;
bounds)
[ -z "$ARG" ] && { echo "需要文字参数"; exit 1; }
centers_of "$ARG"
;;
tap-text)
[ -z "$ARG" ] && { echo "需要文字参数"; exit 1; }
idx="${4:-1}"
all=$(centers_of "$ARG")
[ -z "$all" ] && { echo "界面上找不到文字:$ARG"; exit 1; }
total=$(echo "$all" | wc -l | tr -d ' ')
# 同一文字可能在多处出现(如底部 Tab「国际」与列表里的「国际」
# 默认点第 1 个;命中多个时提示,必要时用第 4 个参数指定序号。
if [ "$total" -gt 1 ]; then
echo "注意:${ARG} 命中 $total 处,正在点第 $idx 处(可用第 4 个参数指定序号)"
echo "$all" | nl -w2 -s' '
fi
line=$(echo "$all" | sed -n "${idx}p")
[ -z "$line" ] && { echo "序号 $idx 超出范围(共 $total 处)"; exit 1; }
cx=${line#*center=}; cx=${cx%%,*}
cy=${line##*,}
# 变量必须写成 ${ARG}:紧跟中文标点时 $ARG 会被当成变量名的一部分
echo "tap ${ARG} @ $cx,$cy"
$A shell input tap "$cx" "$cy"
;;
# 等某段文字出现 / 消失。点击前先等页面就绪很重要:
# 加载弹窗("请稍候……")会盖住整屏并吞掉 input tap导致点击"静默失效"。
wait-text)
[ -z "$ARG" ] && { echo "需要文字参数"; exit 1; }
for _ in $(seq 1 "${4:-20}"); do
if dump | grep -qF "text=\"$ARG\""; then echo "出现: ${ARG}"; exit 0; fi
sleep 1
done
echo "超时未出现: ${ARG}"; exit 1
;;
wait-gone)
[ -z "$ARG" ] && { echo "需要文字参数"; exit 1; }
for _ in $(seq 1 "${4:-20}"); do
if ! dump | grep -qF "text=\"$ARG\""; then echo "已消失: ${ARG}"; exit 0; fi
sleep 1
done
echo "超时仍存在: ${ARG}"; exit 1
;;
shot)
out="${ARG:-/tmp/shot.png}"
$A exec-out screencap -p > "$out"
echo "$out ($(wc -c < "$out") bytes)"
;;
req)
# OkHttp 日志是多行 pretty JSON用 -v raw 拿干净文本再按关键字取上下文
$A logcat -d -v raw | grep -B24 -A24 -- "${ARG:-POST}" | grep -E '^\s*"|POST|<-- [0-9]{3}' | tail -60
;;
crash)
n=$($A logcat -d | grep -c "FATAL EXCEPTION" || true)
echo "FATAL EXCEPTION 次数: $n"
if [ "$n" -gt 0 ]; then
$A logcat -d | grep -A25 "FATAL EXCEPTION" | tail -30
fi
;;
form)
$A logcat -d -s BaseActivity:D | grep -oE "(PHONE|TABLET)\(sw=[0-9]+dp[^)]*\)" | tail -3
;;
*)
echo "未知命令: $CMD"; exit 1
;;
esac