feat(ops): 增加运维面板及诊断导出功能

- 添加运维面板OpsActivity及对应布局,展示设备状态、网络、MQTT、人脸数据等信息
- 实现诊断包导出工具DiagnosticExporter,支持将设备信息和日志导出到U盘
- 新增设备健康信息获取工具DeviceInfoProvider,提供运行时长、内存和存储信息
- 实现运行时日志写入文件FileLoggingTree,支持按天滚动、切分与过期清理
- 增加日志文件管理LogFileManager,支持列出、读取尾部和清理日志文件
- 丰富MQTT管理器MqttManager,记录连接状态、错误、连接次数等详情并支持手动重连
- 为环境切换引入EnvironmentSwitcher,实现基础地址切换和清空本地人脸库后自动重启
- 扩展ArcFace人脸库接口,支持查询最近更新人脸及按userId精确搜索
- MainActivity增加运维面板入口,管理员列表页右上角双击时间触发打开
- 应用启动时种植FileLoggingTree,确保运行日志同时输出至文件和控制台
This commit is contained in:
mazengfei
2026-09-02 17:47:24 +08:00
parent 994bcf5bc2
commit 8bcc5a0289
18 changed files with 1633 additions and 1 deletions
@@ -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))
}
}
}