# 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 | **事件**: FlowBus（Flow）+ 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>

        <!-- 第二行 KV（marginTop=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"运单号："=4，row2"特码："=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`

---

### 类型 4：Tab 详情页

**代表**: `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`** | 只在一个变体存在的 id，Binding 基类中会是可空字段，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` 文字）。
进港移库追加：`bg_phone_btn_green`（绿色主按钮，拍照上传「确认提交」）、`bg_phone_card_header_orange`
（橙色详情卡表头，异常照片）、`bg_phone_photo_add`（虚线加号槽位）、色值 `phone_amber`。

⚠️ **弹层与浮动按钮的层级**：`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、其余→likeNo（likeNo 按 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）
  - 「进港查询」`IntImpQueryActivity`（module_gjj；06 出港查询的进港镜像：三格统计
    总票数/已入库/已出库为已加载数据客户端计数（`pageQueryTotal` 无分项出参，缺口 #30），
    卡片状态前端推断：`dlvTime` 非空=已出库(绿)/`inDate` 非空=已入库(橙)/兜底未入库(灰)；
    筛选弹层 9 项**入参全部经 api-doc 核对真实存在**（分页参数名 `page`/`limit`，品名参数名
    `goods`）；详情页与运单追踪复用 08/06 已适配页零新增；平板侧滑筛选面板代码已判空守卫。
    ⚠️ 踩坑：代理人绑定不能写 `bean.agentName ?? bean.agentCode`——转换器把 JSON null 归一成
    空串，`??` 只判 null 不判空串，须用 Bean 计算属性 `agentDisplay`（06 出港查询同步修正））
  - 「进港移库」`GnjMoveStashListActivity`（app 模块，**2026-08-03 归属更正后重做**：设计稿 11
    实际归属**国内进港移库 DomImpMove**（后端 8 接口齐全、Pad 首页「国内进港→进港移库」既有页面），
    最初误判为国际进港并按 IntExpMove 约定预接了不存在的 IntImpMove 模块（原缺口 #31），该套
    module_gjj 前端实现已整体删除。现行实现：主列表双形态（Pad 布局移 layout-sw600dp 零改动），
    待移库/已移库 Tab 走 **`search`/`searchMoved` 双端点**（`searchMoved` 出参无移库标识，前端按
    端点给 bean 打 `isMoved`），Tab 角标 = 两端点各调一次 limit=1 取分页 total（`initPhoneExtras`
    开关，仅手机发起）；手机专属新页 `GnjMoveStashDetailActivity`（Intent 传 bean 零请求，三卡片：
    基本信息/移库信息(仅已移库)/异常照片）+ `GnjMoveStashPhotoActivity`（进入先调 `detail` 回填
    已有照片——`searchMoved` 出参无 pic 三字段 #34，提交走 **`modify` 最小体**
    `{mawbId,remark,picNumber,pic,originalPic}`，与平板移交编辑页同链路）；批量移库复用平板
    `moveStashClick`（`move`：fid+ids）。手机首页入口在**国内 Tab**（`GnViewModel`，权限串
    `GnjYiKu=AppDomImpMove`，需补通用 route 跳转逻辑，图标从 module_gnj 复制）；
    GnjMoveStashListActivity 补 @Route `/app/GnjMoveStashListActivity`。
    2026-08-03 内网真实数据双端验证通过；筛选 A/B：`wbNo`/`awbType` 生效，
    `fdate`/`fno`/`spCode` 被后端静默忽略（#32）、`opId`/`opDate` 恒 null（#33））
  - 「进港仓库」`IntImpStorageUseActivity`（module_gjj，07 出港仓库的进港镜像；**首页菜单实际跳转页，
    旧版 `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
    include，Binding 字段为 @Nullable）
- ✅ **设计稿 11 个入口（01~11）已于 2026-07-31 全部适配完成**
- ⏳ **待办**：
  - `PhoneSearchBar` 接入 AutoQuery 联想（`AutoQueryManager` 目前只对接了 Pad 系组件）
  - 进港移库筛选三项（航班日期/航班号/特码）待后端实装 `fdate`/`fno`/`spCode` 入参（缺口 #32）、
    移库人/移库时间待后端回填 `opId`/`opDate` 并补姓名出参（缺口 #33）后自动生效
  - 出库交接 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`（平板布局不绑定该字段，无影响） |
| XML 里 `bean.a ?? bean.b` 兜底不生效，字段明明有值却显示占位符 | 网络层转换器把 JSON null 归一成空串 `""`，DataBinding 的 `??` 只判 null 不判空串，永远取到左侧的空串 | 在 Bean 上加计算属性做 `isNullOrEmpty` 双兜底（如 `agentDisplay`），XML 绑计算属性（进港查询/出港查询代理人已踩过） |

---

## 编辑表单下拉框（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        # 可用：Aerologic_Phone_5.5_API31（手机，首选）、Aerologic_Tablet（平板）、Medium_Phone_API_36.1
adb -s <设备> shell wm size && adb -s <设备> shell wm density   # 确认屏幕参数
adb -s <设备> logcat -d -s BaseActivity:D                  # 查看形态识别结果 PHONE(sw=384dp) / TABLET(sw=800dp)
adb -s <设备> exec-out screencap -p > /tmp/shot.png        # 截图比对
```

> 手机端验证**首选 `Aerologic_Phone_5.5_API31`**：按真实交付手持机 1:1 定制——720×1440、5.51 寸（300dpi，sw=384dp）、
> Android 12 / API 31（与 targetSdk 一致）、arm64-v8a，与实机的差异最小。`Medium_Phone_API_36.1`（1080×2400、API 36）
> 仅作兼容性参考，分辨率与系统版本都和交付机不符。

> 改动 `BaseActivity` / `BaseBindingActivity` / 公共组件后，**必须在平板上回归**，确认现有平板版行为不变。
