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