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:
@@ -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 我的"),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/** 统计占位(M6 统计与上传,S4 挂载统计表与文件上传)。 */
|
||||
@AndroidEntryPoint
|
||||
class StatsFragment : PlaceholderFragment()
|
||||
@@ -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.Log,LogCat 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 骨架)")
|
||||
}
|
||||
|
||||
|
||||
@@ -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-04,2.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-04,2.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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-01,2.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
|
||||
}
|
||||
@@ -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/04,2.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.12:data 内列表键名(确切大小写)。 */
|
||||
const val KEY_PLAN_LIST = "PlanList"
|
||||
const val KEY_GROUP_LIST = "GroupList"
|
||||
const val KEY_POINTS_LIST = "PointsList"
|
||||
}
|
||||
}
|
||||
@@ -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-03,2.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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):完成度 → 百分比(0–100)。
|
||||
*
|
||||
* 文档 2.10/2.11 未给出 CompleteDegree 精确格式(2.6/2.7 为「已传/总数」,
|
||||
* 平台他处亦有「0.85」「85%」口径),按宽容序解析:
|
||||
* 「x/y」→ 比值;「85%」/「85」→ 直读;「0.85」→ 小数换算;越界收敛到 0–100。
|
||||
*/
|
||||
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(),
|
||||
)
|
||||
@@ -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-02,2.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-04,2.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-01,2.13 SAF 直传)、项目监测计划统计表(M6-02,2.10,行点击下钻测组→监测点)。
|
||||
* 渲染状态机:Restoring → 加载;SignedOut → 登录 CTA;SignedIn → 配置门控 →
|
||||
* 未选项目 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
|
||||
}
|
||||
}
|
||||
@@ -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-03,2.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-03,2.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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 / 失败回退 PENDING(retry_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,
|
||||
)
|
||||
@@ -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),
|
||||
* 队列 payloadJson(MonitorValue 对象)包装为 `{"MonitorValue":…}` 后 POST;
|
||||
* - [uploadRawFile]:采集原始文件兜底直传(2.13 AnalysisMonitorData,multipart)。
|
||||
*
|
||||
* 失败路径统一以 [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"
|
||||
}
|
||||
}
|
||||
80
app/src/main/res/layout/fragment_point_stats.xml
Normal file
80
app/src/main/res/layout/fragment_point_stats.xml
Normal file
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 监测点统计页(M6-04,2.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>
|
||||
464
app/src/main/res/layout/fragment_stats.xml
Normal file
464
app/src/main/res/layout/fragment_stats.xml
Normal 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-01,2.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>
|
||||
80
app/src/main/res/layout/fragment_survey_group_stats.xml
Normal file
80
app/src/main/res/layout/fragment_survey_group_stats.xml
Normal file
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 测组统计页(M6-03,2.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>
|
||||
89
app/src/main/res/layout/item_stats_group.xml
Normal file
89
app/src/main/res/layout/item_stats_group.xml
Normal file
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 测组统计条目(M6-03,2.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>
|
||||
89
app/src/main/res/layout/item_stats_plan.xml
Normal file
89
app/src/main/res/layout/item_stats_plan.xml
Normal file
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 监测计划统计条目(M6-02,2.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>
|
||||
42
app/src/main/res/layout/item_stats_point.xml
Normal file
42
app/src/main/res/layout/item_stats_point.xml
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 监测点统计条目(M6-04,2.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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 请求体口径):
|
||||
* 队列 payloadJson(MonitorValue 对象)必须包装为 {"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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user