feat: 完成出港仓库与进港仓库页面5.5寸手机端适配

出港仓库(07,module_gjc):
- IntExpStorageUseActivity 手机布局:搜索行+三格统计(总票数/未清仓/已清仓,
  clearNormal=0/1 双 pageQueryTotal 仅手机发起)+卡片级四操作按钮(清仓/修改
  库位/出库/入库,已清仓置灰),取代平板勾选+底部批量条,复用既有 performXxx 链路
- 详情/运单追踪复用出港查询已适配页(GjcQueryDetailsActivity/LogDetailActivity),零新增页面
- 清仓/入库/修改库位弹框双变体化(手机底部弹层);修改库位手机版增加「原库位」单选;
  出库确认 AlertDialog → ConfirmDialogModel(项目强制规范)

进港仓库(08,module_gjj,07 的进港镜像):
- IntImpStorageUseActivity 手机布局:搜索框「运单号/航班号」分流(含字母→fno,
  抓包证实)+始发站字段/筛选(fdep 入参)
- 进港详情 IntImpQueryDetailsActivity + 3 Fragment 首次手机适配(报文区 3 项,
  件重区直取 inPc/inWeight,库位卡片优先中文姓名)
- 修改库位手机弹层「原库位」只列未出库库位(沿用平板校验)

公共:GjcMaWb 增加 isCleared 计算属性;新增 bg_phone_btn_disabled;手机首页
「国际」Tab 增加出港仓库/进港仓库入口;debug manifest 直启项

验证:双端 Proxyman Map Local mock 口径验证通过(开发机不在内网),平板回归一致,
崩溃 0;后端缺口 5 项登记问题清单 #23~#27(筛选入参缺失/clearNormal 分项统计与
detail 推断字段待内网实测)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 20:49:59 +08:00
parent 33c9f0e030
commit 3b732c3aec
57 changed files with 5473 additions and 2255 deletions

View File

@@ -19,6 +19,7 @@ import com.lukouguoji.module_base.impl.observe
import com.lukouguoji.module_base.ktx.commonAdapter
import com.lukouguoji.module_base.ktx.showToast
import com.lukouguoji.module_base.router.ARouterConstants
import com.lukouguoji.module_base.util.DeviceUtil
/**
* 国际进港-仓库
@@ -31,10 +32,13 @@ class IntImpStorageUseActivity :
override fun viewModelClass() = IntImpStorageUseViewModel::class.java
override fun initOnCreate(savedInstanceState: Bundle?) {
setBackArrow("国际进港仓库")
setBackArrow(if (DeviceUtil.isPhone()) "进港仓库" else "国际进港仓库")
binding.viewModel = viewModel
binding.activity = this
// 手机形态专属:筛选字典 + 未清仓/已清仓分项统计(平板不调用,行为零变化)
if (DeviceUtil.isPhone()) viewModel.initPhoneExtras()
// 绑定分页
viewModel.pageModel.bindSmartRefreshLayout(binding.srl, binding.rv, viewModel, this)
@@ -182,6 +186,55 @@ class IntImpStorageUseActivity :
}.show(this)
}
// ==================== 5.5寸手机版:卡片级操作 ====================
// 语义等价于「勾选该主单 + 其全部库位」再执行平板的批量操作,复用 performXxx 请求链路。
/** 卡片「清仓」(已清仓的卡片按钮置灰,ViewHolder 已拦截) */
fun cardClear(bean: com.lukouguoji.module_base.bean.GjcMaWb) {
val maWbListForClear = listOf(bean.copy(storageUseList = bean.storageUseList ?: emptyList()))
IntImpMoveClearDialogModel { dialog ->
viewModel.performClear(dialog.clearNormal.value ?: "", maWbListForClear)
}.show(this)
}
/** 卡片「修改库位」:弹层内先单选原库位(未出库的才可选,单库位时默认选中)再选新库位 */
fun cardModifyStorage(bean: com.lukouguoji.module_base.bean.GjcMaWb) {
// 已出库的分单不允许修改库位(与平板校验同口径)
val modifiableList = (bean.storageUseList ?: emptyList())
.filter { it.outOpId.isNullOrEmpty() && it.outDate.isNullOrEmpty() }
if (modifiableList.isEmpty()) {
showToast(if (bean.storageUseList.isNullOrEmpty()) "该运单暂无库位" else "该运单库位均已出库,不允许修改库位")
return
}
IntImpModifyStorageDialogModel(sourceStorageList = modifiableList) { dialog ->
val sourceStorage = dialog.selectedSourceStorage ?: return@IntImpModifyStorageDialogModel
viewModel.performModifyStorage(dialog.locationName, dialog.locationId, sourceStorage)
}.show(this)
}
/** 卡片「出库」:该运单全部库位出库(与平板勾选主单后出库同语义) */
fun cardOutStorage(bean: com.lukouguoji.module_base.bean.GjcMaWb) {
val storageList = bean.storageUseList ?: emptyList()
if (storageList.isEmpty()) {
showToast("该运单暂无库位")
return
}
ConfirmDialogModel(
message = "确定要将运单 ${bean.wbNo} 的 ${storageList.size} 个库位执行出库操作吗?",
title = "出库确认"
) {
viewModel.performOutStorage(storageList)
}.show(this)
}
/** 卡片「入库」:弹层选库位后整票入库 */
fun cardInStorage(bean: com.lukouguoji.module_base.bean.GjcMaWb) {
val maWbListForInStorage = listOf(bean.copy(storageUseList = bean.storageUseList ?: emptyList()))
IntImpInStorageDialogModel { dialog ->
viewModel.performInStorage(dialog.locationName, dialog.locationId, maWbListForInStorage)
}.show(this)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == Constant.RequestCode.WAYBILL && resultCode == Activity.RESULT_OK) {

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.MutableLiveData
import com.lukouguoji.gjj.R
import com.lukouguoji.gjj.databinding.DialogIntImpInStorageBinding
import com.lukouguoji.module_base.base.BaseDialogModel
import com.lukouguoji.module_base.util.DeviceUtil
import com.lukouguoji.module_base.http.net.NetApply
import com.lukouguoji.module_base.ktx.launchCollect
import com.lukouguoji.module_base.ktx.showToast
@@ -16,7 +17,9 @@ import dev.utils.app.info.KeyValue
*/
class IntImpInStorageDialogModel(
private val callback: (IntImpInStorageDialogModel) -> Unit
) : BaseDialogModel<DialogIntImpInStorageBinding>(DIALOG_TYPE_CENTER) {
) : BaseDialogModel<DialogIntImpInStorageBinding>(
if (DeviceUtil.isPhone()) DIALOG_TYPE_BOTTOM else DIALOG_TYPE_CENTER
) {
// 库位列表
val locationList = MutableLiveData<List<KeyValue>>()

View File

@@ -8,17 +8,26 @@ import com.lukouguoji.module_base.base.BaseDialogModel
import com.lukouguoji.module_base.http.net.NetApply
import com.lukouguoji.module_base.ktx.launchCollect
import com.lukouguoji.module_base.ktx.showToast
import com.lukouguoji.module_base.bean.GjcStorageUse
import com.lukouguoji.module_base.ktx.verifyNullOrEmpty
import com.lukouguoji.module_base.util.DeviceUtil
import dev.utils.app.info.KeyValue
/**
* 国际进港 - 修改库位对话框
* 手机为底部弹层(layout/ 变体),平板为居中弹框(layout-sw600dp/ 变体)。
*
* 平板:列表勾选唯一库位后打开,弹框内只选新库位(sourceStorageList 传 null,原库位项不存在于平板布局)。
* 手机:卡片级操作,运单可能有多个库位——传入该运单未出库的库位列表,弹层内先单选「原库位」再选新库位。
*/
class IntImpModifyStorageDialogModel(
private val currentLocationId: String = "",
private val currentLocationName: String = "",
private val sourceStorageList: List<GjcStorageUse>? = null,
private val callback: (IntImpModifyStorageDialogModel) -> Unit
) : BaseDialogModel<DialogIntImpModifyStorageBinding>(DIALOG_TYPE_CENTER) {
) : BaseDialogModel<DialogIntImpModifyStorageBinding>(
if (DeviceUtil.isPhone()) DIALOG_TYPE_BOTTOM else DIALOG_TYPE_CENTER
) {
// 库位列表
val locationList = MutableLiveData<List<KeyValue>>()
@@ -32,6 +41,13 @@ class IntImpModifyStorageDialogModel(
// 库位名称 (后端需要的name)
var locationName: String = ""
// ========== 手机卡片级操作专属:原库位单选 ==========
val sourceList = MutableLiveData<List<KeyValue>>(emptyList())
val selectedSourceId = MutableLiveData("")
// 选中的原库位对象(保存后由调用方取用)
var selectedSourceStorage: GjcStorageUse? = null
override fun layoutId(): Int {
return R.layout.dialog_int_imp_modify_storage
}
@@ -46,6 +62,19 @@ class IntImpModifyStorageDialogModel(
locationId = selectedItem?.value ?: ""
locationName = selectedItem?.key ?: ""
}
// 手机版:初始化原库位列表,单库位时默认选中
if (sourceStorageList != null) {
sourceList.value = sourceStorageList.map {
KeyValue(it.location ?: "", it.id?.toString() ?: "")
}
selectedSourceId.observeForever { id ->
selectedSourceStorage = sourceStorageList.find { it.id?.toString() == id }
}
if (sourceStorageList.size == 1) {
selectedSourceId.value = sourceStorageList[0].id?.toString() ?: ""
}
}
}
/**
@@ -76,6 +105,10 @@ class IntImpModifyStorageDialogModel(
* 保存按钮点击
*/
fun onSaveClick() {
if (sourceStorageList != null && selectedSourceStorage == null) {
showToast("请选择原库位")
return
}
if (selectedLocationCode.value.verifyNullOrEmpty("请选择库位")) {
return
}

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.MutableLiveData
import com.lukouguoji.gjj.R
import com.lukouguoji.gjj.databinding.DialogIntImpMoveClearBinding
import com.lukouguoji.module_base.base.BaseDialogModel
import com.lukouguoji.module_base.util.DeviceUtil
import com.lukouguoji.module_base.ktx.verifyNullOrEmpty
import dev.utils.app.info.KeyValue
@@ -13,7 +14,9 @@ import dev.utils.app.info.KeyValue
*/
class IntImpMoveClearDialogModel(
private val callback: (IntImpMoveClearDialogModel) -> Unit
) : BaseDialogModel<DialogIntImpMoveClearBinding>(DIALOG_TYPE_CENTER) {
) : BaseDialogModel<DialogIntImpMoveClearBinding>(
if (DeviceUtil.isPhone()) DIALOG_TYPE_BOTTOM else DIALOG_TYPE_CENTER
) {
// 清仓正常(存储的是 code:"0" 或 "1")
val clearNormal = MutableLiveData("")

View File

@@ -2,6 +2,8 @@ package com.lukouguoji.gjj.holder
import android.view.View
import com.lukouguoji.gjj.R
import com.lukouguoji.gjj.activity.IntImpQueryDetailsActivity
import com.lukouguoji.gjj.activity.IntImpStorageUseActivity
import com.lukouguoji.gjj.databinding.ItemIntImpStorageUseBinding
import com.lukouguoji.module_base.adapter.setCommonAdapter
import com.lukouguoji.module_base.base.BaseViewHolder
@@ -12,6 +14,10 @@ import com.lukouguoji.module_base.ktx.refresh
/**
* 国际进港-仓库 ViewHolder
*
* 双形态布局:平板变体含 iv_icon/iv_show/rv_sub/ll_header/divider_header/tv_empty
* (多选 + 库位子列表),手机变体含 ll/btn_clear/btn_modify/btn_out/btn_in
* (整卡进详情 + 卡片级四操作按钮)。单侧独有的 id 在 Binding 基类中为 @Nullable,必须判空访问。
*/
class IntImpStorageUseViewHolder(view: View) :
BaseViewHolder<GjcMaWb, ItemIntImpStorageUseBinding>(view) {
@@ -22,43 +28,58 @@ class IntImpStorageUseViewHolder(view: View) :
binding.position = position
binding.executePendingBindings()
// ==================== 平板:多选 + 子列表 ====================
// 图标点击切换选择状态
binding.ivIcon.setOnClickListener {
binding.ivIcon?.setOnClickListener {
bean.checked.set(!bean.checked.get())
FlowBus.with<String>(ConstantEvent.EVENT_CHECK_CHANGED).tryEmit("check_changed")
binding.executePendingBindings()
}
// 展开按钮点击事件
binding.ivShow.setOnClickListener {
binding.ivShow?.setOnClickListener {
bean.showMore.set(!bean.showMore.get())
}
// 初始化库位明细子列表 RecyclerView
setCommonAdapter(
binding.rvSub,
IntImpStorageUseSubViewHolder::class.java,
R.layout.item_int_imp_storage_use_sub
)
binding.rvSub?.let { rvSub ->
setCommonAdapter(
rvSub,
IntImpStorageUseSubViewHolder::class.java,
R.layout.item_int_imp_storage_use_sub
)
// 刷新库位明细数据(传递父Bean引用)
val storageUseList = bean.storageUseList ?: emptyList()
binding.rvSub.tag = bean
binding.rvSub.refresh(storageUseList)
updateSubListVisibility(storageUseList.isNotEmpty())
// 刷新库位明细数据(传递父Bean引用)
val storageUseList = bean.storageUseList ?: emptyList()
rvSub.tag = bean
rvSub.refresh(storageUseList)
updateSubListVisibility(storageUseList.isNotEmpty())
}
// ==================== 手机:整卡进详情 + 卡片级操作 ====================
val activity = itemView.context as? IntImpStorageUseActivity
// 整卡点击进详情(复用进港查询详情页,3 Tab 运单/仓库/库位信息)
binding.ll?.setOnClickListener {
IntImpQueryDetailsActivity.start(itemView.context, bean.prefix, bean.no)
}
// 清仓(已清仓时置灰不可点,样式由 XML 按 bean.isCleared 切换)
binding.btnClear?.setOnClickListener {
if (!bean.isCleared) activity?.cardClear(bean)
}
binding.btnModify?.setOnClickListener { activity?.cardModifyStorage(bean) }
binding.btnOut?.setOnClickListener { activity?.cardOutStorage(bean) }
binding.btnIn?.setOnClickListener { activity?.cardInStorage(bean) }
}
private fun updateSubListVisibility(hasData: Boolean) {
if (hasData) {
binding.llHeader.visibility = View.VISIBLE
binding.dividerHeader.visibility = View.VISIBLE
binding.rvSub.visibility = View.VISIBLE
binding.tvEmpty.visibility = View.GONE
} else {
binding.llHeader.visibility = View.GONE
binding.dividerHeader.visibility = View.GONE
binding.rvSub.visibility = View.GONE
binding.tvEmpty.visibility = View.VISIBLE
}
binding.llHeader?.visibility = if (hasData) View.VISIBLE else View.GONE
binding.dividerHeader?.visibility = if (hasData) View.VISIBLE else View.GONE
binding.rvSub?.visibility = if (hasData) View.VISIBLE else View.GONE
binding.tvEmpty?.visibility = if (hasData) View.GONE else View.VISIBLE
}
}

View File

@@ -61,6 +61,39 @@ class IntImpQueryDetailsViewModel : BaseViewModel() {
currentTab.value = index
}
// ==================== 5.5寸手机版取值帮助(与出港 GjcQueryDetailsViewModel 同口径) ====================
/**
* 从详情 Map 取字符串展示值;数字自动去掉无意义的 .0;空值兜底 "--"
*/
fun str(map: Map<String, Any>?, key: String): String {
val v = map?.get(key) ?: return "--"
val s = when (v) {
is Double -> if (v % 1.0 == 0.0) v.toLong().toString() else v.toString()
else -> v.toString()
}
return s.ifBlank { "--" }
}
/**
* 依次取第一个非空 key 的展示值(如 by1 优先于 by0)
*/
fun strOr(map: Map<String, Any>?, key: String, fallbackKey: String): String {
val first = str(map, key)
return if (first != "--") first else str(map, fallbackKey)
}
/**
* 取重量类数值(保留小数形式),空值兜底 "--"
*/
fun dec(map: Map<String, Any>?, key: String): String {
val v = map?.get(key) ?: return "--"
return when (v) {
is Double -> v.toString()
else -> v.toString().ifBlank { "--" }
}
}
/**
* 加载详情数据
*/

View File

@@ -18,6 +18,7 @@ import com.lukouguoji.module_base.ktx.noNull
import com.lukouguoji.module_base.ktx.showToast
import com.lukouguoji.module_base.ktx.toRequestBody
import com.lukouguoji.module_base.model.ScanModel
import com.lukouguoji.module_base.util.DictUtils
import dev.utils.app.info.KeyValue
import kotlinx.coroutines.launch
@@ -39,6 +40,109 @@ class IntImpStorageUseViewModel : BasePageViewModel() {
val totalPc = MutableLiveData("0") // 总件数
val totalWeight = MutableLiveData("0") // 总重量
// ========== 5.5寸手机版专属 ==========
// 搜索框统一入口(placeholder「搜索运单号/航班号」,phoneSearchClick 分流)
val phoneSearchKey = MutableLiveData("")
// 筛选弹层显隐
val filterVisible = MutableLiveData(false)
// 筛选条件(fdep 为接口入参;其余 5 项为后端未定义入参,预留传参,见接口问题清单 #25)
val fdep = MutableLiveData("") // 始发站
val agentId = MutableLiveData("") // 代理(agentCode,预留)
val spCode = MutableLiveData("") // 特码(预留)
val awbType = MutableLiveData("") // 运单类型(预留)
val businessType = MutableLiveData("") // 业务类型(预留)
val goodsCn = MutableLiveData("") // 品名(中)(预留)
// 筛选下拉字典(initPhoneExtras 加载,平板不请求)
val agentList = MutableLiveData(listOf(KeyValue("全部", "")))
val specialCodeList = MutableLiveData<List<KeyValue>>(emptyList())
val awbTypeList = MutableLiveData<List<KeyValue>>(emptyList())
val businessTypeList = MutableLiveData(
listOf(
KeyValue("全部", ""),
KeyValue("普货", "1"),
KeyValue("特货", "2")
)
)
// 三格统计:未清仓 / 已清仓(复用 pageQueryTotal 按 clearNormal=0/1 各查一次)
val unclearedCount = MutableLiveData("0")
val clearedCount = MutableLiveData("0")
private var phoneExtrasEnabled = false
/**
* 手机形态专属初始化(Activity 在 DeviceUtil.isPhone() 时调用)。
* 平板端不调用,请求次数与行为零变化。
*/
fun initPhoneExtras() {
phoneExtrasEnabled = true
// 代理(国际进港口径)
DictUtils.getIntImpAgentList(addAll = false) { list ->
agentList.postValue(listOf(KeyValue("全部", "")) + list)
}
// 特码:flag=1 国际;ieFlag 必须留空
DictUtils.getSpecialCodeList(addAll = false, flag = 1, ieFlag = "") { list ->
specialCodeList.postValue(listOf(KeyValue("全部", "")) + list)
}
// 运单类型(type=II 国际进港)
DictUtils.getWaybillTypeList(type = Constant.businessType.II, addAll = true) {
awbTypeList.postValue(it)
}
}
/**
* 手机搜索框查询:按出港计重实测规则分流——含字母→航班号,其余→运单号
*/
fun phoneSearchClick() {
val key = phoneSearchKey.value?.trim() ?: ""
when {
key.isEmpty() -> {
wbNo.value = ""
flightNo.value = ""
}
key.any { it.isLetter() } -> {
flightNo.value = key
wbNo.value = ""
}
else -> {
wbNo.value = key
flightNo.value = ""
}
}
refresh()
}
/** 筛选按钮点击 - 打开筛选面板 */
fun filterClick() {
filterVisible.value = true
}
/** 关闭筛选面板 */
fun closeFilter() {
filterVisible.value = false
}
/** 重置筛选条件 */
fun resetFilter() {
flightDate.value = ""
flightNo.value = ""
fdep.value = ""
agentId.value = ""
spCode.value = ""
awbType.value = ""
businessType.value = ""
goodsCn.value = ""
}
/** 确认筛选 - 关闭面板并刷新 */
fun confirmFilter() {
filterVisible.value = false
refresh()
}
// ========== 全选状态 ==========
val isAllChecked = MutableLiveData(false)
@@ -250,12 +354,20 @@ class IntImpStorageUseViewModel : BasePageViewModel() {
}
override fun getData() {
// 手机专属 6 项平板恒为空,不进请求;agentCode/spCode/awbType/businessType/goodsCn
// 为 pageQuery 未定义入参(后端静默忽略,补齐后自动生效,见接口问题清单 #25)
val filterParams = mapOf(
"fdate" to flightDate.value?.ifEmpty { null },
"fno" to flightNo.value?.ifEmpty { null },
"wbNo" to wbNo.value?.ifEmpty { null },
"location" to location.value?.ifEmpty { null },
"clearNormal" to clearResult.value?.ifEmpty { null }
"clearNormal" to clearResult.value?.ifEmpty { null },
"fdep" to fdep.value?.ifEmpty { null },
"agentCode" to agentId.value?.ifEmpty { null },
"spCode" to spCode.value?.ifEmpty { null },
"awbType" to awbType.value?.ifEmpty { null },
"businessType" to businessType.value?.ifEmpty { null },
"goodsCn" to goodsCn.value?.ifEmpty { null }
)
val listParams = (filterParams + mapOf(
@@ -281,5 +393,17 @@ class IntImpStorageUseViewModel : BasePageViewModel() {
totalWeight.value = (data?.totalWeight ?: 0.0).toString()
}
}
// 手机专属:未清仓/已清仓分项统计(同条件 + clearNormal=0/1 各查一次,平板不发)
if (phoneExtrasEnabled) {
val unclearedParams = (filterParams + mapOf("clearNormal" to "0")).toRequestBody()
launchCollect({ NetApply.api.getIntImpStorageUseTotal(unclearedParams) }) {
onSuccess = { unclearedCount.value = (it.data?.wbNumber ?: 0).toString() }
}
val clearedParams = (filterParams + mapOf("clearNormal" to "1")).toRequestBody()
launchCollect({ NetApply.api.getIntImpStorageUseTotal(clearedParams) }) {
onSuccess = { clearedCount.value = (it.data?.wbNumber ?: 0).toString() }
}
}
}
}