feat: 日志详情页数据驱动渲染(流转状态+操作详情)

- 入口页传递运单号和运单类型至日志详情页
- 区分国际出港(9步骤)/国际进港(6步骤)流转节点
- 用status字段匹配节点状态(蓝色/白色/绿色)
- 修复API返回裸数组被拦截器包装导致解析失败的问题
- ScrollView改为NestedScrollView修复竖向列表不渲染

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 12:58:48 +08:00
parent 56090e5092
commit de69eeefd8
6 changed files with 145 additions and 75 deletions

View File

@@ -38,10 +38,10 @@ class LogDetailActivity : BaseBindingActivity<ActivityLogDetailBinding, LogDetai
// 配置操作详情 RecyclerView垂直时间线
binding.rvTimeline.adapter = timelineAdapter
// 观察流转状态变化,程序化构建步骤进度条
viewModel.currentStepIndex.observe(this) { index ->
buildStepProgressBar(viewModel.allSteps, index)
}
// 观察数据变化,重新构建步骤进度条
viewModel.allSteps.observe(this) { rebuildSteps() }
viewModel.activeStepCodes.observe(this) { rebuildSteps() }
viewModel.latestStepCode.observe(this) { rebuildSteps() }
viewModel.statusLogList.observe(this) { list ->
timelineAdapter.setData(list)
@@ -50,7 +50,19 @@ class LogDetailActivity : BaseBindingActivity<ActivityLogDetailBinding, LogDetai
viewModel.initOnCreated(intent)
}
private fun buildStepProgressBar(steps: List<String>, currentIndex: Int) {
private fun rebuildSteps() {
val steps = viewModel.allSteps.value ?: return
val activeCodes = viewModel.activeStepCodes.value ?: emptySet()
val latestCode = viewModel.latestStepCode.value ?: ""
if (steps.isEmpty()) return
buildStepProgressBar(steps, activeCodes, latestCode)
}
private fun buildStepProgressBar(
steps: List<StepInfo>,
activeCodes: Set<String>,
latestCode: String
) {
val container = binding.llSteps
container.removeAllViews()
@@ -60,30 +72,38 @@ class LogDetailActivity : BaseBindingActivity<ActivityLogDetailBinding, LogDetai
val dotSize = dp(10)
val lineHeight = dp(2)
// 计算每个节点宽度:屏幕宽度的 80% 均分给所有节点
val screenWidth = resources!!.displayMetrics.widthPixels
val stepWidth = (screenWidth * 0.8 / steps.size).toInt()
// 找到最新节点在步骤列表中的索引
val latestIndex = steps.indexOfFirst { it.code == latestCode }
for (i in steps.indices) {
val isCompleted = i <= currentIndex
val isCurrent = i == currentIndex
val step = steps[i]
val isActive = step.code in activeCodes
val isLatest = step.code == latestCode
val isFirst = i == 0
val isLast = i == steps.size - 1
// 每个步骤的根容器(等宽)
// 每个步骤的根容器
val stepLayout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
layoutParams = LinearLayout.LayoutParams(stepWidth, LinearLayout.LayoutParams.WRAP_CONTENT)
}
// 步骤名称(所有步骤统一 padding 确保高度一致)
val tvName = TextView(this).apply {
text = steps[i]
text = step.name
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
gravity = Gravity.CENTER
setPadding(dp(6), dp(2), dp(6), dp(2))
if (isCurrent) {
if (isLatest) {
setBackgroundResource(R.drawable.bg_step_current_badge)
setTextColor(0xFFFFFFFF.toInt())
} else {
setTextColor(if (isCompleted) 0xFF333333.toInt() else 0xFF999999.toInt())
setTextColor(if (isActive) 0xFF333333.toInt() else 0xFF999999.toInt())
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
@@ -99,10 +119,12 @@ class LogDetailActivity : BaseBindingActivity<ActivityLogDetailBinding, LogDetai
).apply { topMargin = dp(8) }
}
// 左半连线(从左边缘到中心,略超过中心以避免断层
// 左半连线(从左边缘到中心)
if (!isFirst) {
// 如果当前节点索引 <= latestIndex左半线为蓝色否则为浅灰色
val leftLineColor = if (latestIndex >= 0 && i <= latestIndex) colorBlue else colorGray
val lineLeft = View(this).apply {
setBackgroundColor(if (isCompleted) colorBlue else colorGray)
setBackgroundColor(leftLineColor)
layoutParams = FrameLayout.LayoutParams(0, lineHeight).apply {
gravity = Gravity.CENTER_VERTICAL or Gravity.START
}
@@ -115,9 +137,10 @@ class LogDetailActivity : BaseBindingActivity<ActivityLogDetailBinding, LogDetai
}
}
// 右半连线(从中心到右边缘,略超过中心以避免断层
// 右半连线(从中心到右边缘)
if (!isLast) {
val rightLineColor = if (isCompleted && !isCurrent) colorBlue else colorGray
// 如果当前节点索引 < latestIndex右半线为蓝色否则为浅灰色
val rightLineColor = if (latestIndex >= 0 && i < latestIndex) colorBlue else colorGray
val lineRight = View(this).apply {
setBackgroundColor(rightLineColor)
layoutParams = FrameLayout.LayoutParams(0, lineHeight).apply {
@@ -136,8 +159,8 @@ class LogDetailActivity : BaseBindingActivity<ActivityLogDetailBinding, LogDetai
val dot = View(this).apply {
setBackgroundResource(
when {
isCurrent -> R.drawable.bg_step_dot_green
isCompleted -> R.drawable.bg_step_dot_blue
isLatest -> R.drawable.bg_step_dot_green
isActive -> R.drawable.bg_step_dot_blue
else -> R.drawable.bg_step_dot_gray
}
)

View File

@@ -2,14 +2,19 @@ package com.lukouguoji.aerologic.page.log.detail
import android.content.Intent
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.google.gson.Gson
import com.lukouguoji.module_base.base.BaseViewModel
import com.lukouguoji.module_base.bean.LogBean
import com.lukouguoji.module_base.bean.StatusLogBean
import com.lukouguoji.module_base.common.Constant
import com.lukouguoji.module_base.http.net.NetApply
import com.lukouguoji.module_base.ktx.launchCollect
import com.lukouguoji.module_base.ktx.toRequestBody
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class StepInfo(val code: String, val name: String)
class LogDetailViewModel : BaseViewModel() {
@@ -18,71 +23,100 @@ class LogDetailViewModel : BaseViewModel() {
val statusLogList = MutableLiveData<List<StatusLogBean>>(emptyList())
// 流转状态步骤定义
val allSteps = listOf(
"预录入", "完成收运", "运抵申报", "海关放行",
"完成组装", "完成复磅", "装载申报", "航班关闭", "理货申报"
)
// 流转状态步骤(根据运单类型动态设置)
val allSteps = MutableLiveData<List<StepInfo>>(emptyList())
// 当前完成到哪一步(索引)
// 操作详情中出现的步骤 code 集合
val activeStepCodes = MutableLiveData<Set<String>>(emptySet())
// 最新步骤的 code
val latestStepCode = MutableLiveData("")
// 当前完成到哪一步(索引),用于线条着色
val currentStepIndex = MutableLiveData(-1)
private var awbType = ""
// 国际出港步骤(来自 AwbGjcStatus.java
private val gjcSteps = listOf(
StepInfo("1", "预录入"), StepInfo("2", "收运完成"),
StepInfo("3", "运抵申报"), StepInfo("4", "海关放行"),
StepInfo("5", "组装完成"), StepInfo("6", "复磅完成"),
StepInfo("7", "装载申报"), StepInfo("8", "航班关闭"),
StepInfo("9", "理货申报")
)
// 国际进港步骤(来自 AwbGjjStatus.java
private val gjjSteps = listOf(
StepInfo("1", "原始舱单"), StepInfo("2", "分拣理货"),
StepInfo("3", "理货申报"), StepInfo("4", "海关放行"),
StepInfo("5", "柜台办结"), StepInfo("6", "提取出库")
)
fun initOnCreated(intent: Intent) {
val json = intent.getStringExtra(Constant.Key.DATA) ?: ""
if (json.isNotEmpty()) {
val bean = Gson().fromJson(json, LogBean::class.java)
waybillNo.value = bean.key
waybillType.value = getAwbTypeName(bean.logType)
loadStatusList(bean.key, bean.logType)
// 优先从 ARouter 参数获取
val key = intent.getStringExtra(Constant.Key.KEY) ?: ""
awbType = intent.getStringExtra(Constant.Key.AWB_TYPE) ?: ""
// 如果没有 ARouter 参数,尝试从 LogBean 解析
if (key.isEmpty()) {
val json = intent.getStringExtra(Constant.Key.DATA) ?: ""
if (json.isNotEmpty()) {
val bean = Gson().fromJson(json, LogBean::class.java)
waybillNo.value = bean.key
awbType = bean.logType
}
} else {
waybillNo.value = key
}
waybillType.value = getAwbTypeName(awbType)
allSteps.value = if (awbType == "II") gjjSteps else gjcSteps
if (waybillNo.value?.isNotEmpty() == true) {
loadStatusList(waybillNo.value!!, awbType)
}
}
private fun loadStatusList(key: String, logType: String) {
if (key.isEmpty()) {
loadMockData()
return
}
launchCollect({
NetApply.api.getLogStatusList(
mapOf(
"key" to key,
"awbType" to logType
).toRequestBody()
)
}) {
onSuccess = {
val list = it.data ?: emptyList()
if (list.isNotEmpty()) {
statusLogList.value = list
val lastStatus = list.last().content
val index = allSteps.indexOfFirst { step ->
lastStatus.contains(step)
}
currentStepIndex.value = if (index >= 0) index else list.size - 1
} else {
loadMockData()
viewModelScope.launch {
try {
// 注意SelfLoginInterceptor 会把裸数组 [...] 包装成 {"data": [...]}
// 所以 API 返回类型必须是 BaseResultBean从 .data 取实际列表
val result = withContext(Dispatchers.IO) {
NetApply.api.getLogStatusList(
mapOf(
"key" to key,
"awbType" to logType
).toRequestBody()
)
}
}
onFailed = { _, _ ->
loadMockData()
val list = result.data ?: emptyList()
statusLogList.value = list
if (list.isNotEmpty()) {
// 提取所有出现的 status code用 status 字段匹配)
val codes = list.mapNotNull { item ->
item.status.ifEmpty { null }
}.toSet()
activeStepCodes.value = codes
// 最新节点 = 列表最后一条的 status
val latestCode = list.last().status
latestStepCode.value = latestCode
// 计算最新节点在步骤列表中的索引
val steps = allSteps.value ?: emptyList()
val index = steps.indexOfFirst { step -> step.code == latestCode }
currentStepIndex.value = if (index >= 0) index else -1
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun loadMockData() {
// Mock 数据:模拟到"装载申报"步骤
currentStepIndex.value = 6 // "装载申报" 在 allSteps 中的索引
statusLogList.value = listOf(
StatusLogBean(content = "托书录入", opDate = "2017-04-01 12:00:00"),
StatusLogBean(content = "完成收运", opDate = "2017-04-01 12:00:00"),
StatusLogBean(content = "完成组装", opDate = "2017-04-01 12:00:00"),
StatusLogBean(content = "已复磅", opDate = "2017-04-01 12:00:00"),
StatusLogBean(content = "海关已放行", opDate = "2017-04-01 12:00:00"),
StatusLogBean(content = "装载申报", opDate = "2017-04-01 12:00:00")
)
}
private fun getAwbTypeName(logType: String): String {
return when (logType) {
"CI" -> "国内进港"

View File

@@ -17,7 +17,7 @@
<include layout="@layout/title_tool_bar" />
<ScrollView
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
@@ -59,7 +59,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="运单号: "
android:text="运单号"
android:textColor="#666666"
android:textSize="14sp" />
@@ -81,7 +81,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="运单类型: "
android:text="运单类型"
android:textColor="#666666"
android:textSize="14sp" />
@@ -153,7 +153,7 @@
</LinearLayout>
</ScrollView>
</androidx.core.widget.NestedScrollView>
</LinearLayout>
</layout>

View File

@@ -395,5 +395,11 @@ interface Constant {
// Bean对象传递
const val BEAN = "bean"
// 运单号(日志详情)
const val KEY = "key"
// 运单类型
const val AWB_TYPE = "awbType"
}
}

View File

@@ -62,8 +62,11 @@ class GjcQueryDetailsActivity :
height = 30.dp
}
setOnClickListener {
val wbNo = viewModel.maWbData.value?.get("wbNo") as? String ?: ""
ARouter.getInstance()
.build(ARouterConstants.ACTIVITY_URL_LOG_DETAIL)
.withString(Constant.Key.KEY, wbNo)
.withString(Constant.Key.AWB_TYPE, "IO")
.navigation()
}
}

View File

@@ -14,6 +14,7 @@ import com.lukouguoji.gjj.databinding.ActivityIntImpQueryDetailsBinding
import com.lukouguoji.gjj.viewModel.IntImpQueryDetailsViewModel
import com.lukouguoji.module_base.base.BaseBindingActivity
import com.lukouguoji.module_base.base.CustomVP2Adapter
import com.lukouguoji.module_base.common.Constant
import com.lukouguoji.module_base.router.ARouterConstants
/**
@@ -61,8 +62,11 @@ class IntImpQueryDetailsActivity :
height = 30.dp
}
setOnClickListener {
val wbNo = viewModel.maWbData.value?.get("wbNo") as? String ?: ""
ARouter.getInstance()
.build(ARouterConstants.ACTIVITY_URL_LOG_DETAIL)
.withString(Constant.Key.KEY, wbNo)
.withString(Constant.Key.AWB_TYPE, "II")
.navigation()
}
}