task(03452fc3-d6cb-4549-b79d-7d389c7e5e8e): Add M6 stats & upload module (2.13/2.10/2.11/2.12 + queue retransmit)

补偿提交:commitOnArchive 连续第四轮未落盘(M3/M4/M5 同款故障),由主控在子会话工作树补交。

M6 统计与数据上传模块交付:
- M6-02 (2.10) 项目监测计划统计列表(完成度进度条+百分比 Chip)
- M6-03 (2.11) / M6-04 (2.12) 测组统计与监测点统计两层下钻
- M6-01 (2.13) SAF 选原始文件 multipart 直传 AnalysisMonitorData + 解析结果反馈
- 上传队列补传闭环 UploadQueueSyncer(应用启动 + ConnectivityManager VALIDATED
  onAvailable 双触发,Mutex 串行,成功 SUCCESS/失败回退 PENDING 记 retry)
- Tab.STATS 业务化:挂载 StatsWorkspaceFragment,删除占位 StatsFragment
- 新增 StatApi/UploadApi/StatModels + StatRepository/UploadRepository
- 单测 StatsModelsTest/UploadPayloadWrapTest;静态核查 7 项 + 生产网关负向契约 4 端点
This commit is contained in:
阿猫
2026-09-04 02:59:47 +08:00
parent 65a693052e
commit b8e4c24fd2
30 changed files with 2729 additions and 8 deletions

View File

@@ -3,6 +3,7 @@ package com.stec.cmd
import com.stec.cmd.feature.collect.CollectWorkspaceFragment
import com.stec.cmd.feature.home.ProjectWorkspaceFragment
import com.stec.cmd.feature.mine.MineFragment
import com.stec.cmd.feature.stats.StatsWorkspaceFragment
import com.stec.cmd.feature.task.TaskWorkspaceFragment
/**
@@ -36,7 +37,7 @@ object FeatureMount {
MountItem(Tab.HOME, ProjectWorkspaceFragment::class.java, "M2 项目工作台"),
MountItem(Tab.TASK, TaskWorkspaceFragment::class.java, "M3 任务管理"),
MountItem(Tab.COLLECT, CollectWorkspaceFragment::class.java, "M4+M5 数据采集"),
MountItem(Tab.STATS, StatsFragment::class.java, "M6 统计与上传"),
MountItem(Tab.STATS, StatsWorkspaceFragment::class.java, "M6 统计与上传"),
MountItem(Tab.MINE, MineFragment::class.java, "M1 我的"),
)

View File

@@ -1,7 +0,0 @@
package com.stec.cmd
import dagger.hilt.android.AndroidEntryPoint
/** 统计占位M6 统计与上传S4 挂载统计表与文件上传)。 */
@AndroidEntryPoint
class StatsFragment : PlaceholderFragment()

View File

@@ -3,15 +3,22 @@ package com.stec.cmd
import android.app.Application
import android.util.Log
import com.stec.cmd.core.common.AppLog
import com.stec.cmd.feature.upload.UploadQueueSyncer
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
/**
* 应用入口:装配 Hilt 图并安装日志实现。
* core:common 的 AppLog 门面不依赖 android.util.LogLogCat sink 在壳层注入。
* 注入 [UploadQueueSyncer] 使其随图创建onCreate 即完成「应用启动」补传触发
* 与网络恢复回调注册(第二个触发点在 Syncer 内部)。
*/
@HiltAndroidApp
class SteCmdApplication : Application() {
@Inject
lateinit var uploadQueueSyncer: UploadQueueSyncer
override fun onCreate() {
super.onCreate()
AppLog.install { level, tag, message, throwable ->
@@ -22,6 +29,7 @@ class SteCmdApplication : Application() {
AppLog.Level.ERROR -> Log.e(tag, message, throwable)
}
}
uploadQueueSyncer.start()
AppLog.i(TAG, "监测数据不落地平台客户端启动S0 骨架)")
}

View File

@@ -0,0 +1,94 @@
package com.stec.cmd.feature.stats
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.stec.cmd.R
import com.stec.cmd.core.common.UiState
import com.stec.cmd.core.network.api.PointStatItem
import com.stec.cmd.databinding.FragmentPointStatsBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
/**
* 监测点统计页M6-042.12 GetPointStatistics历史数据测组统计行下钻入口。
* 传参 PlanID + GroupID + 测组名标签;无独立门控(入口在门控之后)。
*/
@AndroidEntryPoint
class PointStatsFragment : Fragment() {
private var _binding: FragmentPointStatsBinding? = null
private val binding get() = checkNotNull(_binding)
private val viewModel: PointStatsViewModel by viewModels()
private val adapter by lazy { StatsPointListAdapter() }
private val planId: String? get() = arguments?.getString(ARG_PLAN_ID)
private val groupId: String? get() = arguments?.getString(ARG_GROUP_ID)
private val groupLabel: String? get() = arguments?.getString(ARG_GROUP_LABEL)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentPointStatsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.toolbar.title = getString(
R.string.stats_point_title_fmt,
groupLabel?.takeIf { it.isNotBlank() } ?: "-",
)
binding.toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
binding.recyclerPoints.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerPoints.adapter = adapter
binding.btnRetry.setOnClickListener { viewModel.refresh() }
viewModel.bind(planId, groupId)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.points.collect { render(it) }
}
}
}
private fun render(state: UiState<List<PointStatItem>>) {
binding.progress.isVisible = state is UiState.Loading
binding.boxError.isVisible = state is UiState.Error
(state as? UiState.Error)?.let { binding.tvError.text = it.message }
binding.tvEmpty.isVisible = state is UiState.Success && state.data.isEmpty()
if (state is UiState.Success) adapter.submitList(state.data)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
private const val ARG_PLAN_ID = "plan_id"
private const val ARG_GROUP_ID = "group_id"
private const val ARG_GROUP_LABEL = "group_label"
/** [groupLabel] 仅作标题展示(测组名)。 */
fun newInstance(planId: String, groupId: String, groupLabel: String): PointStatsFragment =
PointStatsFragment().apply {
arguments = Bundle().apply {
putString(ARG_PLAN_ID, planId)
putString(ARG_GROUP_ID, groupId)
putString(ARG_GROUP_LABEL, groupLabel)
}
}
}
}

View File

@@ -0,0 +1,57 @@
package com.stec.cmd.feature.stats
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.stec.cmd.core.common.UiState
import com.stec.cmd.core.network.ApiError
import com.stec.cmd.core.network.api.PointStatItem
import com.stec.cmd.feature.mine.userMessage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* 监测点统计页M6-042.12 GetPointStatistics历史数据从测组统计行下钻。
* [bind] 幂等:同一 PlanID+GroupID 重复绑定不重拉。
*/
@HiltViewModel
class PointStatsViewModel @Inject constructor(
private val statRepository: StatRepository,
) : ViewModel() {
private val _points = MutableStateFlow<UiState<List<PointStatItem>>>(UiState.Idle)
/** 监测点统计加载态。 */
val points: StateFlow<UiState<List<PointStatItem>>> = _points.asStateFlow()
private var boundKey: Pair<String, String>? = null
/** 绑定计划与测组并加载;任一 ID 缺失时停留错误态。 */
fun bind(planId: String?, groupId: String?) {
if (planId.isNullOrBlank() || groupId.isNullOrBlank()) {
_points.value = UiState.Error("计划/测组标识缺失,无法加载监测点统计")
return
}
val key = planId to groupId
if (boundKey == key && _points.value !is UiState.Idle) return
boundKey = key
load(planId, groupId)
}
/** 错误重试 / 手动刷新。 */
fun refresh() = boundKey?.let { (planId, groupId) -> load(planId, groupId) }
private fun load(planId: String, groupId: String) {
viewModelScope.launch {
_points.value = UiState.Loading
_points.value = try {
UiState.Success(statRepository.pointStats(planId, groupId))
} catch (e: ApiError) {
UiState.Error(e.userMessage())
}
}
}
}

View File

@@ -0,0 +1,149 @@
package com.stec.cmd.feature.stats
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.stec.cmd.core.network.ApiError
import com.stec.cmd.core.network.api.TaskItem
import com.stec.cmd.feature.collect.PointRepository
import com.stec.cmd.feature.mine.userMessage
import com.stec.cmd.feature.task.TaskRepository
import com.stec.cmd.feature.upload.UploadRepository
import com.stec.cmd.session.ProjectSession
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* 原始文件上传M6-012.13 数据解析·兜底通道)。
*
* 上传上下文当前选中项目ProjectID+ 2.7 任务PlanID/SurveyGroupID
* 采集页同源WorkPointID 优先取任务自带值2.6),缺省回退 2.15 工点首项。
* 文件字节由 Fragment 经 SAF 读出后交本 VM 直传(不入队列,即时反馈解析结果)。
*/
@HiltViewModel
class RawFileUploadViewModel @Inject constructor(
private val taskRepository: TaskRepository,
private val pointRepository: PointRepository,
private val uploadRepository: UploadRepository,
private val projectSession: ProjectSession,
) : ViewModel() {
/** 当前选中项目payload 归属)。 */
val selectedProject = projectSession.selectedProject
private val _tasks = MutableStateFlow<List<TaskItem>>(emptyList())
/** 可选任务列表2.7,项目当前监测计划)。 */
val tasks: StateFlow<List<TaskItem>> = _tasks.asStateFlow()
private val _selectedTask = MutableStateFlow<TaskItem?>(null)
/** 已选上传任务2.13 的 PlanID/SurveyGroupID 来源)。 */
val selectedTask: StateFlow<TaskItem?> = _selectedTask.asStateFlow()
private val _uploading = MutableStateFlow(false)
/** 文件上传中(按钮防抖与进度态)。 */
val uploading: StateFlow<Boolean> = _uploading.asStateFlow()
private val _events = MutableSharedFlow<RawFileUploadEvent>(extraBufferCapacity = 8)
/** 一次性事件Snackbar 反馈)。 */
val events: SharedFlow<RawFileUploadEvent> = _events.asSharedFlow()
init {
// 切项目自动刷新可选任务并清空选中(与统计页联动一致)
viewModelScope.launch {
selectedProject.collect { project ->
if (project != null) {
_selectedTask.value = null
loadTasks(project.ID)
} else {
_tasks.value = emptyList()
}
}
}
}
/** 拉取当前项目计划任务2.7)供选择;失败仅提示不阻塞上传入口。 */
fun refreshTasks() {
selectedProject.value?.ID?.let(::loadTasks)
}
private fun loadTasks(projectId: String) {
viewModelScope.launch {
try {
_tasks.value = taskRepository.planTasks(projectId)
// 默认选中首个任务,减少现场操作步骤
if (_selectedTask.value == null) _selectedTask.value = _tasks.value.firstOrNull()
} catch (e: ApiError) {
_events.tryEmit(RawFileUploadEvent.Message(e.userMessage()))
}
}
}
/** 选定上传任务。 */
fun selectTask(task: TaskItem?) {
_selectedTask.value = task
}
/**
* 直传原始文件2.13上下文不全时提示并放弃WorkPointID 缺失时
* 回退 2.15 工点首项,仍无则传空串(服务端将返回业务错误,如实反馈)。
*/
fun uploadRawFile(fileBytes: ByteArray, fileName: String) {
if (_uploading.value) return
val project = selectedProject.value
val task = _selectedTask.value
if (project == null || task == null) {
_events.tryEmit(RawFileUploadEvent.Message("请先选择项目与上传任务"))
return
}
viewModelScope.launch {
_uploading.value = true
try {
val workPointId = resolveWorkPointId(task)
uploadRepository.uploadRawFile(
projectId = project.ID.orEmpty(),
workPointId = workPointId,
surveyGroupId = task.GroupID.orEmpty(),
planId = task.PlanID.orEmpty(),
monitorDateText = UploadRepository.nowMonitorDateText(),
fileBytes = fileBytes,
fileName = fileName,
)
_events.tryEmit(RawFileUploadEvent.UploadOk(fileName))
} catch (e: ApiError) {
_events.tryEmit(RawFileUploadEvent.Message(e.userMessage()))
} catch (e: Exception) {
_events.tryEmit(RawFileUploadEvent.Message("文件上传失败:${e.message}"))
} finally {
_uploading.value = false
}
}
}
/** WorkPointID 解析:任务自带值优先,缺省回退 2.15 工点首项(仍可为空串)。 */
private suspend fun resolveWorkPointId(task: TaskItem): String {
task.WorkPointID?.takeIf { it.isNotBlank() }?.let { return it }
return runCatching {
pointRepository.workPoints(selectedProject.value?.ID).firstOrNull()?.ID
}.getOrNull().orEmpty()
}
}
/** 原始文件上传一次性事件。 */
sealed interface RawFileUploadEvent {
/** 上传成功2.13 解析受理)。 */
data class UploadOk(val fileName: String) : RawFileUploadEvent
/** 失败/提示文本。 */
data class Message(val text: String) : RawFileUploadEvent
}

View File

@@ -0,0 +1,93 @@
package com.stec.cmd.feature.stats
import com.stec.cmd.core.network.ApiCaller
import com.stec.cmd.core.network.ApiError
import com.stec.cmd.core.network.api.GroupStatItem
import com.stec.cmd.core.network.api.PointStatItem
import com.stec.cmd.core.network.api.PlanStatItem
import com.stec.cmd.core.network.api.StatApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import javax.inject.Inject
import javax.inject.Singleton
/**
* 统计仓储M6-02/03/042.10 GetPlanStatistical / 2.11 GetSurveyGroupList /
* 2.12 GetPointStatistics
*
* 契约宽容data 形态以 [listFromData] 归一提取——
* 兼容文档口径的 `{"XxxList":[…]}` 对象、平铺数组、以及二次 JSON 编码字符串
* TaskRepository.taskListFromData / PointRepository.listFromData 先例,
* 缺键时取对象内首个 JsonArray 兜底,防御键名大小写漂移)。
*/
@Singleton
class StatRepository @Inject constructor(
private val statApi: StatApi,
private val apiCaller: ApiCaller,
private val json: Json,
) {
/** 2.10 项目监测计划统计表;[projectId] 必填(当前选中项目)。 */
suspend fun planStats(projectId: String): List<PlanStatItem> =
listFromData(apiCaller.call { statApi.getPlanStatistical(projectId) }, KEY_PLAN_LIST) {
ListSerializer(PlanStatItem.serializer())
}
/** 2.11 指定监测计划的测组统计表;[planId] 必填2.10 行 PlanID。 */
suspend fun groupStats(planId: String): List<GroupStatItem> =
listFromData(apiCaller.call { statApi.getSurveyGroupList(planId) }, KEY_GROUP_LIST) {
ListSerializer(GroupStatItem.serializer())
}
/** 2.12 监测点统计表(历史数据);[planId]/[groupId] 均必填2.11 行 GroupID。 */
suspend fun pointStats(planId: String, groupId: String): List<PointStatItem> =
listFromData(
apiCaller.call { statApi.getPointStatistics(planId, groupId) },
KEY_POINTS_LIST,
) {
ListSerializer(PointStatItem.serializer())
}
/**
* data → 列表归一提取PointRepository.listFromData 同款宽容链):
* - JsonObject优先取 [key](文档口径);缺键取首个 JsonArray 值兜底;
* - JsonArray文档口径的平铺数组
* - JsonPrimitive二次 JSON 编码的列表字符串。
* 结构无法识别或字段不符时以 [ApiError.EmptyBodyError] 抛出。
*/
private inline fun <reified T : Any> listFromData(
data: JsonElement,
key: String,
serializer: () -> ListSerializer<T>,
): List<T> {
val payload = when (data) {
is JsonObject ->
data[key]
?: data.values.filterIsInstance<JsonArray>().firstOrNull()
?: throw ApiError.EmptyBodyError("统计数据结构异常")
else -> data
}
return try {
when (payload) {
is JsonArray -> json.decodeFromJsonElement(serializer(), payload)
is JsonPrimitive -> json.decodeFromString(serializer(), payload.content)
else -> throw ApiError.EmptyBodyError("统计数据结构异常")
}
} catch (e: SerializationException) {
throw ApiError.EmptyBodyError("统计数据解析失败:${e.message}")
}
}
private companion object {
/** 文档 2.10/2.11/2.12data 内列表键名(确切大小写)。 */
const val KEY_PLAN_LIST = "PlanList"
const val KEY_GROUP_LIST = "GroupList"
const val KEY_POINTS_LIST = "PointsList"
}
}

View File

@@ -0,0 +1,64 @@
package com.stec.cmd.feature.stats
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.stec.cmd.R
import com.stec.cmd.core.network.api.GroupStatItem
import com.stec.cmd.databinding.ItemStatsGroupBinding
/**
* 测组统计表适配器M6-032.11 GroupList测组名 + 监测类型 Chip +
* 责任人 + 完成度进度条;行点击进入监测点统计下钻。
*/
class StatsGroupListAdapter(
private val onGroupClick: (GroupStatItem) -> Unit,
) : ListAdapter<GroupStatItem, StatsGroupListAdapter.GroupViewHolder>(DIFF) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupViewHolder =
GroupViewHolder(
ItemStatsGroupBinding.inflate(LayoutInflater.from(parent.context), parent, false),
)
override fun onBindViewHolder(holder: GroupViewHolder, position: Int) =
holder.bind(getItem(position))
inner class GroupViewHolder(
private val binding: ItemStatsGroupBinding,
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: GroupStatItem) {
val context = binding.root.context
binding.tvGroupTitle.text = item.GroupName.orDash()
binding.chipType.text = item.MonitoringType.orDash()
binding.tvGroupMeta.text = context.getString(
R.string.stats_group_meta_fmt,
item.DutyName.orDash(),
)
val percent = percentFromDegree(item.CompleteDegree)
binding.progressDegree.isVisible = percent != null
percent?.let { binding.progressDegree.setProgressCompat(it, true) }
binding.tvDegree.text = percent?.let {
context.getString(R.string.stats_degree_percent_fmt, it)
} ?: item.CompleteDegree.orDash()
binding.root.setOnClickListener { onGroupClick(item) }
}
private fun String?.orDash(): String = takeIf { !isNullOrBlank() } ?: "-"
}
private companion object {
val DIFF = object : DiffUtil.ItemCallback<GroupStatItem>() {
override fun areItemsTheSame(oldItem: GroupStatItem, newItem: GroupStatItem) =
oldItem.GroupID == newItem.GroupID
override fun areContentsTheSame(oldItem: GroupStatItem, newItem: GroupStatItem) =
oldItem == newItem
}
}
}

View File

@@ -0,0 +1,43 @@
package com.stec.cmd.feature.stats
import android.content.Context
import com.stec.cmd.R
import com.stec.cmd.core.network.api.TaskItem
/**
* 统计页纯函数工具M6-02/03完成度 → 百分比0100
*
* 文档 2.10/2.11 未给出 CompleteDegree 精确格式2.6/2.7 为「已传/总数」,
* 平台他处亦有「0.85」「85%」口径),按宽容序解析:
* 「x/y」→ 比值「85%」/「85」→ 直读「0.85」→ 小数换算;越界收敛到 0100。
*/
fun percentFromDegree(raw: String?): Int? {
val text = raw?.trim().orEmpty()
if (text.isEmpty()) return null
// 口径一:「已上传点数/总点数」(与任务页 parseDegree 同构)
text.split('/', '÷').takeIf { it.size == 2 }?.let { (uploaded, total) ->
val up = uploaded.trim().toDoubleOrNull() ?: return@let
val total2 = total.trim().toDoubleOrNull() ?: return@let
if (total2 > 0) return ((up / total2) * 100).toInt().coerceIn(0, 100)
}
// 口径二:百分号直读 / 纯数值
text.removeSuffix("%").trim().toDoubleOrNull()?.let { value ->
return when {
text.endsWith("%") -> value.toInt().coerceIn(0, 100)
value in 0.0..1.0 -> (value * 100).toInt().coerceIn(0, 100)
value <= 100.0 -> value.toInt().coerceIn(0, 100)
else -> null
}
}
return null
}
/** 上传任务选择条目文案(采集页 taskTitle 同构:任务编号 + 监测类型 + 工点/期数)。 */
fun taskTitleForStats(context: Context, task: TaskItem): String = context.getString(
R.string.stats_task_fmt,
task.TaskCode?.takeIf { it.isNotBlank() } ?: task.SimpleName.orEmpty(),
task.MonitoringType.orEmpty(),
task.WorkPointName?.takeIf { it.isNotBlank() } ?: task.period.orEmpty(),
)

View File

@@ -0,0 +1,70 @@
package com.stec.cmd.feature.stats
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.stec.cmd.R
import com.stec.cmd.core.network.api.PlanStatItem
import com.stec.cmd.databinding.ItemStatsPlanBinding
/**
* 项目监测计划统计表适配器M6-022.10 PlanList
*
* 完成度可视化LinearProgressIndicator + 百分比文本(不引图表库);
* 解析不出百分比时隐藏进度条、透传原文。行点击进入测组统计下钻。
*/
class StatsPlanListAdapter(
private val onPlanClick: (PlanStatItem) -> Unit,
) : ListAdapter<PlanStatItem, StatsPlanListAdapter.PlanViewHolder>(DIFF) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PlanViewHolder =
PlanViewHolder(
ItemStatsPlanBinding.inflate(LayoutInflater.from(parent.context), parent, false),
)
override fun onBindViewHolder(holder: PlanViewHolder, position: Int) =
holder.bind(getItem(position))
inner class PlanViewHolder(
private val binding: ItemStatsPlanBinding,
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: PlanStatItem) {
val context = binding.root.context
binding.tvPlanTitle.text = context.getString(
R.string.stats_plan_title_fmt,
item.PeriodsNum.orDash(),
)
binding.chipMold.text = item.Mold.orDash()
binding.tvPlanMeta.text = context.getString(
R.string.stats_plan_meta_fmt,
item.EndTime.orDash(),
item.Working.orDash(),
)
val percent = percentFromDegree(item.CompleteDegree)
binding.progressDegree.isVisible = percent != null
percent?.let { binding.progressDegree.setProgressCompat(it, true) }
binding.tvDegree.text = percent?.let {
context.getString(R.string.stats_degree_percent_fmt, it)
} ?: item.CompleteDegree.orDash()
binding.root.setOnClickListener { onPlanClick(item) }
}
private fun String?.orDash(): String = takeIf { !isNullOrBlank() } ?: "-"
}
private companion object {
val DIFF = object : DiffUtil.ItemCallback<PlanStatItem>() {
override fun areItemsTheSame(oldItem: PlanStatItem, newItem: PlanStatItem) =
oldItem.PlanID == newItem.PlanID
override fun areContentsTheSame(oldItem: PlanStatItem, newItem: PlanStatItem) =
oldItem == newItem
}
}
}

View File

@@ -0,0 +1,52 @@
package com.stec.cmd.feature.stats
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.stec.cmd.R
import com.stec.cmd.core.network.api.PointStatItem
import com.stec.cmd.databinding.ItemStatsPointBinding
/**
* 监测点统计表适配器M6-042.12 PointsList历史数据点名 + 上传时间 + 设备。
*/
class StatsPointListAdapter :
ListAdapter<PointStatItem, StatsPointListAdapter.PointViewHolder>(DIFF) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PointViewHolder =
PointViewHolder(
ItemStatsPointBinding.inflate(LayoutInflater.from(parent.context), parent, false),
)
override fun onBindViewHolder(holder: PointViewHolder, position: Int) =
holder.bind(getItem(position))
inner class PointViewHolder(
private val binding: ItemStatsPointBinding,
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: PointStatItem) {
val context = binding.root.context
binding.tvPointName.text = item.PointName.orDash()
binding.tvPointMeta.text = context.getString(
R.string.stats_point_meta_fmt,
item.ImportTime.orDash(),
item.MonitorEqu.orDash(),
)
}
private fun String?.orDash(): String = takeIf { !isNullOrBlank() } ?: "-"
}
private companion object {
val DIFF = object : DiffUtil.ItemCallback<PointStatItem>() {
override fun areItemsTheSame(oldItem: PointStatItem, newItem: PointStatItem): Boolean =
oldItem.PointName == newItem.PointName
override fun areContentsTheSame(oldItem: PointStatItem, newItem: PointStatItem) =
oldItem == newItem
}
}
}

View File

@@ -0,0 +1,99 @@
package com.stec.cmd.feature.stats
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.stec.cmd.config.ConfigRepository
import com.stec.cmd.core.common.UiState
import com.stec.cmd.core.network.ApiError
import com.stec.cmd.core.network.api.PlanStatItem
import com.stec.cmd.feature.mine.userMessage
import com.stec.cmd.feature.upload.UploadQueueSyncer
import com.stec.cmd.session.ProjectSession
import com.stec.cmd.session.SessionRepository
import com.stec.cmd.session.SessionState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* 统计工作台M6-02 项目监测计划统计表2.10+ 上传队列状态卡。
*
* 自动加载门控SignedIn 且三要素就绪且已选项目时拉取;切换项目自动重拉
* (订阅 [ProjectSession.selectedProject]);会话登出复位 Idle。
* 队列状态卡直接订阅 [UploadQueueSyncer.snapshot],手动重试转发 [retryUpload]。
*/
@HiltViewModel
class StatsViewModel @Inject constructor(
private val statRepository: StatRepository,
private val projectSession: ProjectSession,
private val uploadQueueSyncer: UploadQueueSyncer,
sessionRepository: SessionRepository,
private val configRepository: ConfigRepository,
) : ViewModel() {
/** 会话状态流Restoring/SignedOut/SignedIn 门控)。 */
val sessionState = sessionRepository.state
/** 连接配置快照流(三要素缺失时警示引导)。 */
val configSnapshot = configRepository.snapshotStateFlow
/** 当前选中项目M2-03 共享选中态,切换即刷新统计)。 */
val selectedProject = projectSession.selectedProject
/** 上传队列状态快照(待传/成功/失败计数 + 运行态 + 最近错误)。 */
val queueSnapshot = uploadQueueSyncer.snapshot
private val _plans = MutableStateFlow<UiState<List<PlanStatItem>>>(UiState.Idle)
/** 计划统计表加载态。 */
val plans: StateFlow<UiState<List<PlanStatItem>>> = _plans.asStateFlow()
private var plansJob: Job? = null
init {
// 切项目即刷新(含首次选中);登出清空
viewModelScope.launch {
selectedProject.collect { project ->
if (project != null && sessionState.value is SessionState.SignedIn) {
loadPlans(project.ID)
} else {
_plans.value = UiState.Idle
}
}
}
viewModelScope.launch {
sessionState.collect { state ->
when (state) {
is SessionState.SignedIn ->
selectedProject.value?.let { loadPlans(it.ID) }
else -> _plans.value = UiState.Idle
}
}
}
}
/** 下拉刷新 / 错误重试 / 门控通过后的主动加载。 */
fun refresh() = loadPlans(selectedProject.value?.ID)
/** 队列状态卡手动重试:立即补传一轮。 */
fun retryUpload() = uploadQueueSyncer.retryNow()
private fun loadPlans(projectId: String?) {
if (projectId.isNullOrBlank()) return
if (_plans.value is UiState.Loading) return
plansJob?.cancel()
plansJob = viewModelScope.launch {
_plans.value = UiState.Loading
_plans.value = try {
UiState.Success(statRepository.planStats(projectId))
} catch (e: ApiError) {
UiState.Error(e.userMessage())
}
}
}
}

View File

@@ -0,0 +1,311 @@
package com.stec.cmd.feature.stats
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.stec.cmd.R
import com.stec.cmd.core.common.UiState
import com.stec.cmd.core.network.api.PlanStatItem
import com.stec.cmd.core.network.api.TaskItem
import com.stec.cmd.databinding.FragmentStatsBinding
import com.stec.cmd.feature.mine.ConfigFragment
import com.stec.cmd.feature.mine.LoginFragment
import com.stec.cmd.feature.upload.UploadQueueSnapshot
import com.stec.cmd.session.SessionState
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 统计工作台M6 统计与上传挂载页Tab.STATS
*
* 内容三块:上传队列状态卡(待传/成功/失败 + 手动重试)、原始文件上传卡
* M6-012.13 SAF 直传、项目监测计划统计表M6-022.10,行点击下钻测组→监测点)。
* 渲染状态机Restoring → 加载SignedOut → 登录 CTASignedIn → 配置门控 →
* 未选项目 CTA → 内容。
*/
@AndroidEntryPoint
class StatsWorkspaceFragment : Fragment() {
private var _binding: FragmentStatsBinding? = null
private val binding get() = checkNotNull(_binding)
private val viewModel: StatsViewModel by viewModels()
private val uploadViewModel: RawFileUploadViewModel by viewModels()
private val planAdapter by lazy {
StatsPlanListAdapter(::openGroupStats)
}
/** M6-01 SAF 文件选择器(系统文档页,无需存储权限)。 */
private val filePicker =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) onFilePicked(uri)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentStatsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.swipeRefresh.setColorSchemeResources(R.color.brand_primary)
binding.swipeRefresh.setOnRefreshListener { viewModel.refresh() }
binding.cardConfigWarning.setOnClickListener { openOnTop(ConfigFragment(), TAG_CONFIG) }
binding.btnGoLogin.setOnClickListener { openOnTop(LoginFragment(), TAG_LOGIN) }
binding.btnRetry.setOnClickListener { viewModel.refresh() }
binding.btnGoHome.setOnClickListener { goHomeTab() }
binding.btnQueueRetry.setOnClickListener { viewModel.retryUpload() }
binding.btnPickTask.setOnClickListener { showTaskPicker() }
binding.btnPickFile.setOnClickListener { filePicker.launch(arrayOf(MIME_ANY)) }
binding.recyclerPlans.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerPlans.adapter = planAdapter
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { viewModel.sessionState.collect { render() } }
launch { viewModel.configSnapshot.collect { render() } }
launch { viewModel.selectedProject.collect { render() } }
launch { viewModel.plans.collect { render() } }
launch { viewModel.queueSnapshot.collect { render() } }
launch { uploadViewModel.selectedTask.collect { renderSelectedTask(it) } }
launch { uploadViewModel.uploading.collect { renderUploading(it) } }
launch { uploadViewModel.events.collect { showUploadEvent(it) } }
}
}
}
/** 任一流变化即整体重渲(读 ViewModel 当前值,与首页/任务页同款)。 */
private fun render() {
val session = viewModel.sessionState.value
val config = viewModel.configSnapshot.value
val plans = viewModel.plans.value
val queue = viewModel.queueSnapshot.value
binding.swipeRefresh.isRefreshing =
session is SessionState.SignedIn && config.isReady && plans is UiState.Loading
binding.progress.isVisible = false
binding.stateSignedOut.isVisible = false
binding.stateError.isVisible = false
binding.stateNoProject.isVisible = false
when (session) {
// 冷启动恢复中:中性加载,不引导登录
SessionState.Restoring -> binding.progress.isVisible = true
SessionState.SignedOut -> binding.stateSignedOut.isVisible = true
is SessionState.SignedIn -> {
binding.cardConfigWarning.isVisible = !config.isReady
if (!config.isReady) return
if (viewModel.selectedProject.value == null) {
binding.stateNoProject.isVisible = true
return
}
renderQueue(queue)
renderPlans(plans)
}
}
}
/** 队列状态卡:三计数 + 最近错误 + 运行进度。 */
private fun renderQueue(queue: UploadQueueSnapshot) {
binding.tvQueuePending.text = queue.pendingCount.toString()
binding.tvQueueSuccess.text = queue.successCount.toString()
binding.tvQueueFailed.text = queue.failedCount.toString()
binding.tvQueueError.isVisible = !queue.lastError.isNullOrBlank()
binding.tvQueueError.text = queue.lastError.orEmpty()
binding.progressQueue.isVisible = queue.running
binding.btnQueueRetry.isEnabled = !queue.running
}
/** 计划统计列表:加载/空/错误/内容四态(空与错误以行内文案呈现,可下拉重试)。 */
private fun renderPlans(plans: UiState<List<PlanStatItem>>) {
when (plans) {
is UiState.Loading -> {
binding.tvPlansState.isVisible = true
binding.tvPlansState.text = getString(R.string.stats_plans_loading)
binding.recyclerPlans.isVisible = false
}
is UiState.Error -> {
binding.tvPlansState.isVisible = true
binding.tvPlansState.text = plans.message
binding.recyclerPlans.isVisible = false
}
is UiState.Success -> {
if (plans.data.isEmpty()) {
binding.tvPlansState.isVisible = true
binding.tvPlansState.text = getString(R.string.stats_plans_empty)
binding.recyclerPlans.isVisible = false
} else {
binding.tvPlansState.isVisible = false
binding.recyclerPlans.isVisible = true
planAdapter.submitList(plans.data)
}
}
UiState.Idle -> {
binding.tvPlansState.isVisible = true
binding.tvPlansState.text = getString(R.string.stats_plans_loading)
binding.recyclerPlans.isVisible = false
}
}
}
private fun renderSelectedTask(task: TaskItem?) {
binding.tvFileTask.text = task?.let { taskTitleForStats(requireContext(), it) }
?: getString(R.string.stats_file_task_none)
}
private fun renderUploading(uploading: Boolean) {
binding.btnPickFile.isEnabled = !uploading
binding.btnPickFile.text = getString(
if (uploading) R.string.stats_file_uploading else R.string.stats_file_pick,
)
binding.progressFile.isVisible = uploading
}
/** 上传任务选择(采集页同款 MaterialAlertDialog 单选)。 */
private fun showTaskPicker() {
val tasks = uploadViewModel.tasks.value
if (tasks.isEmpty()) {
uploadViewModel.refreshTasks()
Snackbar.make(binding.root, R.string.stats_task_empty, Snackbar.LENGTH_SHORT).show()
return
}
val titles = tasks.map { taskTitleForStats(requireContext(), it) } +
getString(R.string.stats_task_none)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.stats_pick_task)
.setItems(titles.toTypedArray()) { _, which ->
uploadViewModel.selectTask(tasks.getOrNull(which))
}
.show()
}
/** SAF 选中文件:读字节(上限内)后交 VM 直传 2.13。 */
private fun onFilePicked(uri: Uri) {
viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { readSafPayload(uri) }
}
result.onSuccess { (name, bytes) ->
uploadViewModel.uploadRawFile(bytes, name)
}.onFailure {
Snackbar.make(
binding.root,
getString(R.string.stats_file_read_error_fmt, it.message ?: "-"),
Snackbar.LENGTH_LONG,
).show()
}
}
}
/** 读 SAF 文件名与字节;超限抛 [IllegalStateException](文件名查询失败时用回退名)。 */
private fun readSafPayload(uri: Uri): Pair<String, ByteArray> {
val context = requireContext()
val name = context.contentResolver
.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
}
?.takeIf { it.isNotBlank() }
?: DEFAULT_FILE_NAME
val bytes = context.contentResolver.openInputStream(uri)?.use { input ->
val buffer = java.io.ByteArrayOutputStream()
val chunk = ByteArray(READ_CHUNK_BYTES)
var total = 0
while (true) {
val read = input.read(chunk)
if (read < 0) break
total += read
check(total <= MAX_FILE_BYTES) { "文件超过 ${MAX_FILE_BYTES / 1024 / 1024}MB" }
buffer.write(chunk, 0, read)
}
buffer.toByteArray()
} ?: error("无法读取所选文件")
return name to bytes
}
/** 2.13 受理成功 / 业务失败反馈。 */
private fun showUploadEvent(event: RawFileUploadEvent) {
val text = when (event) {
is RawFileUploadEvent.UploadOk ->
getString(R.string.stats_file_upload_ok_fmt, event.fileName)
is RawFileUploadEvent.Message -> event.text
}
Snackbar.make(binding.root, text, Snackbar.LENGTH_LONG).show()
}
/** 计划行下钻测组统计页add+hide+addToBackStack 壳层模式)。 */
private fun openGroupStats(plan: PlanStatItem) {
if (plan.PlanID.isNullOrBlank()) {
Snackbar.make(binding.root, R.string.stats_plan_id_missing, Snackbar.LENGTH_SHORT)
.show()
return
}
val label = listOfNotNull(
plan.PeriodsNum?.takeIf { it.isNotBlank() },
plan.Mold?.takeIf { it.isNotBlank() },
).joinToString(" · ")
parentFragmentManager.commit {
setReorderingAllowed(true)
add(R.id.fragment_container, SurveyGroupStatsFragment.newInstance(plan.PlanID!!, label))
hide(this@StatsWorkspaceFragment)
addToBackStack(null)
}
}
private fun openOnTop(fragment: Fragment, tag: String) {
parentFragmentManager.commit {
setReorderingAllowed(true)
add(R.id.fragment_container, fragment, tag)
hide(this@StatsWorkspaceFragment)
addToBackStack(null)
}
}
/** 空态 CTA切回首页 Tab 选项目(任务页同款,经底栏 itemId 触发既有切页)。 */
private fun goHomeTab() {
requireActivity().findViewById<com.google.android.material.bottomnavigation.BottomNavigationView>(
R.id.bottom_nav,
).selectedItemId = R.id.nav_home
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private companion object {
const val TAG_LOGIN = "login"
const val TAG_CONFIG = "config"
const val MIME_ANY = "*/*"
const val DEFAULT_FILE_NAME = "monitor-raw-file"
const val READ_CHUNK_BYTES = 64 * 1024
const val MAX_FILE_BYTES = 20 * 1024 * 1024
}
}

View File

@@ -0,0 +1,114 @@
package com.stec.cmd.feature.stats
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.stec.cmd.R
import com.stec.cmd.core.common.UiState
import com.stec.cmd.core.network.api.GroupStatItem
import com.stec.cmd.databinding.FragmentSurveyGroupStatsBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
/**
* 测组统计页M6-032.11 GetSurveyGroupList计划统计行下钻入口。
*
* 传参 PlanID + 展示标签StatsWorkspaceFragment 下钻传入);
* 无独立门控——入口在统计工作台门控之后,拉取失败以错误态+重试呈现。
* 行点击进入监测点统计2.12)。
*/
@AndroidEntryPoint
class SurveyGroupStatsFragment : Fragment() {
private var _binding: FragmentSurveyGroupStatsBinding? = null
private val binding get() = checkNotNull(_binding)
private val viewModel: SurveyGroupStatsViewModel by viewModels()
private val adapter by lazy { StatsGroupListAdapter(::openPointStats) }
private val planId: String? get() = arguments?.getString(ARG_PLAN_ID)
private val planLabel: String? get() = arguments?.getString(ARG_PLAN_LABEL)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentSurveyGroupStatsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.toolbar.title = getString(
R.string.stats_group_title_fmt,
planLabel?.takeIf { it.isNotBlank() } ?: "-",
)
binding.toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
binding.recyclerGroups.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerGroups.adapter = adapter
binding.btnRetry.setOnClickListener { viewModel.refresh() }
viewModel.bind(planId)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.groups.collect { render(it) }
}
}
}
private fun render(state: UiState<List<GroupStatItem>>) {
binding.progress.isVisible = state is UiState.Loading
binding.boxError.isVisible = state is UiState.Error
(state as? UiState.Error)?.let { binding.tvError.text = it.message }
binding.tvEmpty.isVisible = state is UiState.Success && state.data.isEmpty()
if (state is UiState.Success) adapter.submitList(state.data)
}
/** 测组行下钻监测点统计页2.12 历史数据)。 */
private fun openPointStats(group: GroupStatItem) {
val planIdValue = planId ?: return
val groupId = group.GroupID?.takeIf { it.isNotBlank() } ?: return
parentFragmentManager.commit {
setReorderingAllowed(true)
add(
R.id.fragment_container,
PointStatsFragment.newInstance(
planIdValue,
groupId,
group.GroupName?.takeIf { it.isNotBlank() } ?: "-",
),
)
hide(this@SurveyGroupStatsFragment)
addToBackStack(null)
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
private const val ARG_PLAN_ID = "plan_id"
private const val ARG_PLAN_LABEL = "plan_label"
/** [planLabel] 仅作标题展示(期数 · 类型)。 */
fun newInstance(planId: String, planLabel: String): SurveyGroupStatsFragment =
SurveyGroupStatsFragment().apply {
arguments = Bundle().apply {
putString(ARG_PLAN_ID, planId)
putString(ARG_PLAN_LABEL, planLabel)
}
}
}
}

View File

@@ -0,0 +1,56 @@
package com.stec.cmd.feature.stats
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.stec.cmd.core.common.UiState
import com.stec.cmd.core.network.ApiError
import com.stec.cmd.core.network.api.GroupStatItem
import com.stec.cmd.feature.mine.userMessage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* 测组统计页M6-032.11 GetSurveyGroupList从计划统计行下钻。
* [bind] 幂等:同一 PlanID 重复绑定不重拉(旋转/重建恢复场景)。
*/
@HiltViewModel
class SurveyGroupStatsViewModel @Inject constructor(
private val statRepository: StatRepository,
) : ViewModel() {
private val _groups = MutableStateFlow<UiState<List<GroupStatItem>>>(UiState.Idle)
/** 测组统计加载态。 */
val groups: StateFlow<UiState<List<GroupStatItem>>> = _groups.asStateFlow()
private var boundPlanId: String? = null
/** 绑定监测计划并加载planId 为空时停留空态)。 */
fun bind(planId: String?) {
if (planId.isNullOrBlank()) {
_groups.value = UiState.Error("监测计划标识缺失,无法加载测组统计")
return
}
if (boundPlanId == planId && _groups.value !is UiState.Idle) return
boundPlanId = planId
load(planId)
}
/** 错误重试 / 手动刷新。 */
fun refresh() = boundPlanId?.let(::load)
private fun load(planId: String) {
viewModelScope.launch {
_groups.value = UiState.Loading
_groups.value = try {
UiState.Success(statRepository.groupStats(planId))
} catch (e: ApiError) {
UiState.Error(e.userMessage())
}
}
}
}

View File

@@ -0,0 +1,234 @@
package com.stec.cmd.feature.upload
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import com.stec.cmd.config.ConfigRepository
import com.stec.cmd.core.common.AppLog
import com.stec.cmd.core.database.UploadQueueDao
import com.stec.cmd.core.database.UploadStatus
import com.stec.cmd.core.network.ApiError
import com.stec.cmd.session.SessionRepository
import com.stec.cmd.session.SessionState
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton
/**
* 上传队列补传消费者M6 上传通道):依序补传 upload_queue 内
* pending/failed 数据(含 M4 蓝牙采集与 M5 手动录入entry_source 仅作来源留痕)。
*
* 触发点(任务书口径,不引入前台 Service / WorkManager
* 1. 应用启动([start]SteCmdApplication 调用);
* 2. 网络恢复ConnectivityManager VALIDATED 网络注册回调onAvailable 防抖后触发)。
*
* 串行化:[Mutex] 保证同一时刻只有一轮消费;并发触发直接跳过。
* 状态机UPLOADING → 成功 SUCCESS / 失败回退 PENDINGretry_count+1 并记 lastError
* 断网与 429/403 限流中止本轮(本批后续条目保持待传,等下次触发),
* 其余业务失败记错误后继续下一条,避免单条坏数据阻塞整批。
*/
@Singleton
class UploadQueueSyncer @Inject constructor(
private val uploadQueueDao: UploadQueueDao,
private val uploadRepository: UploadRepository,
private val configRepository: ConfigRepository,
private val sessionRepository: SessionRepository,
@ApplicationContext private val context: Context,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val consumeMutex = Mutex()
private val started = AtomicBoolean(false)
private val _running = MutableStateFlow(false)
private val _lastError = MutableStateFlow<String?>(null)
/** 队列状态快照(统计页队列状态卡数据源)。 */
val snapshot: StateFlow<UploadQueueSnapshot> =
combine(
uploadQueueDao.observeByStatuses(
UploadStatus.PENDING,
UploadStatus.SUCCESS,
),
_running,
_lastError,
) { items, running, lastError ->
// 失败口径:状态已回 pending 且 retry_count>0含失败待重试
UploadQueueSnapshot(
pendingCount = items.count {
it.status == UploadStatus.PENDING && it.retryCount == 0
},
successCount = items.count { it.status == UploadStatus.SUCCESS },
failedCount = items.count {
it.status == UploadStatus.PENDING && it.retryCount > 0
},
running = running,
lastError = lastError,
)
}.stateIn(scope, SharingStarted.Eagerly, UploadQueueSnapshot())
init {
AppLog.i(TAG, "上传队列补传组件就绪")
}
/**
* 应用启动触发:注册网络恢复回调并立即尝试补传一轮。
* 幂等(重复调用无效);注册常驻至进程结束(应用级单例,无界面泄漏面)。
*/
fun start() {
if (!started.compareAndSet(false, true)) return
registerNetworkCallback(context)
trigger(reason = START_TRIGGER)
}
/** 手动重试(统计页队列状态卡按钮)。 */
fun retryNow() = trigger(reason = MANUAL_TRIGGER)
/**
* 发起一轮补传已有轮次进行中则跳过Mutex tryLock
* 门控不满足(未登录/三要素未配置)时静默跳过,队列保持待传。
*/
fun trigger(reason: String) {
scope.launch {
if (!consumeMutex.tryLock()) return@launch
try {
consumeOnce(reason)
} catch (e: Exception) {
AppLog.e(TAG, "补传轮次异常终止", e)
} finally {
consumeMutex.unlock()
}
}
}
/** 单轮消费:批量取待传,依序上传直至取空或触发中止条件。 */
private suspend fun consumeOnce(reason: String) {
if (!gateReady()) {
AppLog.i(TAG, "补传跳过($reason):未登录或三要素未配置")
return
}
_running.value = true
try {
while (true) {
if (!gateReady()) return
val batch = uploadQueueDao.takePending(
listOf(UploadStatus.PENDING, UploadStatus.FAILED),
BATCH_SIZE,
)
if (batch.isEmpty()) return
for (item in batch) {
uploadQueueDao.updateStatus(item.id, UploadStatus.UPLOADING)
try {
uploadRepository.uploadQueuedValue(item.payloadJson)
uploadQueueDao.markSuccess(
item.id,
UploadStatus.SUCCESS,
System.currentTimeMillis(),
)
_lastError.value = null
} catch (e: ApiError) {
// 一律回退待传retry_count+1 留痕);断网/限流中止本轮
uploadQueueDao.markRetry(item.id, UploadStatus.PENDING)
_lastError.value = e.userMessageSafe()
when (e) {
is ApiError.NetworkError,
is ApiError.TooManyRequests,
is ApiError.Locked,
-> {
AppLog.w(TAG, "补传中止($reason${e.message}")
return
}
else -> AppLog.w(TAG, "单条补传失败继续:${e.message}")
}
} catch (e: Exception) {
uploadQueueDao.markRetry(item.id, UploadStatus.PENDING)
_lastError.value = e.message ?: DEFAULT_ERROR_TEXT
AppLog.w(TAG, "单条补传异常继续", e)
}
}
}
} finally {
_running.value = false
}
}
/** 补传门控已登录且连接三要素SecretKey/SystemCode配置就绪。 */
private fun gateReady(): Boolean =
configRepository.snapshotStateFlow.value.isReady &&
sessionRepository.state.value is SessionState.SignedIn
/**
* 网络恢复触发:监听具备 VALIDATED 能力的网络 onAvailable离线→在线、
* WiFi/蜂窝切换均会回调),防抖后补传。覆盖 AppLog 缺失的异常环境。
*/
private fun registerNetworkCallback(context: Context) {
val manager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
if (manager == null) {
AppLog.w(TAG, "ConnectivityManager 不可用,仅保留启动触发点")
return
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
runCatching {
manager.registerNetworkCallback(
request,
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
scope.launch {
delay(NETWORK_RECOVER_DEBOUNCE_MS)
trigger(reason = NETWORK_TRIGGER)
}
}
},
)
}.onFailure {
AppLog.w(TAG, "网络回调注册失败,仅保留启动触发点:${it.message}")
}
}
private fun ApiError.userMessageSafe(): String = message?.takeIf { it.isNotBlank() }
?: DEFAULT_ERROR_TEXT
private companion object {
const val TAG = "UploadQueueSyncer"
const val BATCH_SIZE = 50
const val NETWORK_RECOVER_DEBOUNCE_MS = 2_000L
const val START_TRIGGER = "app_start"
const val NETWORK_TRIGGER = "network_recover"
const val MANUAL_TRIGGER = "manual_retry"
const val DEFAULT_ERROR_TEXT = "上传失败,稍后自动重试"
}
}
/** 队列状态快照UI 渲染口径)。 */
data class UploadQueueSnapshot(
/** 待传(含失败待重试)条数。 */
val pendingCount: Int = 0,
/** 已上传成功条数。 */
val successCount: Int = 0,
/** 失败条数(当前口径与待传合并展示,预留细分)。 */
val failedCount: Int = 0,
/** 是否正在补传。 */
val running: Boolean = false,
/** 最近一次失败原因(成功后清空)。 */
val lastError: String? = null,
)

View File

@@ -0,0 +1,105 @@
package com.stec.cmd.feature.upload
import com.stec.cmd.core.network.ApiCaller
import com.stec.cmd.core.network.api.UploadApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import javax.inject.Singleton
/**
* 数据上传仓储M6
* - [uploadQueuedValue]upload_queue 补传通道2.8.2 UploadMonitorValue
* 队列 payloadJsonMonitorValue 对象)包装为 `{"MonitorValue":…}` 后 POST
* - [uploadRawFile]采集原始文件兜底直传2.13 AnalysisMonitorDatamultipart
*
* 失败路径统一以 [com.stec.cmd.core.network.ApiError] 子类抛出ApiCaller 解包
* HTTP500+ExceptionMessage 内嵌信封)。
*/
@Singleton
class UploadRepository @Inject constructor(
private val uploadApi: UploadApi,
private val apiCaller: ApiCaller,
private val json: Json,
) {
/**
* 补传一条队列数据2.8.2[payloadJson] 为入队时组装的 MonitorValue
* 对象 JSON结构损坏时以 [ApiError.EmptyBodyError] 抛出(该条数据无法修复,
* 由调用方决定回退策略)。
*/
suspend fun uploadQueuedValue(payloadJson: String) {
val body = wrapMonitorValue(payloadJson)
apiCaller.call { uploadApi.uploadMonitorValue(body) }
}
/**
* 2.13 原始文件直传:必填 ProjectID/WorkPointID/SurveyGroupID/PlanID +
* 可选 MonitorDate[monitorDateText] 为空则省略);文件字节
* [fileBytes]/[fileName] 以 application/octet-stream 上传(服务端自动识别类型)。
*/
suspend fun uploadRawFile(
projectId: String,
workPointId: String,
surveyGroupId: String,
planId: String,
monitorDateText: String?,
fileBytes: ByteArray,
fileName: String,
) {
val textPart = { value: String ->
value.toRequestBody(MEDIA_TEXT)
}
val filePart = MultipartBody.Part.createFormData(
/* name = */ UploadApi.FILE_PART,
/* filename = */ fileName.ifBlank { DEFAULT_FILE_NAME },
/* body = */ fileBytes.toRequestBody(MEDIA_OCTET_STREAM),
)
apiCaller.call {
uploadApi.analysisMonitorData(
projectId = textPart(projectId),
workPointId = textPart(workPointId),
surveyGroupId = textPart(surveyGroupId),
planId = textPart(planId),
monitorDate = monitorDateText?.takeIf { it.isNotBlank() }?.let(textPart),
file = filePart,
)
}
}
companion object {
/**
* 队列 payload → 2.8.2 请求体包装:`{"MonitorValue":<payload 对象>}`(纯函数,
* JVM 可测)。[payloadJson] 非对象(如数组/损坏)时抛 [IllegalArgumentException]。
*/
fun wrapMonitorValue(payloadJson: String, json: Json = DEFAULT_JSON): JsonObject {
val payload = json.parseToJsonElement(payloadJson)
require(payload is JsonObject) { "队列 payload 不是 JSON 对象,无法上传" }
return buildJsonObject { put(KEY_MONITOR_VALUE, payload) }
}
/** 文档 2.8.2 参数 MonitorValue 的 JSON 键名。 */
const val KEY_MONITOR_VALUE = "MonitorValue"
/** 文档 2.8.2 MonitorDate 口径(与 CollectPayloadBuilder.MONITOR_DATE_FORMAT 一致)。 */
val MONITOR_DATE_FORMAT: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
/** 当前时刻的 MonitorDate 文本2.13 可选项默认值)。 */
fun nowMonitorDateText(): String =
LocalDateTime.now().format(MONITOR_DATE_FORMAT)
private val DEFAULT_JSON: Json = Json { ignoreUnknownKeys = true }
private val MEDIA_TEXT = "text/plain; charset=utf-8".toMediaType()
private val MEDIA_OCTET_STREAM = "application/octet-stream".toMediaType()
private const val DEFAULT_FILE_NAME = "monitor-data.bin"
}
}

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 监测点统计页M6-042.12 GetPointStatistics历史数据 -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:navigationIcon="@drawable/ic_back"
app:navigationIconTint="?attr/colorOnSurface"
app:title="@string/stats_point_title_fmt"
app:titleTextColor="?attr/colorOnSurface" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_points"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:overScrollMode="never"
android:paddingHorizontal="20dp"
android:paddingTop="8dp"
android:paddingBottom="32dp" />
<TextView
android:id="@+id/tv_empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:alpha="0.6"
android:text="@string/stats_point_empty"
android:textSize="14sp"
android:visibility="gone" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="32dp"
android:indeterminate="true"
android:visibility="gone"
app:trackCornerRadius="3dp" />
<LinearLayout
android:id="@+id/box_error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:id="@+id/tv_error"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="?attr/colorError"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_retry"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/home_retry" />
</LinearLayout>
</FrameLayout>
</LinearLayout>

View File

@@ -0,0 +1,464 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 统计工作台M6 统计与上传):队列状态卡 + 原始文件上传卡 + 计划统计下钻列表 -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:overScrollMode="never">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="24dp">
<!-- 连接配置警示卡(仅三要素缺失时显示,样式对齐首页) -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_config_warning"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="16dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:visibility="gone"
app:cardBackgroundColor="?attr/colorErrorContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stats_config_missing_title"
android:textColor="?attr/colorOnErrorContainer"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:alpha="0.8"
android:text="@string/stats_config_missing_hint"
android:textColor="?attr/colorOnErrorContainer"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/mine_go_config"
android:textColor="?attr/colorOnErrorContainer"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/tv_section_queue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="20dp"
android:layout_marginBottom="8dp"
android:text="@string/stats_section_queue"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<!-- 上传队列状态卡M4/M5 入队数据的补传闭环观测) -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_queue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="16dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/tv_queue_pending"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stats_queue_zero"
android:textColor="?attr/colorOnSurface"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:alpha="0.65"
android:text="@string/stats_queue_pending_label"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/tv_queue_success"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stats_queue_zero"
android:textColor="@color/brand_success"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:alpha="0.65"
android:text="@string/stats_queue_success_label"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/tv_queue_failed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stats_queue_zero"
android:textColor="?attr/colorError"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:alpha="0.65"
android:text="@string/stats_queue_failed_label"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/tv_queue_error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textColor="?attr/colorError"
android:textSize="12sp"
android:visibility="gone" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_queue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="12dp"
android:indeterminate="true"
android:visibility="gone"
app:trackCornerRadius="3dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_queue_retry"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/stats_queue_retry" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/tv_section_upload"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="20dp"
android:layout_marginBottom="8dp"
android:text="@string/stats_section_file"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<!-- 原始文件上传卡M6-012.13 兜底通道) -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_file"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="16dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/stats_file_task_label"
android:textColor="?attr/colorOnSurface"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_file_task"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:alpha="0.75"
android:text="@string/stats_file_task_none"
android:textSize="13sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_pick_task"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:text="@string/stats_pick_task" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_pick_file"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:text="@string/stats_file_pick" />
</LinearLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_file"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:indeterminate="true"
android:visibility="gone"
app:trackCornerRadius="3dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:alpha="0.6"
android:text="@string/stats_file_hint"
android:textSize="12sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/tv_section_plans"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="20dp"
android:layout_marginBottom="8dp"
android:text="@string/stats_section_plans"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_plans"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:overScrollMode="never"
android:paddingHorizontal="20dp" />
<TextView
android:id="@+id/tv_plans_state"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="4dp"
android:alpha="0.65"
android:gravity="center"
android:textSize="13sp" />
</LinearLayout>
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
android:visibility="gone" />
<!-- 未登录空态(对齐首页动线) -->
<LinearLayout
android:id="@+id/state_signed_out"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:clickable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/stats_signed_out_title"
android:textColor="?attr/colorOnSurface"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:alpha="0.7"
android:gravity="center"
android:text="@string/stats_signed_out_hint"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_go_login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/home_go_login" />
</LinearLayout>
<!-- 错误态 -->
<LinearLayout
android:id="@+id/state_error"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:clickable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:id="@+id/tv_error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="?attr/colorError"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_retry"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/home_retry" />
</LinearLayout>
<!-- 未选项目空态 -->
<LinearLayout
android:id="@+id/state_no_project"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:clickable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/task_no_project_title"
android:textColor="?attr/colorOnSurface"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:alpha="0.7"
android:gravity="center"
android:text="@string/task_no_project_hint"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_go_home"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/task_go_home" />
</LinearLayout>
</FrameLayout>

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 测组统计页M6-032.11 GetSurveyGroupList -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:navigationIcon="@drawable/ic_back"
app:navigationIconTint="?attr/colorOnSurface"
app:title="@string/stats_group_title_fmt"
app:titleTextColor="?attr/colorOnSurface" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_groups"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:overScrollMode="never"
android:paddingHorizontal="20dp"
android:paddingTop="8dp"
android:paddingBottom="32dp" />
<TextView
android:id="@+id/tv_empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:alpha="0.6"
android:text="@string/stats_group_empty"
android:textSize="14sp"
android:visibility="gone" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="32dp"
android:indeterminate="true"
android:visibility="gone"
app:trackCornerRadius="3dp" />
<LinearLayout
android:id="@+id/box_error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:id="@+id/tv_error"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="?attr/colorError"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_retry"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/home_retry" />
</LinearLayout>
</FrameLayout>
</LinearLayout>

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 测组统计条目M6-032.11 GroupList测组名 + 监测类型 Chip + 责任人 + 完成度 -->
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="14dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_group_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="?attr/colorOnSurface"
android:textSize="15sp"
android:textStyle="bold" />
<com.google.android.material.chip.Chip
android:id="@+id/chip_type"
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="false"
android:focusable="false"
android:textSize="11sp" />
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="4dp"
android:alpha="0.4"
android:contentDescription="@string/stats_group_open_desc"
android:src="@drawable/ic_chevron_right"
app:tint="?attr/colorOnSurface" />
</LinearLayout>
<TextView
android:id="@+id/tv_group_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:alpha="0.65"
android:textSize="12sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_degree"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="12dp"
app:indicatorColor="@color/brand_primary"
app:trackCornerRadius="3dp"
app:trackThickness="6dp" />
<TextView
android:id="@+id/tv_degree"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?attr/colorPrimary"
android:textSize="13sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 监测计划统计条目M6-022.10 PlanList期数+类型 Chip + 完成度进度条 + 截止/工况 -->
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="14dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_plan_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="?attr/colorOnSurface"
android:textSize="15sp"
android:textStyle="bold" />
<com.google.android.material.chip.Chip
android:id="@+id/chip_mold"
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="false"
android:focusable="false"
android:textSize="11sp" />
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginStart="4dp"
android:alpha="0.4"
android:contentDescription="@string/stats_plan_open_desc"
android:src="@drawable/ic_chevron_right"
app:tint="?attr/colorOnSurface" />
</LinearLayout>
<TextView
android:id="@+id/tv_plan_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:alpha="0.65"
android:textSize="12sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_degree"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="12dp"
app:indicatorColor="@color/brand_primary"
app:trackCornerRadius="3dp"
app:trackThickness="6dp" />
<TextView
android:id="@+id/tv_degree"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?attr/colorPrimary"
android:textSize="13sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 监测点统计条目M6-042.12 PointsList历史数据点名 + 上传时间 + 设备 -->
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="14dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_point_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="?attr/colorOnSurface"
android:textSize="15sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:id="@+id/tv_point_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:alpha="0.65"
android:textSize="12sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -252,4 +252,43 @@
<string name="survey_point_monitor_fmt">上次观测时间:%1$s</string>
<string name="survey_point_alarm_fmt">报警:速率 %1$s 累计 %2$s</string>
<string name="survey_point_axia_fmt">轴力类型:%1$s</string>
<!-- M6 统计与数据上传M6-01/02/03/04 -->
<string name="stats_config_missing_title">尚未配置服务器与密钥</string>
<string name="stats_config_missing_hint">配置后才能加载统计数据与上传</string>
<string name="stats_signed_out_title">登录后查看统计数据</string>
<string name="stats_signed_out_hint">统计表与数据上传需要登录后使用</string>
<string name="stats_section_queue">数据上传队列</string>
<string name="stats_queue_zero">0</string>
<string name="stats_queue_pending_label">待上传</string>
<string name="stats_queue_success_label">已上传</string>
<string name="stats_queue_failed_label">失败</string>
<string name="stats_queue_retry">立即重试</string>
<string name="stats_section_file">原始文件上传</string>
<string name="stats_file_task_label">上传任务</string>
<string name="stats_file_task_none">尚未选择任务2.13 上传需绑定监测计划)</string>
<string name="stats_pick_task">选择任务</string>
<string name="stats_task_none">不绑定任务</string>
<string name="stats_task_empty">暂无可选任务,已尝试刷新</string>
<string name="stats_task_fmt">%1$s · %2$s · %3$s</string>
<string name="stats_file_pick">选择文件并上传</string>
<string name="stats_file_uploading">上传解析中…</string>
<string name="stats_file_hint">选择采集仪导出的原始数据文件直传平台解析(兜底通道)</string>
<string name="stats_file_read_error_fmt">文件读取失败:%1$s</string>
<string name="stats_file_upload_ok_fmt">已提交解析:%1$s</string>
<string name="stats_section_plans">监测计划统计表</string>
<string name="stats_plans_loading">统计加载中…</string>
<string name="stats_plans_empty">当前项目暂无含「APP上传」测组的监测计划</string>
<string name="stats_plan_title_fmt">第 %1$s 期</string>
<string name="stats_plan_meta_fmt">截止:%1$s 工况:%2$s</string>
<string name="stats_degree_percent_fmt">%1$d%%</string>
<string name="stats_plan_open_desc">查看测组统计</string>
<string name="stats_plan_id_missing">该计划缺少标识,无法下钻</string>
<string name="stats_group_title_fmt">测组统计(%1$s</string>
<string name="stats_group_empty">该计划暂无「APP上传」测组</string>
<string name="stats_group_meta_fmt">责任人:%1$s</string>
<string name="stats_group_open_desc">查看监测点统计</string>
<string name="stats_point_title_fmt">监测点统计(%1$s</string>
<string name="stats_point_empty">该测组暂无监测点统计数据</string>
<string name="stats_point_meta_fmt">上传时间:%1$s 设备:%2$s</string>
</resources>

View File

@@ -0,0 +1,63 @@
package com.stec.cmd.feature.stats
import com.stec.cmd.core.network.api.PlanStatItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* 统计页纯函数单测:完成度 → 百分比宽容解析M6-02/03 完成度可视化数据口径)。
* 与 CollectPayloadBuilderTest 同级的 JVM 单测,不依赖 Android 框架。
*/
class StatsModelsTest {
@Test
fun `ratio degree parses to percent`() {
assertEquals(50, percentFromDegree("50/100"))
assertEquals(33, percentFromDegree("33 / 66"))
assertEquals(100, percentFromDegree("12/12"))
}
@Test
fun `ratio with zero or invalid total returns null`() {
assertNull(percentFromDegree("5/0"))
assertNull(percentFromDegree("5/abc"))
}
@Test
fun `percent suffix reads directly`() {
assertEquals(85, percentFromDegree("85%"))
assertEquals(100, percentFromDegree("120%"))
assertEquals(0, percentFromDegree("-3%"))
}
@Test
fun `fraction value converts to percent`() {
assertEquals(85, percentFromDegree("0.85"))
assertEquals(100, percentFromDegree("1"))
}
@Test
fun `plain number reads as percent`() {
assertEquals(60, percentFromDegree("60"))
}
@Test
fun `blank and garbage return null`() {
assertNull(percentFromDegree(null))
assertNull(percentFromDegree(""))
assertNull(percentFromDegree(" "))
assertNull(percentFromDegree("完成"))
}
@Test
fun `plan stat item tolerates unknown fields`() {
val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
val item = json.decodeFromString(
PlanStatItem.serializer(),
"""{"PlanID":"P1","PeriodsNum":"3","CompleteDegree":"2/10","Extra":1}""",
)
assertEquals("P1", item.PlanID)
assertEquals(20, percentFromDegree(item.CompleteDegree))
}
}

View File

@@ -0,0 +1,48 @@
package com.stec.cmd.feature.upload
import com.stec.cmd.feature.upload.UploadRepository.Companion.wrapMonitorValue
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 队列补传 payload 包装单测2.8.2 UploadMonitorValue 请求体口径):
* 队列 payloadJsonMonitorValue 对象)必须包装为 {"MonitorValue":{…}}。
*/
class UploadPayloadWrapTest {
@Test
fun `wraps monitor value object`() {
val payload = """{"ProjectID":"PRJ1","PointID":"PT1","Value":"12.5",""" +
""""MonitorDate":"2026-09-04 10:00:00","MonitoringPlanID":"PLAN1"}"""
val wrapped = wrapMonitorValue(payload)
assertEquals(1, wrapped.size)
val inner = wrapped[UploadRepository.KEY_MONITOR_VALUE]!!
assertTrue(inner is JsonObject)
assertEquals("PRJ1", inner.jsonObject["ProjectID"]?.jsonPrimitive?.content)
}
@Test
fun `keeps extra extension keys untouched`() {
val payload = """{"ProjectID":"P","PointID":"T","Value":"1",""" +
""""MonitorDate":"2026-09-04 10:00:00","MonitoringPlanID":"M",""" +
""""DeviceType":"MANUAL","Extra":{"source":"MANUAL"}}"""
val inner = wrapMonitorValue(payload)[UploadRepository.KEY_MONITOR_VALUE]!!
assertTrue(inner.jsonObject.containsKey("DeviceType"))
assertTrue(inner.jsonObject.containsKey("Extra"))
}
@Test
fun `non object payload is rejected`() {
assertThrows(IllegalArgumentException::class.java) {
wrapMonitorValue("""[1,2,3]""")
}
assertThrows(IllegalArgumentException::class.java) {
wrapMonitorValue("not-json")
}
}
}