feat(ops): 增加运维面板及诊断导出功能
- 添加运维面板OpsActivity及对应布局,展示设备状态、网络、MQTT、人脸数据等信息 - 实现诊断包导出工具DiagnosticExporter,支持将设备信息和日志导出到U盘 - 新增设备健康信息获取工具DeviceInfoProvider,提供运行时长、内存和存储信息 - 实现运行时日志写入文件FileLoggingTree,支持按天滚动、切分与过期清理 - 增加日志文件管理LogFileManager,支持列出、读取尾部和清理日志文件 - 丰富MQTT管理器MqttManager,记录连接状态、错误、连接次数等详情并支持手动重连 - 为环境切换引入EnvironmentSwitcher,实现基础地址切换和清空本地人脸库后自动重启 - 扩展ArcFace人脸库接口,支持查询最近更新人脸及按userId精确搜索 - MainActivity增加运维面板入口,管理员列表页右上角双击时间触发打开 - 应用启动时种植FileLoggingTree,确保运行日志同时输出至文件和控制台
This commit is contained in:
@@ -73,7 +73,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
|
||||
}
|
||||
|
||||
override fun onRightDoubleClick() {
|
||||
|
||||
// 管理员列表页右上角时间双击 → 打开运维面板
|
||||
if (pageType == PageType.SETTING_LIST) {
|
||||
OpsActivity.start(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleScanKeyInfo(scanInfo: String) {
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
package com.sw.platecabinet.activity
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.text.InputType
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.sw.plate.utils.ToastUtils
|
||||
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
|
||||
import com.sw.plate.utils.comn.SerialApi
|
||||
import com.sw.platecabinet.dialog.EnvironmentSelectDialog
|
||||
import com.sw.platecabinet.member.databinding.ActivityOpsBinding
|
||||
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
|
||||
import com.sw.platecabinet.mqtt.MqttManager
|
||||
import com.sw.platecabinet.mqtt.MqttState
|
||||
import com.sw.platecabinet.utils.CrashHandler
|
||||
import com.sw.platecabinet.utils.DeviceInfoProvider
|
||||
import com.sw.platecabinet.utils.DiagnosticExporter
|
||||
import com.sw.platecabinet.utils.EnvironmentSwitcher
|
||||
import com.sw.platecabinet.utils.LogFileManager
|
||||
import com.sw.platecabinet.utils.NetStatusProvider
|
||||
import com.sw.platecabinet.utils.SpTool
|
||||
import com.sw.platecabinet.viewmodel.OpsViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 运维面板:展示网络 / MQTT / 人脸数据 / 最近更新 / 日志,并提供少量二次确认干预。
|
||||
* 入口:管理员列表页(MainActivity SETTING_LIST)右上角时间双击。
|
||||
*/
|
||||
class OpsActivity : BaseActivity<ActivityOpsBinding>() {
|
||||
|
||||
private val opsViewModel by viewModels<OpsViewModel>()
|
||||
|
||||
override fun inflateViewBinding(): ActivityOpsBinding {
|
||||
return ActivityOpsBinding.inflate(layoutInflater)
|
||||
}
|
||||
|
||||
override fun inflateTitleBinding(): ItemTitleTimeBinding? {
|
||||
return binding.includeHeader
|
||||
}
|
||||
|
||||
override fun initialize() {
|
||||
checkPin()
|
||||
}
|
||||
|
||||
/** 进入面板前校验运维密码(默认值见 SpTool,不在 UI 明文提示) */
|
||||
private fun checkPin() {
|
||||
val input = EditText(this).apply {
|
||||
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
||||
hint = "请输入密码"
|
||||
}
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("请输入运维密码")
|
||||
.setView(input)
|
||||
.setCancelable(false)
|
||||
.setNegativeButton("退出") { _, _ -> finish() }
|
||||
.setPositiveButton("确定") { _, _ ->
|
||||
if (input.text.toString() == SpTool.getOpsPin()) {
|
||||
proceedInit()
|
||||
} else {
|
||||
ToastUtils.showToast("密码错误")
|
||||
finish()
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun proceedInit() {
|
||||
renderStaticInfo()
|
||||
opsViewModel.refreshFaceStats()
|
||||
opsViewModel.refreshLogFiles(this)
|
||||
bindActions()
|
||||
startAutoRefresh()
|
||||
}
|
||||
|
||||
/** 30s 定时刷新静态信息与人脸/日志列表 */
|
||||
private fun startAutoRefresh() {
|
||||
lifecycleScope.launch {
|
||||
while (isActive) {
|
||||
delay(30_000)
|
||||
renderStaticInfo()
|
||||
renderSerialInfo()
|
||||
opsViewModel.refreshFaceStats()
|
||||
opsViewModel.refreshLogFiles(this@OpsActivity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun registerDataChange() {
|
||||
super.registerDataChange()
|
||||
// MQTT 状态与时间线变化 → 刷新 MQTT 卡
|
||||
lifecycleScope.launch { opsViewModel.mqttState.collect { renderMqttCard() } }
|
||||
lifecycleScope.launch { opsViewModel.mqttLastConnectedAt.collect { renderMqttCard() } }
|
||||
lifecycleScope.launch { opsViewModel.mqttLastLostAt.collect { renderMqttCard() } }
|
||||
lifecycleScope.launch { opsViewModel.mqttLastError.collect { renderMqttCard() } }
|
||||
lifecycleScope.launch { opsViewModel.mqttLastMessageAt.collect { renderMqttCard() } }
|
||||
lifecycleScope.launch { opsViewModel.mqttConnectCount.collect { renderMqttCard() } }
|
||||
lifecycleScope.launch { opsViewModel.mqttDisconnectCount.collect { renderMqttCard() } }
|
||||
// 人脸统计
|
||||
lifecycleScope.launch { opsViewModel.faceCount.collect { renderFaceCard() } }
|
||||
lifecycleScope.launch { opsViewModel.memberCount.collect { renderFaceCard() } }
|
||||
lifecycleScope.launch { opsViewModel.nonMemberCount.collect { renderFaceCard() } }
|
||||
lifecycleScope.launch { opsViewModel.maxUpdateTs.collect { renderFaceCard() } }
|
||||
lifecycleScope.launch { opsViewModel.recentFaces.collect { renderRecentFaces() } }
|
||||
lifecycleScope.launch { opsViewModel.searchResult.collect { renderSearchResult(it) } }
|
||||
// 服务端自检与日志列表
|
||||
lifecycleScope.launch { opsViewModel.serverCheck.collect { binding.tvServerCheck.text = it } }
|
||||
lifecycleScope.launch { opsViewModel.logFiles.collect { renderLogFiles() } }
|
||||
}
|
||||
|
||||
/** 左上角日期双击 → 返回 */
|
||||
override fun onLeftDoubleClick() {
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun renderStaticInfo() {
|
||||
binding.tvNetSummary.text =
|
||||
"${NetStatusProvider.connectivitySummary(this)} | 本机IP: ${NetStatusProvider.localIpv4()}"
|
||||
binding.tvNetBaseUrl.text = "环境: ${opsViewModel.envName} | ${opsViewModel.appBaseUrl}"
|
||||
binding.tvDeviceInfo.text =
|
||||
"设备ID: ${opsViewModel.deviceId}\n设备编号: ${opsViewModel.equipmentCode} | 版本: ${opsViewModel.appVersion}"
|
||||
binding.tvDeviceHealth.text =
|
||||
"运行时长: ${DeviceInfoProvider.uptime()}\n内存: ${DeviceInfoProvider.memorySummary(this)}\n存储: ${DeviceInfoProvider.internalStorage(this)}"
|
||||
binding.tvCameraInfo.text = DeviceInfoProvider.cameraSummary(this)
|
||||
renderSerialInfo()
|
||||
}
|
||||
|
||||
private fun renderSerialInfo() {
|
||||
val state = if (SerialApi.isOpened()) "已打开" else "未打开"
|
||||
binding.tvSerialInfo.text = "串口: ${SerialApi.getPath()} @ ${SerialApi.getBaudRate()} | 状态: $state"
|
||||
}
|
||||
|
||||
private fun renderMqttCard() {
|
||||
binding.tvMqttState.text = "状态: ${opsViewModel.mqttState.value.name}"
|
||||
val broker = opsViewModel.mqttBrokerUrl ?: "未配置"
|
||||
val clientId = opsViewModel.mqttClientId ?: "未配置"
|
||||
binding.tvMqttBroker.text = "Broker: $broker\nclientId: $clientId"
|
||||
val subs = MqttManager.getSubscriptions().entries
|
||||
.joinToString("; ") { "${it.key}(qos=${it.value})" }
|
||||
.ifBlank {
|
||||
if (opsViewModel.mqttState.value == MqttState.Connected) "无(已连接但无订阅)"
|
||||
else "无(未连接/尚未订阅)"
|
||||
}
|
||||
binding.tvMqttSubs.text = "订阅: $subs"
|
||||
val connected = opsViewModel.formatTs(opsViewModel.mqttLastConnectedAt.value)
|
||||
val lost = opsViewModel.formatTs(opsViewModel.mqttLastLostAt.value)
|
||||
val err = opsViewModel.mqttLastError.value ?: "—"
|
||||
val lastMsg = ago(opsViewModel.mqttLastMessageAt.value)
|
||||
binding.tvMqttTimes.text =
|
||||
"连接: $connected | 丢失: $lost\n错误: $err\n最近消息: $lastMsg | 连接${opsViewModel.mqttConnectCount.value}次 / 断开${opsViewModel.mqttDisconnectCount.value}次"
|
||||
}
|
||||
|
||||
private fun ago(ts: Long?): String {
|
||||
if (ts == null) return "—"
|
||||
val diff = System.currentTimeMillis() - ts
|
||||
return when {
|
||||
diff < 60_000L -> "${diff / 1000}秒前"
|
||||
diff < 3_600_000L -> "${diff / 60_000}分钟前"
|
||||
else -> "${diff / 3_600_000}小时前"
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderFaceCard() {
|
||||
binding.tvFaceCount.text =
|
||||
"人脸总数: ${opsViewModel.faceCount.value} | 会员: ${opsViewModel.memberCount.value} | 非会员: ${opsViewModel.nonMemberCount.value}"
|
||||
val maxTs = opsViewModel.formatTs(opsViewModel.maxUpdateTs.value)
|
||||
val watermark = opsViewModel.formatTs(opsViewModel.lastFaceTimestamp)
|
||||
binding.tvFaceWatermark.text = "同步水位: $watermark | 库内最大更新: $maxTs"
|
||||
}
|
||||
|
||||
private fun renderRecentFaces() {
|
||||
val list = opsViewModel.recentFaces.value
|
||||
binding.llRecentList.removeAllViews()
|
||||
binding.tvRecentEmpty.isVisible = list.isEmpty()
|
||||
list.take(20).forEach { face ->
|
||||
binding.llRecentList.addView(
|
||||
TextView(this).apply {
|
||||
text = buildFaceLine(face)
|
||||
setTextColor(Color.parseColor("#E6E6E6"))
|
||||
textSize = 13f
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildFaceLine(face: FaceEntity): String {
|
||||
val updateTs = opsViewModel.formatTs(face.faceUpdateTimestamp)
|
||||
val insertTs = opsViewModel.formatTs(face.registerTime)
|
||||
val member = if (face.isMember) "会员" else "非会员"
|
||||
return "ufid=${face.userFaceId ?: "—"} uid=${face.userId ?: "—"} $member\n 更新: $updateTs | 落库: $insertTs"
|
||||
}
|
||||
|
||||
private fun renderSearchResult(list: List<FaceEntity>) {
|
||||
binding.tvSearchResult.isVisible = true
|
||||
binding.tvSearchResult.text = if (list.isEmpty()) "无匹配结果"
|
||||
else "匹配 ${list.size} 条:\n" + list.take(20).joinToString("\n") { buildFaceLine(it) }
|
||||
}
|
||||
|
||||
private fun renderLogFiles() {
|
||||
val files = opsViewModel.logFiles.value
|
||||
val crashFiles = CrashHandler.getCrashReportFiles(this)
|
||||
binding.tvLogFiles.text = "运行日志: ${files.size} 个文件" +
|
||||
if (files.isEmpty()) "" else "\n最新: ${files.firstOrNull()?.name}"
|
||||
val lastCrash = crashFiles.maxOfOrNull { it.lastModified() }
|
||||
binding.tvCrashInfo.text = "崩溃日志: ${crashFiles.size} 个文件" +
|
||||
if (lastCrash == null) "" else "\n最近崩溃: ${opsViewModel.formatTs(lastCrash)}"
|
||||
}
|
||||
|
||||
private fun bindActions() {
|
||||
binding.btnCheckServer.setOnClickListener { opsViewModel.checkServer() }
|
||||
binding.btnRefreshFace.setOnClickListener { opsViewModel.refreshFaceStats() }
|
||||
|
||||
binding.btnSearch.setOnClickListener {
|
||||
val keyword = binding.etSearch.text.toString().trim()
|
||||
if (keyword.isEmpty()) {
|
||||
ToastUtils.showToast("请输入 userId")
|
||||
return@setOnClickListener
|
||||
}
|
||||
opsViewModel.searchFace(keyword)
|
||||
}
|
||||
|
||||
binding.btnInitSerial.setOnClickListener {
|
||||
SerialApi.init()
|
||||
renderSerialInfo()
|
||||
ToastUtils.showToast(if (SerialApi.isOpened()) "串口打开成功" else "串口打开失败")
|
||||
}
|
||||
|
||||
binding.btnReconnectMqtt.setOnClickListener {
|
||||
confirm("MQTT 手动重连", "断开并重新连接 MQTT,确定?") {
|
||||
opsViewModel.reconnectMqtt()
|
||||
ToastUtils.showToast("已触发重连")
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnFullSync.setOnClickListener {
|
||||
confirm("获取全量人脸", "将清空本地人脸库并重新全量拉取(耗时较长),确定?") {
|
||||
showWaitingDialog("全量同步中…")
|
||||
netViewModelV2.getUserFaceCache { success, msg ->
|
||||
runOnUiThread {
|
||||
hideWaitingDialog()
|
||||
if (success) {
|
||||
scheduleFaceRefresh()
|
||||
opsViewModel.refreshFaceStats()
|
||||
ToastUtils.showToast("全量同步完成")
|
||||
} else {
|
||||
ToastUtils.showToast("全量同步失败: $msg")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnTriggerSync.setOnClickListener {
|
||||
val ts = SpTool.getLastFaceTimestamp()
|
||||
if (ts <= 0L) {
|
||||
ToastUtils.showToast("同步水位为 0,请先完成首次全量同步")
|
||||
return@setOnClickListener
|
||||
}
|
||||
confirm("手动增量补拉", "以水位 $ts 触发一次 HTTP 增量同步,确定?") {
|
||||
netViewModelV2.getFaceIncrementList(timestamp = ts) { scheduleFaceRefresh() }
|
||||
ToastUtils.showToast("已触发增量补拉")
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnClearFace.setOnClickListener {
|
||||
confirm("清空本地人脸库", "将删除全部本地人脸特征并刷新识别引擎,确定?") {
|
||||
clearAllFace { ToastUtils.showToast("已清空本地人脸库") }
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnReadLog.setOnClickListener { readLatestLog() }
|
||||
|
||||
binding.btnReadCrash.setOnClickListener { readLatestCrash() }
|
||||
|
||||
binding.btnExport.setOnClickListener {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = DiagnosticExporter.exportToUsb(this@OpsActivity)
|
||||
withContext(Dispatchers.Main) { ToastUtils.showToast(result.message) }
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnClearLogs.setOnClickListener {
|
||||
confirm("清空日志", "删除全部运行日志与崩溃日志,确定?") {
|
||||
LogFileManager.clearLogs(this)
|
||||
CrashHandler.clearCrashReports(this)
|
||||
opsViewModel.refreshLogFiles(this)
|
||||
ToastUtils.showToast("已清空日志")
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnSwitchEnv.setOnClickListener {
|
||||
val dialog = EnvironmentSelectDialog(this) { env ->
|
||||
EnvironmentSwitcher.switch(this, env) {
|
||||
ToastUtils.showToast("已切换到${env.name},正在重启")
|
||||
}
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
binding.btnChangePin.setOnClickListener { changePin() }
|
||||
}
|
||||
|
||||
private fun changePin() {
|
||||
val input = EditText(this).apply {
|
||||
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
||||
hint = "4 位以上数字"
|
||||
}
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("设置新运维密码")
|
||||
.setView(input)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("确定") { _, _ ->
|
||||
val pin = input.text.toString().trim()
|
||||
if (pin.length < 4) {
|
||||
ToastUtils.showToast("密码至少 4 位")
|
||||
} else {
|
||||
SpTool.setOpsPin(pin)
|
||||
ToastUtils.showToast("密码已修改")
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun readLatestLog() {
|
||||
val file = opsViewModel.logFiles.value.firstOrNull()
|
||||
if (file == null) {
|
||||
ToastUtils.showToast("暂无日志文件")
|
||||
return
|
||||
}
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val content = LogFileManager.readTail(file, 200)
|
||||
withContext(Dispatchers.Main) {
|
||||
AlertDialog.Builder(this@OpsActivity)
|
||||
.setTitle("最新日志: ${file.name}")
|
||||
.setMessage(content)
|
||||
.setPositiveButton("关闭", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readLatestCrash() {
|
||||
val file = CrashHandler.getCrashReportFiles(this).maxByOrNull { it.lastModified() }
|
||||
if (file == null) {
|
||||
ToastUtils.showToast("暂无崩溃日志")
|
||||
return
|
||||
}
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val content = LogFileManager.readTail(file, 300)
|
||||
withContext(Dispatchers.Main) {
|
||||
AlertDialog.Builder(this@OpsActivity)
|
||||
.setTitle("最新崩溃日志: ${file.name}")
|
||||
.setMessage(content)
|
||||
.setPositiveButton("关闭", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirm(title: String, message: String, onOk: () -> Unit) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("确定") { _, _ -> onOk() }
|
||||
.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun start(context: Context) {
|
||||
context.startActivity(Intent(context, OpsActivity::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user