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
+4
View File
@@ -69,6 +69,10 @@
android:name="com.sw.platecabinet.activity.MainActivity"
android:exported="false">
</activity>
<activity
android:name="com.sw.platecabinet.activity.OpsActivity"
android:exported="false">
</activity>
</application>
</manifest>
@@ -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()
@@ -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))
}
}
}
@@ -45,6 +45,38 @@ object MqttManager {
private val _messages = MutableSharedFlow<MqttMessage>(extraBufferCapacity = 20)
val messages: SharedFlow<MqttMessage> = _messages.asSharedFlow()
/** 最近一次连接成功时间(毫秒时间戳,null 表示从未连上),供运维面板展示 */
private val _lastConnectedAt = MutableStateFlow<Long?>(null)
val lastConnectedAt: StateFlow<Long?> = _lastConnectedAt.asStateFlow()
/** 最近一次连接丢失时间(毫秒时间戳) */
private val _lastLostAt = MutableStateFlow<Long?>(null)
val lastLostAt: StateFlow<Long?> = _lastLostAt.asStateFlow()
/** 最近一次连接/订阅/发布错误信息 */
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError.asStateFlow()
/** 最近一次收到业务消息时间(毫秒时间戳),用于判断"Connected 但链路假活" */
private val _lastMessageArrivedAt = MutableStateFlow<Long?>(null)
val lastMessageArrivedAt: StateFlow<Long?> = _lastMessageArrivedAt.asStateFlow()
/** 累计连接成功 / 断开次数(用于判断闪断) */
private val _connectCount = MutableStateFlow(0)
val connectCount: StateFlow<Int> = _connectCount.asStateFlow()
private val _disconnectCount = MutableStateFlow(0)
val disconnectCount: StateFlow<Int> = _disconnectCount.asStateFlow()
/** 当前 Broker URL(未配置返回 null */
val brokerUrl: String? get() = config?.brokerUrl
/** 当前 clientId(未配置返回 null */
val clientId: String? get() = config?.clientId
/** 已订阅主题与 QoS 的线程安全快照 */
fun getSubscriptions(): Map<String, Int> = 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}"
@@ -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 "未授予"}"
}
}
@@ -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()
}
}
@@ -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())
}
}
@@ -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()
}
}
}
@@ -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<File> {
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<String>()
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() }
}
}
@@ -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<InetAddress>()
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 ?: "自定义"
}
}
@@ -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);
}
}
@@ -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<File> = 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
}
}
}
@@ -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<MqttState> = MqttManager.state
val mqttLastConnectedAt: StateFlow<Long?> = MqttManager.lastConnectedAt
val mqttLastLostAt: StateFlow<Long?> = MqttManager.lastLostAt
val mqttLastError: StateFlow<String?> = MqttManager.lastError
val mqttLastMessageAt: StateFlow<Long?> = MqttManager.lastMessageArrivedAt
val mqttConnectCount: StateFlow<Int> = MqttManager.connectCount
val mqttDisconnectCount: StateFlow<Int> = MqttManager.disconnectCount
// ---- 人脸统计 ----
private val _faceCount = MutableStateFlow(0)
val faceCount: StateFlow<Int> = _faceCount.asStateFlow()
private val _memberCount = MutableStateFlow(0)
val memberCount: StateFlow<Int> = _memberCount.asStateFlow()
private val _nonMemberCount = MutableStateFlow(0)
val nonMemberCount: StateFlow<Int> = _nonMemberCount.asStateFlow()
private val _maxUpdateTs = MutableStateFlow<Long?>(null)
val maxUpdateTs: StateFlow<Long?> = _maxUpdateTs.asStateFlow()
private val _recentFaces = MutableStateFlow<List<FaceEntity>>(emptyList())
val recentFaces: StateFlow<List<FaceEntity>> = _recentFaces.asStateFlow()
// ---- 人脸搜索 ----
private val _searchResult = MutableStateFlow<List<FaceEntity>>(emptyList())
val searchResult: StateFlow<List<FaceEntity>> = _searchResult.asStateFlow()
// ---- 服务端连通自检 ----
private val _serverCheck = MutableStateFlow("未检测")
val serverCheck: StateFlow<String> = _serverCheck.asStateFlow()
// ---- 日志文件 ----
private val _logFiles = MutableStateFlow<List<File>>(emptyList())
val logFiles: StateFlow<List<File>> = _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))
}
}
+452
View File
@@ -0,0 +1,452 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@drawable/bg"
tools:context=".activity.OpsActivity">
<include
android:id="@+id/includeHeader"
layout="@layout/item_title_time" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="24dp"
android:paddingTop="12dp"
android:paddingBottom="40dp">
<!-- 网络状态 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="网络状态"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvNetSummary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="以太网 | 本机IP: 192.168.1.100" />
<TextView
android:id="@+id/tvNetBaseUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="环境: 测试 | https://dev.yixiong-tech.com:8081" />
<TextView
android:id="@+id/tvDeviceInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="设备ID: xxx | 设备编号: 202507231144 | 版本: 1.1" />
<Button
android:id="@+id/btnCheckServer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="服务端连通自检"
android:textSize="14sp" />
<TextView
android:id="@+id/tvServerCheck"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#FFCC99"
android:textSize="14sp"
tools:text="可达 HTTP 200123ms" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="#333D4D" />
<!-- 设备健康 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="设备健康"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvDeviceHealth"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="运行时长: 12小时3分 | 内存: 可用 512MB/总 2048MB | 存储: 可用 8GB/总 16GB" />
<TextView
android:id="@+id/tvCameraInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="摄像头: 2 个 | 权限: 已授予" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="#333D4D" />
<!-- 串口 / 柜锁 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="串口 / 柜锁"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvSerialInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="串口: /dev/ttyS2 @ 19200 | 状态: 未打开" />
<Button
android:id="@+id/btnInitSerial"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="初始化串口"
android:textSize="14sp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="#333D4D" />
<!-- MQTT 连接 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="MQTT 连接"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvMqttState"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="状态: Connected" />
<TextView
android:id="@+id/tvMqttBroker"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="Broker: wss://dev.yixiong-tech.com:8089 | clientId: platecabinet-xxx" />
<TextView
android:id="@+id/tvMqttSubs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="订阅: yx/device/face/update-test (qos=1)" />
<TextView
android:id="@+id/tvMqttTimes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="连接: 2026-09-02 10:00:00 | 丢失: — | 错误: —" />
<Button
android:id="@+id/btnReconnectMqtt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="手动重连"
android:textSize="14sp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="#333D4D" />
<!-- 人脸数据 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="人脸数据"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvFaceCount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="人脸总数: 0 | 会员: 0 | 临时: 0" />
<TextView
android:id="@+id/tvFaceWatermark"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="同步水位: 2026-09-02 10:00:00 | 库内最大更新: —" />
<Button
android:id="@+id/btnRefreshFace"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="刷新统计"
android:textSize="14sp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="#333D4D" />
<!-- 最近更新人脸 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="最近更新人脸"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvRecentEmpty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="暂无数据"
android:textColor="#999999"
android:textSize="14sp"
android:visibility="gone"
tools:visibility="visible" />
<!-- 人脸搜索(按 userId -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<EditText
android:id="@+id/etSearch"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="按 userId 搜索"
android:inputType="text"
android:maxLines="1"
android:textColor="#E6E6E6"
android:textColorHint="#888888"
android:textSize="14sp" />
<Button
android:id="@+id/btnSearch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="搜索"
android:textSize="14sp" />
</LinearLayout>
<TextView
android:id="@+id/tvSearchResult"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#E6E6E6"
android:textSize="13sp"
android:visibility="gone"
tools:visibility="visible"
tools:text="匹配 1 条: ufid=xxx uid=123 member=true 2026-09-02 10:00:00" />
<LinearLayout
android:id="@+id/llRecentList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="#333D4D" />
<!-- 运行日志 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="运行日志"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvLogFiles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="运行日志: 0 个文件" />
<TextView
android:id="@+id/tvCrashInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="崩溃日志: 0 个文件" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical">
<Button
android:id="@+id/btnReadLog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="查看最新日志"
android:textSize="14sp" />
<Button
android:id="@+id/btnReadCrash"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="查看最新崩溃日志"
android:textSize="14sp" />
<Button
android:id="@+id/btnExport"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="导出诊断包"
android:textSize="14sp" />
<Button
android:id="@+id/btnClearLogs"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="清空日志"
android:textSize="14sp" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="#333D4D" />
<!-- 干预操作 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="干预操作(需确认)"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical">
<Button
android:id="@+id/btnFullSync"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="获取全量人脸"
android:textSize="14sp" />
<Button
android:id="@+id/btnTriggerSync"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="手动增量补拉"
android:textSize="14sp" />
<Button
android:id="@+id/btnClearFace"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="清空本地人脸库"
android:textSize="14sp" />
<Button
android:id="@+id/btnSwitchEnv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="切换环境(重启)"
android:textSize="14sp" />
<Button
android:id="@+id/btnChangePin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="修改运维密码"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
</ScrollView>
</LinearLayout>
@@ -131,6 +131,37 @@ public class FaceApi {
return getFaceDao().queryAllByUserName(userName);
}
/**
* 按服务端更新时间倒序取最近更新的 N 条人脸记录(运维面板展示)
*/
public List<FaceEntity> queryRecentUpdatedFaces(int limit) {
return getFaceDao().getRecentUpdatedFaces(limit);
}
/**
* 库内最大的服务端更新时间戳(无记录返回 null)
*/
public Long queryMaxFaceUpdateTimestamp() {
return getFaceDao().getMaxFaceUpdateTimestamp();
}
/**
* 按 userId 精确过滤(运维面板搜索用)
*/
public List<FaceEntity> 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();
}
@@ -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<FaceEntity> 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<FaceEntity> 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);
}
@@ -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;
}
}