From b8e4c24fd285b30de6987a92e6122718356797f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E7=8C=AB?= Date: Fri, 4 Sep 2026 02:59:47 +0800 Subject: [PATCH] task(03452fc3-d6cb-4549-b79d-7d389c7e5e8e): Add M6 stats & upload module (2.13/2.10/2.11/2.12 + queue retransmit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补偿提交: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 端点 --- .../main/kotlin/com/stec/cmd/FeatureMount.kt | 3 +- .../main/kotlin/com/stec/cmd/StatsFragment.kt | 7 - .../kotlin/com/stec/cmd/SteCmdApplication.kt | 8 + .../cmd/feature/stats/PointStatsFragment.kt | 94 ++++ .../cmd/feature/stats/PointStatsViewModel.kt | 57 +++ .../feature/stats/RawFileUploadViewModel.kt | 149 ++++++ .../stec/cmd/feature/stats/StatRepository.kt | 93 ++++ .../feature/stats/StatsGroupListAdapter.kt | 64 +++ .../com/stec/cmd/feature/stats/StatsModels.kt | 43 ++ .../cmd/feature/stats/StatsPlanListAdapter.kt | 70 +++ .../feature/stats/StatsPointListAdapter.kt | 52 ++ .../stec/cmd/feature/stats/StatsViewModel.kt | 99 ++++ .../feature/stats/StatsWorkspaceFragment.kt | 311 ++++++++++++ .../feature/stats/SurveyGroupStatsFragment.kt | 114 +++++ .../stats/SurveyGroupStatsViewModel.kt | 56 +++ .../cmd/feature/upload/UploadQueueSyncer.kt | 234 +++++++++ .../cmd/feature/upload/UploadRepository.kt | 105 ++++ .../main/res/layout/fragment_point_stats.xml | 80 +++ app/src/main/res/layout/fragment_stats.xml | 464 ++++++++++++++++++ .../layout/fragment_survey_group_stats.xml | 80 +++ app/src/main/res/layout/item_stats_group.xml | 89 ++++ app/src/main/res/layout/item_stats_plan.xml | 89 ++++ app/src/main/res/layout/item_stats_point.xml | 42 ++ app/src/main/res/values/strings.xml | 39 ++ .../stec/cmd/feature/stats/StatsModelsTest.kt | 63 +++ .../feature/upload/UploadPayloadWrapTest.kt | 48 ++ .../stec/cmd/core/network/NetworkModule.kt | 12 + .../com/stec/cmd/core/network/api/StatApi.kt | 52 ++ .../stec/cmd/core/network/api/StatModels.kt | 54 ++ .../stec/cmd/core/network/api/UploadApi.kt | 66 +++ 30 files changed, 2729 insertions(+), 8 deletions(-) delete mode 100644 app/src/main/kotlin/com/stec/cmd/StatsFragment.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/stats/PointStatsFragment.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/stats/PointStatsViewModel.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/stats/RawFileUploadViewModel.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/stats/StatRepository.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/stats/StatsGroupListAdapter.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/stats/StatsModels.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/stats/StatsPlanListAdapter.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/stats/StatsPointListAdapter.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/stats/StatsViewModel.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/stats/StatsWorkspaceFragment.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/stats/SurveyGroupStatsFragment.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/stats/SurveyGroupStatsViewModel.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/upload/UploadQueueSyncer.kt create mode 100644 app/src/main/kotlin/com/stec/cmd/feature/upload/UploadRepository.kt create mode 100644 app/src/main/res/layout/fragment_point_stats.xml create mode 100644 app/src/main/res/layout/fragment_stats.xml create mode 100644 app/src/main/res/layout/fragment_survey_group_stats.xml create mode 100644 app/src/main/res/layout/item_stats_group.xml create mode 100644 app/src/main/res/layout/item_stats_plan.xml create mode 100644 app/src/main/res/layout/item_stats_point.xml create mode 100644 app/src/test/kotlin/com/stec/cmd/feature/stats/StatsModelsTest.kt create mode 100644 app/src/test/kotlin/com/stec/cmd/feature/upload/UploadPayloadWrapTest.kt create mode 100644 core/network/src/main/kotlin/com/stec/cmd/core/network/api/StatApi.kt create mode 100644 core/network/src/main/kotlin/com/stec/cmd/core/network/api/StatModels.kt create mode 100644 core/network/src/main/kotlin/com/stec/cmd/core/network/api/UploadApi.kt diff --git a/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt b/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt index 3845f9b..11a6f7f 100644 --- a/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt +++ b/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt @@ -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 我的"), ) diff --git a/app/src/main/kotlin/com/stec/cmd/StatsFragment.kt b/app/src/main/kotlin/com/stec/cmd/StatsFragment.kt deleted file mode 100644 index 7b22d31..0000000 --- a/app/src/main/kotlin/com/stec/cmd/StatsFragment.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.stec.cmd - -import dagger.hilt.android.AndroidEntryPoint - -/** 统计占位(M6 统计与上传,S4 挂载统计表与文件上传)。 */ -@AndroidEntryPoint -class StatsFragment : PlaceholderFragment() diff --git a/app/src/main/kotlin/com/stec/cmd/SteCmdApplication.kt b/app/src/main/kotlin/com/stec/cmd/SteCmdApplication.kt index 723bb2c..4cc1ba1 100644 --- a/app/src/main/kotlin/com/stec/cmd/SteCmdApplication.kt +++ b/app/src/main/kotlin/com/stec/cmd/SteCmdApplication.kt @@ -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 骨架)") } diff --git a/app/src/main/kotlin/com/stec/cmd/feature/stats/PointStatsFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/stats/PointStatsFragment.kt new file mode 100644 index 0000000..abc0be8 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/stats/PointStatsFragment.kt @@ -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>) { + 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) + } + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/stats/PointStatsViewModel.kt b/app/src/main/kotlin/com/stec/cmd/feature/stats/PointStatsViewModel.kt new file mode 100644 index 0000000..04aa937 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/stats/PointStatsViewModel.kt @@ -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.Idle) + + /** 监测点统计加载态。 */ + val points: StateFlow>> = _points.asStateFlow() + + private var boundKey: Pair? = 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()) + } + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/stats/RawFileUploadViewModel.kt b/app/src/main/kotlin/com/stec/cmd/feature/stats/RawFileUploadViewModel.kt new file mode 100644 index 0000000..13ca07b --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/stats/RawFileUploadViewModel.kt @@ -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>(emptyList()) + + /** 可选任务列表(2.7,项目当前监测计划)。 */ + val tasks: StateFlow> = _tasks.asStateFlow() + + private val _selectedTask = MutableStateFlow(null) + + /** 已选上传任务(2.13 的 PlanID/SurveyGroupID 来源)。 */ + val selectedTask: StateFlow = _selectedTask.asStateFlow() + + private val _uploading = MutableStateFlow(false) + + /** 文件上传中(按钮防抖与进度态)。 */ + val uploading: StateFlow = _uploading.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 8) + + /** 一次性事件(Snackbar 反馈)。 */ + val events: SharedFlow = _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 +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/stats/StatRepository.kt b/app/src/main/kotlin/com/stec/cmd/feature/stats/StatRepository.kt new file mode 100644 index 0000000..8cb319d --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/stats/StatRepository.kt @@ -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 = + listFromData(apiCaller.call { statApi.getPlanStatistical(projectId) }, KEY_PLAN_LIST) { + ListSerializer(PlanStatItem.serializer()) + } + + /** 2.11 指定监测计划的测组统计表;[planId] 必填(2.10 行 PlanID)。 */ + suspend fun groupStats(planId: String): List = + 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 = + 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 listFromData( + data: JsonElement, + key: String, + serializer: () -> ListSerializer, + ): List { + val payload = when (data) { + is JsonObject -> + data[key] + ?: data.values.filterIsInstance().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" + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsGroupListAdapter.kt b/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsGroupListAdapter.kt new file mode 100644 index 0000000..92560eb --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsGroupListAdapter.kt @@ -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(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() { + override fun areItemsTheSame(oldItem: GroupStatItem, newItem: GroupStatItem) = + oldItem.GroupID == newItem.GroupID + + override fun areContentsTheSame(oldItem: GroupStatItem, newItem: GroupStatItem) = + oldItem == newItem + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsModels.kt b/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsModels.kt new file mode 100644 index 0000000..777539d --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsModels.kt @@ -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(), +) diff --git a/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsPlanListAdapter.kt b/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsPlanListAdapter.kt new file mode 100644 index 0000000..15140f4 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsPlanListAdapter.kt @@ -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(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() { + override fun areItemsTheSame(oldItem: PlanStatItem, newItem: PlanStatItem) = + oldItem.PlanID == newItem.PlanID + + override fun areContentsTheSame(oldItem: PlanStatItem, newItem: PlanStatItem) = + oldItem == newItem + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsPointListAdapter.kt b/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsPointListAdapter.kt new file mode 100644 index 0000000..337f4d7 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsPointListAdapter.kt @@ -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(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() { + override fun areItemsTheSame(oldItem: PointStatItem, newItem: PointStatItem): Boolean = + oldItem.PointName == newItem.PointName + + override fun areContentsTheSame(oldItem: PointStatItem, newItem: PointStatItem) = + oldItem == newItem + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsViewModel.kt b/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsViewModel.kt new file mode 100644 index 0000000..8c21abd --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsViewModel.kt @@ -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.Idle) + + /** 计划统计表加载态。 */ + val plans: StateFlow>> = _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()) + } + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsWorkspaceFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsWorkspaceFragment.kt new file mode 100644 index 0000000..1685c6b --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/stats/StatsWorkspaceFragment.kt @@ -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>) { + 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 { + 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( + 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 + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/stats/SurveyGroupStatsFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/stats/SurveyGroupStatsFragment.kt new file mode 100644 index 0000000..36b7720 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/stats/SurveyGroupStatsFragment.kt @@ -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>) { + 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) + } + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/stats/SurveyGroupStatsViewModel.kt b/app/src/main/kotlin/com/stec/cmd/feature/stats/SurveyGroupStatsViewModel.kt new file mode 100644 index 0000000..221a6ad --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/stats/SurveyGroupStatsViewModel.kt @@ -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.Idle) + + /** 测组统计加载态。 */ + val groups: StateFlow>> = _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()) + } + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/upload/UploadQueueSyncer.kt b/app/src/main/kotlin/com/stec/cmd/feature/upload/UploadQueueSyncer.kt new file mode 100644 index 0000000..08566c3 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/upload/UploadQueueSyncer.kt @@ -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(null) + + /** 队列状态快照(统计页队列状态卡数据源)。 */ + val snapshot: StateFlow = + 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, +) diff --git a/app/src/main/kotlin/com/stec/cmd/feature/upload/UploadRepository.kt b/app/src/main/kotlin/com/stec/cmd/feature/upload/UploadRepository.kt new file mode 100644 index 0000000..0d5c8e2 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/upload/UploadRepository.kt @@ -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":}`(纯函数, + * 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" + } +} diff --git a/app/src/main/res/layout/fragment_point_stats.xml b/app/src/main/res/layout/fragment_point_stats.xml new file mode 100644 index 0000000..61b018f --- /dev/null +++ b/app/src/main/res/layout/fragment_point_stats.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_stats.xml b/app/src/main/res/layout/fragment_stats.xml new file mode 100644 index 0000000..be4dc98 --- /dev/null +++ b/app/src/main/res/layout/fragment_stats.xml @@ -0,0 +1,464 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_survey_group_stats.xml b/app/src/main/res/layout/fragment_survey_group_stats.xml new file mode 100644 index 0000000..7bc9e3b --- /dev/null +++ b/app/src/main/res/layout/fragment_survey_group_stats.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_stats_group.xml b/app/src/main/res/layout/item_stats_group.xml new file mode 100644 index 0000000..2c496ac --- /dev/null +++ b/app/src/main/res/layout/item_stats_group.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_stats_plan.xml b/app/src/main/res/layout/item_stats_plan.xml new file mode 100644 index 0000000..7b4149b --- /dev/null +++ b/app/src/main/res/layout/item_stats_plan.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_stats_point.xml b/app/src/main/res/layout/item_stats_point.xml new file mode 100644 index 0000000..0d5c1da --- /dev/null +++ b/app/src/main/res/layout/item_stats_point.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7a44509..de59ca5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -252,4 +252,43 @@ 上次观测时间:%1$s 报警:速率 %1$s | 累计 %2$s 轴力类型:%1$s + + + 尚未配置服务器与密钥 + 配置后才能加载统计数据与上传 + 登录后查看统计数据 + 统计表与数据上传需要登录后使用 + 数据上传队列 + 0 + 待上传 + 已上传 + 失败 + 立即重试 + 原始文件上传 + 上传任务 + 尚未选择任务(2.13 上传需绑定监测计划) + 选择任务 + 不绑定任务 + 暂无可选任务,已尝试刷新 + %1$s · %2$s · %3$s + 选择文件并上传 + 上传解析中… + 选择采集仪导出的原始数据文件直传平台解析(兜底通道) + 文件读取失败:%1$s + 已提交解析:%1$s + 监测计划统计表 + 统计加载中… + 当前项目暂无含「APP上传」测组的监测计划 + 第 %1$s 期 + 截止:%1$s | 工况:%2$s + %1$d%% + 查看测组统计 + 该计划缺少标识,无法下钻 + 测组统计(%1$s) + 该计划暂无「APP上传」测组 + 责任人:%1$s + 查看监测点统计 + 监测点统计(%1$s) + 该测组暂无监测点统计数据 + 上传时间:%1$s | 设备:%2$s diff --git a/app/src/test/kotlin/com/stec/cmd/feature/stats/StatsModelsTest.kt b/app/src/test/kotlin/com/stec/cmd/feature/stats/StatsModelsTest.kt new file mode 100644 index 0000000..9de3998 --- /dev/null +++ b/app/src/test/kotlin/com/stec/cmd/feature/stats/StatsModelsTest.kt @@ -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)) + } +} diff --git a/app/src/test/kotlin/com/stec/cmd/feature/upload/UploadPayloadWrapTest.kt b/app/src/test/kotlin/com/stec/cmd/feature/upload/UploadPayloadWrapTest.kt new file mode 100644 index 0000000..ae9a7d8 --- /dev/null +++ b/app/src/test/kotlin/com/stec/cmd/feature/upload/UploadPayloadWrapTest.kt @@ -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") + } + } +} diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt index 7ed43b8..2e7b3d7 100644 --- a/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt @@ -3,7 +3,9 @@ package com.stec.cmd.core.network import com.stec.cmd.core.network.api.AuthApi import com.stec.cmd.core.network.api.PointApi import com.stec.cmd.core.network.api.ProjectApi +import com.stec.cmd.core.network.api.StatApi import com.stec.cmd.core.network.api.TaskApi +import com.stec.cmd.core.network.api.UploadApi import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -90,6 +92,16 @@ object NetworkModule { @Singleton fun providePointApi(retrofit: Retrofit): PointApi = retrofit.create(PointApi::class.java) + /** 统计接口(M6,2.10/2.11/2.12)。 */ + @Provides + @Singleton + fun provideStatApi(retrofit: Retrofit): StatApi = retrofit.create(StatApi::class.java) + + /** 数据上传接口(M6,2.8.2 队列补传 / 2.13 原始文件解析)。 */ + @Provides + @Singleton + fun provideUploadApi(retrofit: Retrofit): UploadApi = retrofit.create(UploadApi::class.java) + /** 仅满足 Retrofit 构造约束;真实地址请求期经 DynamicBaseUrlInterceptor 改写。 */ private const val PLACEHOLDER_BASE_URL = "https://placeholder.stec.invalid/" diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/api/StatApi.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/StatApi.kt new file mode 100644 index 0000000..28f922f --- /dev/null +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/StatApi.kt @@ -0,0 +1,52 @@ +package com.stec.cmd.core.network.api + +import com.stec.cmd.core.network.ApiEnvelope +import kotlinx.serialization.json.JsonElement +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * 统计接口(接口管理规范 V4.5): + * - 2.10 获取项目监测计划统计表(M6-02,P0) + * - 2.11 获取指定监测计划的测组统计表(M6-03,P1) + * - 2.12 监测点统计表(M6-04,P1,历史数据) + * + * 三要素请求头(SecretKey/SystemCode/Token)由 AuthInterceptor 统一注入。 + * + * 契约宽容:文档约定 data 为 {"PlanList"/"GroupList"/"PointsList":[…]}; + * 与项目/任务/测点列表(M2/M3/M5 先例)同理保留形态偏差可能,故以 [JsonElement] + * 宽容承载,由 app 层 StatRepository 归一提取。 + */ +interface StatApi { + + /** 2.10 项目监测计划统计表:[projectId] 必填。 */ + @GET(PATH_GET_PLAN_STATISTICAL) + suspend fun getPlanStatistical( + @Query(QUERY_PROJECT_ID) projectId: String, + ): ApiEnvelope + + /** 2.11 指定监测计划的测组统计表:[planId] 必填。 */ + @GET(PATH_GET_SURVEY_GROUP_LIST) + suspend fun getSurveyGroupList( + @Query(QUERY_PLAN_ID) planId: String, + ): ApiEnvelope + + /** 2.12 监测点统计表:[planId]/[groupId] 均必填。 */ + @GET(PATH_GET_POINT_STATISTICS) + suspend fun getPointStatistics( + @Query(QUERY_PLAN_ID) planId: String, + @Query(QUERY_GROUP_ID) groupId: String, + ): ApiEnvelope + + companion object { + // ---- 接口管理规范 V4.5 · 章节 2.1 所有 API 目录 ---- + const val PATH_GET_PLAN_STATISTICAL = "OutWebApi/api/GetPlanStatistical" + const val PATH_GET_SURVEY_GROUP_LIST = "OutWebApi/api/GetSurveyGroupList" + const val PATH_GET_POINT_STATISTICS = "OutWebApi/api/GetPointStatistics" + + /** 查询参数名(2.12 复用 PlanID;2.10/2.11/2.12 各取所需)。 */ + const val QUERY_PROJECT_ID = "ProjectID" + const val QUERY_PLAN_ID = "PlanID" + const val QUERY_GROUP_ID = "GroupID" + } +} diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/api/StatModels.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/StatModels.kt new file mode 100644 index 0000000..70256d0 --- /dev/null +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/StatModels.kt @@ -0,0 +1,54 @@ +package com.stec.cmd.core.network.api + +import kotlinx.serialization.Serializable + +/** + * 统计 DTO(接口管理规范 V4.5 · 2.10/2.11/2.12 返回值示例)。 + * + * 字段名与平台契约一致(PascalCase);文档未给类型,全 `String?` 容错, + * 响应侧配合 Json.ignoreUnknownKeys 容忍字段演进。 + * 2.10 仅统计含「APP上传」测组的监测计划,非 APP 上传测组不计入完成度。 + */ + +/** 2.10 监测计划统计条目(data.PlanList)。 */ +@Serializable +data class PlanStatItem( + /** 监测计划 ID(2.11/2.12 下钻与 2.13 上传 PlanID 的取值来源)。 */ + val PlanID: String? = null, + /** 期数。 */ + val PeriodsNum: String? = null, + /** 截止时间。 */ + val EndTime: String? = null, + /** 施工工况。 */ + val Working: String? = null, + /** 完成度(未开始上传的计划为 0)。 */ + val CompleteDegree: String? = null, + /** 类型:常规 | 加测。 */ + val Mold: String? = null, +) + +/** 2.11 测组统计条目(data.GroupList)。 */ +@Serializable +data class GroupStatItem( + /** 测组 ID(2.12 下钻 GroupID 取值来源)。 */ + val GroupID: String? = null, + /** 测组名称。 */ + val GroupName: String? = null, + /** 责任人。 */ + val DutyName: String? = null, + /** 完成度。 */ + val CompleteDegree: String? = null, + /** 监测类型:水平位移/竖向位移/测斜/水位/轴力等。 */ + val MonitoringType: String? = null, +) + +/** 2.12 监测点统计条目(data.PointsList,历史数据)。 */ +@Serializable +data class PointStatItem( + /** 点名。 */ + val PointName: String? = null, + /** 上传时间。 */ + val ImportTime: String? = null, + /** 设备。 */ + val MonitorEqu: String? = null, +) diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/api/UploadApi.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/UploadApi.kt new file mode 100644 index 0000000..9e42816 --- /dev/null +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/UploadApi.kt @@ -0,0 +1,66 @@ +package com.stec.cmd.core.network.api + +import com.stec.cmd.core.network.ApiEnvelope +import kotlinx.serialization.json.JsonElement +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part + +/** + * 数据上传接口(接口管理规范 V4.5): + * - 2.8.2 上传本次监测值(M4 蓝牙采集 / M5 手动录入 Room 队列的统一补传通道) + * - 2.13 数据解析·原始文件上传(M6-01,采集原始文件兜底通道;全程序唯一 multipart 端点) + * + * 三要素请求头(SecretKey/SystemCode/Token)由 AuthInterceptor 统一注入(multipart 同样生效), + * 真实目标地址由 DynamicBaseUrlInterceptor 请求期改写。 + * + * 契约宽容:两端点成功均返回 data:null,仍以 [JsonElement] 承载以复用 + * ApiEnvelope/ApiCaller 统一错误解包链(HTTP500+ExceptionMessage 内嵌信封)。 + */ +interface UploadApi { + + /** + * 2.8.2 上传本次监测值:文档「Post 请求传递 Json 参数」,参数名为 + * MonitorValue,故 body 为 `{"MonitorValue":{…}}` 包装对象 + * (队列 payloadJson 为 MonitorValue 对象本身,由 app 层包装)。 + */ + @POST(PATH_UPLOAD_MONITOR_VALUE) + suspend fun uploadMonitorValue( + @Body body: JsonElement, + ): ApiEnvelope + + /** + * 2.13 数据解析(原始文件上传):multipart/form-data。 + * 必填 ProjectID/WorkPointID/SurveyGroupID/PlanID;[monitorDate] 可选(传 null 省略); + * 文档约定 Files 参数名可省(服务端自动识别文件),此处显式命名并携带原始文件名。 + */ + @Multipart + @POST(PATH_ANALYSIS_MONITOR_DATA) + suspend fun analysisMonitorData( + @Part(PROJECT_ID_PART) projectId: RequestBody, + @Part(WORK_POINT_ID_PART) workPointId: RequestBody, + @Part(SURVEY_GROUP_ID_PART) surveyGroupId: RequestBody, + @Part(PLAN_ID_PART) planId: RequestBody, + @Part(MONITOR_DATE_PART) monitorDate: RequestBody?, + @Part file: MultipartBody.Part, + ): ApiEnvelope + + companion object { + // ---- 接口管理规范 V4.5 · 章节 2.1 所有 API 目录 ---- + const val PATH_UPLOAD_MONITOR_VALUE = "OutWebApi/api/UploadMonitorValue" + const val PATH_ANALYSIS_MONITOR_DATA = "OutWebApi/api/AnalysisMonitorData" + + /** 2.13 multipart 字段名(文档 2.13.4 接口参数表)。 */ + const val PROJECT_ID_PART = "ProjectID" + const val WORK_POINT_ID_PART = "WorkPointID" + const val SURVEY_GROUP_ID_PART = "SurveyGroupID" + const val PLAN_ID_PART = "PlanID" + const val MONITOR_DATE_PART = "MonitorDate" + + /** 2.13 文件字段名(服务端自动识别,命名仅作自描述)。 */ + const val FILE_PART = "Files" + } +}