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