Files
aerologic-app/CLAUDE.md
YANG JIANKUAN b2a2cce585 feat: 完成提取出库页面5.5寸手机端适配
提取出库(09,module_gjj):
- IntImpPickUpDLVActivity 手机布局:搜索行+待出库/已出库状态Tab(outState 为
  pageQuery 文档定义入参 0/1,Tab 真实过滤+角标双 pageQueryTotal,抓包证实携带)
  +双形态卡片(isPickedUp=dlvTime非空:待出库勾选框+橙标签+库位行/已出库绿√+
  出库人·出库时间行)+卡片内 PhoneStatBox 三列+PhoneBottomBar(仅待出库Tab显示,
  复用既有 confirmOutbound)
- 手机专属新页 IntImpPickUpDetailActivity 运单详情:蓝头运单卡+绿头提货信息卡,
  Intent 传 Serializable bean 零新增请求
- 筛选弹层 4 项(航班日期为未定义入参预留传参);手机进入时清空平板默认
  「当天缴费日期」查全量,平板默认值不变
- IntImpPickUpDLVBean 增加 isPickedUp 计算属性;手机首页「国际」Tab 加入口

修复:checkAllClick 全选后底部「已选 N 项」不更新(批量勾选不走单卡 FlowBus
事件,需自行同步 selectedCountText),实测修复生效

验证:双端 Proxyman Map Local mock 口径验证通过,平板回归一致(含缴费日期默认值),
崩溃 0;后端缺口 2 项登记问题清单 #28~#29(无 fdate 入参、出参无车牌/目的港/
业务类型;outState 过滤与 dlvName=出库人语义待内网实测)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 21:31:58 +08:00

1515 lines
72 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. **弹框BaseDialogModel同样要适配**dialog 布局照样走同名双变体Pad 版移 `layout-sw600dp/`
手机版在 `layout/` 写设计稿的底部弹层样式),弹出位置在构造参数按形态切换:
`BaseDialogModel<XxxBinding>(if (DeviceUtil.isPhone()) DIALOG_TYPE_BOTTOM else DIALOG_TYPE_CENTER)`
(参考 `IntExpArriveResetDialogModel` + `dialog_int_exp_arrive_reset.xml` 双变体)。
页面适配完要**逐个排查该页所有 DialogModel**,漏掉的会在手机上弹出 Pad 居中样式
7. **回到手机端首页加菜单入口**(最易遗忘,见下节)——不加的话页面在手机上根本进不去
### 手机端首页菜单入口(适配完必须加,否则没入口)
手机端首页是 `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``tagColor`("orange" 默认/"green") |
| `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`
出港计重追加:`bg_phone_btn_weak`(浅蓝弱按钮,卡片内提前运抵)、`bg_phone_card_top_accent`
(卡片顶部主色描边条)、`ic_phone_clipboard`FAB 剪贴板)、`ic_phone_weight`(砝码图标,可 tint
出港查询追加:`ic_phone_package`(件数)、`ic_phone_tag`(特码)、`bg_phone_dot_blue/gray`(状态圆点);
`PhoneFilterPanel` 内容插槽已内置 ScrollView筛选项超屏时收缩滚动底部按钮始终可见——
9 个筛选项曾把「重置/确认」挤出屏幕)。
出港仓库追加:`bg_phone_btn_disabled`(灰底禁用按钮,与 `bg_phone_btn_weak` 同 10dp 圆角,
卡片操作按钮禁用态配 `phone_tag_gray_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
- 「出港计重」全链路 4+1 页:`GjcWeighingListActivity`(待计重列表,三格统计 + 卡片单票提前运抵 +
FAB 进记录)、`GjcWeighingStartActivity`(开始计重,托盘数量/自重按设计稿手输)、
`GjcWeighingRecordListActivity`(计重记录)、`GjcWeighingRecordDetailsActivity`(计重明细,
isEditMode 复用为手机全屏「修改记录」覆层)+ 手机专属新页 `GjcWeighingWbDetailActivity`(运单详情)。
搜索分流规则实测含字母→fno、11 位数字→wbNo、其余→likeNolikeNo 按 8 位 no 匹配并非模糊);
checkIn 过滤等 7 项后端缺口见问题清单 #12~#18
- 「出港运抵」主列表 + 3 个手机专属新页:`IntExpArriveActivity`(待运抵/已运抵 Tab客户端按
`declareStatus` 是否为空拆分数据集,卡片长按浮层:待运抵→补充、已运抵→重置+回执)、
`IntExpArriveSubOrderActivity`(分单明细,数据直接复用主列表已加载的 `haWbList`,零新增请求)、
`IntExpArriveReceiptActivity`(回执内容只读页,复用 `haWbList[].response`)、
`IntExpArriveSupplementActivity`(补充信息,参照 Pad 端国际进港「收发货人信息」批量应用模式,
后端接口不存在,实机 404 已确认,见问题清单 #19)。
Tab 过滤两个坑:① **RecyclerView 里 `View.GONE` 不会让 item 塌陷为 0 高度**(不同于普通
ViewGroup必须在数据层整体 `refresh()` 过滤后的子集给 adapter不能给 item 根节点加
visibility 绑定(实机截图验证过,见「常见编译错误速查」);② 通过 `Intent.putExtra(Serializable)`
传递的 `GjcHaWb`/`GjcMaWb`,其 `checked`/`showMore`/`actionMenuVisible` 字段标了
`@Transient`= JVM `transient`Java 序列化会跳过),反序列化后为 null直接
`.checked.set(...)` 会崩溃——接收方 `initOnCreated` 必须对列表 `.map { it.copy() }` 补上。
2026-07-30 review 修正:状态重置弹框改手机底部弹层(弹框双变体首例,见改造步骤第 6 条)、
主列表/回执页补空态、分单浮层补「回执」、分单统计加分母、Tab 角标初始 0
- 「提取出库」`IntImpPickUpDLVActivity`module_gjj类型2 多选批量页 + 手机专属详情新页
`IntImpPickUpDetailActivity`Intent 传 Serializable bean 零请求)。待出库/已出库 Tab 绑
**文档定义入参 `outState`**0/1抓包证实携带效果待内网 #29+ 角标双 pageQueryTotal
双形态卡片按 `isPickedUp`dlvTime 非空)切换;手机进入时清空平板默认「当天缴费日期」查全量;
缺口 #28:无 fdate 入参、出参无车牌(提货车辆)/目的港/业务类型。
⚠️ 踩坑:`checkAllClick` 全选后底部「已选 N 项」不更新——批量勾选不走 FlowBus 单卡事件,
必须在 checkAllClick 里同步 selectedCountText
- 「进港仓库」`IntImpStorageUseActivity`module_gjj07 出港仓库的进港镜像;**首页菜单实际跳转页,
旧版 `GjjWareHouseActivity` 路由已注释弃用**。差异:卡片/筛选用始发站fdep 入参)替代目的站;
搜索框「运单号/航班号」分流含字母→fno、其余→wbNo抓包证实筛选 5 项无入参(缺口 #25
比出港多 2 项);连带首次适配进港详情 `IntImpQueryDetailsActivity` + 3 Fragment报文区 3 项:
原始舱单/理货报告/海关放行,件重区直取 inPc/inWeight 无需求和detail keys 以平板绑定为准 +
dep/dest1/dest/unNumber/opDate 按出港推断待实测 #27);修改库位手机弹层「原库位」只列未出库
库位(沿用平板校验);**前端接口前缀是 `IntImpStorage` 不是 `IntImpStorageUse`**api-doc 按
后者搜不到)
- 「出港仓库」`IntExpStorageUseActivity`(嵌套多选列表页型的首个适配:平板「勾选主/子列表 +
底部批量操作」在手机上改为**卡片级四按钮**(清仓/修改库位/出库/入库,已清仓置灰),语义等价
「勾选该主单+全部库位」直接复用既有 performXxx 链路ViewHolder 对双侧独有 id 全判空;
三格统计 总票数/未清仓/已清仓(后两格 = 同条件 + `clearNormal=0/1` 各调一次 pageQueryTotal
过滤是否生效待内网 A/B缺口 #24);筛选弹层 8 项中运单类型/业务类型/品名(中) 为后端未定义
入参(缺口 #23,预留传参);三个操作弹框双变体化 + 手机版修改库位弹层加「原库位」单选;
出库确认 AlertDialog → ConfirmDialogModel详情与运单追踪完全复用出港查询已适配页零新增页面
- 「出港查询」`GjcQueryActivity`(列表:三格统计 总票数/已入库/已离港 + 卡片状态标签,
状态为前端推断:`fclose` 非空=已离港/`opDate` 非空=已入库;筛选弹层 9 项,日期起止与代理
从平板搜索行并入,特码改下拉 `getSpecialCodeList(flag=1, ieFlag="")`;分项统计为已加载
数据客户端计数,`isFclose` 实测被后端忽略,缺口 #21+ `GjcQueryDetailsActivity`3 Tab
复用 ViewPager2+Fragment手机变体 PhoneKvItem 网格;入库件数/重量= warehouseList 求和;
锁定状态/计费重量后端无字段,缺口 #22+ 「运单追踪」`LogDetailActivity`app 模块公共页
双变体首例:手机竖排时间线由 Activity `buildVerticalSteps` 动态构建,平板横向步骤条不动;
手机标题按形态切「运单追踪」)。平板侧滑筛选面板代码已判空守卫(手机变体无 filter_panel
includeBinding 字段为 @Nullable
-**待办**
- `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`(见「屏幕方向」小节的 ⛔ 说明) |
| Tab/分类切换后列表顶部留一大块空白,卡片数量却不对 | 给 item 根节点加了 `visibility="@{tab条件 ? VISIBLE : GONE}"`——RecyclerView/LinearLayoutManager 不会像普通 ViewGroup 那样让 GONE 的子项自动塌陷为 0 高度,仍按测量高度参与滚动区域计算 | 不要用 item 级 visibility 做过滤;在 ViewModel 里自行维护完整列表Tab 切换时把过滤后的子集整体 `commonAdapter()?.refresh(filteredList)` 给 adapter |
| 通过 `Intent.putExtra(Serializable)` 传递的 Bean切换其 `checked`/`showMore` 等状态时崩溃 `NullPointerException: ... on a null object reference` | 这些字段标了 `@Transient`= JVM `transient`,本意是避免 Gson 网络请求序列化 `ObservableBoolean`),但 Java 对象序列化Intent 传递走的机制)会跳过 `transient` 字段的恢复,反序列化后是 null | 接收方 `initOnCreated` 里对列表做 `.map { it.copy() }`——`.copy()` 会重新走一遍类体初始化逻辑,用全新 `ObservableBoolean(false)` 补上 |
| 布局构建报 `Error: The string "--" is not permitted within comments` | XML 注释中出现连续双横线(如写"兜底 \"--\""XML 规范禁止 | 注释里改用文字描述(如「兜底双横线」),代码绑定表达式中的 `"--"` 不受影响 |
| 多选页手机版点「全选」后底部「已选 N 项」不更新(单卡勾选正常) | 批量勾选在 `checkAllClick` 里直接 set不走 FlowBus 的单卡 EVENT_CHECK_CHANGED 事件,`selectedCountText` 只在事件回调里更新 | `checkAllClick` 末尾自行同步 `selectedCountText`(平板布局不绑定该字段,无影响) |
---
## 编辑表单下拉框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` / 公共组件后,**必须在平板上回归**,确认现有平板版行为不变。