diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index eb5da4e..06c8e38 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -69,6 +69,10 @@
android:name="com.sw.platecabinet.activity.MainActivity"
android:exported="false">
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/sw/platecabinet/MyApp.kt b/app/src/main/java/com/sw/platecabinet/MyApp.kt
index 3a6718a..edde069 100644
--- a/app/src/main/java/com/sw/platecabinet/MyApp.kt
+++ b/app/src/main/java/com/sw/platecabinet/MyApp.kt
@@ -4,6 +4,7 @@ import android.util.Log
import com.sw.plate.App
import com.sw.plate.utils.AppUtil
import com.sw.platecabinet.utils.CrashHandler
+import com.sw.platecabinet.utils.FileLoggingTree
import com.sw.platecabinet.utils.SpTool
import timber.log.Timber
@@ -15,6 +16,8 @@ class MyApp : App() {
override fun onCreate() {
super.onCreate()
Timber.plant(Timber.DebugTree())
+ // 运行时日志落盘(供运维页查看/导出),与 DebugTree 并存
+ Timber.plant(FileLoggingTree(this))
Timber.d("初始化")
initGlobalData()
diff --git a/app/src/main/java/com/sw/platecabinet/activity/MainActivity.kt b/app/src/main/java/com/sw/platecabinet/activity/MainActivity.kt
index 1d24581..52ea052 100644
--- a/app/src/main/java/com/sw/platecabinet/activity/MainActivity.kt
+++ b/app/src/main/java/com/sw/platecabinet/activity/MainActivity.kt
@@ -73,7 +73,10 @@ class MainActivity : BaseActivity() {
}
override fun onRightDoubleClick() {
-
+ // 管理员列表页右上角时间双击 → 打开运维面板
+ if (pageType == PageType.SETTING_LIST) {
+ OpsActivity.start(this)
+ }
}
override fun handleScanKeyInfo(scanInfo: String) {
diff --git a/app/src/main/java/com/sw/platecabinet/activity/OpsActivity.kt b/app/src/main/java/com/sw/platecabinet/activity/OpsActivity.kt
new file mode 100644
index 0000000..985adc6
--- /dev/null
+++ b/app/src/main/java/com/sw/platecabinet/activity/OpsActivity.kt
@@ -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() {
+
+ private val opsViewModel by viewModels()
+
+ 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) {
+ 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))
+ }
+ }
+}
diff --git a/app/src/main/java/com/sw/platecabinet/mqtt/MqttManager.kt b/app/src/main/java/com/sw/platecabinet/mqtt/MqttManager.kt
index b5d6228..a18b94a 100644
--- a/app/src/main/java/com/sw/platecabinet/mqtt/MqttManager.kt
+++ b/app/src/main/java/com/sw/platecabinet/mqtt/MqttManager.kt
@@ -45,6 +45,38 @@ object MqttManager {
private val _messages = MutableSharedFlow(extraBufferCapacity = 20)
val messages: SharedFlow = _messages.asSharedFlow()
+ /** 最近一次连接成功时间(毫秒时间戳,null 表示从未连上),供运维面板展示 */
+ private val _lastConnectedAt = MutableStateFlow(null)
+ val lastConnectedAt: StateFlow = _lastConnectedAt.asStateFlow()
+
+ /** 最近一次连接丢失时间(毫秒时间戳) */
+ private val _lastLostAt = MutableStateFlow(null)
+ val lastLostAt: StateFlow = _lastLostAt.asStateFlow()
+
+ /** 最近一次连接/订阅/发布错误信息 */
+ private val _lastError = MutableStateFlow(null)
+ val lastError: StateFlow = _lastError.asStateFlow()
+
+ /** 最近一次收到业务消息时间(毫秒时间戳),用于判断"Connected 但链路假活" */
+ private val _lastMessageArrivedAt = MutableStateFlow(null)
+ val lastMessageArrivedAt: StateFlow = _lastMessageArrivedAt.asStateFlow()
+
+ /** 累计连接成功 / 断开次数(用于判断闪断) */
+ private val _connectCount = MutableStateFlow(0)
+ val connectCount: StateFlow = _connectCount.asStateFlow()
+
+ private val _disconnectCount = MutableStateFlow(0)
+ val disconnectCount: StateFlow = _disconnectCount.asStateFlow()
+
+ /** 当前 Broker URL(未配置返回 null) */
+ val brokerUrl: String? get() = config?.brokerUrl
+
+ /** 当前 clientId(未配置返回 null) */
+ val clientId: String? get() = config?.clientId
+
+ /** 已订阅主题与 QoS 的线程安全快照 */
+ fun getSubscriptions(): Map = synchronized(subscriptions) { subscriptions.toMap() }
+
/** 首连失败后的退避重试协程,连接成功或主动断开时取消 */
private var retryJob: Job? = null
@@ -94,6 +126,7 @@ object MqttManager {
connect(options)
}
_state.value = MqttState.Connected
+ _lastConnectedAt.value = System.currentTimeMillis()
Timber.i("MQTT 连接成功: ${cfg.brokerUrl}, clientId=$id, cleanSession=${cfg.cleanSession}")
return
} catch (e: Exception) {
@@ -101,6 +134,7 @@ object MqttManager {
try { client?.close() } catch (_: Exception) {}
client = null
_state.value = MqttState.Disconnected
+ _lastError.value = "连接失败: ${e.message}"
_messages.emit(MqttMessage.Error("连接失败: ${e.message}", e))
Timber.e(e, "MQTT 连接失败,${backoffMs / 1000} 秒后重试")
delay(backoffMs)
@@ -140,6 +174,7 @@ object MqttManager {
Timber.i("MQTT 订阅成功: $topic qos=$qos")
} catch (e: Exception) {
_messages.emit(MqttMessage.Error("订阅失败: ${e.message}", e))
+ _lastError.value = "订阅失败: ${e.message}"
}
}
}
@@ -151,10 +186,32 @@ object MqttManager {
_messages.emit(MqttMessage.Published(topic))
} catch (e: Exception) {
_messages.emit(MqttMessage.Error("发布失败: ${e.message}", e))
+ _lastError.value = "发布失败: ${e.message}"
}
}
}
+ /**
+ * 手动重连(运维面板用):单个协程内先断开清理、状态归位,再复用 [doConnect] 的退避重试。
+ * 区别于 [connect]——后者在非 Disconnected 状态会直接忽略,无法用于"已连接但需强制重连"。
+ */
+ fun reconnect() {
+ retryJob?.cancel()
+ retryJob = scope.launch {
+ val cfg = config ?: run {
+ _lastError.value = "MQTT 未配置,无法重连"
+ Timber.e("MQTT 未配置,请先调用 configure()")
+ return@launch
+ }
+ try { client?.disconnect() } catch (_: Exception) {}
+ try { client?.close() } catch (_: Exception) {}
+ client = null
+ _state.value = MqttState.Disconnected
+ _messages.emit(MqttMessage.Disconnected())
+ doConnect(cfg)
+ }
+ }
+
fun destroy() {
disconnect()
scope.cancel()
@@ -165,6 +222,8 @@ object MqttManager {
override fun connectComplete(reconnect: Boolean, serverURI: String?) {
scope.launch {
_state.value = MqttState.Connected
+ _lastConnectedAt.value = System.currentTimeMillis()
+ _connectCount.value += 1
Timber.i("MQTT connectComplete: reconnect=$reconnect serverURI=$serverURI")
// 重连后恢复历史订阅(cleanSession=false 时 Broker 已保留订阅,重复订阅幂等)
val restore = synchronized(subscriptions) { subscriptions.toMap() }
@@ -185,6 +244,9 @@ object MqttManager {
override fun connectionLost(cause: Throwable?) {
scope.launch {
_state.value = MqttState.Disconnected
+ _lastLostAt.value = System.currentTimeMillis()
+ _lastError.value = cause?.message ?: "未知原因(可能是 Broker 踢线/网络中断)"
+ _disconnectCount.value += 1
_messages.emit(MqttMessage.Disconnected(cause))
// cause 可能为 null(如 Broker 主动踢 clientId 重复时仅表现为静默断开),
// 打印消息体帮助定位掉线原因
@@ -194,6 +256,7 @@ object MqttManager {
override fun messageArrived(topic: String?, message: org.eclipse.paho.client.mqttv3.MqttMessage?) {
if (topic != null && message != null) {
+ _lastMessageArrivedAt.value = System.currentTimeMillis()
Timber.i(
"MQTT messageArrived: topic=$topic size=${message.payload.size} " +
"qos=${message.qos} dup=${message.isDuplicate} retained=${message.isRetained}"
diff --git a/app/src/main/java/com/sw/platecabinet/utils/DeviceInfoProvider.kt b/app/src/main/java/com/sw/platecabinet/utils/DeviceInfoProvider.kt
new file mode 100644
index 0000000..fd5c093
--- /dev/null
+++ b/app/src/main/java/com/sw/platecabinet/utils/DeviceInfoProvider.kt
@@ -0,0 +1,61 @@
+package com.sw.platecabinet.utils
+
+import android.Manifest
+import android.app.ActivityManager
+import android.content.Context
+import android.content.pm.PackageManager
+import android.hardware.camera2.CameraManager
+import android.os.StatFs
+import android.os.SystemClock
+import androidx.core.content.ContextCompat
+
+/**
+ * 设备健康只读信息:运行时长、内存、内部存储。
+ */
+object DeviceInfoProvider {
+
+ /** 进程运行时长(工控机 App 常驻,近似设备运行时长),格式「x小时x分」 */
+ fun uptime(): String {
+ val ms = SystemClock.elapsedRealtime()
+ val days = ms / (24 * 3600 * 1000L)
+ val hours = (ms % (24 * 3600 * 1000L)) / (3600 * 1000L)
+ val minutes = (ms % (3600 * 1000L)) / (60 * 1000L)
+ return if (days > 0) "${days}天${hours}小时${minutes}分" else "${hours}小时${minutes}分"
+ }
+
+ /** 内存摘要:可用/总(MB) */
+ fun memorySummary(context: Context): String {
+ val am = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager ?: return "未知"
+ val mi = ActivityManager.MemoryInfo()
+ am.getMemoryInfo(mi)
+ val avail = mi.availMem / (1024 * 1024)
+ val total = mi.totalMem / (1024 * 1024)
+ val low = if (mi.lowMemory) "(低内存告警)" else ""
+ return "可用 ${avail}MB / 总 ${total}MB$low"
+ }
+
+ /** 内部存储剩余(日志与人脸库都落在 filesDir,满盘是隐形故障源),格式「可用/总 MB」 */
+ fun internalStorage(context: Context): String {
+ return try {
+ val stat = StatFs(context.filesDir.absolutePath)
+ val total = stat.totalBytes / (1024 * 1024)
+ val avail = stat.availableBytes / (1024 * 1024)
+ "可用 ${avail}MB / 总 ${total}MB"
+ } catch (e: Exception) {
+ "未知"
+ }
+ }
+
+ /** 摄像头状态:系统识别到的摄像头数量 + 相机权限是否授予 */
+ fun cameraSummary(context: Context): String {
+ val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
+ PackageManager.PERMISSION_GRANTED
+ val count = try {
+ val cm = context.getSystemService(Context.CAMERA_SERVICE) as? CameraManager
+ cm?.cameraIdList?.size ?: 0
+ } catch (e: Exception) {
+ 0
+ }
+ return "摄像头: $count 个 | 权限: ${if (granted) "已授予" else "未授予"}"
+ }
+}
diff --git a/app/src/main/java/com/sw/platecabinet/utils/DiagnosticExporter.kt b/app/src/main/java/com/sw/platecabinet/utils/DiagnosticExporter.kt
new file mode 100644
index 0000000..d592024
--- /dev/null
+++ b/app/src/main/java/com/sw/platecabinet/utils/DiagnosticExporter.kt
@@ -0,0 +1,119 @@
+package com.sw.platecabinet.utils
+
+import android.content.Context
+import com.sw.plate.utils.arcface.FaceApi
+import com.sw.plate.utils.comn.SerialApi
+import com.sw.platecabinet.GlobalData
+import com.sw.platecabinet.mqtt.MqttManager
+import java.io.BufferedOutputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+/**
+ * 诊断包导出:把设备信息、运行日志、崩溃日志打包成 zip,直接写入 U 盘。
+ * 工控机现场无微信等分享应用,故不走 FileProvider 分享,改为写 U 盘。
+ */
+object DiagnosticExporter {
+
+ /** 导出结果 */
+ data class ExportResult(val success: Boolean, val message: String, val file: File? = null)
+
+ private val faceApi = FaceApi()
+
+ /** 导出到 U 盘(在 IO 线程调用) */
+ fun exportToUsb(context: Context): ExportResult {
+ val usbDir = UsbStorageHelper.findUsbDir(context)
+ ?: return ExportResult(false, "未检测到可写 U 盘,请插入后重试")
+ return try {
+ val file = buildZip(context, usbDir)
+ ExportResult(true, "已导出: ${file.name}", file)
+ } catch (e: Exception) {
+ ExportResult(false, "导出失败: ${e.message}")
+ }
+ }
+
+ /** 生成诊断 zip 并写入目标目录 */
+ private fun buildZip(context: Context, outDir: File): File {
+ val time = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
+ val zipFile = File(outDir, "diagnostic_$time.zip")
+
+ ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
+ // 1. 设备快照 info.txt
+ zos.putNextEntry(ZipEntry("info.txt"))
+ zos.write(buildInfo(context).toByteArray(Charsets.UTF_8))
+ zos.closeEntry()
+
+ // 2. 运行日志
+ LogFileManager.getLogFiles(context).forEach { file ->
+ addFile(zos, file, "logs/${file.name}")
+ }
+
+ // 3. 崩溃日志
+ CrashHandler.getCrashReportFiles(context).forEach { file ->
+ addFile(zos, file, "crash/${file.name}")
+ }
+ }
+ return zipFile
+ }
+
+ /** 设备信息 + 网络 + MQTT + 人脸 + 设备健康 纯文本快照 */
+ private fun buildInfo(context: Context): String {
+ val mqttState = MqttManager.state.value.name
+ val subs = MqttManager.getSubscriptions().entries
+ .joinToString("; ") { "${it.key}(qos=${it.value})" }
+ .ifBlank { "无" }
+ val time = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
+ val lastCrash = CrashHandler.getCrashReportFiles(context)
+ .maxOfOrNull { it.lastModified() }
+ ?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(it)) }
+ ?: "—"
+ val usbDir = UsbStorageHelper.findUsbDir(context)?.absolutePath ?: "未检测"
+ return buildString {
+ appendLine("========== 诊断快照 ==========")
+ appendLine("生成时间: $time")
+ appendLine("设备ID: ${GlobalData.deviceId}")
+ appendLine("设备编号: ${GlobalData.globalEquipmentCode}")
+ appendLine("应用版本: ${GlobalData.appVersion}")
+ appendLine()
+ appendLine("--- 设备健康 ---")
+ appendLine("运行时长: ${DeviceInfoProvider.uptime()}")
+ appendLine("内存: ${DeviceInfoProvider.memorySummary(context)}")
+ appendLine("存储: ${DeviceInfoProvider.internalStorage(context)}")
+ appendLine(DeviceInfoProvider.cameraSummary(context))
+ appendLine("串口: ${SerialApi.getPath()} @ ${SerialApi.getBaudRate()} | ${if (SerialApi.isOpened()) "已打开" else "未打开"}")
+ appendLine("U盘: $usbDir")
+ appendLine("最近崩溃: $lastCrash")
+ appendLine()
+ appendLine("--- 网络 ---")
+ appendLine("连通性: ${NetStatusProvider.connectivitySummary(context)}")
+ appendLine("本机IP: ${NetStatusProvider.localIpv4()}")
+ appendLine("环境: ${NetStatusProvider.envName(GlobalData.appBaseUrl)}")
+ appendLine("BaseUrl: ${GlobalData.appBaseUrl}")
+ appendLine()
+ appendLine("--- MQTT ---")
+ appendLine("状态: $mqttState")
+ appendLine("Broker: ${MqttManager.brokerUrl ?: "未配置"}")
+ appendLine("clientId: ${MqttManager.clientId ?: "未配置"}")
+ appendLine("订阅: $subs")
+ appendLine("连接次数: ${MqttManager.connectCount.value} / 断开次数: ${MqttManager.disconnectCount.value}")
+ appendLine("最近错误: ${MqttManager.lastError.value ?: "—"}")
+ appendLine()
+ appendLine("--- 人脸库 ---")
+ appendLine("总数: ${faceApi.queryFaceCount()}")
+ appendLine("会员: ${faceApi.queryFaceCountByMember(true)} / 非会员: ${faceApi.queryFaceCountByMember(false)}")
+ appendLine("同步水位: ${SpTool.getLastFaceTimestamp()}")
+ appendLine("库内最大更新时间: ${faceApi.queryMaxFaceUpdateTimestamp() ?: "—"}")
+ }
+ }
+
+ private fun addFile(zos: ZipOutputStream, file: File, entryName: String) {
+ zos.putNextEntry(ZipEntry(entryName))
+ file.inputStream().use { input -> input.copyTo(zos) }
+ zos.closeEntry()
+ }
+}
diff --git a/app/src/main/java/com/sw/platecabinet/utils/EnvironmentSwitcher.kt b/app/src/main/java/com/sw/platecabinet/utils/EnvironmentSwitcher.kt
new file mode 100644
index 0000000..f02bc9d
--- /dev/null
+++ b/app/src/main/java/com/sw/platecabinet/utils/EnvironmentSwitcher.kt
@@ -0,0 +1,64 @@
+package com.sw.platecabinet.utils
+
+import android.app.Activity
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Process
+import com.sw.plate.utils.arcface.facedb.FaceDatabase
+import com.sw.platecabinet.Environment
+import com.sw.platecabinet.GlobalData
+import com.sw.platecabinet.GlobalKey
+
+/**
+ * 环境切换:落盘 baseUrl + 清空本地人脸库 + 重置增量水位 + 自动重启。
+ * 供运维面板复用(登录页连点切换的那套逻辑与此等价)。
+ */
+object EnvironmentSwitcher {
+
+ /**
+ * 切换业务环境。
+ * @param activity 用于写 SharedPreferences / 取 applicationContext / 切主线程
+ * @param env 目标环境
+ * @param onRestarting 重启前回调(主线程,用于 Toast 提示)
+ */
+ fun switch(activity: Activity, env: Environment, onRestarting: () -> Unit) {
+ // 用 commit 同步写盘,确保自动重启前状态已持久化(apply 是异步的,重启会丢)
+ activity.getSharedPreferences("default_sp", Context.MODE_PRIVATE).edit()
+ .putString(GlobalKey.KEY_BASE_URL, env.url)
+ .putLong(SpTool.LAST_FACE_TIMESTAMP, 0L)
+ .putBoolean(SpTool.IS_FIRST_GET_FACE, true)
+ .commit()
+ GlobalData.appBaseUrl = env.url
+
+ // Room 同步访问不能在主线程,后台清库后回主线程重启
+ Thread {
+ try {
+ val faceDao = FaceDatabase.getInstance(activity.applicationContext).faceDao()
+ faceDao.deleteAll()
+ faceDao.resetId()
+ } catch (_: Exception) {
+ // 清库失败不阻断切换,重启后首次全量同步会兜底
+ }
+ activity.runOnUiThread {
+ onRestarting()
+ restart(activity)
+ }
+ }.start()
+ }
+
+ /** 用 AlarmManager 拉起启动页后杀进程,实现应用自重启 */
+ private fun restart(context: Context) {
+ val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ } ?: return
+ val pending = PendingIntent.getActivity(
+ context, 0, intent,
+ PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
+ )
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + 500, pending)
+ Process.killProcess(Process.myPid())
+ }
+}
diff --git a/app/src/main/java/com/sw/platecabinet/utils/FileLoggingTree.kt b/app/src/main/java/com/sw/platecabinet/utils/FileLoggingTree.kt
new file mode 100644
index 0000000..9d4aa21
--- /dev/null
+++ b/app/src/main/java/com/sw/platecabinet/utils/FileLoggingTree.kt
@@ -0,0 +1,83 @@
+package com.sw.platecabinet.utils
+
+import android.content.Context
+import android.util.Log
+import timber.log.Timber
+import java.io.File
+import java.io.FileOutputStream
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.concurrent.Executors
+
+/**
+ * 运行时日志落盘:写入 filesDir/logs/log-YYYYMMDD.txt,按天滚动、单文件超 5MB 切分、保留 7 天。
+ * 与 Timber.DebugTree 并存,专供运维页查看与导出。
+ */
+class FileLoggingTree(private val context: Context) : Timber.Tree() {
+
+ companion object {
+ private const val LOG_DIR = "logs"
+ private const val MAX_FILE_SIZE = 5L * 1024 * 1024
+ private const val MAX_AGE_DAYS = 7L
+
+ fun logDir(context: Context): File = File(context.filesDir, LOG_DIR)
+ }
+
+ // 单线程串行写盘,避免多线程并发写同一文件导致行交错;写盘失败不影响业务。
+ private val executor = Executors.newSingleThreadExecutor()
+
+ override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
+ // Timber 在 prepareLog 阶段已把 Throwable 的堆栈拼进 message,此处只需落 message,避免重复。
+ executor.execute {
+ try {
+ val dir = logDir(context)
+ if (!dir.exists() && !dir.mkdirs()) return@execute
+ cleanupOldLogs(dir)
+ val file = resolveLogFile(dir)
+ FileOutputStream(file, true).use { fos ->
+ fos.write(buildLine(priority, tag, message).toByteArray(Charsets.UTF_8))
+ }
+ } catch (_: Exception) {
+ // 日志写失败静默,不阻断业务
+ }
+ }
+ }
+
+ private fun buildLine(priority: Int, tag: String?, message: String): String {
+ val time = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()).format(Date())
+ val level = when (priority) {
+ Log.VERBOSE -> "V"
+ Log.DEBUG -> "D"
+ Log.INFO -> "I"
+ Log.WARN -> "W"
+ Log.ERROR -> "E"
+ Log.ASSERT -> "A"
+ else -> "?"
+ }
+ val safeTag = tag ?: "Timber"
+ return "$time $level/$safeTag: $message\n"
+ }
+
+ /** 当前日志文件:不存在或未超限直接使用,超限则追加序号切分 */
+ private fun resolveLogFile(dir: File): File {
+ val day = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())
+ val base = File(dir, "log-$day.txt")
+ if (!base.exists() || base.length() < MAX_FILE_SIZE) return base
+ var index = 1
+ while (true) {
+ val candidate = File(dir, "log-$day-$index.txt")
+ if (!candidate.exists() || candidate.length() < MAX_FILE_SIZE) return candidate
+ index++
+ }
+ }
+
+ /** 清理超过保留天数的旧日志 */
+ private fun cleanupOldLogs(dir: File) {
+ val now = System.currentTimeMillis()
+ val maxAgeMillis = MAX_AGE_DAYS * 24 * 60 * 60 * 1000L
+ dir.listFiles()?.forEach { file ->
+ if (file.isFile && file.lastModified() < now - maxAgeMillis) file.delete()
+ }
+ }
+}
diff --git a/app/src/main/java/com/sw/platecabinet/utils/LogFileManager.kt b/app/src/main/java/com/sw/platecabinet/utils/LogFileManager.kt
new file mode 100644
index 0000000..a864577
--- /dev/null
+++ b/app/src/main/java/com/sw/platecabinet/utils/LogFileManager.kt
@@ -0,0 +1,36 @@
+package com.sw.platecabinet.utils
+
+import android.content.Context
+import java.io.File
+
+/**
+ * 日志文件管理:列出 / 读尾部 / 清理(供运维页与 OpsViewModel 使用)。
+ */
+object LogFileManager {
+
+ /** 列出运行时日志文件(按修改时间倒序) */
+ fun getLogFiles(context: Context): List {
+ val dir = FileLoggingTree.logDir(context)
+ if (!dir.exists() || !dir.isDirectory) return emptyList()
+ val files = dir.listFiles { f -> f.isFile && f.name.endsWith(".txt") } ?: return emptyList()
+ return files.sortedByDescending { it.lastModified() }
+ }
+
+ /** 读取文件末尾 maxLines 行(只保留末尾,避免大文件一次性载入内存) */
+ fun readTail(file: File, maxLines: Int = 200): String {
+ if (!file.exists()) return "文件不存在"
+ val tail = ArrayDeque()
+ file.bufferedReader(Charsets.UTF_8).useLines { seq ->
+ seq.forEach { line ->
+ if (tail.size >= maxLines) tail.removeFirst()
+ tail.addLast(line)
+ }
+ }
+ return tail.joinToString("\n")
+ }
+
+ /** 清理所有运行时日志文件 */
+ fun clearLogs(context: Context) {
+ getLogFiles(context).forEach { it.delete() }
+ }
+}
diff --git a/app/src/main/java/com/sw/platecabinet/utils/NetStatusProvider.kt b/app/src/main/java/com/sw/platecabinet/utils/NetStatusProvider.kt
new file mode 100644
index 0000000..8aebc9c
--- /dev/null
+++ b/app/src/main/java/com/sw/platecabinet/utils/NetStatusProvider.kt
@@ -0,0 +1,50 @@
+package com.sw.platecabinet.utils
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import com.sw.platecabinet.ENVIRONMENTS
+import java.net.Inet4Address
+import java.net.InetAddress
+import java.net.NetworkInterface
+
+/**
+ * 网络状态只读提供器:连通性、本机 IP、业务环境名。
+ */
+object NetStatusProvider {
+
+ /** 连通性摘要(离线/WiFi/蜂窝/以太网/未知) */
+ fun connectivitySummary(context: Context): String {
+ val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
+ ?: return "未知"
+ val network = cm.activeNetwork ?: return "离线"
+ val caps = cm.getNetworkCapabilities(network) ?: return "离线"
+ return when {
+ !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) -> "离线(无外网)"
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "以太网"
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "WiFi"
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "蜂窝"
+ else -> "已连接"
+ }
+ }
+
+ /** 本机 IPv4 地址(取首个非回环地址,无则返回"未知") */
+ fun localIpv4(): String {
+ return try {
+ val addresses = mutableListOf()
+ val interfaces = NetworkInterface.getNetworkInterfaces() ?: return "未知"
+ for (nif in interfaces) {
+ val addrs = nif.inetAddresses ?: continue
+ for (addr in addrs) addresses.add(addr)
+ }
+ addresses.firstOrNull { it is Inet4Address && !it.isLoopbackAddress }?.hostAddress ?: "未知"
+ } catch (e: Exception) {
+ "未知"
+ }
+ }
+
+ /** 由 baseUrl 反查预设环境名,未匹配返回"自定义" */
+ fun envName(baseUrl: String): String {
+ return ENVIRONMENTS.firstOrNull { it.url == baseUrl }?.name ?: "自定义"
+ }
+}
diff --git a/app/src/main/java/com/sw/platecabinet/utils/SpTool.java b/app/src/main/java/com/sw/platecabinet/utils/SpTool.java
index af3bf9d..4416ab9 100644
--- a/app/src/main/java/com/sw/platecabinet/utils/SpTool.java
+++ b/app/src/main/java/com/sw/platecabinet/utils/SpTool.java
@@ -39,4 +39,15 @@ public class SpTool {
SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").put(GlobalKey.KEY_BASE_URL, value);
}
+ /**
+ * 运维面板密码(默认 1234,运维可在面板内修改)
+ */
+ public static String getOpsPin() {
+ return SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").get("opsPin", "1234");
+ }
+
+ public static void setOpsPin(String value) {
+ SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").put("opsPin", value);
+ }
+
}
diff --git a/app/src/main/java/com/sw/platecabinet/utils/UsbStorageHelper.kt b/app/src/main/java/com/sw/platecabinet/utils/UsbStorageHelper.kt
new file mode 100644
index 0000000..bfffcbb
--- /dev/null
+++ b/app/src/main/java/com/sw/platecabinet/utils/UsbStorageHelper.kt
@@ -0,0 +1,73 @@
+package com.sw.platecabinet.utils
+
+import android.content.Context
+import android.os.Build
+import android.os.storage.StorageManager
+import java.io.File
+
+/**
+ * U 盘定位:工控机现场无微信等分享应用,诊断包需直接写入 U 盘。
+ * 按可靠性依次尝试:StorageManager 可移动卷(API30+)→ /storage 卷目录 → /mnt 常见挂载点 → /proc/mounts。
+ */
+object UsbStorageHelper {
+
+ fun findUsbDir(context: Context): File? {
+ api30Volume(context)?.let { return it }
+ storageVolumeDir()?.let { return it }
+ commonMountPoints().forEach { if (isWritableDir(it)) return it }
+ procMountsDir()?.let { return it }
+ return null
+ }
+
+ private fun isWritableDir(dir: File): Boolean = dir.exists() && dir.isDirectory && dir.canWrite()
+
+ /** API 30+ 通过 StorageManager 拿可移动卷目录(最可靠) */
+ private fun api30Volume(context: Context): File? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
+ return try {
+ val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager ?: return null
+ sm.storageVolumes.firstOrNull { it.isRemovable }?.directory
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ /** /storage 下非 emulated/self 的目录(U 盘卷标形如 /storage/ABCD-1234) */
+ private fun storageVolumeDir(): File? {
+ return try {
+ File("/storage").listFiles()
+ ?.filter { it.isDirectory && it.name != "emulated" && it.name != "self" && it.canWrite() }
+ ?.firstOrNull()
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ /** 常见挂载点(不同 Android 版本/固件差异大,做兜底) */
+ private fun commonMountPoints(): List = listOf(
+ File("/mnt/usb_storage"),
+ File("/mnt/usb"),
+ File("/storage/usb"),
+ File("/mnt/external_sd"),
+ File("/mnt/sdcard2")
+ )
+
+ /** 读 /proc/mounts 找可移动介质挂载点(vfat/exfat/ntfs/fuseblk) */
+ private fun procMountsDir(): File? {
+ return try {
+ File("/proc/mounts").readLines()
+ .mapNotNull { line ->
+ val parts = line.split(" ")
+ if (parts.size < 3) return@mapNotNull null
+ val fsType = parts[2]
+ val mountPoint = parts[1].replace("\\040", " ")
+ if (fsType.contains("vfat") || fsType.contains("exfat") ||
+ fsType.contains("ntfs") || fsType.contains("fuseblk")
+ ) File(mountPoint) else null
+ }
+ .firstOrNull { isWritableDir(it) }
+ } catch (e: Exception) {
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/com/sw/platecabinet/viewmodel/OpsViewModel.kt b/app/src/main/java/com/sw/platecabinet/viewmodel/OpsViewModel.kt
new file mode 100644
index 0000000..64769ab
--- /dev/null
+++ b/app/src/main/java/com/sw/platecabinet/viewmodel/OpsViewModel.kt
@@ -0,0 +1,151 @@
+package com.sw.platecabinet.viewmodel
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.sw.plate.utils.arcface.FaceApi
+import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
+import com.sw.platecabinet.GlobalData
+import com.sw.platecabinet.mqtt.MqttManager
+import com.sw.platecabinet.mqtt.MqttState
+import com.sw.platecabinet.utils.LogFileManager
+import com.sw.platecabinet.utils.NetStatusProvider
+import com.sw.platecabinet.utils.SpTool
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+
+/**
+ * 运维面板 ViewModel:聚合 MQTT / 网络 / 人脸 / 日志 状态与动作。
+ */
+class OpsViewModel : ViewModel() {
+
+ private val faceApi = FaceApi()
+
+ // ---- MQTT(直接桥接 MqttManager 单例的 StateFlow) ----
+ val mqttState: StateFlow = MqttManager.state
+ val mqttLastConnectedAt: StateFlow = MqttManager.lastConnectedAt
+ val mqttLastLostAt: StateFlow = MqttManager.lastLostAt
+ val mqttLastError: StateFlow = MqttManager.lastError
+ val mqttLastMessageAt: StateFlow = MqttManager.lastMessageArrivedAt
+ val mqttConnectCount: StateFlow = MqttManager.connectCount
+ val mqttDisconnectCount: StateFlow = MqttManager.disconnectCount
+
+ // ---- 人脸统计 ----
+ private val _faceCount = MutableStateFlow(0)
+ val faceCount: StateFlow = _faceCount.asStateFlow()
+
+ private val _memberCount = MutableStateFlow(0)
+ val memberCount: StateFlow = _memberCount.asStateFlow()
+
+ private val _nonMemberCount = MutableStateFlow(0)
+ val nonMemberCount: StateFlow = _nonMemberCount.asStateFlow()
+
+ private val _maxUpdateTs = MutableStateFlow(null)
+ val maxUpdateTs: StateFlow = _maxUpdateTs.asStateFlow()
+
+ private val _recentFaces = MutableStateFlow>(emptyList())
+ val recentFaces: StateFlow> = _recentFaces.asStateFlow()
+
+ // ---- 人脸搜索 ----
+ private val _searchResult = MutableStateFlow>(emptyList())
+ val searchResult: StateFlow> = _searchResult.asStateFlow()
+
+ // ---- 服务端连通自检 ----
+ private val _serverCheck = MutableStateFlow("未检测")
+ val serverCheck: StateFlow = _serverCheck.asStateFlow()
+
+ // ---- 日志文件 ----
+ private val _logFiles = MutableStateFlow>(emptyList())
+ val logFiles: StateFlow> = _logFiles.asStateFlow()
+
+ // ---- 只读快照 ----
+ val appBaseUrl: String get() = GlobalData.appBaseUrl
+ val envName: String get() = NetStatusProvider.envName(GlobalData.appBaseUrl)
+ val lastFaceTimestamp: Long get() = SpTool.getLastFaceTimestamp()
+ val deviceId: String get() = GlobalData.deviceId
+ val equipmentCode: String get() = GlobalData.globalEquipmentCode
+ val appVersion: String get() = GlobalData.appVersion
+ val mqttBrokerUrl: String? get() = MqttManager.brokerUrl
+ val mqttClientId: String? get() = MqttManager.clientId
+
+ /** 刷新本地人脸统计与最近更新列表(FaceDao 为同步调用,放 IO 线程) */
+ fun refreshFaceStats() {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ _faceCount.value = faceApi.queryFaceCount()
+ _memberCount.value = faceApi.queryFaceCountByMember(true)
+ _nonMemberCount.value = faceApi.queryFaceCountByMember(false)
+ _maxUpdateTs.value = faceApi.queryMaxFaceUpdateTimestamp()
+ _recentFaces.value = faceApi.queryRecentUpdatedFaces(50)
+ } catch (e: Exception) {
+ // 数据库读取异常仅打印,不影响面板其余区块
+ _recentFaces.value = emptyList()
+ }
+ }
+ }
+
+ /** 按 userId 精确搜索人脸(库内无姓名字段,只能按 userId 搜索) */
+ fun searchFace(userId: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ _searchResult.value = faceApi.queryByUserId(userId.trim(), 50)
+ }
+ }
+
+ /**
+ * 服务端连通自检:GET 真实设备配置接口(而非 baseUrl 根路径,避免 404 也判可达)。
+ * 任何 HTTP 响应都视为"服务进程存活"(能区分"网络断/服务没起" vs "服务在跑")。
+ */
+ fun checkServer() {
+ viewModelScope.launch(Dispatchers.IO) {
+ val baseUrl = GlobalData.appBaseUrl.ifBlank {
+ _serverCheck.value = "未配置业务地址"
+ return@launch
+ }
+ _serverCheck.value = "检测中…"
+ val url = "${baseUrl.trimEnd('/')}/nutrition/neglect/pickup/device/config"
+ val client = OkHttpClient.Builder()
+ .connectTimeout(3, TimeUnit.SECONDS)
+ .readTimeout(3, TimeUnit.SECONDS)
+ .build()
+ val request = Request.Builder().url(url).get().build()
+ val start = System.currentTimeMillis()
+ try {
+ client.newCall(request).execute().use { resp ->
+ val cost = System.currentTimeMillis() - start
+ _serverCheck.value = "可达 HTTP ${resp.code},${cost}ms"
+ }
+ } catch (e: Exception) {
+ val cost = System.currentTimeMillis() - start
+ _serverCheck.value = "不可达(${cost}ms):${e.message}"
+ }
+ }
+ }
+
+ /** 刷新日志文件列表 */
+ fun refreshLogFiles(context: Context) {
+ viewModelScope.launch(Dispatchers.IO) {
+ _logFiles.value = LogFileManager.getLogFiles(context)
+ }
+ }
+
+ /** 手动重连 MQTT */
+ fun reconnectMqtt() {
+ MqttManager.reconnect()
+ }
+
+ /** 时间戳格式化(毫秒 → yyyy-MM-dd HH:mm:ss,空/0 显示 —) */
+ fun formatTs(ts: Long?): String {
+ if (ts == null || ts <= 0L) return "—"
+ return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(ts))
+ }
+}
diff --git a/app/src/main/res/layout/activity_ops.xml b/app/src/main/res/layout/activity_ops.xml
new file mode 100644
index 0000000..0e9d77c
--- /dev/null
+++ b/app/src/main/res/layout/activity_ops.xml
@@ -0,0 +1,452 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/lib_face/src/main/java/com/sw/plate/utils/arcface/FaceApi.java b/lib_face/src/main/java/com/sw/plate/utils/arcface/FaceApi.java
index 319d033..a43cd6b 100644
--- a/lib_face/src/main/java/com/sw/plate/utils/arcface/FaceApi.java
+++ b/lib_face/src/main/java/com/sw/plate/utils/arcface/FaceApi.java
@@ -131,6 +131,37 @@ public class FaceApi {
return getFaceDao().queryAllByUserName(userName);
}
+ /**
+ * 按服务端更新时间倒序取最近更新的 N 条人脸记录(运维面板展示)
+ */
+ public List queryRecentUpdatedFaces(int limit) {
+ return getFaceDao().getRecentUpdatedFaces(limit);
+ }
+
+ /**
+ * 库内最大的服务端更新时间戳(无记录返回 null)
+ */
+ public Long queryMaxFaceUpdateTimestamp() {
+ return getFaceDao().getMaxFaceUpdateTimestamp();
+ }
+
+ /**
+ * 按 userId 精确过滤(运维面板搜索用)
+ */
+ public List queryByUserId(String userId, int limit) {
+ if (TextUtils.isEmpty(userId)) {
+ return new java.util.ArrayList<>();
+ }
+ return getFaceDao().queryByUserId(userId, limit);
+ }
+
+ /**
+ * 按 member 布尔统计人脸数(true-会员,false-非会员)
+ */
+ public int queryFaceCountByMember(boolean member) {
+ return getFaceDao().getFaceCountByMember(member);
+ }
+
public FaceDao getFaceDao() {
return FaceDatabase.getInstance(App.getContext()).faceDao();
}
diff --git a/lib_face/src/main/java/com/sw/plate/utils/arcface/facedb/dao/FaceDao.java b/lib_face/src/main/java/com/sw/plate/utils/arcface/facedb/dao/FaceDao.java
index 31a837f..850fe37 100644
--- a/lib_face/src/main/java/com/sw/plate/utils/arcface/facedb/dao/FaceDao.java
+++ b/lib_face/src/main/java/com/sw/plate/utils/arcface/facedb/dao/FaceDao.java
@@ -125,4 +125,28 @@ public interface FaceDao {
*/
@Query("SELECT COUNT(1) FROM face WHERE user_type = :userType")
int getFaceCountByUserType(int userType);
+
+ /**
+ * 按服务端更新时间倒序取最近更新的 N 条人脸记录(运维面板展示)
+ */
+ @Query("SELECT * FROM face ORDER BY face_update_timestamp DESC LIMIT :limit")
+ List getRecentUpdatedFaces(int limit);
+
+ /**
+ * 库内最大的服务端更新时间戳(无记录时返回 null)
+ */
+ @Query("SELECT MAX(face_update_timestamp) FROM face")
+ Long getMaxFaceUpdateTimestamp();
+
+ /**
+ * 按 userId 精确过滤(运维面板搜索用)
+ */
+ @Query("SELECT * FROM face WHERE user_id = :userId ORDER BY faceId DESC LIMIT :limit")
+ List queryByUserId(String userId, int limit);
+
+ /**
+ * 按 member 布尔统计人脸数(true-会员,false-非会员;比 user_type 更可靠,user_type 存的是服务端 personType 字符串)
+ */
+ @Query("SELECT COUNT(1) FROM face WHERE member = :member")
+ int getFaceCountByMember(boolean member);
}
diff --git a/lib_face/src/main/java/com/sw/plate/utils/comn/SerialApi.java b/lib_face/src/main/java/com/sw/plate/utils/comn/SerialApi.java
index d5b7801..7341b85 100644
--- a/lib_face/src/main/java/com/sw/plate/utils/comn/SerialApi.java
+++ b/lib_face/src/main/java/com/sw/plate/utils/comn/SerialApi.java
@@ -44,4 +44,25 @@ public class SerialApi {
serialPortManager.close();
}
}
+
+ /**
+ * 串口是否已打开(运维面板展示用)
+ */
+ public static boolean isOpened() {
+ return serialPort != null;
+ }
+
+ /**
+ * 串口设备路径(运维面板展示用)
+ */
+ public static String getPath() {
+ return path;
+ }
+
+ /**
+ * 串口波特率(运维面板展示用)
+ */
+ public static int getBaudRate() {
+ return speed;
+ }
}