diff --git a/Android/app/src/main/java/com/xhai/iot/model/TuyaDevice.java b/Android/app/src/main/java/com/xhai/iot/model/TuyaDevice.java
index fc4260e..adf2085 100644
--- a/Android/app/src/main/java/com/xhai/iot/model/TuyaDevice.java
+++ b/Android/app/src/main/java/com/xhai/iot/model/TuyaDevice.java
@@ -51,6 +51,15 @@ public class TuyaDevice {
@SerializedName("parentDeviceId")
private String parentDeviceId;
+ /**
+ * 后端原始状态值(online / offline / unknown)
+ *
+ *
子设备(网关下挂)没有独立在线通道,后端会按父网关聚合状态,
+ * 无法用 online/offline 两态表达"未知",因此保留原始值供 UI 区分展示。
+ */
+ @SerializedName("statusRaw")
+ private String statusRaw;
+
/** 是否为网关 */
@SerializedName("isGateway")
private Integer isGateway;
@@ -94,6 +103,9 @@ public class TuyaDevice {
public String getParentDeviceId() { return parentDeviceId; }
public void setParentDeviceId(String parentDeviceId) { this.parentDeviceId = parentDeviceId; }
+ public String getStatusRaw() { return statusRaw; }
+ public void setStatusRaw(String statusRaw) { this.statusRaw = statusRaw; }
+
public Integer getIsGateway() { return isGateway; }
public void setIsGateway(Integer isGateway) { this.isGateway = isGateway; }
diff --git a/Android/app/src/main/java/com/xhai/iot/ui/DevicesFragment.java b/Android/app/src/main/java/com/xhai/iot/ui/DevicesFragment.java
index f42e0c2..5e71eb2 100644
--- a/Android/app/src/main/java/com/xhai/iot/ui/DevicesFragment.java
+++ b/Android/app/src/main/java/com/xhai/iot/ui/DevicesFragment.java
@@ -581,6 +581,8 @@ public class DevicesFragment extends Fragment {
td.setPlatform(bd.getPlatform());
td.setParentDeviceId(bd.getParentDeviceId());
td.setIsGateway(bd.getIsGateway());
+ // 保留后端原始状态,供列表区分"在线/离线/状态未知"
+ td.setStatusRaw(bd.getStatus());
// 后端设备类型作为兜底(涂鸦设备若 SDK 有 category 会覆盖)
if (bd.getDeviceType() != null && !bd.getDeviceType().isEmpty()) {
td.setCategory(bd.getDeviceType());
@@ -593,10 +595,20 @@ public class DevicesFragment extends Fragment {
// 在线状态以涂鸦 SDK 实时数据为准(后端 status 可能未及时刷新)
boolean sdkOnline = com.xhai.iot.service.TuyaSdkService.resolveDeviceOnline(
bd.getDeviceId(), devBean, bd.isOnline(), bd.getParentDeviceId());
- td.setOnline(sdkOnline);
- if (sdkOnline != bd.isOnline()) {
- com.xhai.iot.service.TuyaSdkService
- .reportStatusToBackend(bd.getDeviceId(), sdkOnline, null);
+ // 网关下挂设备(子设备)特殊处理:涂鸦 SDK / 云端快照对 Mesh、Zigbee
+ // 子设备的 online 恒为 false(通电在线也一样),照搬会把明明在线的
+ // 子设备显示成离线;此时以「父网关在线」为权威依据 —— 后端已按父网关
+ // 聚合过状态,这里直接采信,不再用 SDK 快照覆盖。
+ boolean isSubDevice = bd.getParentDeviceId() != null
+ && !bd.getParentDeviceId().isEmpty();
+ if (isSubDevice) {
+ td.setOnline(bd.isOnline());
+ } else {
+ td.setOnline(sdkOnline);
+ if (sdkOnline != bd.isOnline()) {
+ com.xhai.iot.service.TuyaSdkService
+ .reportStatusToBackend(bd.getDeviceId(), sdkOnline, null);
+ }
}
if (devBean.getDps() != null) {
td.setDps(devBean.getDps());
diff --git a/Android/app/src/main/java/com/xhai/iot/ui/adapter/DeviceRowAdapter.java b/Android/app/src/main/java/com/xhai/iot/ui/adapter/DeviceRowAdapter.java
index 67b4428..e0123c6 100644
--- a/Android/app/src/main/java/com/xhai/iot/ui/adapter/DeviceRowAdapter.java
+++ b/Android/app/src/main/java/com/xhai/iot/ui/adapter/DeviceRowAdapter.java
@@ -145,7 +145,7 @@ public class DeviceRowAdapter extends RecyclerView.Adapter后端对网关下挂的子设备会按父网关聚合状态,取不到权威信息时用 unknown
+ * 表达"状态未知",而不是一律兜底成"离线"(曾导致通电在线的子设备显示离线)。
+ */
+ private String statusText(TuyaDevice device) {
+ String raw = device.getStatusRaw();
+ if (raw == null || raw.isEmpty()) {
+ return device.isOnline() ? "在线" : "离线";
+ }
+ if ("online".equalsIgnoreCase(raw)) {
+ return "在线";
+ }
+ if ("offline".equalsIgnoreCase(raw)) {
+ return "离线";
+ }
+ return "状态未知";
+ }
+
/**
* 绑定胶囊开关与状态文字(抽成独立方法)。
* 局部刷新时只调用这里,不重载图标,避免列表闪烁
diff --git a/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java b/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java
index 3b6eacf..2c28914 100644
--- a/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java
+++ b/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java
@@ -23,6 +23,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.LinkedHashMap;
@@ -412,8 +413,12 @@ public class TuyaController {
update.setPlatform("tuya");
update.setDeviceType(tuyaDevice.getCategory() != null
? tuyaDevice.getCategory() : existing.getDeviceType());
- update.setStatus(Boolean.TRUE.equals(tuyaDevice.getOnline())
- ? "online" : "offline");
+ update.setStatus(resolveEffectiveOnlineStatus(existing, tuyaDevice.getOnline()));
+ if (tuyaDevice.getParentDeviceId() != null && !tuyaDevice.getParentDeviceId().isEmpty()) {
+ // 子设备的父子关系由前端从涂鸦 SDK 一并上报
+ update.setParentDeviceId(tuyaDevice.getParentDeviceId());
+ update.setIsGateway(0);
+ }
update.setPlatformProductId(tuyaDevice.getProductId() != null
? tuyaDevice.getProductId() : existing.getPlatformProductId());
update.setIcon(tuyaDevice.getIcon() != null
@@ -435,8 +440,9 @@ public class TuyaController {
localDevice.setPlatform("tuya");
localDevice.setDeviceType(tuyaDevice.getCategory() != null
? tuyaDevice.getCategory() : "unknown");
- localDevice.setStatus(Boolean.TRUE.equals(tuyaDevice.getOnline())
- ? "online" : "offline");
+ localDevice.setStatus(resolveEffectiveOnlineStatus(null, tuyaDevice.getOnline()));
+ localDevice.setParentDeviceId(tuyaDevice.getParentDeviceId());
+ localDevice.setIsGateway(resolveIsGateway(tuyaDevice) ? 1 : 0);
localDevice.setLocation(req.getRoomId() != null
? String.valueOf(req.getRoomId()) : null);
// 家庭/房间真实关联(V11: device.home_id/room_id)
@@ -526,7 +532,7 @@ public class TuyaController {
update.setPlatform(existing.getPlatform());
}
if (req.getOnline() != null) {
- update.setStatus(req.getOnline() ? "online" : "offline");
+ update.setStatus(resolveEffectiveOnlineStatus(existing, req.getOnline()));
}
// 更新DP能力(存到capabilities字段的dps子键,便于查询当前状态)
if (req.getDps() != null && !req.getDps().isEmpty()) {
@@ -561,6 +567,58 @@ public class TuyaController {
return Result.success(1);
}
+ /**
+ * 计算涂鸦设备的有效在线状态。
+ *
+ * 为什么需要它:涂鸦 Mesh/Zigbee 子设备没有独立的在线通道 —— 涂鸦 SDK 与
+ * OpenAPI 快照对它们的 online 恒为 false(实测:通电在线的 JD6BTSA 云端仍为 false)。
+ * APP 把这个 false 上报后,device.status 就被写成 offline,用户看到「明明通电、网关
+ * 在线,子设备却显示离线」。
+ *
+ * 判定规则:
+ *
+ * - 上报在线 → online;
+ * - 子设备(parent_device_id 非空)上报离线 → 以父网关为准:父网关在线则 online,
+ * 否则 offline;
+ * - 顶层设备上报离线 → offline。
+ *
+ */
+ private String resolveEffectiveOnlineStatus(Device device, Boolean reportedOnline) {
+ if (Boolean.TRUE.equals(reportedOnline)) {
+ return "online";
+ }
+ String parentId = device == null ? null : device.getParentDeviceId();
+ if (parentId != null && !parentId.isEmpty()) {
+ Device parent = deviceMapper.selectByDeviceId(parentId);
+ if (parent != null && "online".equalsIgnoreCase(parent.getStatus())) {
+ log.info("子设备在线状态按父网关判定为在线: device={}, parent={}",
+ device.getDeviceId(), parentId);
+ return "online";
+ }
+ }
+ return "offline";
+ }
+
+ /**
+ * 判断是否为网关设备。
+ *
+ * 子设备一定有父设备,因此「没有父设备」且品类属于网关类(wg/gateway/gw)即为网关。
+ */
+ private boolean resolveIsGateway(TuyaBindRequest.DeviceInfo info) {
+ if (info == null) {
+ return false;
+ }
+ if (info.getParentDeviceId() != null && !info.getParentDeviceId().isEmpty()) {
+ return false;
+ }
+ String category = info.getCategory();
+ if (category == null) {
+ return false;
+ }
+ String c = category.trim().toLowerCase(Locale.ROOT);
+ return "wg".equals(c) || "gateway".equals(c) || "gw".equals(c) || "sub_gateway".equals(c);
+ }
+
// ==================== 设备控制 ====================
/**
diff --git a/backend/iot-device/src/main/java/com/iot/device/dto/tuya/TuyaBindRequest.java b/backend/iot-device/src/main/java/com/iot/device/dto/tuya/TuyaBindRequest.java
index ca873a3..1a934a0 100644
--- a/backend/iot-device/src/main/java/com/iot/device/dto/tuya/TuyaBindRequest.java
+++ b/backend/iot-device/src/main/java/com/iot/device/dto/tuya/TuyaBindRequest.java
@@ -57,5 +57,15 @@ public class TuyaBindRequest {
private String productId;
@Schema(description = "设备图标URL")
private String icon;
+ /**
+ * 父设备(网关)ID
+ *
+ * 涂鸦 Mesh/Zigbee 子设备没有独立在线通道,涂鸦 SDK 与云端快照对它们的
+ * online 恒为 false(实测通电在线的子设备也恒 false)。子设备的在线状态只能
+ * 依据「父网关是否在线」判定,因此前端需把涂鸦 SDK 里的父设备关系一并上报,
+ * 后端据此写入 device.parent_device_id。
+ */
+ @Schema(description = "父设备(网关)ID")
+ private String parentDeviceId;
}
}
diff --git a/backend/iot-device/src/main/java/com/iot/device/service/impl/DeviceServiceImpl.java b/backend/iot-device/src/main/java/com/iot/device/service/impl/DeviceServiceImpl.java
index 6d1771f..fe7a17d 100644
--- a/backend/iot-device/src/main/java/com/iot/device/service/impl/DeviceServiceImpl.java
+++ b/backend/iot-device/src/main/java/com/iot/device/service/impl/DeviceServiceImpl.java
@@ -174,9 +174,55 @@ public class DeviceServiceImpl extends ServiceImpl impleme
Page result = page(new Page<>(query.getPageNum(), query.getPageSize()), wrapper);
fillProductInfo(result.getRecords());
+ resolveSubDeviceStatus(result.getRecords());
return result;
}
+ /**
+ * 子设备在线状态聚合(仅影响返回结果,不写库)。
+ *
+ * 涂鸦 Mesh/Zigbee 子设备没有独立的在线通道 —— 涂鸦 SDK 与 OpenAPI 快照对它们的
+ * online 恒为 false,APP 把这个 false 上报后,库里存的就是 offline。用户明明看到
+ * 子设备通电、网关在线,列表里却显示离线。
+ *
+ * 这里以「父网关在线」为判据做一次聚合:父网关在线 → 子设备按在线展示;
+ * 父网关不在线且子设备状态未知 → 按离线展示。写入侧同样有对��规则
+ * (TuyaController.resolveEffectiveOnlineStatus),这里是展示侧的兜底,
+ * 保证历史数据也能立即显示正确。
+ */
+ private void resolveSubDeviceStatus(List devices) {
+ if (devices == null || devices.isEmpty()) {
+ return;
+ }
+ Set parentIds = devices.stream()
+ .map(Device::getParentDeviceId)
+ .filter(StringUtils::hasText)
+ .collect(Collectors.toSet());
+ if (parentIds.isEmpty()) {
+ return;
+ }
+ Map parentMap = baseMapper.selectList(new LambdaQueryWrapper()
+ .in(Device::getDeviceId, parentIds))
+ .stream()
+ .collect(Collectors.toMap(Device::getDeviceId, d -> d, (a, b) -> a));
+ for (Device device : devices) {
+ String parentId = device.getParentDeviceId();
+ if (!StringUtils.hasText(parentId)) {
+ continue;
+ }
+ Device parent = parentMap.get(parentId);
+ if (parent == null) {
+ continue;
+ }
+ if ("online".equalsIgnoreCase(parent.getStatus())) {
+ device.setStatus("online");
+ } else if (!StringUtils.hasText(device.getStatus())
+ || "unknown".equalsIgnoreCase(device.getStatus())) {
+ device.setStatus("offline");
+ }
+ }
+ }
+
/**
* 批量回填设备所属产品名称与业务编码(供后台设备列表展示"所属产品"列)。
*/
diff --git a/backend/sql/V25__add_product_category.sql b/backend/sql/V25__add_product_category.sql
index ac80102..276f4cf 100644
--- a/backend/sql/V25__add_product_category.sql
+++ b/backend/sql/V25__add_product_category.sql
@@ -15,8 +15,13 @@
-- 品类字典只是"可选值来源",避免历史数据与物模型解析受影响。
--
-- 无存储过程、无 DELIMITER;幂等可重复执行。
+--
+-- 注意:本脚本含中文字面量,首行必须 SET NAMES utf8mb4 —— 服务器 mysql 客户端在 C locale 下
+-- character_set_client 默认 latin1,缺少该设置会把 UTF-8 字节按 latin1 解读,导致中文双重编码乱码。
-- =====================================================================
+SET NAMES utf8mb4;
+
-- ---------- 1) 品类字典表 ----------
CREATE TABLE IF NOT EXISTS product_category (
id BIGINT NOT NULL COMMENT '主键',
diff --git a/backend/sql/V26__fix_mojibake_chinese.sql b/backend/sql/V26__fix_mojibake_chinese.sql
new file mode 100644
index 0000000..7deba03
--- /dev/null
+++ b/backend/sql/V26__fix_mojibake_chinese.sql
@@ -0,0 +1,32 @@
+-- =====================================================================
+-- V26 · 修复品类字典中文乱码(双重编码)
+-- ---------------------------------------------------------------------
+-- 现象:后台「品类管理」品类名称显示为 "煉明" 之类乱码。
+--
+-- 成因:服务器 mysql 客户端运行在 C locale,character_set_client 默认 latin1
+-- (容器内 `SELECT @@character_set_client` → latin1)。
+-- 迁移脚本经 Windows `type | plink` 管道喂给客户端时,UTF-8 字节被当作
+-- latin1 字符接收,再按 utf8mb4 列存储 → 中文变成双重编码。
+-- (历史数据如 product.name 中文正常,是因为当时用 docker cp 进容器再执行,
+-- 没走管道。)
+--
+-- 处理:
+-- 1) 本脚本首行 SET NAMES utf8mb4,保证脚本里的中文字面量按 utf8mb4 解释;
+-- 2) 直接按 code 写回正确的中文名称与备注(幂等:仅在值不一致时才更新);
+-- 3) 后续所有含中文的迁移脚本都需在首行加 SET NAMES utf8mb4,
+-- deploy-all.bat 也已为 mysql 客户端补 --default-character-set=utf8mb4。
+--
+-- 无存储过程、无 DELIMITER;幂等可重复执行。
+-- =====================================================================
+
+SET NAMES utf8mb4;
+
+UPDATE product_category SET name = '照明', remark = '灯/灯泡/灯带', update_time = NOW() WHERE deleted = 0 AND code = 'light';
+UPDATE product_category SET name = '开关/插座', remark = '开关/插座/墙面面板', update_time = NOW() WHERE deleted = 0 AND code = 'switch';
+UPDATE product_category SET name = '空调/暖通', remark = '空调/风扇/取暖器', update_time = NOW() WHERE deleted = 0 AND code = 'ac';
+UPDATE product_category SET name = '窗帘', remark = '窗帘/遮阳', update_time = NOW() WHERE deleted = 0 AND code = 'curtain';
+UPDATE product_category SET name = '传感器', remark = '温湿度/人体/门磁', update_time = NOW() WHERE deleted = 0 AND code = 'sensor';
+UPDATE product_category SET name = '门锁', remark = '智能门锁', update_time = NOW() WHERE deleted = 0 AND code = 'lock';
+UPDATE product_category SET name = '安防', remark = '报警器/摄像头', update_time = NOW() WHERE deleted = 0 AND code = 'security';
+UPDATE product_category SET name = '其它', remark = '未归类', update_time = NOW() WHERE deleted = 0 AND code = 'other';
+
diff --git a/deploy-all.bat b/deploy-all.bat
index 715e96f..bc7672b 100644
--- a/deploy-all.bat
+++ b/deploy-all.bat
@@ -45,10 +45,10 @@ echo [3/8] 应用未执行的 SQL 迁移(%BACKEND_DIR%\sql\V*.sql,按 db_mig
echo ============================================
set MIG_DIR=%BACKEND_DIR%\sql
set CHK_FILE=%TEMP%\_mig_chk.txt
-%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql -uroot -p'IoT.p2026.Xk9Qm' iot_platform -e \"CREATE TABLE IF NOT EXISTS db_migrations_applied (name VARCHAR(80) NOT NULL PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)\"" 2>nul
+%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql --default-character-set=utf8mb4 -uroot -p'IoT.p2026.Xk9Qm' iot_platform -e \"CREATE TABLE IF NOT EXISTS db_migrations_applied (name VARCHAR(80) NOT NULL PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)\"" 2>nul
REM 首次使用(tracking 表为空)则标记当前所有 V 文件为已应用:假定目标库已手工迁移到最新。
REM 全新库请先手动执行 sql/ 下全部 V 迁移,再运行本脚本(或清空 db_migrations_applied 让其补跑,需迁移文件幂等)。
-%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql -uroot -p'IoT.p2026.Xk9Qm' iot_platform -N -e \"SELECT COUNT(*) FROM db_migrations_applied\"" 2>nul > "%CHK_FILE%"
+%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql --default-character-set=utf8mb4 -uroot -p'IoT.p2026.Xk9Qm' iot_platform -N -e \"SELECT COUNT(*) FROM db_migrations_applied\"" 2>nul > "%CHK_FILE%"
set /p MIG_CNT=<"%CHK_FILE%"
if "!MIG_CNT!"=="0" (
echo 首次使用:标记当前所有 V 迁移为已应用(假定库已迁移到最新)
@@ -118,12 +118,12 @@ goto :eof
:: ============ 迁移子例程(顶层执行,避开括号块内转义问题) ============
:mig_seed
-%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql -uroot -p'IoT.p2026.Xk9Qm' iot_platform -e \"INSERT IGNORE INTO db_migrations_applied(name) VALUES('%1')\"" 2>nul
+%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql --default-character-set=utf8mb4 -uroot -p'IoT.p2026.Xk9Qm' iot_platform -e \"INSERT IGNORE INTO db_migrations_applied(name) VALUES('%1')\"" 2>nul
goto :eof
:mig_apply
set CHK=0
-%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql -uroot -p'IoT.p2026.Xk9Qm' iot_platform -N -e \"SELECT COUNT(*) FROM db_migrations_applied WHERE name='%1'\"" 2>nul > "%CHK_FILE%"
+%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql --default-character-set=utf8mb4 -uroot -p'IoT.p2026.Xk9Qm' iot_platform -N -e \"SELECT COUNT(*) FROM db_migrations_applied WHERE name='%1'\"" 2>nul > "%CHK_FILE%"
set /p CHK=<"%CHK_FILE%"
if not "!CHK!"=="0" (
echo 跳过已应用 %1
@@ -131,7 +131,7 @@ if not "!CHK!"=="0" (
)
echo 应用 %1 ...
REM --force: 迁移脚本中"已存在"的 DDL(重复列/索引)属正常情况,不应中断整体部署
-type "%MIG_DIR%\%1" | %PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql --force -uroot -p'IoT.p2026.Xk9Qm' iot_platform"
+type "%MIG_DIR%\%1" | %PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql --force --default-character-set=utf8mb4 -uroot -p'IoT.p2026.Xk9Qm' iot_platform"
if !ERRORLEVEL! NEQ 0 ( echo [错误] 迁移 %1 执行失败。 & pause & exit /b 1 )
-%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql -uroot -p'IoT.p2026.Xk9Qm' iot_platform -e \"INSERT IGNORE INTO db_migrations_applied(name) VALUES('%1')\"" 2>nul
+%PLINK% %PLINK_OPTS% "docker exec -i iot-mysql mysql --default-character-set=utf8mb4 -uroot -p'IoT.p2026.Xk9Qm' iot_platform -e \"INSERT IGNORE INTO db_migrations_applied(name) VALUES('%1')\"" 2>nul
goto :eof
diff --git a/uniapp/src/services/deviceService.ts b/uniapp/src/services/deviceService.ts
index e50e489..5fbb788 100644
--- a/uniapp/src/services/deviceService.ts
+++ b/uniapp/src/services/deviceService.ts
@@ -85,8 +85,12 @@ class DeviceService {
name: item.deviceName || item.name,
type: item.deviceType || item.type || 'default',
category: item.category || 'default',
- online: item.status === 'ONLINE' || item.online,
- status: this.parseStatus(item.statusData || item.status),
+ // 后端状态是小写的 online/offline(网关下挂子设备可能为 unknown),
+ // 这里统一按小写比较并兼容布尔值;此前比的是大写 'ONLINE',导致设备恒显示离线。
+ online: String(item.status ?? '').toLowerCase() === 'online' || item.online === true,
+ // status 字段是状态字符串而非 DP JSON,不能拿去做 JSON 解析;
+ // 真正的 DP 在 capabilities.dps 里。
+ status: this.parseStatus(item.capabilities?.dps || item.statusData || {}),
platform: 'local' as const,
icon: item.icon,
roomId: item.roomId,