Files
aerologic-app/CLAUDE.md
YANG JIANKUAN 5a019a55c4 feat: 完成ULD管理页面5.5寸手机端适配
- 手机版列表(三色状态标签卡片 + 筛选弹层 + 浮动新增 + 全选/批量删除)、
  一套 activity_uld_edit 手机布局按 pageType 覆盖 ULD 新增/修改/详情三态
  (详情右上铅笔进修改、状态底部选择面板、所在港默认 HFE、手机端必填校验);
  平板布局迁至 layout-sw600dp,Kotlin 双端共用,平板端实机回归零变化
- 新增公共组件 PhoneFormRow(行式表单,INPUT/SELECT/DATE/TEXT 四形态,
  SELECT 内置底部选择面板);PhoneBottomBar 增加 actionDanger 危险按钮样式;
  新增 FAB/danger按钮/浅红标签/垃圾桶/铅笔等资源
- ULDBean 补解析接口既有出参 ifNo/efNo/checkInDate/checkOutDate 并预留 source;
  批量删除以队列串行调用单条 deleteUld 实现
- 手机首页「国际」Tab 接入 ULD管理入口(ARouter + ComprehensiveUld 权限),
  登录→菜单→页面全链路实机验证通过,方向无闪屏,crash=0
- 后端缺口 5 项(状态三态、来源字段、筛选入参、批量删除接口、字段语义)
  登记《后端接口对接问题清单.xlsx》#7~#11;skill 沉淀本次踩坑
  (FAB elevation 盖弹层、Spinner 异步回调竞态、登录哈希回填)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 16:30:42 +08:00

1448 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概况
**项目名称**: AirLogistics - 航空物流信息管理系统
**架构模式**: MVVM + 组件化 | **语言**: Kotlin 1.6.21 + Java | **版本**: 1.8.4 (versionCode 84)
**SDK**: minSdk 24 / targetSdk 30 / compileSdk 31 | **Gradle**: 7.3.3 | **JDK**: 1.8
### 核心架构
- **MVVM 基类**: `BaseActivity``BaseBindingActivity`DataBinding`BaseViewModel``BasePageViewModel`(分页列表)
- **适配器**: `CommonAdapter` + `BaseViewHolder` 统一列表封装
- **路由**: ARouter 1.5.2 | **事件**: FlowBusFlow+ EventBus 3.1.1
- **网络**: Retrofit 2.6.1 + OkHttp 3.12.12 + Coroutines
- `launchCollect`:无 Loading 后台请求
- `launchLoadingCollect`:带 Loading 请求
- `toRequestBody`Map/Bean 转 JSON
### 组件化模块
| 模块 | 说明 | 模块 | 说明 |
|------|------|------|------|
| `app/` | 应用壳层 | `module_base/` | 核心基础库 |
| `module_gnc/` | 国内出港 | `module_gnj/` | 国内进港 |
| `module_gjc/` | 国际出港 | `module_gjj/` | 国际进港 |
| `module_hangban/` | 航班管理 | `module_cargo/` | 货物追踪 |
| `module_mit/` | 监装监卸 | `module_p/` | PDA 功能 |
| `Printer/` | 蓝牙打印 | `MPChartLib/` | 图表库 |
### 关键目录结构
```
aerologic-app/
├── app/src/main/java/com/lukouguoji/aerologic/
│ ├── ui/viewModel/ # ViewModel
│ ├── ui/fragment/ # Fragment
│ └── page/ # 业务页面
├── module_base/src/main/java/com/lukouguoji/module_base/
│ ├── base/ # 基类 (BaseActivity, BaseViewModel, BaseViewHolder, BaseDialogModel)
│ ├── bean/ # 数据模型
│ ├── common/ # 常量 (Constant, DetailsPageType, ConstantEvent)
│ ├── http/net/ # 网络 (NetApply, Api)
│ ├── ktx/ # 扩展函数
│ ├── impl/ # FlowBus, observe
│ ├── interfaces/ # IOnItemClickListener
│ ├── router/ # ARouterConstants
│ └── ui/weight/ # UI 组件 (PadSearchLayout, PadDataLayout, PadDataLayoutNew)
├── module_gjc/src/main/ # 国际出港(典型参考模块)
└── 其他业务模块...
```
---
## 6 种典型页面类型
基于 `module_gjc`(国际出港)模块归纳,覆盖项目中所有常见页面模式。
### 类型 1列表查询页
**代表**: `GjcBoxWeighingActivity` / `GjcInspectionActivity`
**结构**: 搜索条件区 + SmartRefreshLayout 分页列表 + 底部统计/操作栏
**Activity 骨架**:
```kotlin
@Route(path = ARouterConstants.ACTIVITY_URL_XXX)
class XxxActivity : BaseBindingActivity<ActivityXxxBinding, XxxViewModel>() {
override fun layoutId() = R.layout.activity_xxx
override fun viewModelClass() = XxxViewModel::class.java
override fun initOnCreate(savedInstanceState: Bundle?) {
setBackArrow("页面标题")
binding.viewModel = viewModel
// 绑定分页
viewModel.pageModel.bindSmartRefreshLayout(binding.srl, binding.rv, viewModel, this)
// 监听刷新事件
FlowBus.with<String>(ConstantEvent.EVENT_REFRESH).observe(this) { viewModel.refresh() }
viewModel.refresh()
}
}
```
**ViewModel 骨架**:
```kotlin
class XxxViewModel : BasePageViewModel() {
// 搜索条件
val flightDate = MutableLiveData(DateUtils.getCurrentTime().formatDate())
val flightNo = MutableLiveData("")
// 适配器配置
val itemViewHolder = XxxViewHolder::class.java
val itemLayoutId = R.layout.item_xxx
// 统计数据
val totalCount = MutableLiveData("0")
fun searchClick() { refresh() }
override fun getData() {
val params = mapOf(
"pageNum" to pageModel.page, "pageSize" to pageModel.limit,
"fdate" to flightDate.value?.ifEmpty { null },
"fno" to flightNo.value?.ifEmpty { null }
).toRequestBody()
launchLoadingCollect({ NetApply.api.getXxxList(params) }) {
onSuccess = { pageModel.handleListBean(it) }
}
// 统计(无 Loading不阻塞列表
launchCollect({ NetApply.api.getXxxTotal(totalParams) }) {
onSuccess = { totalCount.value = (it.data?.count ?: 0).toString() }
}
}
}
```
**布局结构**:
```xml
<LinearLayout orientation="vertical">
<include layout="@layout/title_tool_bar" />
<!-- 搜索区PadSearchLayout 横排 + 操作按钮(如有) -->
<LinearLayout orientation="horizontal">
<PadSearchLayout type="@{SearchLayoutType.DATE}" value="@={viewModel.flightDate}" />
<PadSearchLayout type="@{SearchLayoutType.INPUT}" value="@={viewModel.flightNo}" />
<ImageView style="@style/iv_search_action" android:onClick="@{()-> viewModel.searchClick()}" />
<!-- 如需新增/删除按钮,尺寸规范见「开发原则」工具栏图标尺寸规范 -->
</LinearLayout>
<!-- 分页列表 -->
<SmartRefreshLayout android:id="@+id/srl" layout_weight="1">
<RecyclerView android:id="@+id/rv"
itemLayoutId="@{viewModel.itemLayoutId}" viewHolder="@{viewModel.itemViewHolder}" />
</SmartRefreshLayout>
<!-- 底部统计栏 -->
<LinearLayout background="@color/color_bottom_layout" height="50dp">
<TextView text='@{"合计:" + viewModel.totalCount + "票"}' />
</LinearLayout>
</LinearLayout>
```
**参考文件**: `module_gjc/.../GjcBoxWeighingActivity.kt``GjcBoxWeighingViewModel.kt`
#### 列表项布局规范 (`item_xxx.xml`)
**整体结构**: 水平 LinearLayout → 左侧图标 + 中间内容区(多行 KV+ 右侧箭头
```xml
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/ll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="15dp"
android:layout_marginVertical="5dp"
android:background="@drawable/bg_item"
android:orientation="horizontal"
android:padding="10dp">
<!-- 左侧图标(普通列表用 img_plane多选列表用 img_plane/img_plane_s 切换) -->
<ImageView
android:id="@+id/iv_icon"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center"
android:src="@drawable/img_plane" />
<!-- 中间内容区 -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_weight="1"
android:orientation="vertical">
<!-- 第一行 KV -->
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content">
<!-- KV 组件(见下方单个 KV 模板) -->
</androidx.appcompat.widget.LinearLayoutCompat>
<!-- 第二行 KVmarginTop=10dp -->
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp">
<!-- KV 组件weight 和 completeSpace 必须与第一行对应位置相同) -->
</androidx.appcompat.widget.LinearLayoutCompat>
</LinearLayout>
<!-- 右侧箭头(固定写法) -->
<ImageView
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="center"
android:layout_marginLeft="10dp"
android:src="@drawable/img_pda_right" />
</androidx.appcompat.widget.LinearLayoutCompat>
```
**单个 KV 组件模板**:
```xml
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1.0"
android:gravity="center_vertical">
<TextView
completeSpace="@{5}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="标签名:" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{bean.fieldName}" />
</androidx.appcompat.widget.LinearLayoutCompat>
```
**关键对齐规则**:
| 规则 | 说明 | 示例 |
|------|------|------|
| **列 weight 一致** | 第一行和第二行**相同位置**的 KV 组件必须使用相同的 `layout_weight` | 第一行第 1 列 `weight=1.0`,第二行第 1 列也必须 `weight=1.0` |
| **列 completeSpace 取最大值** | 同一列位置的 completeSpace 取两行中**较大**的那个值1 个汉字 = 1 宽度1 个标点 = 1 宽度) | 第一行"运单号:"=4第二行"特码:"=3 → 两行都用 `completeSpace=4` |
| **右侧箭头统一** | 固定使用 `@drawable/img_pda_right`,尺寸 `30x30dp``layout_gravity="center"``marginLeft="10dp"` | — |
**completeSpace 计算方法**: 统计 Label 字符数(含冒号),每个汉字算 1每个标点算 1。示例
- "运单号:" = 3字 + 1标点 = 4
- "运单类型:" = 4字 + 1标点 = 5
- "状态:" = 2字 + 1标点 = 3
- "始发站:" = 3字 + 1标点 = 4此处"始发站:"虽然有4个字符位但""也要占1个宽度
同列两行的 completeSpace 统一取 `max(row1, row2)`。例如第 1 列row1"运单号:"=4row2"特码:"=3 → 两行都用 4。
**常用字段 weight 参考表**(基于国际出港模块统计):
| 字段类型 | 典型 weight | 常见范围 | 典型 completeSpace |
|----------|------------|----------|-------------------|
| 运单号 | 1.0 | 0.9~1.2 | 4 |
| 件数 | 1.2 | 0.8~1.2 | 3~5 |
| 重量 | 0.8 | 0.7~1.0 | 3~5 |
| 状态 | 0.8 | 0.7~0.8 | 3~4 |
| 代理 | 0.8 | 0.7~0.8 | 3~4 |
| 特码 | 1.0 | 0.9~1.0 | 3~4 |
| 始发站/目的站 | 0.8 | 0.7~0.8 | 4 |
| 运单类型/业务类型 | 1.2 | 1.0~1.2 | 5 |
| 分单数 | 0.8 | 0.6~0.8 | 4 |
| 航班号/航班 | 1.0~1.2 | 1.0~1.2 | 4~5 |
| 时间类(入库/离港/过磅) | 1.0~1.2 | 1.0~1.2 | 5 |
> **原则**: 相同字段在不同页面应使用相近的 weight优先参照同模块已有布局。若新页面与已有页面**字段完全相同**,应直接复用其 weight 和 completeSpace 配置。
**典型 weight 分布示例**5列运单号/状态/代理/件数/重量 + 特码/始发站/目的站/运单类型/分单数):
```
位置: 第1列 第2列 第3列 第4列 第5列
weight: 1.0 0.8 0.8 1.2 0.8 ← 第一行
weight: 1.0 0.8 0.8 1.2 0.8 ← 第二行(必须相同)
cSpace: 4 4 4 5 4 ← 第一行
cSpace: 4 4 4 5 4 ← 第二行(必须相同,取 max
```
**参考文件**: `module_gjc/.../item_int_exp_tally.xml`(典型)、`item_gjc_query.xml``item_gjc_box_weighing.xml`
---
### 类型 2多选列表 + 批量操作页
**代表**: `IntExpOutHandoverActivity` / `GjcAssembleAllocateActivity`
**结构**: 类型 1 基础上 + 全选按钮 + ObservableBoolean 选中状态 + 批量操作
**与类型 1 的区别**:
1. **Bean 增加 ObservableBoolean**:
```kotlin
class XxxBean {
val checked: ObservableBoolean = ObservableBoolean(false)
var isSelected: Boolean
get() = checked.get()
set(value) = checked.set(value)
}
```
2. **ViewModel 增加全选逻辑**:
```kotlin
val isAllChecked = MutableLiveData(false)
init {
isAllChecked.observeForever { checked ->
val list = pageModel.rv?.commonAdapter()?.items as? List<XxxBean> ?: return@observeForever
list.forEach { it.checked.set(checked) }
pageModel.rv?.commonAdapter()?.notifyDataSetChanged()
}
}
fun checkAllClick() {
val list = pageModel.rv?.commonAdapter()?.items as? List<XxxBean> ?: return
val shouldCheckAll = !isAllChecked.value!!
list.forEach { it.checked.set(shouldCheckAll) }
isAllChecked.value = shouldCheckAll
pageModel.rv?.commonAdapter()?.notifyDataSetChanged()
}
fun batchAction() {
val selected = (pageModel.rv?.commonAdapter()?.items as? List<XxxBean>)
?.filter { it.isSelected } ?: return
if (selected.isEmpty()) { showToast("请选择数据"); return }
launchLoadingCollect({ NetApply.api.batchXxx(selected.toRequestBody()) }) {
onSuccess = { showToast("操作成功"); refresh() }
}
}
```
3. **Activity 观察全选图标**:
```kotlin
viewModel.isAllChecked.observe(this) { binding.checkIcon.alpha = if (it) 1.0f else 0.5f }
```
4. **Item 布局图片切换**:
```xml
<ImageView android:id="@+id/iv_icon"
loadImage="@{bean.checked.get() ? @drawable/img_plane_s : @drawable/img_plane}" />
```
5. **ViewHolder 中处理点击**:
```kotlin
binding.ivIcon.setOnClickListener {
bean.checked.set(!bean.checked.get())
binding.executePendingBindings()
}
```
**参考文件**: `module_gjc/.../IntExpOutHandoverActivity.kt``IntExpOutHandoverViewModel.kt`
---
### 类型 3嵌套多选列表页
**代表**: `IntExpStorageUseActivity`
**结构**: 主列表含子列表(展开/收起)+ 主子联动全选 + Dialog 操作
**与类型 2 的区别**:
1. **Activity 暴露给布局**(用于调用 Dialog 方法):
```kotlin
binding.activity = this // XML 中可调用 activity.showXxxDialog()
```
2. **联动全选(主+子列表)**:
```kotlin
isAllChecked.observeForever { checked ->
val list = pageModel.rv?.commonAdapter()?.items as? List<GjcMaWb> ?: return@observeForever
list.forEach {
it.checked.set(checked)
it.storageUseList?.forEach { sub -> sub.checked.set(checked) }
}
}
```
3. **全局展开/收起**:
```kotlin
val isAllExpanded = MutableLiveData(false)
fun toggleAllExpand() {
val shouldExpand = !isAllExpanded.value!!
isAllExpanded.value = shouldExpand
(pageModel.rv?.commonAdapter()?.items as? List<GjcMaWb>)?.forEach {
if (!it.storageUseList.isNullOrEmpty()) it.showMore.set(shouldExpand)
}
pageModel.rv?.commonAdapter()?.notifyDataSetChanged()
}
```
4. **子列表项 checkbox 样式**(必须使用 `_style` 系列,禁止使用 `_gray` 系列):
```xml
<!-- 子列表项 item_xxx_sub.xml 中的 iv_checkbox -->
<ImageView
android:id="@+id/iv_checkbox"
android:layout_width="0dp"
android:layout_height="20dp"
android:layout_gravity="center_vertical"
android:layout_weight="0.5"
loadImage="@{bean.checked.get() ? @drawable/radiobtn_checked_style : @drawable/radiobtn_unchecked_style}"
android:src="@drawable/radiobtn_unchecked_style" />
```
| 资源 | 含义 | 外观 |
|------|------|------|
| `radiobtn_checked_style` | 选中 | colorPrimary 蓝色实心圆 + 白色内环 |
| `radiobtn_unchecked_style` | 未选中 | 透明 + 黑色边框圆 |
| ~~`radiobtn_checked_gray`~~ | ❌ 禁用 | 灰色实心圆(错误样式) |
#### 含子列表的列表项 UI 规范(以 `item_int_exp_storage_use.xml` 为基准)
**主列表项卡片**
| 部位 | 属性 | 标准值 |
|------|------|--------|
| 外层容器 | marginHorizontal / marginTop | 10dp / 10dp |
| 卡片背景 | background | `@drawable/bg_white_radius_8` |
| 内容区 | padding | **10dp** |
| 选中图标 | 尺寸 / marginEnd / marginTop | 40×40dp / 10dp / **0.5px**(像素级对齐)|
| 选中图标 | 切换资源 | `img_plane_s`(选中)/ `img_plane`(未选中)|
| KV 文字 | textSize | **15sp**Key 和 Value 均需显式设置)|
| 首要字段值(运单号)| textColor | `@color/colorPrimary` |
| 其他字段 | textColor | 无需设置(继承默认 text_normal|
| 两行间距 | layout_marginTop | 10dp |
**展开/折叠按钮(`iv_show`**
| 属性 | 标准值 |
|------|--------|
| layout_width | `match_parent` |
| layout_height | **10dp** |
| layout_marginTop | **-10dp**(向上收紧,紧贴卡片底边)|
| scaleType | **centerInside**(保证箭头不被压扁/截断)|
| src | `@mipmap/img_down` |
| 显示控制 | `visible="@{bean.subList != null && !bean.subList.empty}"` |
| 不设 padding不设 layout_marginBottom | — |
> 旧版本18dp + padding=4dp + marginBottom=5dp已统一替换为上述新标准参考 `item_int_exp_arrive.xml`。组装类列表若需展开后翻转箭头,附加 `android:rotation="@{bean.showMore.get() ? 180f : 0f}"`(参考 `item_int_exp_assemble.xml`)。
**子列表区域**
- 容器:`layout_marginTop="5dp"``background="#e3f6e0"`
- 表头行:`layout_marginVertical="10dp"``paddingHorizontal="10dp"`
- 表头文字:`textSize="14sp"``textColor="@color/text_normal"``textStyle="bold"``gravity="center"`
- 表头下方分隔线:`MaterialDivider` 高度 1px`background="@color/c999999"`
- 子列表项 padding`paddingHorizontal="10dp"``paddingVertical="8dp"`
- 子列表文字:`textSize="14sp"``textColor="@color/text_normal"``gravity="center"``layout_gravity="center_vertical"`
**子列表复选框(关键)**
```xml
<ImageView
android:layout_width="0dp"
android:layout_height="20dp"
android:layout_gravity="center_vertical"
android:layout_weight="0.5"
loadImage="@{bean.checked.get() ? @drawable/radiobtn_checked_style : @drawable/radiobtn_unchecked_style}"
android:src="@drawable/radiobtn_unchecked_style" />
```
> `loadImage` 和 `android:src` **均须**使用 `_style` 系列,**禁止**使用 `_gray` 系列(参见上方复选框样式表)
**参考文件**: `module_gjc/.../IntExpStorageUseActivity.kt``IntExpStorageUseViewModel.kt`
---
### 类型 4Tab 详情页
**代表**: `GjcQueryDetailsActivity`
**结构**: 自定义 Tab 栏 + ViewPager2 + 多 Fragment
**Activity 骨架**:
```kotlin
override fun initOnCreate(savedInstanceState: Bundle?) {
setBackArrow("查询详情")
binding.viewModel = viewModel
viewModel.initOnCreated(intent)
// ViewPager2 配置
binding.vp.adapter = CustomVP2Adapter(viewModel.fragmentList, supportFragmentManager, lifecycle)
binding.vp.isUserInputEnabled = false // 禁用滑动
binding.vp.offscreenPageLimit = 3
// Tab 切换
viewModel.currentTab.observe(this) { binding.vp.setCurrentItem(it, false) }
viewModel.loadDetails()
}
companion object {
@JvmStatic
fun start(context: Context, id: Long?) {
context.startActivity(Intent(context, XxxDetailsActivity::class.java)
.putExtra(Constant.Key.ID, id?.toString() ?: ""))
}
}
```
**ViewModel 骨架**:
```kotlin
class XxxDetailsViewModel : BaseViewModel() {
val currentTab = MutableLiveData(0)
val fragmentList by lazy {
listOf(
FragmentA.newInstance(this),
FragmentB.newInstance(this),
FragmentC.newInstance(this)
)
}
fun onTabClick(index: Int) { currentTab.value = index }
}
```
**Tab 布局模式**:
```xml
<!-- 自定义 Tab非 TabLayout -->
<LinearLayout height="40dp" orientation="horizontal">
<LinearLayout width="100dp" gravity="center" onClick="@{()->viewModel.onTabClick(0)}" orientation="vertical">
<TextView text="运单信息"
textColor="@{viewModel.currentTab == 0 ? @color/colorPrimary : @color/text_gray}" />
<View height="3dp"
background="@{viewModel.currentTab == 0 ? @color/colorPrimary : @color/transparent}"
visibility="@{viewModel.currentTab == 0 ? View.VISIBLE : View.INVISIBLE}" />
</LinearLayout>
<!-- 更多 Tab... -->
</LinearLayout>
<ViewPager2 android:id="@+id/vp" layout_weight="1" />
```
**参考文件**: `module_gjc/.../GjcQueryDetailsActivity.kt``GjcQueryDetailsViewModel.kt`
---
### 类型 5编辑表单页
**代表**: `GjcQueryEditActivity`
**结构**: ScrollView + PadDataLayoutNew 表单(只读+可编辑混合)+ 保存/取消
**Activity 骨架**:
```kotlin
override fun initOnCreate(savedInstanceState: Bundle?) {
setBackArrow("运单修改")
binding.viewModel = viewModel
viewModel.initOnCreated(intent)
}
companion object {
@JvmStatic
fun start(context: Context, bean: XxxBean) {
context.startActivity(Intent(context, XxxEditActivity::class.java)
.putExtra(Constant.Key.DATA, Gson().toJson(bean)))
}
}
```
**ViewModel 骨架**:
```kotlin
class XxxEditViewModel : BaseViewModel() {
val dataBean = MutableLiveData(XxxBean())
val packageTypeList = MutableLiveData<List<KeyValue>>(emptyList())
fun initOnCreated(intent: Intent) {
val json = intent.getStringExtra(Constant.Key.DATA) ?: ""
if (json.isNotEmpty()) {
val bean = Gson().fromJson(json, XxxBean::class.java)
loadDropdownLists()
loadDetails(bean.id)
}
}
fun submit() {
val bean = dataBean.value ?: return
if (bean.wbNo.verifyNullOrEmpty("运单号不能为空")) return
launchLoadingCollect({ NetApply.api.updateXxx(bean.toRequestBody()) }) {
onSuccess = {
showToast("修改成功")
viewModelScope.launch { FlowBus.with<String>(ConstantEvent.EVENT_REFRESH).emit("refresh") }
getTopActivity().finish()
}
}
}
fun cancel() { getTopActivity().finish() }
}
```
**表单布局模式**:
```xml
<ScrollView layout_weight="1" fillViewport="true">
<LinearLayout padding="15dp" orientation="vertical">
<LinearLayout background="@drawable/bg_white_radius_8" padding="15dp" orientation="vertical">
<!-- 三列表单行 -->
<LinearLayout orientation="horizontal">
<PadDataLayoutNew layout_weight="1" enable="@{false}" title='@{"运单号"}'
type="@{DataLayoutType.INPUT}" value='@{viewModel.dataBean.wbNo}' />
<PadDataLayoutNew layout_weight="1" enable="@{true}" title='@{"特码"}'
type="@{DataLayoutType.INPUT}" value='@={viewModel.dataBean.spCode}' />
<PadDataLayoutNew layout_weight="1" title='@{"包装类型"}'
type="@{DataLayoutType.SPINNER}" list="@{viewModel.packageTypeList}"
value='@={viewModel.dataBean.packageType}' />
</LinearLayout>
<!-- 备注(多行) -->
<PadDataLayoutNew inputHeight="@{80}" title='@{"备注"}'
type="@{DataLayoutType.INPUT}" value='@={viewModel.dataBean.remark}' />
</LinearLayout>
<!-- 底部按钮 -->
<LinearLayout gravity="center" marginTop="24dp">
<TextView style="@style/tv_bottom_btn" width="120dp" onClick="@{()-> viewModel.cancel()}" text="取消" />
<TextView style="@style/tv_bottom_btn" width="120dp" onClick="@{()-> viewModel.submit()}" text="保存" />
</LinearLayout>
</LinearLayout>
</ScrollView>
```
**参考文件**: `module_gjc/.../GjcQueryEditActivity.kt``GjcQueryEditViewModel.kt`
---
### 类型 6添加表单页含输入回调
**代表**: `GjcBoxWeighingAddActivity`
**结构**: 类型 5 基础上 + `setRefreshCallBack` 输入完成回调 + 实时计算 + 扫码自动填充
**与类型 5 的区别**:
1. **输入完成回调**(关键特性):
```xml
<!-- 使用方法引用,不能用 Lambda -->
<PadDataLayoutNew
setRefreshCallBack="@{viewModel::onCarIdInputComplete}"
title='@{"架子车号"}' type="@{DataLayoutType.INPUT}" value='@={viewModel.carId}' />
```
```kotlin
private var lastQueriedCarId = ""
fun onCarIdInputComplete() {
val id = carId.value
if (!id.isNullOrEmpty() && id != lastQueriedCarId) {
lastQueriedCarId = id
queryFlatcarInfo(id) // 输入完成后自动查询
}
}
```
2. **级联查询**(航班日期+航班号同时有值时查询):
```kotlin
fun onFlightNoInputComplete() { queryFlightIfReady() }
fun onFlightDateInputComplete() { lastQueriedFlight = ""; queryFlightIfReady() }
private fun queryFlightIfReady() {
val fdate = flightDate.value; val fno = flightNo.value
if (!fdate.isNullOrEmpty() && !fno.isNullOrEmpty()) {
val key = "$fdate-$fno"
if (key != lastQueriedFlight) { lastQueriedFlight = key; queryFlightInfo(fdate, fno) }
}
}
```
3. **实时计算**:
```kotlin
fun initOnCreated(activity: Activity) {
totalWeight.observe(activity as LifecycleOwner) {
val total = it?.toDoubleOrNull() ?: 0.0
val net = total - (dataBean.value?.carWeight ?: 0.0)
val cargo = net - (dataBean.value?.uldWeight ?: 0.0)
netWeight.value = if (net > 0) net.toString() else "0"
cargoWeight.value = if (cargo > 0) cargo.toString() else "0"
}
}
```
4. **输入限制**:
```kotlin
// Activity 中设置
binding.carIdInput.et.setUpperCaseAlphanumericFilter()
```
5. **重置功能**:
```kotlin
fun resetClick() {
dataBean.value = XxxBean()
carId.value = ""; uldNo.value = ""; flightNo.value = ""
lastQueriedCarId = ""; lastQueriedUld = ""; lastQueriedFlight = ""
}
```
**参考文件**: `module_gjc/.../GjcBoxWeighingAddActivity.kt``GjcBoxWeighingAddViewModel.kt`
---
## 自定义 Dialog 开发模式
基于 `BaseDialogModel`XPopup 封装),支持 5 种弹窗类型。
> ⚠️ **强制规则**:所有二次确认弹框**必须**使用 `ConfirmDialogModel``com.lukouguoji.module_base.model.ConfirmDialogModel`**禁止**使用系统 `AlertDialog`。
### 基础模板
```kotlin
class XxxDialogModel(
private val onConfirm: () -> Unit
) : BaseDialogModel<DialogXxxBinding>(DIALOG_TYPE_CENTER) { // CENTER/BOTTOM/DRAWER/FULL
override fun layoutId() = R.layout.dialog_xxx
override fun onDialogCreated(context: Context) {
binding.model = this
}
fun onConfirmClick() { onConfirm(); dismiss() }
}
// 使用
XxxDialogModel(onConfirm = { refresh() }).show()
```
### 弹窗类型
| 类型 | 常量 | 场景 | 示例 |
|------|------|------|------|
| 底部 | `DIALOG_TYPE_BOTTOM` | 操作选项 | 默认类型 |
| 中间 | `DIALOG_TYPE_CENTER` | 确认框、表单输入 | `ConfirmDialogModel` |
| 抽屉 | `DIALOG_TYPE_DRAWER` | 通用右侧抽屉 | 见下方模板 |
| 全屏 | `DIALOG_TYPE_FULL` | 列表展示 | `NoticeMessageDialogModel` |
### 抽屉弹窗(筛选场景)
```kotlin
class XxxFilterDialogModel(
val filterField: MutableLiveData<String>,
private val onConfirm: () -> Unit
) : BaseDialogModel<DialogXxxFilterBinding>(DIALOG_TYPE_DRAWER) {
override fun onBuild(builder: XPopup.Builder) {
builder.popupPosition(PopupPosition.Right)
val width = DevUtils.getTopActivity().window.decorView.width / 3
builder.maxWidth(width).popupWidth(width)
}
override fun onDialogCreated(context: Context) {
binding.model = this
binding.lifecycleOwner = context as? LifecycleOwner
}
fun onResetClick() { filterField.value = "" }
fun onConfirmClick() { dismiss(); onConfirm() }
}
```
**参考文件**: `module_base/.../BaseDialogModel.kt``module_gjc/.../dialog/`
---
## 命名与文件组织
| 类型 | 命名 | 目录 |
|------|------|------|
| Activity | `XxxActivity` | `模块/page/``模块/activity/` |
| ViewModel | `XxxViewModel` | `模块/viewModel/` |
| ViewHolder | `XxxViewHolder` | `模块/holder/` |
| Adapter | `XxxAdapter` | `模块/adapter/` |
| Bean | `XxxBean` | `module_base/bean/` |
| Dialog | `XxxDialogModel` | `模块/dialog/` |
| 布局 | `activity_xxx.xml` / `item_xxx.xml` / `dialog_xxx.xml` | `res/layout/` |
---
## 构建命令
```bash
./gradlew clean # 清理
./gradlew assembleDebug # Debug APK
./gradlew assembleRelease # Release APK已签名
./gradlew installDebug # 安装到设备
adb devices -l # 查看设备
adb logcat | grep "com.lukouguoji.aerologic" # 日志
```
---
## DataBinding 关键规则
1. **lifecycleOwner 必须设置**`BaseBindingActivity` 已自动设置,手动使用时 `binding.lifecycleOwner = this`
2. **字符串拼接用反引号**: `@{` + `` ` `` + `姓名:` + `` ` `` + ` + viewModel.name}`
3. **LiveData 自动解包**: XML 中直接 `viewModel.dataBean.name`,不写 `.value`
4. **修改对象属性需重新赋值**: `dataBean.value = dataBean.value?.copy(name = "新值")`
5. **双向绑定用 `@={}`**: `value="@={viewModel.searchText}"`
6. **点击事件用 Lambda**: `onClick="@{() -> viewModel.submit()}"`
7. **setRefreshCallBack 用方法引用**: `setRefreshCallBack="@{viewModel::methodName}"`(不能用 Lambda
8. **使用 View.VISIBLE/GONE 必须导入**: `<import type="android.view.View" />`
9. **textStyle 不支持 DataBinding**: 只能用固定值如 `android:textStyle="bold"`
---
## UI 组件速查
### PadSearchLayout搜索区
| type | 用途 | 示例 |
|------|------|------|
| `SearchLayoutType.INPUT` | 文本输入 | `value="@={viewModel.flightNo}"` |
| `SearchLayoutType.DATE` | 日期选择 | `value="@={viewModel.flightDate}"` |
| `SearchLayoutType.SPINNER` | 下拉选择 | `list="@{viewModel.statusList}" value="@={viewModel.status}"` |
支持扫码图标: `icon="@{@mipmap/scan_code}" setOnIconClickListener="@{(v)-> viewModel.scan()}"`
### PadDataLayoutNew表单区
| type | 用途 | 关键属性 |
|------|------|----------|
| `DataLayoutType.INPUT` | 文本/多行输入 | `enable`, `required`, `maxLength`, `inputHeight`, `hint` |
| `DataLayoutType.SPINNER` | 下拉选择 | `list`, `hint` |
| `DataLayoutType.DATE` | 日期选择 | `hint` |
通用属性: `title='@{"标题"}'``titleLength="@{5}"``value='@={viewModel.field}'`
回调属性: `setRefreshCallBack="@{viewModel::onInputComplete}"`
### completeSpace 对齐
`completeSpace="@{5}"` 设置 Key 文本宽度(以"一"字宽度为单位),用于 Key-Value 布局对齐。
### AutoQuery 自动查询PadSearchLayout / PadDataLayoutNew
两个组件均支持输入时自动联想查询,只需在 XML 添加属性,无需修改 Kotlin
```xml
<PadSearchLayout
autoQueryEnabled="@{true}"
autoQueryUrl="@{`/IntExpSearch/queryWbNoList`}"
autoQueryParamKey="@{`wbNo`}"
autoQueryMinLength="@{4}"
autoQueryMaxLength="@{8}"
autoQueryTitle="@{`选择运单号`}"
... />
```
- 1条结果 → 直接填充;多条结果 → 弹出选择列表0条结果 → 无处理
- 通用 API 方法:`Api.getWbNoList(@Url url, @Body data)` 返回 `BaseResultBean<List<String>>`
- 关键文件:`module_base/.../ui/weight/data/layout/AutoQueryManager.kt`
---
## 5.5 寸手持端(手机版)双形态适配规范
项目需同时支持 **10 寸平板横屏****5.5 寸手机竖屏**。核心原则:**业务逻辑层完全复用,只重写布局**。
### 核心机制:布局按资源限定符自动切换
```
res/layout-sw600dp/activity_xxx.xml ← 平板(最小宽度 >= 600dp
res/layout/activity_xxx.xml ← 手机兜底5.5 寸约 360~411dp
```
两个变体**同名**DataBinding 会生成同一个 Binding 基类 + 两个变体实现:
```
ActivityXxxBinding.java 公共基类Activity 中引用的就是它)
ActivityXxxBindingImpl.java 手机变体
ActivityXxxBindingSw600dpImpl.java 平板变体
```
因此 **Activity / ViewModel / ViewHolder 的 `layoutId()`、`itemLayoutId` 全部保持原样,无需任何设备判断**
### ⛔ 严禁:用「不同文件名 + 代码判断」切换布局
```kotlin
// ❌ 错误:会生成两个不同的 Binding 类
override fun layoutId() =
if (DeviceUtil.isPhone()) R.layout.activity_xxx_phone else R.layout.activity_xxx
```
`activity_xxx_phone.xml` 生成的是 `ActivityXxxPhoneBinding`,而 `binding` 字段声明的类型是 `ActivityXxxBinding`
泛型擦除导致**编译期不报错**,但一进入该页面就 `ClassCastException` 崩溃。**必须用同名文件 + 资源限定符。**
### 布局变体的强制约束
| 约束 | 说明 |
|------|------|
| **DataBinding 变量必须完全一致** | 两个变体 `<data>` 中的 `<variable>` 名称与类型必须相同,否则编译失败 |
| **共用控件 id 必须双方都有** | Activity / ViewHolder 中引用的 id`srl`/`rv`/`checkIcon`/`iv_icon`)两个变体都要保留 |
| **单侧独有的 id 自动变 `@Nullable`** | 只在一个变体存在的 idBinding 基类中会是可空字段Kotlin 侧强制 null 检查(编译器自动保障,不用担心漏改) |
### 已改造的基础设施
| 文件 | 作用 |
|------|------|
| `module_base/.../util/DeviceUtil.kt` | 设备形态判定。按 `smallestScreenWidthDp >= 600` 区分平板/手机;读 `Resources.getSystem()` 以绕开 AutoSize 对 density 的改写,保证判定稳定 |
| `module_base/BaseActivity.kt` | `applyDeviceOrientation()` 按形态锁方向(手机竖屏 / 平板横屏。Manifest 统一写 `unspecified` 配合它使用。子类覆写 `lockOrientationByDevice()` 返回 false 可跳过 |
| `module_base/base/BaseBindingActivity.kt` | 注释固化变体约定(`layoutId()` 不做设备分支) |
| `module_base/MyApplication.kt` | **AutoSize 增加手机竖屏基准 390×844**(见下节) |
| `module_base/res/values/colors.xml` | 手机端配色 `phone_*` 系列 |
| `module_base/res/drawable/bg_phone_*.xml` | 手机端卡片、搜索框、状态标签、主按钮背景 |
| `module_base/res/layout/title_tool_bar.xml` | 手机版公共标题栏(平板版已移至 `layout-sw600dp/` |
`DeviceUtil` 用法:
```kotlin
DeviceUtil.isPhone() // 是否手机
DeviceUtil.isTablet() // 是否平板
DeviceUtil.describe() // "PHONE(sw=411dp, force=AUTO)",日志排查用
DeviceUtil.forceMode = DeviceUtil.ForceMode.PHONE // 调试:在平板上强制走手机布局
```
> `DeviceUtil.layout(padId, phoneId)` 仅用于**极少数**确实需要代码级判断的场景(如非 DataBinding 的布局)。
> **常规页面一律用资源限定符,不要调用它。**
### ⚠️ 屏幕方向Manifest 一律写 `unspecified`,由 `BaseActivity` 按形态锁定
**症状(改造前)**:手机上打开页面(开屏页最明显)先显示横屏,约 1 秒后才转竖屏,伴随 Activity 重建。
**原因**`android:screenOrientation` 由系统在**创建 Activity 窗口时**生效,此时业务代码还没执行。
Manifest 写死 `userLandscape` 时,即使 `BaseActivity``super.onCreate` 之前
`setRequestedOrientation(PORTRAIT)`,也只能在横屏窗口已建好之后再纠正 → 必然闪一下。
**做法**Manifest 统一写 `unspecified`,让系统按设备**当前物理方向**建窗口
(平板横屏摆放 → 横屏起,手机 → 竖屏起,两端起始方向本就正确),
`BaseActivity.applyDeviceOrientation()` 再按形态锁定,不产生二次纠正:
```xml
<activity android:name="…"
android:configChanges="orientation|keyboardHidden"
android:screenOrientation="unspecified" />
```
全项目 192 处已统一(`module_p` 等 24 处本就是 `portrait` 的 PDA 页面保持不变)。
#### ⛔ 别用 `@integer/screen_orientation` 这类资源引用(已验证行不通)
一度改成 `android:screenOrientation="@integer/screen_orientation"` + `values/`=portrait、
`values-sw600dp/`=userLandscape**看似优雅,实测是错的**
系统解析 Manifest 属性时**用的是默认配置,不套用设备限定符**,所以 `values-sw600dp/` 永远不会被选中,
所有 Activity 实际都拿到了 `values/` 的竖屏值。后果是平板端:
1. 先竖屏、随后被 `BaseActivity` 纠正成横屏 —— 反而把闪屏问题搬到了平板上
2. **AutoSize 在竖屏配置下选了平板竖屏基准 720dp**density=1280/720≈1.78,正常应为 1280/1152≈1.11
→ 交互几次后整个 UI 放大约 1.6 倍
> 验证方法(可复现):把 `values/` 与 `values-sw600dp/` 的值**互换**,并临时让
> `lockOrientationByDevice()` 返回 false排除运行时纠正装到平板上看首屏方向。
> 结果是平板取了 `values/` 的值 → 证明限定符被忽略。
### ⚠️ AutoSize 设计基准必须区分手机(易漏且后果严重)
项目使用 AndroidAutoSize`MyApplication.initAutoSizeConfig()` 按方向+设备切换设计尺寸:
| 场景 | 设计基准 |
|------|----------|
| 横屏(平板) | 1152 × 720 dp |
| 竖屏 + 手机 | **390 × 844 dp**(对应 5.5 寸设计稿画布) |
| 竖屏 + 平板 | 720 × 1280 dp |
| PictureSelector | 360 × 480 dp |
**若手机沿用平板竖屏的 720dp 基准,手机上所有 dp 会被压缩约一半,布局全部错位。**
### 手机版 UI 规范(依据 `documents/5.5寸手持端设计文件/` 设计稿)
| 部位 | 规范 |
|------|------|
| 画布基准 | 390 × 844 dp |
| 顶栏 | 高 48dp`@color/phone_primary`(#2563EB),左侧「‹ 返回」,标题居中 18sp 加粗 |
| 页面背景 | `@color/phone_bg` (#F3F4F6) |
| 列表卡片 | `@drawable/bg_phone_card`,圆角 12dp`marginHorizontal=16dp``marginTop=12dp``padding=14dp` |
| 卡片头行 | 圆形勾选框 20dp`radiobtn_checked_style`/`radiobtn_unchecked_style`+ 运单号 15sp 加粗 + 右侧状态标签 |
| 卡片字段 | 两列网格13sp`@color/phone_text_body`,行距 8dp |
| 状态标签 | `bg_phone_tag_green`(已完成)/ `bg_phone_tag_gray`未完成11sp |
| 搜索行 | 搜索框 40dp 高 + `bg_phone_search`,内含放大镜 + 输入 + 扫码;右侧筛选按钮 40dp |
| 状态 Tab | 高 42dp选中文字 `phone_primary` + 底部 28×3dp 指示条 |
| 底部操作条 | 白底 + `elevation=8dp`,全选 + 统计13sp/11sp 两行)+ 主按钮 `bg_phone_btn_primary` |
| 筛选弹层 | `FrameLayout` 遮罩 `#80000000` + 底部白色面板,重置 / 确定各占一半 |
### 手机版页面改造步骤
1. **移动平板布局**`git mv res/layout/activity_xxx.xml res/layout-sw600dp/activity_xxx.xml`item 布局同理)
2. **新建手机布局**:在 `res/layout/` 下创建**同名**文件,保持相同 `<variable>` 与共用控件 id
3. **Kotlin 侧不动**`layoutId()``itemLayoutId`、ViewHolder 全部保持原样
4. **纯 UI 交互**Tab 切换、弹层显隐)写在 Activity不污染 ViewModel状态过滤优先复用 ViewModel 既有字段
5. 手机布局顶部 `<include layout="@layout/title_tool_bar" />` 不变(变体自动切换)
6. **回到手机端首页加菜单入口**(最易遗忘,见下节)——不加的话页面在手机上根本进不去
### 手机端首页菜单入口(适配完必须加,否则没入口)
手机端首页是 `module_p``PDAEnterActivity`(手持机首页),底部四个 Tab 对应:
| Tab | ViewModel | 位置 |
|-----|-----------|------|
| 国内 | `GnViewModel` | `module_p/.../ui/enter/gn/` |
| 国际 | `GjViewModel` | `module_p/.../ui/enter/gj/` |
**菜单项是手写清单,不是接口下发**。权限机制只做过滤:登录返回的权限串存在
`SharedPreferenceUtil.getString(Constant.Share.authList)`(一个逗号分隔的长字符串),
菜单项按 `authList.contains(bean.key)` 决定显不显示。
所以「适配完页面但手机首页看不到入口」的原因通常**不是没权限,而是清单里没加**。
排查顺序:先确认权限串里有对应 key再去看清单。
```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
```
加一项就是加一行 —— `ActionBean` 的第 4 个参数填 ARouter 路由即可,无需写 when 分支
`module_p` 只依赖 `module_base`,看不到 `module_gjc` 等业务模块,跨模块只能走路由):
```kotlin
ActionBean(
"出港移库",
R.drawable.gjc_yi_ku_icon, // 图标复用 Pad 端同款
Constant.AuthName.GjcYiKuListActivity, // 权限串,与 Pad 端菜单保持一致
ARouterConstants.ACTIVITY_URL_INT_EXP_MOVE // 有 route 就走通用跳转
),
```
两点注意:
- **权限串与路由都要和 Pad 端菜单对齐**:以 `app/.../HomeFragment.kt` 里该权限对应的
`ARouter.build(...)` 为准。同一业务常有新旧两个 Activity如移库的 `GjcYiKuListActivity`
`IntExpMoveActivity`Pad 菜单实际跳的才是要适配的那个
- **图标**`module_p` 看不到 `module_gjc` 的资源,把 Pad 图标 PNG 复制一份到
`module_p/src/main/res/drawable-xxhdpi/`(该模块既有 4 个图标就是这么放的,同名不同密度桶可共存)
**示例:状态 Tab 零 ViewModel 改动**`IntExpMoveActivity` 已实现)
```kotlin
// Activity 中新增纯 UI 方法,写入 ViewModel 既有的 moveState 字段("" 全部 / "0" 未移库 / "1" 已移库)
fun switchTab(state: String) {
if (viewModel.moveState.value == state) return
viewModel.moveState.value = state
viewModel.searchClick() // 复用原有查询链路
}
```
```xml
<!-- XML 中判等要把字面量放前面,避免 LiveData 为 null 时崩溃 -->
android:textColor="@{`0`.equals(viewModel.moveState) ? @color/phone_primary : @color/phone_text_body}"
```
### 手机版公共组件库(`module_base/.../ui/weight/phone/`
7 个组件覆盖设计稿中反复出现的部件,**新页面优先复用,不要重写 XML**。属性均为无命名空间写法,
DataBinding 适配器统一放在 `PhoneWidgetKtx.kt`(同名属性按控件类型解析,与平板端 Pad* 组件互不冲突)。
| 组件 | 用途 | 关键属性 |
|------|------|----------|
| `PhoneSearchBar` | 顶部搜索行:搜索框 + 扫码 + 圆形筛选按钮 | `hint``value`(双向)、`showScan``showFilter``upperCase``setOnScanClickListener``setOnFilterClickListener``setRefreshCallBack` |
| `PhoneStatusTab` | 状态 Tab 条(文字 + 角标,选中态下划线) | `tabs`(List\<KeyValue\>)、`counts`(List\<String\>)、`value`(双向)、`setOnTabChanged` |
| `PhoneDataLayout` | 表单/筛选项,`PhoneDataLayoutType` = `INPUT`/`DATE`/`SPINNER` | `title``hint``type``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` | 详情页字段项(灰标签 + 数值),`tag=true` 转橙色高亮 | `title``value``tag` |
| `PhoneFormRow` | 行式表单项(左标签+右值+分隔线),`PhoneFormRowType` = `INPUT`/`SELECT`(内置底部选择面板)/`DATE`/`TEXT`(只读右对齐,空值 "--") | `title``hint``type``value`(双向)、`list``required``enable``numeric``setRefreshCallBack` |
`PhoneBottomBar` 追加 `actionDanger` 属性true 时主按钮转浅红底红字 + 垃圾桶图标ULD 管理批量删除)。
新增资源:`bg_phone_btn_danger``bg_phone_fab`(圆形浮动 + 按钮)、`bg_phone_tag_red_light`(浅红标签)、
`ic_phone_trash``ic_phone_edit`、色值 `phone_danger_bg/text`
⚠️ **弹层与浮动按钮的层级**`PhoneFilterPanel` 默认无 elevation页面里有带 elevation 的浮动按钮FAB
会穿透到弹层之上——给弹层实例加 `android:elevation="8dp"`(大于 FAB 的 6dp
**`PhoneFilterPanel` 自带内容插槽**:把 `PhoneDataLayout` 直接写在其标签内即可,组件会自动移入面板中部
并施加 20dp 项间距,无需关心内部层级:
```xml
<com.lukouguoji.module_base.ui.weight.phone.PhoneFilterPanel
android:layout_width="match_parent"
android:layout_height="match_parent"
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>
```
#### ⚠️ 复合自定义 View 必须屏蔽子控件状态恢复(已踩坑)
同一页面放多个 `PhoneDataLayout`/`PhoneStatBox` 时,它们的内部子控件(`et`/`tv`/`spinner`…)**共用同一套 id**。
系统按 id 保存/恢复视图状态会跨实例串档 —— 实测表现为DATE 项的默认日期被邻近输入框恢复的空文本清空,
并通过双向绑定把 ViewModel 字段也写成了空值(筛选条件静默丢失)。
因此每个含内部 id 的手机版组件都重写了:
```kotlin
override fun dispatchSaveInstanceState(container: SparseArray<Parcelable>) = dispatchFreezeSelfOnly(container)
override fun dispatchRestoreInstanceState(container: SparseArray<Parcelable>) = dispatchThawSelfOnly(container)
```
**新增同类组件时必须照做**(取值由 ViewModel + DataBinding 持有,本就不需要视图级恢复)。
### 已完成 / 待办
-**已完成**:适配框架 + 手机端公共资源 + 7 个手机版公共组件
-**已完成页面**
- 「国际出港移库」`IntExpMoveActivity` + 「运单详情」`IntExpMoveDetailActivity`(框架参考页)
- 「国际出港出库交接」`IntExpOutHandoverActivity` + 「板箱详情」`IntExpOutHandoverDetailActivity`
(公共组件首个完整落地页,双端实机验证通过)
- 「ULD管理」`UldListActivity` + `UldEditActivity`app 模块首个适配页;一套编辑布局覆盖
新增/修改/详情三态;批量删除为前端循环调用单条接口;状态三态/来源等 5 项后端缺口见问题清单 #7~#11
-**待办**
- `PhoneSearchBar` 接入 AutoQuery 联想(`AutoQueryManager` 目前只对接了 Pad 系组件)
- 出库交接 Tab 角标恒 0`IntExpOutHandover/pageQueryTotal` 出参恒 0后端缺陷问题清单 #6
后端修复后角标自动恢复(`handoverState` 过滤本身已于 2026-07-29 实机验证通过)生效
- 📋 **接口对接**:适配中发现的后端接口缺口统一记录在 `documents/后端接口对接问题清单.xlsx`
(按模块/页面/接口定义/接口地址索引,与后端对接的正式文档,每页适配完必须维护);
接口入参/出参一律以 api-doc MCP 为准核对,流程见
`.claude/skills/phone-adapt/references/api-doc.md`——**后端对不认识的参数静默忽略**
猜参数名会得出「后端不支持」的误判(出库交接 `hoState``handoverState` 已踩过)
### 调试便利:直接拉起页面
`app/src/debug/AndroidManifest.xml`**仅 Debug 生效,不进 Release 包**)通过 `tools:replace` 将适配调试页临时置为 `exported="true"`,免去每次登录再逐级点入:
```bash
adb shell am start -n com.lukouguoji.aerologic/com.lukouguoji.gjc.page.move.IntExpMoveActivity
adb shell am start -n com.lukouguoji.aerologic/com.lukouguoji.gjc.activity.IntExpOutHandoverActivity
```
新增调试页时在该文件追加对应 `<activity>` 即可。
> 模拟器通常连不上内网服务(提示「接口访问失败」),列表为空。若需验证卡片/详情的还原度,
> 可在 Activity 里临时注入几条样例 Bean`binding.rv.commonAdapter()?.refresh(list)`)截图比对,**验证后务必删除**。
**参考文件**`module_base/.../util/DeviceUtil.kt``module_gjc/res/layout/activity_int_exp_move.xml`(手机)、`module_gjc/res/layout-sw600dp/activity_int_exp_move.xml`(平板)
---
## Import 路径速查
### 基类与常用类
| 类 | 正确路径 |
|----|----------|
| `BaseActivity` | `com.lukouguoji.module_base.base.BaseActivity` |
| `BaseBindingActivity` | `com.lukouguoji.module_base.base.BaseBindingActivity` |
| `BaseViewModel` | `com.lukouguoji.module_base.base.BaseViewModel` |
| `BasePageViewModel` | `com.lukouguoji.module_base.base.BasePageViewModel` |
| `BaseViewHolder` | `com.lukouguoji.module_base.base.BaseViewHolder` |
| `BaseDialogModel` | `com.lukouguoji.module_base.base.BaseDialogModel` |
| `CustomVP2Adapter` | `com.lukouguoji.module_base.base.CustomVP2Adapter` |
| `Constant` | `com.lukouguoji.module_base.common.Constant` |
| `DetailsPageType` | `com.lukouguoji.module_base.common.DetailsPageType` |
| `ConstantEvent` | `com.lukouguoji.module_base.common.ConstantEvent` |
| `NetApply` | `com.lukouguoji.module_base.http.net.NetApply` |
| `FlowBus` | `com.lukouguoji.module_base.impl.FlowBus` |
| `observe`FlowBus 扩展) | `com.lukouguoji.module_base.impl.observe` |
| `IOnItemClickListener` | `com.lukouguoji.module_base.interfaces.IOnItemClickListener` |
| `ARouterConstants` | `com.lukouguoji.module_base.router.ARouterConstants` |
### 扩展函数(均在 `com.lukouguoji.module_base.ktx` 包下)
`launchCollect``launchLoadingCollect``showToast``toRequestBody``verifyNullOrEmpty``noNull``formatDate``setUpperCaseAlphanumericFilter`
### 工具类
| 类 | 正确路径 |
|----|----------|
| `DictUtils` | `com.lukouguoji.module_base.util.DictUtils` |
| `DeviceUtil` | `com.lukouguoji.module_base.util.DeviceUtil` |
| `MediaUtil` | `com.lukouguoji.module_base.util.MediaUtil` |
| `UploadUtil` | `com.lukouguoji.module_base.util.UploadUtil` |
| `KeyValue` | `dev.utils.app.info.KeyValue` |
| `DateUtils` | `dev.utils.common.DateUtils` |
| `SharedPreferenceUtil` | `com.lukouguoji.module_base.db.perference.SharedPreferenceUtil` |
| `ScanModel` | `com.lukouguoji.module_base.model.ScanModel` |
| `ConfirmDialogModel` | `com.lukouguoji.module_base.model.ConfirmDialogModel` |
---
## 常见编译错误速查
| 错误 | 原因 | 修复 |
|------|------|------|
| `DetailsPageType` 找不到 | 包名错误 | `common.DetailsPageType`,非 `constant.` |
| `DataLayoutType.INTEGER` | 不存在 | 用 `DataLayoutType.INPUT` |
| `DetailsPageType.Edit` | 不存在 | 用 `DetailsPageType.Modify` |
| `IOnItemClickListener` 找不到 | 包名错误 | `interfaces.`,非 `impl.` |
| `FlowBus.observe` 无法调用 | 未导入扩展 | 单独导入 `com.lukouguoji.module_base.impl.observe` |
| `FlowBus.emit()` 报错 | 需在协程中 | `viewModelScope.launch { FlowBus.with<String>(...).emit(...) }` |
| 图片上传字段错误 | 无 `url` 字段 | 用 `result.data?.newName` |
| `pageType` 绑定失效 | 非 LiveData | 用 `MutableLiveData(DetailsPageType.Add)` |
| `RecyclerView items` 报错 | 不支持该属性 | Activity 中手动 `rv.commonAdapter()?.refresh(data)` |
| 资源引用不存在 | drawable/color/string 缺失 | 先检查资源是否存在,不存在则创建或用已有资源 |
| `View.VISIBLE` 报错 | 未导入 | XML `<data>` 中加 `<import type="android.view.View" />` |
| `textStyle` DataBinding 报错 | 不支持 | 用固定值 `android:textStyle="bold"` |
| 手机版页面一进入就 `ClassCastException` | 用了「不同文件名 + 代码判断」切换布局,生成了两个 Binding 类 | 改用同名布局 + 资源限定符(`layout/``layout-sw600dp/`见「5.5 寸手持端双形态适配规范」 |
| 布局变体编译报 `variable not found` | 两个变体的 `<data>``<variable>` 不一致 | 手机与平板变体的变量名/类型必须完全相同 |
| 手机上元素小一半 / 布局错位 | AutoSize 仍按平板 720dp 基准换算 | 确认 `MyApplication.initAutoSizeConfig()` 中手机竖屏走 390×844 分支 |
| 手机版筛选项默认值(如日期)自动被清空 | 多个复合自定义 View 内部子控件 id 相同,系统按 id 恢复状态时跨实例串档 | 组件重写 `dispatchSaveInstanceState`/`dispatchRestoreInstanceState``dispatchFreezeSelfOnly`/`dispatchThawSelfOnly`(见「手机版公共组件库」) |
| 手机上页面先横屏再转竖屏(闪一下) | Manifest 写死 `screenOrientation="userLandscape"`,系统建窗口时已按横屏,代码纠正为时已晚 | 改成 `android:screenOrientation="unspecified"`,由 `BaseActivity` 按形态锁定(见「屏幕方向」小节) |
| 平板先竖屏再转横屏 + 交互后 UI 放大约 1.6 倍 | Manifest 用了 `@integer/screen_orientation` 资源引用;系统解析 Manifest 不套用限定符全部取到竖屏值AutoSize 随之选了平板竖屏 720dp 基准 | Manifest 改回 `unspecified`(见「屏幕方向」小节的 ⛔ 说明) |
---
## 编辑表单下拉框SPINNER回填规范
编辑页面DetailsPageType.Modify下拉框需要根据已有数据自动选中对应项。**必须使用 `DictUtils``checkedValue` 参数**,禁止依赖组件自动匹配 value。
### 原理
`DictUtils``handleCallBack` 会将 `checkedValue` 匹配的 `KeyValue` 置于列表首位。`PadDataLayoutNew` 的 SPINNER 默认显示列表第 0 项,因此匹配项自动成为选中项,无需额外设置 selectedIndex。
### 标准做法(参考 `GjjManifestDetailsViewModel`、`GjjManifestAddViewModel`
1. **字典加载必须在编辑数据加载之后**(不能放在 `init` 中),确保 `checkedValue` 可用
2. **编辑模式传入 `checkedValue`**,新增模式传 `null`
3. **编辑模式不预置空 `KeyValue("", "")`**(否则空项会占据首位,覆盖 checkedValue 排序)
```kotlin
fun initOnCreated(intent: Intent) {
// 1. 先解析页面类型和编辑数据
if (pageType.value == DetailsPageType.Modify) {
loadManifestFromBean(bean) // 设置 agent.value、specialCode.value 等
}
// 2. 再加载字典列表(此时 checkedValue 已可用)
loadDictLists()
}
private fun loadDictLists() {
val isModify = pageType.value == DetailsPageType.Modify
DictUtils.getXxxList(
addAll = false,
checkedValue = if (isModify) field.value else null // 编辑模式传值,新增传 null
) {
xxxList.postValue(if (isModify) it else listOf(KeyValue("", "")) + it)
}
}
```
### checkedValue 取值规则
提交时用的哪个字段值,`checkedValue` 就传哪个。对照 `toKeyValue()``value` 字段确认匹配:
| DictUtils 方法 | KeyValue.value 来源 | checkedValue 示例 |
|---|---|---|
| 通用(`handleCallBack` | `DictBean.code` | `manifest.agentCode`(如 "SFINT" |
| `getShouYunPackageTypeList` | `PackageBean.name` | `manifest.packageType`(如 "木框" |
### 禁止做法
- ❌ 在 `init` 中加载字典(编辑数据尚未可用,无法传 `checkedValue`
- ❌ 依赖 `PadDataLayoutNew``value` 属性自动匹配列表Spinner adapter 重建时 `onItemSelected` 回调会覆盖已有值)
- ❌ 编辑模式下在列表前添加 `KeyValue("", "")`(会干扰 `checkedValue` 置顶排序)
---
## 开发检查清单
### 新页面开发必做
1. 创建 Bean如需→ API 接口 → ViewHolder列表页→ ViewModel → Activity → 布局
2. **在 `app/src/main/AndroidManifest.xml` 注册 Activity**(最易遗忘):
```xml
<activity android:name="com.lukouguoji.gjc.activity.XxxActivity"
android:configChanges="orientation|keyboardHidden"
android:exported="false"
android:screenOrientation="unspecified" />
```
> `screenOrientation` **必须写 `unspecified`**,不要写死 `userLandscape`、也不要用资源引用。
> 写死横屏会让手机端「先横屏、约 1 秒后转竖屏」闪一下;资源引用则会让平板端出问题
> (详见下方「屏幕方向」小节)。
3.`ARouterConstants` 注册路由(如需)
4. 标题栏统一用 `<include layout="@layout/title_tool_bar" />`Activity 中 `setBackArrow("标题")`
5. **双形态布局**:需同时支持手机的页面,平板布局放 `res/layout-sw600dp/`、手机布局放 `res/layout/`,两者**同名**且变量与共用 id 一致。详见「5.5 寸手持端双形态适配规范」
### 常见业务操作
**扫码**:
```kotlin
fun scanWaybill() { ScanModel.startScan(getTopActivity(), Constant.RequestCode.WAYBILL) }
// Activity.onActivityResult 中: waybillNo.value = data?.getStringExtra(Constant.Result.CODED_CONTENT)
```
**图片上传**: `UploadUtil.upload(filePath)``result.data?.newName`(注意是 `newName``url`
**刷新事件**:
```kotlin
// 发送ViewModel 中,必须在协程中)
viewModelScope.launch { FlowBus.with<String>(ConstantEvent.EVENT_REFRESH).emit("refresh") }
// 接收Activity 中,必须导入 observe
FlowBus.with<String>(ConstantEvent.EVENT_REFRESH).observe(this) { viewModel.refresh() }
```
**静态启动方法**:
```kotlin
companion object {
@JvmStatic
fun start(context: Context, id: String) {
context.startActivity(Intent(context, XxxActivity::class.java).putExtra(Constant.Key.ID, id))
}
}
```
### API 接口目录对应规则
为某页面查找接口时,**必须按业务路径匹配对应 API 目录**,不能跨模块借用。
| API 前缀 | 所属模块 |
|----------|---------|
| `IntImpManiFest/` | 国际进港-进港舱单(增删改查) |
| `IntImpAirManifest/` | 国际进港-原始舱单(申报、补充信息等) |
不同前缀代表不同业务,即使功能语义相似(如"更新"),也不能混用。不确定时询问用户。
### 页面定位规则
修改代码前,必须确认目标文件是**首页菜单实际跳转到的 Activity/ViewModel**,而非同名旧版文件。同一业务有多个实现时,以首页菜单入口链路为准。
---
## 图片上传与展示规范
### 图片上传三字段规范
上传图片后提交表单时,**必须同时传 `pic``originalPic``picNumber` 三个字段**,缺一不可。
**`UploadUtil.upload()` 返回值**(注意:**与字面意思相反**:
- `data?.newName` — **原图**文件名(较大)
- `data?.zipFileName` — **缩略图/压缩图**文件名(较小)
**提交时字段映射**(参考事故签证 `AccidentVisaDetailsViewModel``IntImpAccidentVisaEditViewModel`:
```kotlin
// FileBean 字段含义(约定用途,与 UploadBean 字段名不一致):
// - FileBean.url 作缩略图标识(提交到 bean.pic
// - FileBean.originalPic 作原图标识(提交到 bean.originalPic
// 上传新图片(注意 UploadBean 字段名的误导性,按实际含义赋值)
val data = UploadUtil.upload(fileBean.path).data
fileBean.url = data?.zipFileName ?: "" // 缩略图
fileBean.originalPic = data?.newName ?: "" // 原图
// 提交时设置三个字段
bean.picNumber = list.size.toString()
bean.pic = list.joinToString(",") { MediaUtil.removeUrl(it.url) } // 缩略图
bean.originalPic = list.joinToString(",") { MediaUtil.removeUrl(it.originalPic) } // 原图
```
**常见错误**:
- ❌ 只传 `images``originalPic` 单个字段 — 接口不认或数据不完整
- ❌ 只取 `newName` 不取 `zipFileName` — 丢失缩略图/原图之一
- ❌ 按 `UploadBean` 字段字面含义赋值(`url = newName`)— 会导致 pic/originalPic 内容和字段语义颠倒(缩略图字段装原图、原图字段装缩略图)
- ❌ 用 `fileBean.path.startsWith("http")` 判断已上传 — 应该用 `fileBean.url.isNotEmpty()`
### 编辑页加载已有图片
从详情接口获取图片后,需要同时解析 `pic`(缩略图)和 `originalPic`(原图),构建完整的 `FileBean`
```kotlin
val picList = bean.pic.split(",").filter { it.isNotEmpty() }
val originalPicList = bean.originalPic.split(",").filter { it.isNotEmpty() }
val images = picList.mapIndexed { index, picUrl ->
val originalFile = originalPicList.getOrElse(index) { picUrl }
FileBean(
path = MediaUtil.fillUrl(picUrl), // 完整URL用于显示
url = picUrl, // 相对路径,提交时用
originalPic = MediaUtil.fillUrl(originalFile) // 原图完整URL
)
}.toMutableList()
```
### 图片加载必须带 Authorization Header
`/file/getImg/` 接口需要鉴权Glide 默认不带 token直接用 `loadImage` BindingAdapter 会 **403 Forbidden**
**正确做法** — 在 ViewHolder 中使用 `GlideUrl` + `LazyHeaders`
```kotlin
// 缩略图加载ViewHolder 中)
val glideUrl = GlideUrl(
bean.path,
LazyHeaders.Builder()
.addHeader("Authorization", SharedPreferenceUtil.getString(Constant.Share.token))
.build()
)
Glide.with(itemView.context).load(glideUrl).into(binding.ivThumbnail)
```
**同时必须去掉 XML 布局中的 `loadImage` 属性**,否则 BindingAdapter 会触发不带 token 的请求覆盖手动加载:
```xml
<!-- ❌ 错误:会触发不带 token 的 Glide 请求 -->
<ImageView loadImage="@{bean.path}" />
<!-- ✅ 正确:只保留 id由 ViewHolder 手动加载 -->
<ImageView android:id="@+id/iv_thumbnail" />
```
**大图预览同理**`PreviewImageViewHolder` 也需要用 `GlideUrl` 带 token 加载网络图片。
**参考文件**:
- 缩略图加载: `module_gjj/.../GjjManifestPicViewHolder.kt`
- 大图预览: `module_base/.../PreviewImageViewHolder.kt`
- 图片上传提交: `app/.../AccidentVisaDetailsViewModel.kt`
- 带 token 的 Glide 加载: `module_mit/.../PictureAdapter.kt`
---
## 开发原则
- 资源引用必须存在 — 创建布局前确认 drawable/color/string 资源真实存在或主动创建
- 标题栏统一用 `title_tool_bar` — 禁止手动编写 Toolbar
- 优先使用 PadDataLayoutNew 和 PadSearchLayout 组件
- 在每个页面布局时,如有截图,务必尽可能还原图片上的页面设计,而不是推测假想。如有困难一律要询问,禁止自己想象
- 工具栏图标尺寸规范:
- `img_search` 36dp + padding 2dp
- `img_add` 40dp 无 padding使用 `drawable/img_add.xml` 矢量图,`drawable-xhdpi/img_add.png` 已废弃删除)
- `img_delete` 36dp + padding 4dp
- `ic_new_expand`(全部展开/收起36dp + padding 4dp
- **图标之间间距统一 `marginStart=16dp`**(禁止使用 8dp会导致视觉过近
- 常用资源: `bg_white_radius_8``colorPrimary``text_normal``text_gray``color_bottom_layout`
- **双形态适配:逻辑与 UI 严格分离** — 手机版只重写布局,业务逻辑一律复用现有 ViewModel / ViewHolder纯 UI 交互Tab 切换、弹层显隐)写在 Activity不下沉到 ViewModel
- **禁止用不同文件名切换布局** — 手机/平板布局必须同名 + 资源限定符,否则 `ClassCastException`详见「5.5 寸手持端双形态适配规范」)
- 手机版资源统一使用 `phone_*` 颜色与 `bg_phone_*` drawable禁止直接写死色值
### 环境配置
- **服务器**: `module_base/.../res/values/strings.xml``system_url_inner` / `weight_url`
- **签名**: `key.jks`(根目录),密码 `123321`,别名 `key`
- **模块独立运行**: `gradle.properties``isBuildModule=true`
### 错误排查流程
1. Import 错误 → 查上方 Import 速查表
2. 资源引用错误 → 检查 drawable/color/string 是否存在
3. DataBinding 错误 → 检查 import、枚举值、View 类导入
4. suspend function 错误 → 在 `viewModelScope.launch` 中调用
5. 双形态布局问题 → 查「常见编译错误速查」中 3 条布局变体相关条目;可用 `DeviceUtil.describe()` 打日志确认当前形态
6. 仍有问题 → `./gradlew clean` 后重新构建
### 双端验证命令
```bash
~/Library/Android/sdk/emulator/emulator -list-avds # 可用Medium_Phone_API_36.1手机、Aerologic_Tablet平板
adb -s <设备> shell wm size && adb -s <设备> shell wm density # 确认屏幕参数
adb -s <设备> logcat -d -s BaseActivity:D # 查看形态识别结果 PHONE(sw=411dp) / TABLET(sw=800dp)
adb -s <设备> exec-out screencap -p > /tmp/shot.png # 截图比对
```
> 改动 `BaseActivity` / `BaseBindingActivity` / 公共组件后,**必须在平板上回归**,确认现有平板版行为不变。